# Phase 15: WiFi/DDP Fallback and Transport Selection - Research

**Researched:** 2026-04-19
**Domain:** WLED DDP-over-UDP transport, Win32 Winsock UDP, SetupAPI VID/PID enumeration, multi-line named-pipe responses, transport selection in `DeviceProvider`
**Confidence:** HIGH

## Summary

Phase 15 closes out the backglow transport story by adding a second `ILedTransport` implementation (`WledDdpTransport`) that speaks WLED DDP over UDP, by wiring transport selection into VRSettings (`backglow.transport` = `usb | ddp | auto`), by replacing the user-typed `backglow.com_port` with a SetupAPI VID/PID scan (Espressif `0x303A` / `0x1001`) for the USB path, and by adding the `backglow status` multi-line diagnostic. The Phase 14 architecture already anticipated all of this — `ILedTransport` is intentionally transport-agnostic, `LedController` is constructor-injected with a transport, and `DeviceProvider::HandleBackglowCommand()` is a strncmp-prefix dispatcher with one new verb (`status`). The blast radius is small: one new transport class (~200 LOC), one new helper (~80 LOC), surgical edits to `DeviceProvider::InitBackglow()` and `HandleBackglowCommand()`, two new `target_link_libraries` entries (`ws2_32`, `setupapi`), and a small CLI tweak to widen the response buffer.

Every external-facing protocol used in this phase is documented in upstream research already locked: STACK.md §"DDP Protocol (FALLBACK -- WiFi path)" gives the exact 10-byte DDP header bytes used for our 10-LED single-packet payload, STACK.md §"COM Port Detection Strategy" lays out the 4-step VID/PID scan, and ARCHITECTURE.md §"Hardware Discovery" preserves the same plan for COM enumeration. The two genuinely new things to research were (a) the `/json/info.leds.count` field shape (verified against kno.wled.ge — confirmed) and (b) the SetupAPI sequence to extract `PortName` ("COM5") from a matched device's registry entry (verified against MS Learn + canonical pattern article). Both are LOW-risk and well-documented.

**Primary recommendation:** Mirror Phase 14's `WledSerialTransport` shape exactly for `WledDdpTransport` — same lifecycle (`Open` does the probe, `Close` invalidates state, `Send*` returns false on failure and self-invalidates). Implement COM scan as a freestanding helper used inside `WledSerialTransport::Open` when port is empty, OR called from `DeviceProvider::InitBackglow` before constructing the transport. Add `status` as the 6th verb in `HandleBackglowCommand`. Use raw socket + minimal handcrafted HTTP/1.1 GET for the `/json/info` probe (avoids pulling in WinHTTP/WinINet — the request is 5 lines of code and the response parse is "find `\"count\":` and `strtoul`"). Keep `auto` simple: if `transport=auto`, try USB factory first; on failure, try DDP factory; on failure, degraded. No runtime hot-swap.

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

### Locked Decisions

**Transport Selection**
- **D-01:** VRSettings `backglow.transport` ∈ `usb | ddp | auto`. Explicit values are strict (chosen transport fails → degraded-disabled). `auto` tries USB first; on USB `Open()` failure, falls through to DDP. Default value TBD by planner (likely `usb` to preserve Phase 14 behavior).
- **D-02:** Transport is chosen at driver init only. Restart required to change. No runtime pipe command, no VRSettings hot-reload. Matches Phase 14 D-14 (`backglow.com_port`) behavior.
- **D-03:** When the chosen transport cannot be opened, enter the same degraded-disabled state defined by Phase 14 D-15: log INFO once, mark backglow disabled, pipe `backglow.*` commands reply `ERR backglow disabled (<reason>)`. Driver loads normally — backglow failure must never block proximity/IPD.
- **D-04:** `auto` fallback fires once at startup only. Mid-session USB drop stays on USB reconnect loop (Phase 14 pattern) — does not migrate to DDP. Intent: "user unplugged USB and moved to WiFi before session" scenario, not live failover.

**DDP Target Configuration**
- **D-05:** VRSettings `backglow.ddp_host` = IPv4 string (e.g. `192.168.1.42`). No DNS resolution, no mDNS discovery. Keeps Phase 14 "explicit user config" philosophy.
- **D-06:** DDP port fixed at **4048** (WLED default, per `TRNS-02`). No VRSettings knob.
- **D-07:** "Connected" for DDP = `Open()` succeeds in reaching host: bind UDP socket + one-shot HTTP `GET /json/info` to the target within bounded timeout (planner picks timeout; ~500 ms suggested). Response received = connected. No periodic heartbeat — after `Open()`, DDP frames are fire-and-forget UDP.
- **D-08:** The same `GET /json/info` at `Open()` also yields the WLED-reported strip length (LED count). `LedController` surface remains locked at `kBackglowMaxLeds=10` (Phase 14). DDP encoder uses the returned count for wire framing. If returned count ≠ 10, log WARN and clamp encoding to 10.

**VID/PID Auto-Detect (USB transport)**
- **D-09:** COM port resolution when transport resolves to USB:
  1. If `backglow.com_port` is set and non-empty → try it first (Phase 14 behavior preserved).
  2. If that `Open()` fails **or** the key is blank/missing → fall through to `SetupDiGetClassDevs(GUID_DEVINTERFACE_COMPORT)` scan, matching VID `0x303A` / PID `0x1001`.
  3. Scan yields zero matches → degraded-disabled (D-03).
- **D-10:** Scan multi-match policy: lowest COM number wins. Log WARN listing all matched ports and the hint that `backglow.com_port` can pin a specific one.
- **D-11:** Scan fires at driver init. `DBT_DEVICEARRIVAL` hotplug notification (already registered in Phase 14) re-runs the scan **if and only if** backglow is currently disabled. Already-connected state is not disturbed.
- **D-12:** TPM2 stub (Phase 14 D-08a) stays as a class skeleton in Phase 15. No expansion, no removal.

**`backglow status` (DIAG-01)**
- **D-13:** Output format = **multi-line human-readable**, aligned columns. Breaks the Phase 14 single-line `OK ...` convention for this command only. CLI (`beyond_prox_ctl.exe`) reads the pipe response until EOF / bounded timeout rather than one line.
- **D-14:** Fields included: `transport`, `conn`, `port` (when transport=usb) **or** `host` (when transport=ddp), `bri`, `ceiling`, `leds`. **Not included:** `color` (no LedController tracking of last-sent color in Phase 15).
- **D-15:** Error context: when `conn` is `disabled` or `reconnecting`, append an `err:` line with a short reason token (e.g. `err: ERROR_FILE_NOT_FOUND`, `err: no_com_port_configured`, `err: ddp_probe_timeout`, `err: scan_no_match`). Omitted on healthy connections.

**File Layout (delta on Phase 14)**
- **D-16:** New files in `src/led/`:
  - `wled_ddp.h/.cpp` — `WledDdpTransport : public ILedTransport`. Implements DDP protocol header + payload chunking per WLED DDP docs. Uses Winsock UDP socket + WinHTTP (or WinINet) for the one-shot `/json/info` probe at `Open()`.
- **D-17:** New helper in `src/led/` or `src/driver/`:
  - `com_port_scan.h/.cpp` (or inline in `wled_serial.cpp`) — SetupDi-based VID/PID enumeration. `setupapi.lib` now linked (already anticipated in Phase 14 integration points).
- **D-18:** `DeviceProvider` changes:
  - Read `backglow.transport` + `backglow.ddp_host` at init alongside existing `backglow.com_port` + `backglow.brightness_ceiling`.
  - Factory logic to build correct `ILedTransport` concrete type (with USB scan fallback per D-09, `auto` fallback per D-04).
  - `HandleBackglowCommand()` gains `status` subcommand producing multi-line output per D-13/D-14/D-15.
- **D-19:** `beyond_prox_ctl.exe` gains `backglow status` validation and adjusts pipe read loop to handle multi-line response.

### Claude's Discretion
- Exact default value of `backglow.transport` (likely `usb` to preserve Phase 14 behavior; planner decides).
- DDP probe timeout (500 ms suggested, planner tunes).
- Order and exact formatting of `status` fields (only the field *set* and format *family* are locked).
- Whether `/json/info` probe uses WinHTTP, WinINet, or raw socket + minimal HTTP/1.1 write.
- DDP payload chunking strategy (WLED DDP allows data fragmentation; 10 LEDs × 3 bytes fits one packet easily).
- Whether `com_port_scan` is a standalone helper file or inlined into `wled_serial.cpp`.
- UAT scaffolding for WiFi-mode testing (SMOKE doc analogous to 14-SMOKE.md).
- VRSettings key naming: `backglow.transport` vs `backglow.transport_mode` vs similar.

### Deferred Ideas (OUT OF SCOPE)
- Runtime transport switching via VRSettings hot-reload or `backglow transport <usb|ddp>` pipe command — rejected D-02.
- Mid-session USB→DDP failover on cable drop — rejected D-04.
- mDNS / Bonjour WLED discovery — rejected D-05 in favor of explicit IP.
- DDP heartbeat / periodic reachability probe — rejected D-07.
- `color` field in `status` — dropped D-14 (would require LedController to track last-sent frame).
- TPM2 functional implementation — Phase 14 D-08a still gated on Adalight reliability issues that have not materialized.
- Per-transport brightness ceiling — current shared-ceiling design sufficient for v3.0.
- Configurable DDP port — rejected D-06; fixed 4048.
- VRChat OSC bridge daemon → Phase 16 (`VRCH-01`, `VRCH-02`).
- Avatar prefab + reference world → Phase 17 (`VRCH-03`, `VRCH-04`).
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| LHWD-04 | Driver auto-detects ESP32-C3 COM port via VID/PID (0x303A/0x1001) and handles disconnect/reconnect | `com_port_scan.cpp` performs `SetupDiGetClassDevs(GUID_DEVINTERFACE_COMPORT, …, DIGCF_PRESENT \| DIGCF_DEVICEINTERFACE)` → `SetupDiEnumDeviceInfo` loop → `SPDRP_HARDWAREID` substring match for `VID_303A&PID_1001` → `SetupDiOpenDevRegKey(DIREG_DEV)` + `RegQueryValueEx("PortName")` to extract "COM5" string. Reconnect path is unchanged from Phase 14 (`LedController::TryReopen` + `RegisterDeviceNotification` already in place); D-11 only re-runs the *scan* when in disabled state, leaving the existing reconnect loop untouched for the live-USB case. **[CITED:** STACK.md §"COM Port Detection Strategy"; aticleworld.com SetupAPI VID/PID pattern.**]** |
| TRNS-02 | WiFi/DDP fallback transport sends per-LED RGB data via UDP to WLED on port 4048 | `WledDdpTransport::SendRgbFrame` builds the 10-byte DDP header per STACK.md §"DDP Protocol (FALLBACK)" + the 30-byte RGB payload, sends as a single UDP datagram via `sendto` to `host:4048`. Header bytes locked: `0x41 0x01 0x07 0x00 [offset:u32_be=0] [length:u16_be=30]`. **[CITED:** STACK.md §"Complete 10-LED DDP Packet"; kno.wled.ge/interfaces/ddp/.**]** |
| TRNS-03 | User can select transport (USB or DDP) via VRSettings configuration | `DeviceProvider::InitBackglow` reads `backglow_transport` (string ∈ `usb|ddp|auto`) and dispatches to the correct factory. `auto` mode falls through USB → DDP per D-04. **[CITED:** Phase 14 `device_provider.cpp:1294` `InitBackglow` extension point; CONTEXT D-01.**]** |
| DIAG-01 | `backglow status` multi-line pipe command shows transport, connection state, brightness ceiling, current host/port, leds | `HandleBackglowCommand` gains `status` verb → emits 5–7 newline-separated lines per D-13/D-14/D-15. CLI widens response buffer from 256 to 1024 bytes; pipe is already MESSAGE-mode so a single `ReadFile` returns the entire message atomically. **[CITED:** `src/ctl/main.cpp:111` ReadFile loop; `src/driver/device_provider.cpp:577` WriteFile single-message send; CreateNamedPipe `PIPE_TYPE_MESSAGE` at line 390.**]** |
</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 |
|---------|---------|---------|--------------|
| Winsock2 (`ws2_32.lib`) | Windows SDK (always present) | UDP socket for DDP transport (`socket(AF_INET, SOCK_DGRAM, 0)` + `sendto`); also TCP socket for `/json/info` probe at Open. | Zero-dep, ships with Windows SDK; project is Windows-only; DDP is a single-datagram protocol — no need for higher-level networking. `[CITED: STACK.md §"Fallback: DDP over UDP (WiFi path)"]` |
| WLED DDP protocol | WLED 0.15.x+ | Per-LED RGB streaming over WiFi (UDP/4048) | WLED accepts DDP natively, no firmware config required; protocol is 10 header bytes + raw RGB payload, no framing complexity. `[CITED: https://kno.wled.ge/interfaces/ddp/]` |
| WLED `/json/info` HTTP endpoint | WLED 0.15.x+ | One-shot reachability + LED-count probe at DDP `Open()` (D-07, D-08) | Returns `info.leds.count` (1..1200) — exactly what we need to confirm "connected" and learn strip length without any sync/realtime side-effect. `[VERIFIED: web fetch 2026-04-19, https://kno.wled.ge/interfaces/json-api/]` |
| Win32 SetupAPI (`setupapi.lib`) | Windows SDK | VID/PID-driven COM port enumeration (LHWD-04, D-09) | Only first-party API for device-info enumeration; `GUID_DEVINTERFACE_COMPORT` filter is the canonical lookup for serial ports; `SPDRP_HARDWAREID` returns hardware ID strings of the form `USB\VID_303A&PID_1001\xxxx`. `[CITED: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/guid-devinterface-comport; https://aticleworld.com/get-com-port-of-usb-serial-device/]` |
| Existing Phase 14 `RegisterDeviceNotification` + message-only window | Windows SDK `user32.lib` | Hotplug re-scan when in disabled state (D-11) | Already wired in `device_provider.cpp:1491` `StartHotplugWatcher` / `OnHotplugArrival`. Phase 15 only patches `OnHotplugArrival` to also re-run the SetupAPI scan if `m_backglowPort` is empty. `[VERIFIED: device_provider.cpp:1566 OnHotplugArrival]` |
| Existing `ILedTransport` interface | Phase 14 (`src/led/led_transport.h`) | Stable polymorphic seam; second concrete impl plugs in here. | Interface is already designed (Phase 14 `<deferred>` D-08a) so the second impl drops in with zero refactor. `[VERIFIED: src/led/led_transport.h:19-44]` |
| Existing `LedController` (Phase 14) | unchanged | Owns transport, writer thread, ceiling, shutdown-off. | The controller is transport-agnostic by design (`std::unique_ptr<ILedTransport>` ctor parameter). DDP plugs in unchanged. `[VERIFIED: src/led/led_controller.cpp:33-39]` |

**Version verification note:** No npm packages — pure Win32 C++17 plus the Phase 14 stack (CMake ≥ 3.20, MSVC 2022, OpenVR SDK v2.5.1, HIDAPI 0.14.0, all unchanged). Two new `target_link_libraries` entries for the `${DRIVER_NAME}` target: `ws2_32` (Winsock for DDP) and `setupapi` (VID/PID scan). Both ship with Windows SDK; nothing to install.

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| C STL `<cstdio>` `snprintf` | C++17 | Build the minimal HTTP/1.1 `GET /json/info` request line | Avoids dragging WinHTTP/WinINet (~500KB import library + complex async API) for one synchronous 4-line GET. |
| C STL `<cstring>` `strstr` / `strtoul` | C++17 | Locate `"count":` substring in `/json/info` response and parse the integer | Response parser doesn't need a real JSON library — single key lookup is one substring search + `strtoul`. Mirrors how Phase 14 parsed VRSettings JSON config (`device_provider.cpp:687` `ExtractVrPathValue`). |
| `<string>` + manual IPv4 dotted-quad parser | C++17 | Validate `backglow.ddp_host` is a well-formed IPv4 string (T-15-01 mitigation) | `inet_pton(AF_INET, ...)` from Winsock returns 1 on valid dotted quad — use that. Reject hostnames per D-05 (no DNS). `[VERIFIED: Winsock inet_pton signature]` |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Raw socket + minimal HTTP/1.1 write for `/json/info` | WinHTTP (`winhttp.lib`) | WinHTTP is the "blessed" Microsoft HTTP client and handles redirects, TLS, proxies. None of those features are needed against a LAN-local WLED HTTP/1.1 server. Adds ~50KB binary, async-callback API complexity, plus a new `target_link_libraries`. **Reject** — keep the dependency surface flat. |
| Raw socket | WinINet (`wininet.lib`) | WinINet is the IE-era HTTP client; semantically equivalent verdict to WinHTTP for our use case. Same reject. |
| Single fire-and-forget DDP packet (push=1) | DDP heartbeat / per-frame ACK | WLED DDP is fire-and-forget by design; explicitly excluded by D-07 ("after Open(), DDP frames are fire-and-forget UDP"). |
| `connect()` on UDP socket then `send()` | `sendto()` per frame | `connect()` on a UDP socket caches the destination — slightly faster but loses the option of sending probes to alternate hosts later. With 30 fps × 40 bytes the perf delta is invisible. **Use `sendto`** — simpler, matches DDP's connectionless nature. |
| Standalone `com_port_scan.h/.cpp` | Inline static helper inside `wled_serial.cpp` | Standalone makes the helper testable independently from the transport, and keeps the `wled_serial.cpp` file at its current size. **Recommend standalone** — Discretion D-17. |
| WLED `/json` (full state) for probe | WLED `/json/info` (info-only) | `/json/info` is smaller (~600 bytes vs ~3KB) and has no side-effects. Locked by D-08. |
| Separate `setupapi.lib` link line | Conditional only when ENABLE_BACKGLOW=ON | Mirrors Phase 14's pattern of linking `user32` inside the `if(ENABLE_BACKGLOW)` block. **Use the same pattern.** |

**Installation / link changes (delta on Phase 14):**

```cmake
# CMakeLists.txt — extend the existing if(ENABLE_BACKGLOW) block
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
        src/led/wled_ddp.h          # NEW (D-16)
        src/led/wled_ddp.cpp        # NEW (D-16)
        src/led/com_port_scan.h     # NEW (D-17)
        src/led/com_port_scan.cpp   # NEW (D-17)
    )
    target_compile_definitions(${DRIVER_NAME} PRIVATE ENABLE_BACKGLOW)
    target_link_libraries(${DRIVER_NAME} PRIVATE
        user32        # already present (Phase 14)
        ws2_32        # NEW — Winsock UDP/TCP for DDP transport
        setupapi      # NEW — SetupDi VID/PID enumeration
    )
endif()
```

No installer-side change. Both libs are stock Windows SDK.

## Architecture Patterns

### Project Structure (delta on Phase 14)

```
src/
├── driver/
│   └── device_provider.cpp          # MODIFIED — InitBackglow factory branch + status verb
├── led/
│   ├── led_transport.h              # UNCHANGED — Phase 14 interface absorbs DDP transport
│   ├── led_controller.h/.cpp        # UNCHANGED — transport-agnostic by design
│   ├── wled_serial.h/.cpp           # UNCHANGED (logic) — but USB now reachable via VID/PID scan when port is empty
│   ├── wled_tpm2.h/.cpp             # UNCHANGED (D-12 stays a stub)
│   ├── wled_ddp.h                   # NEW (D-16)
│   ├── wled_ddp.cpp                 # NEW (D-16)
│   ├── com_port_scan.h              # NEW (D-17)
│   └── com_port_scan.cpp            # NEW (D-17)
├── ctl/
│   └── main.cpp                     # MODIFIED — `backglow status` validation + 1024-byte response buffer
└── spike/
    └── (unchanged — no spike for Phase 15)
```

### Pattern 1: ILedTransport second implementation (mirrors `WledSerialTransport`)

**Source:** `src/led/wled_serial.cpp` is the reference shape. `WledDdpTransport` mirrors:
- `Open()` does the one-shot blocking work (TCP probe to /json/info, parse `count`, then bind UDP socket).
- `Close()` invalidates state.
- `IsOpen()` returns atomic bool.
- `SendRgbFrame()` returns false on error AND self-invalidates (`Close()`) — the `LedController::WriterThreadFunc` reopen gate then attempts reopen on the next iteration. Same contract as Phase 14.
- `SendBrightness()` and `SendPower()` are no-ops over DDP (DDP is RGB-only). Implementation should still send a minimal HTTP `POST /json/state` with body `{"bri":N}` or `{"on":bool}` — same WLED endpoint as serial, just over HTTP instead. Returns true on 200/204 response, false otherwise. **Discretion:** alternative is to implement these as no-ops returning true and rely on the per-channel ceiling clamp inside LedController to enforce safety. Recommend the HTTP-POST path since the strip needs to be `{"on":true}` for live pixels to render (Phase 14 SMOKE finding 3 — segment baseline override).

```cpp
// src/led/wled_ddp.h (sketch — see Code Examples for impl)
class WledDdpTransport : public ILedTransport {
public:
    WledDdpTransport();
    ~WledDdpTransport() override;

    // portName format: "192.168.1.42" — D-05 IPv4 only, no host:port (port locked at 4048)
    bool Open(const std::string& portName) override;
    void Close() override;
    bool IsOpen() const override;

    bool SendRgbFrame(const uint8_t* rgb, int numLeds) override;
    bool SendBrightness(uint8_t value) override;
    bool SendPower(bool on) override;

    unsigned long LastErrorCode() const override;

private:
    SOCKET m_sock;
    sockaddr_in m_dest;
    std::atomic<bool> m_bOpen;
    std::atomic<unsigned long> m_lastError;
    int m_reportedLedCount;       // populated in Open() from /json/info; clamp encoder to 10 if mismatch
    bool m_bWsaInitialized;       // we own WSAStartup/WSACleanup pairing
    uint8_t m_seq;                // DDP sequence number (1..15, wraps; 0 = "unused")
};
```

### Pattern 2: DDP packet encoder (single packet for ≤ 480 LEDs)

**Source:** `[CITED: STACK.md §"Complete 10-LED DDP Packet"]` and `[CITED: http://www.3waylabs.com/ddp/]`.

For 10 LEDs (30 bytes RGB) the entire frame fits in one UDP packet (40 bytes total — well under the 1472-byte safe MTU). No fragmentation needed in v3.0. Header byte layout:

| Byte | Value | Meaning |
|------|-------|---------|
| 0 | `0x41` | Flags: bit7..6 = `01` (version 1), bit1 = 1 (push/final = display now). All other bits 0. |
| 1 | `0x01..0x0F` | Sequence number (1..15, wraps). Optional per spec; WLED accepts. Use 0 to mean "no sequence". Recommend: increment per frame for diagnostic value (a packet capture shows ordering). |
| 2 | `0x07` | Data type: bits 2..0 = `111` (8 bits per channel - 1 = 7). Bits 7..3 reserved (0). |
| 3 | `0x00` | Source / destination ID (0 = default channel) |
| 4..7 | `0x00 0x00 0x00 0x00` | Data offset (uint32 big-endian) — 0 means "start of pixel buffer" |
| 8..9 | `0x00 0x1E` | Data length (uint16 big-endian) — 30 = `numLeds * 3` |
| 10..39 | `R0 G0 B0 ... R9 G9 B9` | 30 bytes RGB payload |

**WLED-specific quirks:**
- WLED ignores the timecode field. Do NOT set the timecode bit (bit 5 of byte 0). `[CITED: https://kno.wled.ge/interfaces/ddp/]`
- WLED does not require sequence numbers but accepts them. Sequence 0 = "unused". `[CITED: 3waylabs DDP spec]`

```cpp
// One-shot encode + sendto (impl detail in Code Examples §1)
bool WledDdpTransport::SendRgbFrame(const uint8_t* rgb, int numLeds) {
    if (!IsOpen() || !rgb) return false;
    if (numLeds <= 0) return false;
    if (numLeds > kBackglowMaxLeds) numLeds = kBackglowMaxLeds;  // D-08 clamp

    const uint16_t dataLen = static_cast<uint16_t>(numLeds * 3);
    uint8_t pkt[10 + kBackglowMaxLeds * 3];
    pkt[0] = 0x41;                                        // version=1, push=1
    pkt[1] = m_seq ? m_seq : 1; m_seq = (m_seq % 15) + 1; // 1..15 wrap
    pkt[2] = 0x07;                                        // 8 bits/channel
    pkt[3] = 0x00;                                        // default channel
    pkt[4] = 0; pkt[5] = 0; pkt[6] = 0; pkt[7] = 0;       // offset=0
    pkt[8] = static_cast<uint8_t>((dataLen >> 8) & 0xFF); // length hi
    pkt[9] = static_cast<uint8_t>(dataLen & 0xFF);        // length lo
    std::memcpy(pkt + 10, rgb, dataLen);

    int sent = ::sendto(m_sock, reinterpret_cast<const char*>(pkt), 10 + dataLen,
                        0, reinterpret_cast<sockaddr*>(&m_dest), sizeof(m_dest));
    if (sent != static_cast<int>(10 + dataLen)) {
        m_lastError.store(static_cast<unsigned long>(WSAGetLastError()));
        Close();   // self-invalidate, mirrors WledSerialTransport pattern
        return false;
    }
    m_lastError.store(0);
    return true;
}
```

### Pattern 3: One-shot HTTP/1.1 GET probe over raw socket

**Why raw socket and not WinHTTP:** WinHTTP is async-callback-heavy, depends on registered service handles, and adds ~50 KB import overhead. The probe is a 4-line `GET /json/info HTTP/1.0` request and a `find("count":")` parse. Raw socket is ~30 lines, easier to bound timeouts (use `select` for connect, `select` again for recv), and aligns with the "no library that costs more than 30 lines of hand-rolled code is worth it" principle that runs throughout STACK.md.

```cpp
// Pseudocode — full impl in Code Examples §2
static bool ProbeJsonInfo(const std::string& host, int& outLedCount,
                          unsigned long& outErr, int timeoutMs = 500) {
    // 1. Create non-blocking TCP socket
    SOCKET s = ::socket(AF_INET, SOCK_STREAM, 0);
    u_long nb = 1; ::ioctlsocket(s, FIONBIO, &nb);
    sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(80);
    ::inet_pton(AF_INET, host.c_str(), &addr.sin_addr);

    // 2. connect() returns WSAEWOULDBLOCK; select() with timeout
    ::connect(s, (sockaddr*)&addr, sizeof(addr));
    fd_set wfds; FD_ZERO(&wfds); FD_SET(s, &wfds);
    timeval tv{ timeoutMs / 1000, (timeoutMs % 1000) * 1000 };
    if (::select(0, nullptr, &wfds, nullptr, &tv) <= 0) { /* timeout */ }

    // 3. Send GET — keep it HTTP/1.0 to avoid chunked encoding
    const char* req = "GET /json/info HTTP/1.0\r\nHost: wled\r\n\r\n";
    ::send(s, req, (int)std::strlen(req), 0);

    // 4. Read response with select() timeout per recv
    char buf[2048]; int total = 0;
    /* select+recv loop, accumulate into buf */

    // 5. Find "leds":{...,"count":N,...} — substring then strtoul
    const char* p = std::strstr(buf, "\"count\":");
    if (!p) return false;
    p += 8;  // past "count":
    while (*p == ' ' || *p == '\t') ++p;
    outLedCount = static_cast<int>(std::strtoul(p, nullptr, 10));
    return true;
}
```

### Pattern 4: SetupAPI VID/PID scan (canonical 7-step sequence)

**Source:** `[CITED: STACK.md §"COM Port Detection Strategy"]`, `[CITED: https://aticleworld.com/get-com-port-of-usb-serial-device/]`, `[CITED: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/guid-devinterface-comport]`. Pattern is well-established and used in `EnumSerialPorts` reference implementations.

```
1. SetupDiGetClassDevs(&GUID_DEVINTERFACE_COMPORT, nullptr, nullptr,
                       DIGCF_PRESENT | DIGCF_DEVICEINTERFACE)
        → returns HDEVINFO
2. for each i:
       SetupDiEnumDeviceInfo(hDevInfo, i, &devInfoData)
        → returns SP_DEVINFO_DATA
3.     SetupDiGetDeviceRegistryProperty(hDevInfo, &devInfoData, SPDRP_HARDWAREID,
                                        nullptr, buf, bufSz, nullptr)
        → returns "USB\VID_303A&PID_1001\<serial>" multi-string
4.     If buf contains substring "VID_303A&PID_1001" (case-insensitive):
5.         hKey = SetupDiOpenDevRegKey(hDevInfo, &devInfoData,
                                       DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ)
6.         RegQueryValueExA(hKey, "PortName", nullptr, nullptr, comBuf, &comBufSz)
            → returns "COM5" (or similar)
7.         RegCloseKey(hKey)
       end if
   end for
8. SetupDiDestroyDeviceInfoList(hDevInfo)
```

**Multi-match policy (D-10):** accumulate every matching port string into a `std::vector<std::string>`; sort by trailing integer; pick element 0; log the others as a WARN with `backglow.com_port` pinning hint.

### Pattern 5: Multi-line pipe response (D-13)

**Existing infrastructure analysis:**
- Server (`device_provider.cpp:577`): `WriteFile(m_hPipe, response, strlen(response), &written, nullptr)` sends a single message of up to `sizeof(response)` bytes. The pipe was created with `PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE` (line 390), so each `WriteFile` is one atomic message.
- Client (`src/ctl/main.cpp:111`): `ReadFile(hPipe, response, sizeof(response) - 1, ...)` reads ONE message. Buffer is `char response[256]` — too small for a multi-line status response (~150-200 bytes is plausible, but `256-1` leaves no headroom and we want to be safe against future field additions).

**Required changes:**
1. Server-side: widen `char response[512]` (line 442) to `char response[1024]`. The WriteFile at line 577 already uses `strlen(response)` so it adapts.
2. Client-side: widen `char response[256]` to `char response[1024]` and print as-is (the `\n` line breaks are already in the message). No loop needed — message-mode reads the whole message in one call.

**This is NOT a stream-mode protocol switch.** The pipe stays MESSAGE-mode; we're just sending a longer message that happens to contain newlines. No client-side EOF detection, no bounded-timeout loop required. The CLI simply gets one bigger string back and `printf("%s\n", response)` prints it correctly.

**If response > 1024 bytes ever:** ReadFile returns `ERROR_MORE_DATA` (234) and the partial buffer; client would need to call ReadFile again to pick up the remainder. Phase 15's max status response is ~200 bytes, so 1024 is 5× headroom and avoids the multi-call complexity entirely. **Discretion:** planner could go to 2048 if VRChat-bridge metadata ends up in `status` later (Phase 16+).

### Pattern 6: Transport factory in `InitBackglow`

```cpp
// device_provider.cpp::InitBackglow (extension)
//   Reads backglow_transport (string) + backglow_ddp_host (string) alongside
//   existing backglow_brightness_ceiling + backglow_com_port.
//
// Pseudocode:
//
//   read transport ∈ {"usb","ddp","auto"}, default "usb" (Discretion: planner picks)
//   read ddp_host (may be empty)
//   read com_port (may be empty)
//   read ceiling
//
//   std::unique_ptr<ILedTransport> t;
//   std::string portArg;     // what we pass to t->Open()
//   std::string failReason;  // for D-15 err: token
//
//   if (transport == "usb" || transport == "auto") {
//       t = std::make_unique<WledSerialTransport>();
//       portArg = com_port;
//       if (portArg.empty()) {
//           auto matches = ScanForEspressifComPorts(0x303A, 0x1001);
//           if (matches.empty()) { failReason = "scan_no_match"; t.reset(); }
//           else                  { portArg = matches.front(); LogMatches(matches); }
//       }
//       // try open
//       if (t && !t->Open(portArg)) {
//           // configured port failed — try scan fallback per D-09 step 2
//           auto matches = ScanForEspressifComPorts(0x303A, 0x1001);
//           if (matches.empty()) { failReason = "scan_no_match"; t.reset(); }
//           else                  { portArg = matches.front(); if (!t->Open(portArg)) { failReason = ToErrToken(t->LastErrorCode()); t.reset(); } }
//       }
//       if (!t && transport == "auto") {
//           // fall through to DDP (D-04)
//           goto try_ddp;
//       }
//   }
//
//   if (transport == "ddp" || (!t && transport == "auto")) {
//       try_ddp:
//       if (ddp_host.empty()) { failReason = "no_ddp_host_configured"; }
//       else {
//           t = std::make_unique<WledDdpTransport>();
//           if (!t->Open(ddp_host)) { failReason = "ddp_probe_timeout"; t.reset(); }
//           else { portArg = ddp_host; }
//       }
//   }
//
//   if (!t) {
//       m_backglowDisabled = true;
//       m_backglowDisabledReason = failReason;
//       DriverLog("Backglow: disabled (reason=%s)\n", failReason.c_str());
//       // Still register hotplug watcher for USB recovery (D-11)
//       StartHotplugWatcher();
//       return;
//   }
//
//   m_pLedController = std::make_unique<LedController>(std::move(t), ceiling);
//   m_pLedController->Start(portArg);   // LedController's Start signature already takes a string
```

**Note on `LedController::Start(portName)`:** Phase 14's `LedController::Start` calls `m_transport->Open(portName)` internally. For Phase 15 we want `Open()` to have already been called (so we know the transport is good before constructing the controller). Two ways to handle this:

**Option A (recommended):** Construct controller with `portArg` already saved; let `LedController::Start` re-Open via the controller's reconnect gate. Open is idempotent on both transports (Phase 14 `WledSerialTransport::Open` calls `Close()` first; same pattern for DDP). The probe `Open()` happens once during factory selection (to know which transport works), then `Start` re-opens for the writer thread to own the handle. Slight overhead — one extra Open per startup — but architectural simplicity wins.

**Option B:** Add a hand-off seam to `LedController` that accepts an already-open transport. More code, more surface area.

**Recommend A.** Document the double-open in `InitBackglow` so future readers don't think it's a bug.

### Anti-Patterns to Avoid
- **Adding a heartbeat / keep-alive thread for DDP** — explicitly rejected by D-07. WLED's realtime mode timeout is governed by the live-frame stream itself; if RunFrame stops feeding frames, WLED reverts to baseline behavior (typically 1-2 s after last packet, per WLED docs). That's fine — we're either streaming or we're shutting down.
- **Resolving `backglow.ddp_host` via DNS** — D-05 forbids it. Reject any value that isn't a valid IPv4 dotted quad via `inet_pton`.
- **Using `getaddrinfo` for the DDP host** — same reason as above. We want the user's explicit IP and we want to fail loudly if they typo'd.
- **Hot-swapping transport at runtime** — D-02 forbids it. The choice is locked at `InitBackglow` and a SteamVR restart is required to change. Future enhancement only.
- **Using TCP for DDP frames** — DDP is UDP per spec. Connection-oriented for a 30 fps push protocol would inflate latency.
- **Setting the timecode bit (bit 5 of byte 0)** — WLED ignores timecode fields, so adding it just inflates the packet by 4 bytes and risks parser confusion. STACK.md is explicit.
- **Treating `WSAEWOULDBLOCK` as an error during connect()** — non-blocking connect returns this immediately; the `select()` on the writefd set is what tells you the connect succeeded.
- **Forgetting `WSAStartup` / `WSACleanup`** — Winsock requires init/teardown per process. Best to do `WSAStartup` lazily in `WledDdpTransport::Open` (with a guard so we only call it once across multiple opens) and `WSACleanup` in the destructor. **Discretion:** alternatively, do it in `DeviceProvider::Init` so the ws2_32 lifecycle is unambiguous; the latter is cleaner for a single-process driver where the DLL outlives the transport. **Recommend driver-level Init/Cleanup pair** — documented in Code Examples §3.
- **Skipping `Close()` on UDP `sendto` failure** — keep the contract from Phase 14: any send failure → `Close()` → next writer iteration tries reopen. Even though UDP `sendto` failures are rare on a single-NIC LAN, network adapter reset / interface down events do trigger them.
- **Using `inet_addr` instead of `inet_pton`** — `inet_addr` returns `INADDR_NONE` for both `255.255.255.255` and parse failure. `inet_pton` distinguishes via return value and is the recommended modern API.
- **Logging the `/json/info` raw response at INFO level** — it can contain WiFi SSID and other house-keeping. Log the parsed `count` field at INFO; raw response at DEBUG only, gated on the existing `log_verbosity` setting.
- **Re-running the SetupAPI scan on every `WM_DEVICECHANGE`** — D-11 says only when `m_backglowDisabled` is true. Don't disturb a working USB session.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| COM port enumeration | Iterate `\\.\COM1`..`\\.\COM256` and try `CreateFile` on each | `SetupDiGetClassDevs(GUID_DEVINTERFACE_COMPORT, …)` + VID/PID match | PITFALLS.md §"Performance Traps": brute-force enumeration triggers SetupAPI internally anyway and freezes for seconds with many virtual ports installed. SetupAPI direct is correct. |
| HTTP/1.1 GET to WLED | A full HTTP client (WinHTTP/WinINet) | 4-line raw socket `send` of `GET /json/info HTTP/1.0\r\n...\r\n\r\n` | WLED LAN target, single GET, no TLS/redirect/proxy needed. `[CITED: STACK.md "What NOT To Add"]` |
| JSON parsing for `/json/info` | nlohmann/rapidjson | `strstr("\"count\":")` + `strtoul` | Single key from a known-shape response; avoids ~200 KB of dependency for one field. Same pattern Phase 14 already uses for `openvrpaths.vrpath` (`device_provider.cpp:687`). |
| DDP packet construction | C++ DDP library | 10-byte header struct + memcpy | No mature C++ DDP library exists — STACK.md notes "ddp-rs is Rust, ddp is Go". Protocol is one struct + sendto. |
| IPv4 validation | Regex / hand parser | `inet_pton(AF_INET, ...)` from Winsock | Correct, fast, ships with the lib we're already linking. |
| WSAStartup/Cleanup pairing | Per-transport WSA init | One `WSAStartup` in `DeviceProvider::Init`, matching `WSACleanup` in `Cleanup()` | Single-process driver, single DLL load — one pair is correct. Multiple WSAStartup calls are reference-counted and work, but lifetime symmetry is cleaner at driver level. |
| HTTP response chunked decoding | A real HTTP parser | Send `GET /json/info HTTP/1.0` (not 1.1) so server sends `Connection: close` and a single Content-Length-or-EOF response | HTTP/1.0 GET is almost always non-chunked. Read until EOF / timeout, find `\r\n\r\n` body separator, parse from there. |

**Key insight:** Every "don't hand-roll" here is the inverse — we explicitly hand-roll trivial protocol code (4-line GET, 40-byte UDP packet) and refuse to take on dependencies for things that fit in ~30 lines. Same philosophy as Phase 14's serial code.

## Common Pitfalls

### Pitfall 1: Treating a successful `/json/info` probe as proof DDP frames will arrive
**What goes wrong:** TCP connect to port 80 succeeded → we mark "DDP open" and return. But port 4048 UDP traffic might still be blocked by Windows Firewall, the user's router (rare on a flat LAN), or a misconfigured WLED build with `realtime UDP` disabled.
**Why it happens:** Probing the wrong port. HTTP API on 80 ≠ DDP listener on 4048.
**How to avoid:** Two layers of mitigation: (a) Document this in `15-SMOKE.md` so the UAT explicitly verifies "after `Open()` returns true, sending `backglow fill FF0000` over DDP actually changes the LEDs". (b) The `Open()` probe is "best effort" — D-07 explicitly says the probe is for *reachability*, not full DDP confirmation. The first `SendRgbFrame` is the real proof.
**Warning signs:** Driver log "Backglow: online via DDP", but LEDs don't change on `backglow fill`. Capture with Wireshark on udp port 4048 to confirm packets are leaving.

### Pitfall 2: ESP32-C3 USB enumeration race with WLED firmware boot
**What goes wrong:** SetupAPI scan during driver init finds NO matching devices, then 800 ms later `WM_DEVICECHANGE` fires when WLED's USB CDC interface finishes coming up.
**Why it happens:** ESP32-C3 powers up its native USB peripheral after WLED's `setup()` completes — there's a window of ~300-1500 ms after physical USB plug where the device is enumerated but the CDC sub-interface isn't yet ready.
**How to avoid:** D-11 already handles this — the hotplug arrival re-runs the scan when in disabled state. Phase 14's existing `Sleep(500)` debounce in `OnHotplugArrival` (`device_provider.cpp:1570`) gives the CDC sub-interface time to register. Phase 15 should reuse that same 500 ms debounce.
**Warning signs:** Driver log shows `Backglow: scan_no_match` immediately, then `Backglow: hotplug re-init succeeded on COMxx` within 1-3 seconds. Normal — not an error.

### Pitfall 3: Stale `m_seq` after Close/reopen
**What goes wrong:** DDP sequence number in `WledDdpTransport` is a member variable. After `Close()` + `Open()`, sequence continues from where it left off (e.g. mid-stream at seq=7). WLED accepts this, but a packet capture or downstream consumer expecting "session restart" semantics would be confused.
**Why it happens:** Member init only happens once at construction.
**How to avoid:** Reset `m_seq = 1` in `Open()`. Trivial. Also document that DDP sequence numbers are per-session, not per-transport-instance.

### Pitfall 4: `/json/info` response > 2 KB on heavily-customized WLED builds
**What goes wrong:** A user with a long device name, many segments, 50 effects, or a custom `info` field can have `/json/info` exceed 2 KB. Our 2048-byte recv buffer truncates, and the `"count":` substring may live in the truncated tail.
**Why it happens:** WLED's info object grows with installed effects/segments/usermods.
**How to avoid:** Size the recv buffer at 4096 bytes, OR loop `recv` until EOF (HTTP/1.0 server closes after sending). The loop is correct and unbounded by buffer size — recommend it. Cap total bytes at 16 KB to avoid pathological responses; bail if exceeded.
**Warning signs:** Response received but `count` parse fails with `outLedCount = 0`. Add a DEBUG log of "received N bytes, count not found" to surface this.

### Pitfall 5: Windows Firewall prompt on first DDP send
**What goes wrong:** First time the driver `sendto`s on UDP port 4048, Windows Firewall MAY pop a "Allow access?" dialog if vrserver.exe (which loaded our DLL) doesn't have an existing outbound UDP rule.
**Why it happens:** Windows Firewall's default behavior for outbound UDP from a previously-untrusted process.
**How to avoid:** In practice, vrserver.exe runs as Administrator (SteamVR launches it elevated) and has broad outbound permissions; the firewall prompt is unlikely. If it does happen, document in `15-SMOKE.md` that the user must "Allow access" the first time. Long-term: the installer (Phase 9) could pre-register a firewall rule, but that's out of scope.
**Warning signs:** First DDP send appears to succeed (sendto returns 40) but no LED change. User reports "I saw a Windows popup".

### Pitfall 6: Multi-line `status` response truncated by 256-byte CLI buffer
**What goes wrong:** Phase 14 `beyond_prox_ctl.exe` reads up to 255 bytes. Phase 15 `status` response is ~150 bytes nominal but could grow to 250+ with the `err:` line. ReadFile returns `ERROR_MORE_DATA` (234) and the partial buffer.
**Why it happens:** Insufficient buffer in client.
**How to avoid:** Widen client buffer to 1024 bytes (Pattern 5). Server-side response buffer also widened. Both done in this phase.
**Warning signs:** CLI prints partial status (cuts off mid-line). Test by inducing the error path (set `transport=ddp ddp_host=invalid_ip`).

### Pitfall 7: Calling SetupAPI during DLL_PROCESS_DETACH
**What goes wrong:** SetupAPI loads `setupapi.dll` which loads further dependencies (cfgmgr32.dll, etc.). Calling these during `DllMain(DLL_PROCESS_DETACH)` is unsafe (loader lock).
**Why it happens:** A poorly-placed `SetupDi*` call during `DeviceProvider::Cleanup()` could trip this if Cleanup is called late in DLL teardown.
**How to avoid:** Don't call SetupAPI from `Cleanup()`. The scan only needs to run from `InitBackglow` and `OnHotplugArrival`, both of which happen at safe points (driver Init thread / hotplug watcher thread). Phase 14's existing teardown order already has `StopHotplugWatcher()` join the thread before the DLL exits, so we're safe.

### Pitfall 8: WLED first-frame quirk carryover (Phase 14 SMOKE follow-up)
**What goes wrong:** Phase 14 SMOKE found WLED 0.15.0 silently drops the first Adalight frame after idle, requiring a double-send with 20 ms gap. Does the same quirk apply to DDP?
**Why it happens:** WLED's realtime state machine appears to consume the first incoming packet to switch into realtime mode without rendering it.
**How to avoid:** Mirror the Phase 14 mitigation in `WledDdpTransport::SendRgbFrame` — double-send with 20 ms gap. Verify in 15-SMOKE.md whether DDP exhibits the same behavior. **If DDP does NOT need it:** still keep the double-send for parity with USB so the two transports are visually identical to the user.
**Warning signs:** Sending `backglow fill FF0000` once shows no LED change; sending again immediately works. Same symptom Phase 14 found.
**Reference:** `src/led/wled_serial.cpp:228-234` — Phase 14's mitigation.

### Pitfall 9: WLED segment baseline overrides realtime pixels (Phase 14 SMOKE follow-up)
**What goes wrong:** A coloured WLED segment baseline (e.g. shipped MagWLED-1 default blue) overrides realtime DDP pixels even when `if.live.mso=true` is set.
**Why it happens:** Per Phase 14 SMOKE Followups: "The driver does not currently force a black/off segment at init. Phase 15 should POST `{"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]}` at driver init to guarantee realtime visibility, or document the device-side prerequisite."
**How to avoid:** **Discretion** — either:
- (a) On `Open()` (both transports), POST `/json/state` with the segment-clear payload above. Mirrors what Phase 14's SMOKE recommended. Adds one HTTP write at startup.
- (b) Document the device-side prerequisite in 15-SMOKE.md (user runs the WLED web UI's "Solid color = black" once).
**Recommend (a)** — drop-once into the same probe path as `/json/info`. The user shouldn't have to know about WLED segments. **The same fix benefits the USB transport** (which already exhibited this in Phase 14 UAT) and should be unified.
**Warning signs:** `backglow fill FF0000` returns OK, log shows `live=True`, but LEDs stay blue/baseline. Confirmed in Phase 14 SMOKE.

## Runtime State Inventory

Phase 15 adds two new VRSettings keys, no new persistent state files, no databases. The phase IS partly net-new (DDP transport, COM scan helper) and partly an extension of Phase 14 wiring. Carrying the Phase 14 disclosure forward — physical LED state is hardware-side and behaves like out-of-process state.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None — no databases or state files owned by Phase 15. New VRSettings keys (`backglow_transport`, `backglow_ddp_host`) live in `steamvr.vrsettings`, managed by SteamVR. | None |
| Live service config | **WLED ESP32-C3 firmware:** Sync Settings → Realtime modes → must allow DDP (default-on in stock WLED). Unverified for non-stock builds. **Segment baseline (Phase 14 SMOKE finding):** if active, baseline color overrides realtime pixels — Pitfall 9 above documents the fix. | Pitfall 9 mitigation (POST `{"seg":[...]}` at Open). |
| OS-registered state | **COM port mapping** in Windows Device Manager — managed by Windows, not us. Phase 15 *reads* this state via SetupAPI (one-time at init + on hotplug). | None — read-only consumer. |
| Secrets / env vars | None | None |
| Build artifacts | `driver_BeyondProximity.dll` gains `wled_ddp.obj`, `com_port_scan.obj` under `ENABLE_BACKGLOW`. Two new linked Windows SDK libraries (`ws2_32.lib`, `setupapi.lib`) — no new files in repo. Installer (Phase 9 Inno Setup) requires no new files; everything ships inside the driver DLL. | `cmake --build` rebuilds cleanly; no installer change. |
| **Physical LED state (non-standard)** | **WS2812B latches** — same caveat as Phase 14: WS2812B retain last set color until power-cycle or explicit zero-write. DDP path inherits the same `Cleanup()` / `ShutdownAllOff` flow as USB; final off-frame goes via whichever transport is active. | None — handled by inherited `LedController::ShutdownAllOff` (sends black frame + `{"on":false}` if SendPower implemented). Confirm DDP path actually does both in 15-SMOKE.md. |

## Code Examples

### 1. DDP packet encode + sendto (Pattern 2 applied)

```cpp
// src/led/wled_ddp.cpp (encoder + sendto, full impl)
// [CITED: STACK.md §"Complete 10-LED DDP Packet"; http://www.3waylabs.com/ddp/]
bool WledDdpTransport::SendRgbFrame(const uint8_t* rgb, int numLeds) {
    if (!m_bOpen.load() || m_sock == INVALID_SOCKET) {
        m_lastError.store(WSAEINVAL);
        return false;
    }
    if (numLeds <= 0 || !rgb) return false;
    if (numLeds > kBackglowMaxLeds) numLeds = kBackglowMaxLeds;  // D-08 clamp

    const uint16_t dataLen = static_cast<uint16_t>(numLeds * 3);
    uint8_t pkt[10 + kBackglowMaxLeds * 3];

    pkt[0] = 0x41;                                          // ver=1, push=1
    pkt[1] = m_seq; m_seq = (m_seq % 15) + 1;               // 1..15 wrap
    pkt[2] = 0x07;                                          // 8 bits/channel
    pkt[3] = 0x00;                                          // default channel
    pkt[4] = 0; pkt[5] = 0; pkt[6] = 0; pkt[7] = 0;         // offset = 0
    pkt[8] = static_cast<uint8_t>((dataLen >> 8) & 0xFF);   // length hi (BE)
    pkt[9] = static_cast<uint8_t>(dataLen & 0xFF);          // length lo (BE)
    std::memcpy(pkt + 10, rgb, dataLen);

    const int total = 10 + dataLen;
    int sent = ::sendto(m_sock, reinterpret_cast<const char*>(pkt), total, 0,
                        reinterpret_cast<const sockaddr*>(&m_dest), sizeof(m_dest));
    if (sent != total) {
        m_lastError.store(static_cast<unsigned long>(WSAGetLastError()));
        Close();   // self-invalidate; mirrors WledSerialTransport contract
        return false;
    }

    // Pitfall 8: mirror Phase 14 double-send for first-frame quirk safety.
    // Adds 20 ms latency on every frame but guarantees parity with USB.
    std::this_thread::sleep_for(std::chrono::milliseconds(20));
    sent = ::sendto(m_sock, reinterpret_cast<const char*>(pkt), total, 0,
                    reinterpret_cast<const sockaddr*>(&m_dest), sizeof(m_dest));
    if (sent != total) {
        m_lastError.store(static_cast<unsigned long>(WSAGetLastError()));
        Close();
        return false;
    }

    m_lastError.store(0);
    return true;
}
```

### 2. `Open()` — UDP setup + /json/info probe + segment-clear POST

```cpp
// src/led/wled_ddp.cpp (Open — orchestrates probe, parse, bind)
// [CITED: kno.wled.ge/interfaces/json-api/ for info.leds.count]
bool WledDdpTransport::Open(const std::string& host) {
    Close();

    // T-15-01: validate host as IPv4 dotted quad. No DNS per D-05.
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(80);
    if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
        m_lastError.store(WSAEINVAL);
        return false;
    }

    // 1. HTTP probe → /json/info → "leds":{...,"count":N,...}
    int ledCount = 0; unsigned long probeErr = 0;
    if (!ProbeJsonInfo(host, ledCount, probeErr, /*timeoutMs*/ 500)) {
        m_lastError.store(probeErr);
        return false;
    }
    m_reportedLedCount = ledCount;
    if (ledCount != kBackglowMaxLeds) {
        // D-08 — log WARN, encoder will clamp to 10 anyway. (DriverLog
        // happens at the DeviceProvider layer; we surface via a member or
        // an out-param the caller can log.)
    }

    // 2. (Pitfall 9) one-shot POST /json/state to clear segment baseline so
    //    realtime DDP pixels render. Body: {"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]}
    //    Best-effort — failure here does NOT fail Open.
    PostJsonState(host, R"({"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]})", /*timeoutMs*/ 500);

    // 3. Bind UDP socket for DDP frames; cache the dest sockaddr for sendto.
    m_sock = ::socket(AF_INET, SOCK_DGRAM, 0);
    if (m_sock == INVALID_SOCKET) {
        m_lastError.store(WSAGetLastError());
        return false;
    }
    addr.sin_port = htons(4048);     // D-06 fixed
    m_dest = addr;
    m_seq = 1;                       // Pitfall 3: reset per-Open

    m_bOpen.store(true);
    m_lastError.store(0);
    return true;
}
```

### 3. WSAStartup/Cleanup pairing in DeviceProvider (recommended)

```cpp
// device_provider.cpp::Init (extension)
#ifdef ENABLE_BACKGLOW
    WSADATA wsa{};
    if (::WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
        DriverLog("Backglow: WSAStartup failed (err=%d) — DDP transport disabled\n",
                  WSAGetLastError());
        m_bWsaInitialized = false;
    } else {
        m_bWsaInitialized = true;
    }
    InitBackglow();
#endif

// device_provider.cpp::Cleanup (extension — order matters)
#ifdef ENABLE_BACKGLOW
    if (m_pLedController) { m_pLedController->ShutdownAllOff(); m_pLedController.reset(); }
    StopHotplugWatcher();
    if (m_bWsaInitialized) { ::WSACleanup(); m_bWsaInitialized = false; }
#endif
```

### 4. SetupAPI VID/PID scan (Pattern 4 applied)

```cpp
// src/led/com_port_scan.cpp (full helper)
// [CITED: STACK.md §"COM Port Detection Strategy"; aticleworld.com SetupAPI pattern]
#include <windows.h>
#include <setupapi.h>
#include <initguid.h>
#include <devguid.h>          // GUID_DEVCLASS_PORTS
#include <Ntddser.h>          // GUID_DEVINTERFACE_COMPORT
#include <vector>
#include <string>
#include <algorithm>
#include <cstdio>

std::vector<std::string> ScanForUsbComPorts(uint16_t vid, uint16_t pid) {
    std::vector<std::string> results;

    HDEVINFO hDevInfo = SetupDiGetClassDevsA(
        &GUID_DEVINTERFACE_COMPORT, nullptr, nullptr,
        DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
    if (hDevInfo == INVALID_HANDLE_VALUE) return results;

    // Build the substring we're looking for, e.g. "VID_303A&PID_1001".
    char wantHwId[32];
    std::snprintf(wantHwId, sizeof(wantHwId), "VID_%04X&PID_%04X", vid, pid);

    SP_DEVINFO_DATA devInfo{};
    devInfo.cbSize = sizeof(devInfo);

    for (DWORD i = 0; SetupDiEnumDeviceInfo(hDevInfo, i, &devInfo); ++i) {
        // SPDRP_HARDWAREID returns a multi-string ("USB\VID_303A&PID_1001\xxxx\0"),
        // case mixed. We do a case-insensitive substring scan.
        char hwBuf[256] = {0};
        DWORD hwSz = 0;
        if (!SetupDiGetDeviceRegistryPropertyA(
                hDevInfo, &devInfo, SPDRP_HARDWAREID, nullptr,
                reinterpret_cast<PBYTE>(hwBuf), sizeof(hwBuf), &hwSz)) {
            continue;
        }
        // Uppercase compare on the substring we want.
        std::string upper(hwBuf, hwSz ? hwSz : std::strlen(hwBuf));
        std::transform(upper.begin(), upper.end(), upper.begin(),
                       [](unsigned char c){ return (char)std::toupper(c); });
        if (upper.find(wantHwId) == std::string::npos) continue;

        // Open per-device registry key and read PortName.
        HKEY hKey = SetupDiOpenDevRegKey(
            hDevInfo, &devInfo, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ);
        if (hKey == INVALID_HANDLE_VALUE) continue;

        char portBuf[16] = {0};
        DWORD portSz = sizeof(portBuf);
        DWORD valType = 0;
        LSTATUS rc = RegQueryValueExA(hKey, "PortName", nullptr, &valType,
                                      reinterpret_cast<LPBYTE>(portBuf), &portSz);
        RegCloseKey(hKey);

        if (rc == ERROR_SUCCESS && portBuf[0]) {
            results.emplace_back(portBuf);    // "COM5"
        }
    }
    SetupDiDestroyDeviceInfoList(hDevInfo);

    // D-10: lowest COM number wins → sort by trailing integer.
    std::sort(results.begin(), results.end(),
        [](const std::string& a, const std::string& b) {
            int na = std::atoi(a.c_str() + 3);   // skip "COM"
            int nb = std::atoi(b.c_str() + 3);
            return na < nb;
        });

    return results;
}
```

### 5. Multi-line `status` response (Pattern 5 applied)

```cpp
// device_provider.cpp::HandleBackglowCommand (new branch)
// Output format per D-13/D-14/D-15. Always emit `transport`, `conn`,
// `bri`, `ceiling`, `leds`. Emit `port:` xor `host:` based on transport.
// Append `err:` line iff conn ∈ {disabled, reconnecting}.
if (std::strcmp(verb, "status") == 0) {
    const char* tStr = (m_backglowTransport == BackglowXp::USB) ? "usb" : "ddp";
    const char* cStr = "open";
    int s = m_pLedController ? m_pLedController->GetConnectionState() : 0;
    if (m_backglowDisabled || s == 0) cStr = "disabled";
    else if (s == 2)                   cStr = "reconnecting";

    int n = std::snprintf(response, responseSize,
        "transport: %s\n"
        "conn:      %s\n"
        "%s:      %s\n"
        "bri:       %u\n"
        "ceiling:   %u\n"
        "leds:      %d\n",
        tStr, cStr,
        (m_backglowTransport == BackglowXp::USB) ? "port" : "host",
        (m_backglowTransport == BackglowXp::USB) ? m_backglowPort.c_str()
                                                 : m_backglowDdpHost.c_str(),
        (unsigned)m_lastReportedBri, (unsigned)m_backglowCeiling,
        kBackglowMaxLeds);

    if ((!std::strcmp(cStr, "disabled") || !std::strcmp(cStr, "reconnecting")) &&
        !m_backglowDisabledReason.empty() && n > 0 &&
        (size_t)n < responseSize) {
        std::snprintf(response + n, responseSize - (size_t)n,
                      "err:       %s\n", m_backglowDisabledReason.c_str());
    }
    return;
}
```

**Sample healthy USB output (~110 bytes):**
```
transport: usb
conn:      open
port:      COM5
bri:       50
ceiling:   50
leds:      10
```

**Sample disabled output (~150 bytes):**
```
transport: usb
conn:      disabled
port:      
bri:       0
ceiling:   50
leds:      10
err:       scan_no_match
```

### 6. CLI client buffer widening + `status` validation

```cpp
// src/ctl/main.cpp (deltas)
// 1. Add status to the validCommand check
bool validCommand =
    /* ...existing... */
    || strcmp(command, "backglow status") == 0;

// 2. Widen response buffer
char response[1024] = {0};   // was 256
DWORD bytesRead = 0;
if (ReadFile(hPipe, response, sizeof(response) - 1, &bytesRead, nullptr) && bytesRead > 0) {
    response[bytesRead] = '\0';
    fputs(response, stdout);  // already contains \n line breaks for multi-line
}
```

### 7. VRSettings reads (D-18) — extension to existing InitBackglow

```cpp
// device_provider.cpp::InitBackglow (top of function, alongside existing reads)
// New keys:
char tBuf[16] = {0};
vr::VRSettings()->GetString(kSettingsSection, "backglow_transport",
    tBuf, sizeof(tBuf), &sErr);
std::string transportStr = (sErr == vr::VRSettingsError_None && tBuf[0])
                            ? std::string(tBuf) : std::string("usb");  // Discretion default

char hBuf[64] = {0};
vr::VRSettings()->GetString(kSettingsSection, "backglow_ddp_host",
    hBuf, sizeof(hBuf), &sErr);
m_backglowDdpHost = (sErr == vr::VRSettingsError_None) ? std::string(hBuf) : std::string();

if      (transportStr == "ddp")  m_backglowTransport = BackglowXp::DDP;
else if (transportStr == "auto") m_backglowTransport = BackglowXp::AUTO;
else                              m_backglowTransport = BackglowXp::USB;

DriverLog("Backglow: transport=%s ddp_host='%s' ceiling=%d com_port='%s'\n",
          transportStr.c_str(), m_backglowDdpHost.c_str(),
          m_backglowCeiling, m_backglowPort.c_str());
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| WLED UDP "WARLS"/"DRGB"/"DNRGB" with timeout-byte protocols | DDP for streaming | WLED 0.10+ | DDP doesn't have a timeout byte; it relies on the WLED-server-side realtime timeout (default 2.5 s). Simpler header. |
| Brute-force `CreateFile("\\.\COM1")` enumeration | SetupAPI `GUID_DEVINTERFACE_COMPORT` enumeration | Mid-2010s for Win10 hotplug correctness | Avoids 2-3 second freezes when many virtual COM ports are installed (PITFALLS.md §"Performance Traps"). |
| `inet_addr` for IPv4 parsing | `inet_pton` | Vista era | `inet_addr` returns `INADDR_NONE` on both `255.255.255.255` and parse failure — ambiguous. `inet_pton` returns 0/-1/1 distinctly. |

**Deprecated / outdated in upstream research vs. CONTEXT.md:**
- ARCHITECTURE.md mentions `src/driver/ddp_transport.cpp/h` as the DDP file path → **superseded** by D-16 which puts DDP under `src/led/wled_ddp.{h,cpp}` (matches Phase 14's actual `src/led/` layout).
- STACK.md §"COM Port Detection Strategy" mentions sending `'v'` byte to verify WLED — **NOT required** by Phase 15 D-09. Multi-match policy is "lowest COM number wins" (D-10), not "probe each one". The `'v'` round-trip remains a Phase 14 spike artifact and can be removed from the playbook.
- PITFALLS.md §"WiFi unreliability" cautions against WiFi-first — **applies** but Phase 15 design respects it (USB is the default; DDP is opt-in).

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | WLED `/json/info` response always contains `"count":` somewhere within the first 4 KB. | Pattern 3, Pitfall 4 | LOW — verified by web fetch; the field is part of the documented schema for all WLED 0.10+. Mitigated by sized recv loop. |
| A2 | WLED accepts DDP packets with `seq=1..15` and `seq=0` interchangeably; sequence numbers are advisory. | Pattern 2 | LOW — 3waylabs spec says seq 0 = unused, 1..15 = active; no ordering enforcement at packet level. |
| A3 | First-frame quirk observed in Phase 14 SMOKE applies to DDP too, requiring double-send. | Pitfall 8 | MEDIUM — unverified. UAT in 15-SMOKE.md must confirm or refute. If DDP is fine, double-send is wasted bandwidth (~75 B/s extra at 30 fps); if it does need it, parity with USB is preserved. **Recommendation:** keep the double-send, measure, drop it if SMOKE shows it's unnecessary. |
| A4 | `SetupDiGetClassDevs(&GUID_DEVINTERFACE_COMPORT, …, DIGCF_PRESENT \| DIGCF_DEVICEINTERFACE)` returns ESP32-C3 USB-CDC-ACM ports on Windows 10/11. | Pattern 4 | LOW — `GUID_DEVINTERFACE_COMPORT` is THE canonical interface for "anything that exposes a COM port on this machine". Verified by Phase 14's existing hotplug filter using the same GUID and successfully receiving notifications for the MagWLED-1. |
| A5 | `RegQueryValueExA(hKey, "PortName", ...)` returns the COM port string ("COM5") for a Ports-class device. | Pattern 4 | LOW — canonical pattern documented by aticleworld + EnumSerialPorts reference impl. |
| A6 | `SPDRP_HARDWAREID` returns a multi-string buffer; first NUL-terminated string is enough for VID/PID substring search. | Pattern 4 | LOW — `wcsstr`/`strstr` against the buffer treats the first string only (terminates at first `\0`). The VID/PID substring lives in the first hardware ID for USB devices. |
| A7 | Windows Firewall does NOT block outbound UDP from vrserver.exe by default when SteamVR is run as Administrator. | Pitfall 5 | MEDIUM — true on most user setups but not all. UAT must include "if you see a firewall popup, click Allow". |
| A8 | WLED's segment baseline override behavior (Phase 14 SMOKE finding) is fixed by `POST /json/state {"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]}`. | Pitfall 9 | MEDIUM — Phase 14 SMOKE recommended this fix but did NOT implement or test it. Phase 15 verifies in 15-SMOKE.md. |
| A9 | `LedController::Start(portName)` re-Open after factory probe-Open is harmless. | Pattern 6 Option A | LOW — Phase 14 `WledSerialTransport::Open` calls `Close()` first; idempotent. New DDP transport must follow the same convention. |
| A10 | `ProbeJsonInfo` running for 500 ms inside `DeviceProvider::Init` is acceptable to SteamVR's driver-init budget. | D-07 default timeout | LOW — Phase 14 already does a 150 ms post-open delay in `WledSerialTransport::Open` synchronously during InitBackglow with no SteamVR complaints. 500 ms one-shot is well within the per-driver init budget (vrserver typically tolerates several seconds). |

## Open Questions

1. **Default value of `backglow_transport`.**
   - What we know: Discretion item in CONTEXT.md.
   - What's unclear: Whether default should be `usb` (preserve Phase 14 behavior — most users have the cable still plugged in) or `auto` (zero-config win).
   - Recommendation: **`usb`** as the default. Predictable, matches existing user expectations, and the user who wants WiFi already has to set `backglow_ddp_host` anyway — so they'll set `backglow_transport` at the same time.

2. **Whether `SendBrightness` / `SendPower` for DDP transport are no-ops or HTTP POSTs.**
   - What we know: DDP itself is RGB only.
   - What's unclear: Practical impact. WLED's master `bri` interacts with realtime pixels (multiplied per channel), and the segment-baseline finding (Pitfall 9) shows `{"on":true}` matters too.
   - Recommendation: **Implement both as HTTP POST `/json/state`** with the appropriate body. Mirrors Phase 14 USB JSON-over-serial behavior. Latency cost is ~50 ms once per command, only on `bri`/`off`/`on` events (not per frame). 15-SMOKE.md verifies.

3. **`com_port_scan` location: standalone or inlined.**
   - What we know: Discretion item in CONTEXT.md.
   - What's unclear: Whether the helper has any non-Phase-15 callers.
   - Recommendation: **Standalone** (`src/led/com_port_scan.h/.cpp`). Keeps the file inventory grep-able; allows a future Phase 16+ to enumerate other USB devices (e.g. an alternate LED MCU) using the same helper with a different VID/PID; allows independent testing.

4. **Should `OnHotplugArrival` re-run the SetupAPI scan, or only re-Open the cached `m_backglowPort`?**
   - What we know: D-11 says re-scan when in disabled state.
   - What's unclear: Phase 14 stores `m_backglowPort` from VRSettings + uses it in `OnHotplugArrival` to call `m_pLedController->Start(m_backglowPort)`. If the user's VRSettings port is empty AND we previously disabled because of `scan_no_match`, the hotplug arrival should re-run the scan (not just retry an empty port).
   - Recommendation: In `OnHotplugArrival`, if `m_backglowTransport == USB` and `m_backglowDisabled`, re-run the scan, update `m_backglowPort` if found, then call `m_pLedController->Start(m_backglowPort)`. If transport is DDP, the hotplug notification doesn't apply (ignore).

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| MSVC 2022 + cmake.exe | Build | ✓ | CMake path in CLAUDE.md | — |
| Windows SDK (Winsock, SetupAPI, dbt.h, Ntddser.h) | Build + runtime | ✓ | Ships with MSVC 2022 | — |
| `ws2_32.lib`, `setupapi.lib` | Link | ✓ | Stock Windows SDK | — |
| Existing Phase 14 infra (LedController, hotplug watcher, ENABLE_BACKGLOW) | Build | ✓ | shipped 2026-04-19 | — |
| MagWLED-1 hardware (USB UAT) | 15-SMOKE.md USB transport verification | ✓ on COM11 | WLED 0.15.0 "Kösen" (10 LEDs configured) | None — same hardware as Phase 14. |
| MagWLED-1 hardware on WiFi (DDP UAT) | 15-SMOKE.md DDP transport verification | NEEDS USER CONFIRMATION — Phase 14 only tested USB. User must (a) provision WLED with WiFi creds via WLED web UI, (b) note the assigned IP (DHCP from local router or static), (c) provide that IP for `backglow_ddp_host`. | 2.4 GHz / 5 GHz LAN, IPv4 | If user can't provision WiFi: only the USB+VID/PID+`status` portions of Phase 15 are verifiable. DDP transport ships behind `transport=ddp` and must be verified later. **This is a phase-level blocker if not resolvable.** |
| LAN reachability between dev PC and MagWLED-1 | DDP UAT | Same LAN segment (no firewall between dev PC and ESP32) | — | Verifiable with `ping <ddp_host>`. |
| Camera stream for agent UAT (vdo.ninja) | Optional visual verification | User can enable on demand: https://vdo.ninja/?view=JYMW97gq | — | User visual confirmation verbally / screenshot. |

**Missing dependencies with fallback:**
- WiFi-provisioned MagWLED-1: if not pre-configured, USB-only verification works for LHWD-04 + DIAG-01 + transport=usb path; DDP path defers to a follow-up SMOKE iteration.

**Missing dependencies, blocking:**
- For full success-criterion 1 (DDP frames drive LEDs): MagWLED-1 must be on WiFi with a known IPv4. **Planner should explicitly call out this prerequisite** in 15-SMOKE.md and ask the user to provision WiFi before UAT.

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | **None existing** — same as Phase 14. Project has no unit-test harness; manual + hardware-visible verification is the established pattern (verified by `grep -i test` returning zero matches against `CMakeLists.txt`, and 14 prior phases shipped this way). |
| 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` — successful build = "transport classes compile against ILedTransport, no symbol errors". |
| Full suite command | Build + hardware smoke per criterion (15-SMOKE.md). |
| Phase gate | All 4 success criteria from ROADMAP.md §Phase 15 pass on real hardware. `15-VERIFICATION.md` records evidence. |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| LHWD-04 (scan) | SetupAPI VID/PID scan finds COM port without `backglow_com_port` set | hardware-visible smoke + log inspection | Clear `backglow_com_port` in steamvr.vrsettings; restart SteamVR; log shows `Backglow: scanned VID_303A&PID_1001 → COM11` and `Backglow: online on COM11`. Then `beyond_prox_ctl.exe "backglow fill FF0000"` → LEDs red. | N/A — manual |
| LHWD-04 (multi-match) | Scan WARN log lists multiple matches if user has another ESP32 attached | hardware-visible (requires 2nd ESP32) — **Discretion**: skip if not feasible | N/A | N/A — manual |
| LHWD-04 (hotplug rescan) | Unplug → log shows reconnect attempts; replug → scan re-runs and finds port | hardware-visible | `beyond_prox_ctl.exe "backglow status"` → conn=reconnecting, then re-plug → conn=open within ~3 s | N/A — manual |
| TRNS-02 (DDP frames) | DDP transport sends pixel data over UDP/4048 to WLED IP | hardware-visible smoke | Set `backglow_transport=ddp`, `backglow_ddp_host=<IP>`; restart SteamVR; `beyond_prox_ctl.exe "backglow fill 00FF00"` → 10 green | N/A — manual + Wireshark optional for packet capture |
| TRNS-02 (probe failure → degraded) | Bad `backglow_ddp_host` → degraded mode w/ err token | hardware-visible smoke + log + status | Set `backglow_ddp_host=192.0.2.1` (TEST-NET-1); `backglow status` → `conn: disabled` + `err: ddp_probe_timeout` | N/A — manual |
| TRNS-03 (selection) | `backglow_transport` value drives the chosen impl | log inspection | For each of `usb`, `ddp`, `auto`: restart SteamVR and confirm log line `Backglow: transport=<value>` and `online on <port|host>` | N/A — manual + log grep |
| TRNS-03 (auto fallback) | `auto` with USB unplugged falls through to DDP | hardware-visible smoke | Unplug USB; set `backglow_transport=auto`; ensure `ddp_host` is configured; restart SteamVR; expect `online on <ddp_host>` | N/A — manual |
| DIAG-01 (status fields) | All required fields present per D-14 | CLI inspection | `beyond_prox_ctl.exe "backglow status"` → multi-line output containing `transport:`, `conn:`, `port:` or `host:`, `bri:`, `ceiling:`, `leds:` | N/A — manual |
| DIAG-01 (err token) | Error token appears when disabled | CLI inspection | Force degraded (bad config), `backglow status` → `err:` line present with token | N/A — manual |
| DIAG-01 (multi-line OK) | CLI client doesn't truncate | CLI inspection | Verify CLI prints all lines without `ERROR_MORE_DATA` | N/A — manual |
| Pure-function: SetupAPI scan with mock device list | DICT-style unit | (none — no test framework) | A future Wave-0 "harness" exe (mirrors Phase 14 spike pattern) could feed mock SP_DEVINFO_DATA into the parser. **Discretion** — recommend skipping unless time permits. | No |
| Pure-function: DDP packet encoder | Compute over fixed input | (none) | Could add to a tiny `src/spike/` harness if desired. **Discretion** — skip unless wave plan calls for it. | No |

### Sampling Rate

- **Per task commit:** `cmake --build build --config Release` (≤ 30 s incremental). Phase 14 spike cadence sets the precedent — pure-function sanity checks (DDP encoder, COM scan parser) can go into `src/spike/backglow_15_spike/harness.cpp` if a wave needs it. No new test framework introduced.
- **Per wave merge:** Full build + hardware smoke for each requirement above (USB scan, DDP send, transport selection, status output).
- **Phase gate:** Complete hardware UAT covering all 4 ROADMAP §Phase 15 success criteria against MagWLED-1 in both USB and WiFi modes; results recorded in `15-VERIFICATION.md` and `15-SMOKE.md`.

### Wave 0 Gaps

- [ ] **No unit test framework exists** — same as Phase 14. Recommend status quo: pure-function sanity checks stay inline in any throwaway spike harness, no Catch2/doctest introduced.
- [ ] **No mock ILedTransport** — not needed for Phase 15 (the new DDP impl is itself the new transport; the LedController/transport seam was already exercised by Phase 14). A future MockLedTransport would be useful for Phase 16 OSC bridge tests but is out of Phase 15 scope.
- [ ] **No CI** — local build + manual smoke is the codebase-canonical workflow; carrying forward.

*Nyquist validation note: `workflow.nyquist_validation: true` (per .planning/config.json). Every Phase 15 requirement maps to a concrete verification command/procedure even though the project has no automated test framework — manual + hardware-visible smoke is the verification modality of record for this codebase, consistent with all 14 shipped phases.*

## Security Domain

> Driver runs in vrserver.exe (single user-elevated host process). Threat surface is small: VRSettings strings (user-controlled) flowing into Win32 APIs, a UDP send to a user-supplied IP, and a TCP probe to that same IP.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | No auth surface — the driver is local IPC + LAN UDP. WLED itself has no auth. |
| V3 Session Management | no | N/A |
| V4 Access Control | no | All callers are already in-process (vrserver) or local-named-pipe-only (CLI). |
| V5 Input Validation | yes | (a) `backglow_ddp_host` validated as IPv4 dotted quad via `inet_pton` — reject hostnames, paths, embedded whitespace. (b) `backglow_com_port` already validated by `WledSerialTransport::IsValidComName` (Phase 14). (c) `backglow_transport` whitelisted to {`usb`,`ddp`,`auto`}, default `usb` on unknown. (d) `backglow status` parser already in place; new verb routes through existing strncmp dispatch. |
| V6 Cryptography | no | DDP/UDP is plaintext on LAN by design. WLED has no TLS. |
| V8 Data Protection | partial | The `/json/info` response can include WiFi SSID and other house-keeping. Phase 15 logs only the parsed `count` at INFO level; raw response goes to DEBUG only when `log_verbosity=1` (Phase 14 setting). Documented in Pitfall 4 / Pattern 3. |

### Known Threat Patterns for {Win32 driver, LAN UDP, SetupAPI, CLI pipe}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| **T-15-01:** Path injection via `backglow_ddp_host` (e.g. `host/../../etc/passwd`) used in HTTP GET | Tampering | `inet_pton(AF_INET, host)` returns 0/-1 unless input is exactly N.N.N.N. Reject otherwise; never embed in URL path; URL is always `GET /json/info HTTP/1.0`. |
| **T-15-02:** Buffer overflow in SetupAPI hardware-ID buffer | Tampering / DoS | Fixed `char hwBuf[256]` with `sizeof(hwBuf)` passed to SetupDiGetDeviceRegistryPropertyA; failure path `continue;` skips the device. |
| **T-15-03:** Buffer overflow in `backglow status` response (server) | DoS / Tampering | `snprintf(response, responseSize, …)` with fixed 1024-byte buffer; field values clamped (transport whitelisted, port/host length-limited by VRSettings GetString buffer). |
| **T-15-04:** Buffer overflow in CLI client response | DoS | `ReadFile(hPipe, response, sizeof(response) - 1, ...)` with 1024-byte buffer + explicit NUL. Message-mode pipe reads atomic message; if larger than buffer, ERROR_MORE_DATA returned and partial buffer is still NUL-terminated. |
| **T-15-05:** UDP socket exhaustion via repeated Open/Close cycle | DoS | Single socket created per `Open`, closed in `Close()`. Reconnect gate in `LedController` (1s minimum between reopen attempts) bounds churn. WSAStartup/WSACleanup paired at driver level. |
| **T-15-06:** Side-channel through `/json/info` raw response logging | Information Disclosure | Log only parsed `count` at INFO; raw body at DEBUG (gated by `log_verbosity=1`). Documented in Pitfall 4. |
| **T-15-07:** Loader-lock deadlock via SetupAPI from DLL_PROCESS_DETACH | DoS | SetupAPI calls confined to `InitBackglow` and `OnHotplugArrival` — both run on threads that join cleanly before DLL teardown. Documented in Pitfall 7. |
| **T-15-08:** Crash on malformed `/json/info` response | DoS | Substring scan + `strtoul` on bounded buffer; fall through to "probe failed" → degraded mode. No assumption about JSON structure beyond presence of `"count":` substring. |

## Sources

### Primary (HIGH confidence)
- `.planning/research/STACK.md` — DDP packet structure for 10 LEDs, COM port enumeration strategy, Win32 / Winsock library mapping
- `.planning/research/ARCHITECTURE.md` — `ILedTransport` abstract boundary, transport-agnostic LedController, file organization
- `.planning/research/PITFALLS.md` — COM port enumeration fragility, polling traps, WiFi unreliability
- `.planning/REQUIREMENTS.md` — LHWD-04 / TRNS-02 / TRNS-03 / DIAG-01 phase mapping
- `.planning/phases/14-usb-serial-foundation-and-led-control/14-CONTEXT.md` — Phase 14 D-01..D-23 still in force (command surface, hex format, brightness ceiling, writer thread)
- `.planning/phases/14-usb-serial-foundation-and-led-control/14-RESEARCH.md` — `ILedTransport` design rationale, anti-patterns, error code map
- `.planning/phases/14-usb-serial-foundation-and-led-control/14-SMOKE.md` — Phase 14 hardware UAT findings (WLED first-frame quirk, segment baseline override, hotplug debounce)
- `src/led/led_transport.h` — stable interface
- `src/led/wled_serial.cpp` — reference shape for second transport impl
- `src/led/led_controller.cpp` — transport-agnostic owner
- `src/driver/device_provider.cpp` — `InitBackglow`, `HandleBackglowCommand`, hotplug pattern, pipe write
- `src/ctl/main.cpp` — CLI client read pattern
- `CMakeLists.txt` — link strategy
- WLED JSON API: https://kno.wled.ge/interfaces/json-api/ (verified 2026-04-19; `info.leds.count` confirmed)
- WLED DDP Interface: https://kno.wled.ge/interfaces/ddp/ (verified 2026-04-19; port 4048 confirmed; timecode ignored)
- DDP Protocol Spec: http://www.3waylabs.com/ddp/ (canonical packet structure)
- WLED UDP Realtime: https://kno.wled.ge/interfaces/udp-realtime/ (timeout-byte concept; doesn't apply to DDP)
- SetupDiGetClassDevs MS Learn: https://learn.microsoft.com/en-us/windows/win32/api/setupapi/nf-setupapi-setupdigetclassdevsa (verified 2026-04-19)
- GUID_DEVINTERFACE_COMPORT: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/guid-devinterface-comport
- ESP32-C3 USB Serial/JTAG: https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-guides/usb-serial-jtag-console.html

### Secondary (MEDIUM confidence, verified via web 2026-04-19)
- aticleworld.com canonical SetupAPI VID/PID + PortName extraction sequence: https://aticleworld.com/get-com-port-of-usb-serial-device/
- EnumSerialPorts reference implementation: https://github.com/Forvater/EnumSerialPorts/blob/master/enumser.cpp
- WLED issue #2356 — realtime mode timeout behavior: https://github.com/Aircoookie/WLED/issues/2356

### Tertiary (LOW confidence — flagged for SMOKE validation)
- A3 (DDP first-frame quirk parity with USB) — must be confirmed during 15-SMOKE.md UAT
- A7 (Windows Firewall doesn't block outbound UDP from elevated vrserver.exe) — validated empirically by user during UAT; if blocks, document in SMOKE
- A8 (segment-baseline override fix `POST /json/state {"seg":[…]}`) — Phase 14 SMOKE recommended but never tested; Phase 15 UAT confirms

## Metadata

**Confidence breakdown:**
- Standard stack: **HIGH** — every choice is either pre-locked in CONTEXT.md, in upstream STACK.md, or canonical Win32 (Winsock + SetupAPI). No speculative libraries.
- Architecture: **HIGH** — drops into Phase 14's existing `ILedTransport` seam without refactoring; transport factory is one new branch in `InitBackglow`.
- Pitfalls: **HIGH** — 9 distinct pitfalls catalogued, including 2 carried forward from Phase 14 SMOKE findings (first-frame quirk, segment baseline). Highest-risk items (firewall, segment override, DDP first-frame parity) are flagged for SMOKE validation.
- Validation architecture: **MEDIUM** — no automated test framework (consistent with Phase 14 and all 14 prior phases). Manual + hardware-visible smoke is codebase-canonical.
- Security domain: **HIGH** — STRIDE-mapped 8 threats; mitigations are bounded snprintf, whitelisted enums, IPv4 validation, constrained logging, scope-limited SetupAPI usage.

**Research date:** 2026-04-19
**Valid until:** 2026-05-19 (30 days — WLED 0.15.x firmware, Win32 APIs, and DDP spec are all stable; ESP32-C3 USB CDC behavior is unchanged since 2021)
