---
phase: 14-usb-serial-foundation-and-led-control
plan: 03
subsystem: driver-integration
tags: [driver, device-provider, vrsettings, named-pipe, ctl, hotplug, registerdevicenotification, enable-backglow, wled-quirk, phase14]

requires:
  - phase: 14-02
    provides: LedController + WledSerialTransport + ILedTransport (module built, no consumer)
provides:
  - VRSettings-driven backglow wiring (D-12 brightness ceiling, D-14 COM port, DIAG-02 configurable)
  - HandleBackglowCommand pipe surface (LCTL-01..04 + fill polymorphism per D-03 + on/off per D-06 + err gating per D-15)
  - beyond_prox_ctl.exe backglow subcommand validation (D-23)
  - Cleanup() wiring that shuts LEDs off before reset + before HID cleanup (LHWD-03)
  - RegisterDeviceNotification hotplug watcher with HWND_MESSAGE + GUID_DEVINTERFACE_COMPORT + 500 ms debounce (D-15)
  - WledSerialTransport double-send Adalight patch (WLED 0.15 first-frame quirk workaround)
  - scripts/deploy-backglow-dev.ps1 — dev deploy helper for the registered external-driver folder
  - 14-SMOKE.md with all PASS verdicts captured via agent-driven UAT
affects: []

tech-stack:
  added:
    - Win32 dbt.h + Ntddser.h (GUID_DEVINTERFACE_COMPORT) for serial hotplug
    - HWND_MESSAGE message-only window in a DLL (Pattern 5)
  patterns:
    - "DeviceProvider owns LedController under ENABLE_BACKGLOW (D-21)"
    - "Pipe prefix routing: strncmp(cmd, 'backglow ', 9) alongside existing ipd/load_lh_config branches"
    - "Degraded startup: empty/invalid/unopenable port => driver loads, controller idle, hotplug watcher armed"
    - "Hotplug thread lifecycle: StartHotplugWatcher spins a std::thread that owns the HWND_MESSAGE window; StopHotplugWatcher PostMessageA(WM_QUIT) + join"
    - "Adalight double-send (20 ms gap) inside WledSerialTransport::SendRgbFrame to mask WLED 0.15 first-frame loss"

key-files:
  created:
    - .planning/phases/14-usb-serial-foundation-and-led-control/14-SMOKE.md
    - scripts/deploy-backglow-dev.ps1
  modified:
    - src/driver/device_provider.h     # +25 lines: ENABLE_BACKGLOW member/method block, LedController fwd-decl, atomic+thread includes
    - src/driver/device_provider.cpp   # +375 lines: InitBackglow, HandleBackglowCommand, Cleanup wiring, Hotplug thread + WndProc
    - src/ctl/main.cpp                 # +17 lines: usage text + 5 validCommand ORs for backglow subcommands
    - src/led/wled_serial.cpp          # +5 lines: double-send Adalight frame (WLED 0.15 first-frame quirk)

key-decisions:
  - "OnHotplugArrival made public (friend-free alternative): the static WndProc in the .cpp calls s_hotplugOwner->OnHotplugArrival(). Private access was the compile failure during Task 1 build; exposing the single method as a public trampoline keeps the header simpler than a friend declaration for a static free function."
  - "fill tokeniser rejects >kBackglowMaxLeds tokens explicitly: after consuming 10 hex values it probes for an extra token with strtok_s; any remainder yields the standard 'ERR fill expects 1 or 10 hex values' response. Without this, `fill ...10+ args` would silently drop the extras and respond OK."
  - "Response for `bri`: reports `OK bri=<clamped> ceiling=<ceiling>` where <clamped> = min(v, ceiling). Per D-13 the WLED master receives the raw user value (LedController re-clamps internally); the CLI reply shows the post-clamp effective value so the operator can observe the ceiling at work."
  - "Cleanup ordering: backglow shutdown runs AFTER DestroyPipeServer() (no inbound backglow commands possible) and BEFORE HID cleanup. Matches RESEARCH.md Code Example 6. ShutdownAllOff itself (implemented in 14-02) joins the writer thread first, then performs final writes on the caller thread — that decision lives in led_controller.cpp and this plan just invokes it."
  - "Hotplug debounce at 500 ms (Open Question 2 recommendation), hard-coded. Not VRSettings-tunable — not worth the surface area for Phase 14; revisit in Phase 15 if spike-bench data suggests a different value."
  - "WledSerialTransport::SendRgbFrame double-sends every Adalight frame with a 20 ms gap — this is a 14-02 defect surfaced during 14-03 UAT where a single-shot Adalight frame produces live=True but leds.pwr=0 (realtime override activates but pixel buffer stays at zero). Empirically: 1 frame → pwr=0, 2 frames → pwr=303 mA. Fix lives in 14-02's transport TU but is charged to 14-03 since 14-03 ran the UAT that caught it."

metrics:
  duration_seconds: 5400
  duration_friendly: "~90 minutes wall-clock — Tasks 1+2 build + Task 3 hardware debug (WLED first-frame quirk diagnosis + double-send patch + full 7-criterion UAT)"
  tasks: 3
  tasks_completed: 3
  files_created: 2
  files_modified: 4
  lines_added: ~430
  commits: 5
  completed: 2026-04-19

requirements-completed: [LHWD-01, LHWD-02, LHWD-03, TRNS-01, LCTL-01, LCTL-02, LCTL-03, LCTL-04, LCTL-05, DIAG-02]
requirements-hardware-verified: [LHWD-01, LHWD-02, LHWD-03, LCTL-01, LCTL-02, LCTL-03, LCTL-04, LCTL-05, DIAG-02]
---

# Phase 14 Plan 03: Driver Integration Summary

**Wired Plan 14-02's LedController into DeviceProvider under ENABLE_BACKGLOW: VRSettings read at Init (backglow_brightness_ceiling default 50 clamped [1..255], backglow_com_port), HandlePipeCommand `backglow ` prefix branch routing to HandleBackglowCommand (fill polymorphic, set, bri, off, on; all five response strings locked), Cleanup calls ShutdownAllOff before HID teardown, and a dedicated hotplug thread owning an HWND_MESSAGE window with GUID_DEVINTERFACE_COMPORT + 500 ms debounce re-attempts Start() on DBT_DEVICEARRIVAL. beyond_prox_ctl.exe validates the five subcommands. Task 3 hardware UAT ran agent-driven against MagWLED-1 on COM11 and caught a WLED 0.15 first-frame quirk (single Adalight frame → leds.pwr=0) that was fixed with a double-send patch in WledSerialTransport. All 5 ROADMAP.md success criteria + D-03 polymorphic fill + D-15 degraded mode now PASS. Both ENABLE_BACKGLOW=ON and =OFF builds green.**

## Integration points confirmed

- **Init** (`device_provider.cpp:85-88`): `#ifdef ENABLE_BACKGLOW InitBackglow(); #endif` runs after HID startup and before `CreatePipeServer()`. InitBackglow reads `backglow_brightness_ceiling` + `backglow_com_port` via `vr::VRSettings()`, constructs `LedController(std::make_unique<WledSerialTransport>(), ceiling)`, calls `Start(port)`, then branches into `StartHotplugWatcher()` or steady-state logging based on `GetConnectionState()`.
- **HandlePipeCommand** (`device_provider.cpp:565-570`): new `else if (strncmp(cmd, "backglow ", 9) == 0)` branch delegates to `HandleBackglowCommand(cmd + 9, ...)`. Placed after `load_lh_config`/`test_handle` and before the fallthrough `ERR unknown command` so every existing command keeps working untouched.
- **Cleanup** (`device_provider.cpp:137-147`): new `#ifdef ENABLE_BACKGLOW` block runs after `DestroyPipeServer()` and before `m_pHidDevice->StopReading()`. Calls `m_pLedController->ShutdownAllOff()` (joins writer + final black frame + `{"on":false}`), `m_pLedController.reset()`, then `StopHotplugWatcher()` (joins the hotplug thread).
- **Hotplug** (`device_provider.cpp:1490-1584`): `StartHotplugWatcher` sets `s_hotplugOwner = this` and spawns `HotplugThreadFunc`. The thread registers the class `BeyondBackglowHotplug`, creates a message-only window (`HWND_MESSAGE`), registers for `DBT_DEVTYP_DEVICEINTERFACE` filter with `GUID_DEVINTERFACE_COMPORT`, and runs a `GetMessageA` pump. `WM_DEVICECHANGE + DBT_DEVICEARRIVAL` triggers `s_hotplugOwner->OnHotplugArrival()` which `Sleep(500)` then retries `LedController::Start` if still disabled.
- **CLI** (`src/ctl/main.cpp`): `validCommand` accepts five new forms; PrintUsage documents the surface.
- **Transport** (`src/led/wled_serial.cpp:227-234`): `SendRgbFrame` writes the Adalight buffer twice with a 20 ms gap (see WLED first-frame quirk below).

## Response strings locked (downstream re-use — Phase 15, 16, 17)

These exact strings are authoritative. Phase 16's VRChat bridge daemon and Phase 17's avatar prefab pipeline can parse these without further clarification.

| Command | Success response | Failure response |
|---------|-------------------|------------------|
| `backglow fill <hex>` (1 arg) | `OK fill=RRGGBB leds=10` | `ERR fill expects 1 or 10 hex values` |
| `backglow fill <h0>...<h9>` (10 args) | `OK fill=per-led leds=10` | `ERR fill expects 1 or 10 hex values` |
| `backglow set <idx> <hex>` | `OK set idx=N rgb=RRGGBB` | `ERR set requires <idx 0..9> <6-hex>` |
| `backglow bri <0..255>` | `OK bri=<clamped> ceiling=<ceiling>` | `ERR bri requires 0..255` |
| `backglow off` | `OK off` | — |
| `backglow on` | `OK on` | — |
| Any backglow subcommand, disabled | — | `ERR backglow disabled (no port)` |
| `backglow <unknown>` | — | `ERR unknown backglow subcommand` |
| `backglow` (no subcommand, just trailing space) | — | `ERR backglow requires subcommand` |

## WLED first-frame quirk (caught during Task 3 UAT)

**Symptom:** User reported "strip did not illuminate at all" during the first UAT run. CLI responses all matched expected strings; driver log showed `Backglow: online on COM11`; but physical LEDs stayed dark.

**Investigation path (for posterity):**

1. **Initial hypothesis — LED count mismatch.** MagWLED-1 was configured for 12 LEDs (per 14.1-SPIKE-FINDINGS); driver hard-coded `kBackglowMaxLeds = 10`. Spike findings explicitly warned: *"LED count is hardware-configurable; don't hard-code 10."* User reconfigured WLED to 10 LEDs to match. Strip still dark.

2. **Second hypothesis — double brightness scaling.** Driver applies ceiling 50/255 → pixel=50, then WLED's global `bri=50` applies another 50/255 → ~4 % output. Bumped WLED bri to 255 and ceiling to 255 in vrsettings. Strip still dark on single fills.

3. **Third hypothesis — segment baseline override.** WLED solid-colour segment overrides realtime even with `mso=true`. Set `seg[0].col=[[0,0,0]]` (black-on baseline) per spike findings. Strip still dark for fills; but `set 3 green` visibly lit LED 4.

4. **Diagnostic instrumentation.** Temporary DriverLog calls added to `HandleBackglowCommand` and `WledSerialTransport::SendRgbFrame` logged the rgb buffer and Adalight bytes. Log confirmed bytes leaving driver were correct:
   ```
   DIAG Ada tx numLeds=10 total=36 hdr=41 64 61 00 09 5C px0=FF 00 00 px1=FF 00 00
   DIAG Ada tx write=1 lastError=0
   ```
   (Instrumentation reverted before commit.)

5. **Root cause — WLED 0.15 first-frame loss.** Polled `/json/info` at 200 ms intervals after a single fill:
   - `live=True`, `lm="USB Adalight/TPM2"`, `liveseg=0` — WLED in realtime mode.
   - `leds.pwr=0` — strip drawing zero current — LEDs off.

   Repeated with a burst of 30 frames (100 ms spacing): `leds.pwr=303 mA` — LEDs lit.
   Narrowed down: **1 frame → pwr=0, 2 frames within ~25 ms → pwr=303**. WLED 0.15's Adalight state machine consumes the first frame without writing to the render buffer.

**Fix:** `WledSerialTransport::SendRgbFrame` now calls `WriteAllWithTimeout` twice with a `std::this_thread::sleep_for(20 ms)` between them. Single-shot pipe commands now render reliably.

**Why this fix lives in 14-02's transport TU but is charged to 14-03:**
- The defect is in the transport that 14-02 shipped, but 14-02 never ran hardware UAT — 14-02's acceptance was build-clean + interface stability. 14-03 is where the UAT ran and the bug surfaced.
- Fixing it here keeps the blast radius contained to this plan's commit; LedController + caller API are unchanged.
- Phase 15 follow-up options tracked in 14-SMOKE.md "Followups" section: file WLED upstream issue, add `double_send` config flag, or (preferred) move to sustained 30 fps streaming so the double-send naturally becomes a no-op.

## Verification against plan acceptance criteria

### Task 1 — PASS

- [x] `device_provider.h` declares `m_pLedController`, `HandleBackglowCommand`, hotplug members inside `#ifdef ENABLE_BACKGLOW`.
- [x] `device_provider.cpp` reads `backglow_brightness_ceiling` via `GetInt32`, default 50, clamped [1..255].
- [x] `device_provider.cpp` reads `backglow_com_port` via `GetString` into 64-byte buffer.
- [x] `strncmp(cmd, "backglow ", 9) == 0` branch present in `HandlePipeCommand`.
- [x] `HandleBackglowCommand` emits all ten literal response strings.
- [x] `Cleanup()` calls `m_pLedController->ShutdownAllOff()` BEFORE `m_pLedController.reset()`, and before HID cleanup.
- [x] `RegisterDeviceNotificationA` present with `GUID_DEVINTERFACE_COMPORT` + `DEVICE_NOTIFY_WINDOW_HANDLE`.
- [x] `HWND_MESSAGE` present.
- [x] `Sleep(500)` present in `OnHotplugArrival`.
- [x] `ENABLE_BACKGLOW=ON` build exits 0.
- [x] `ENABLE_BACKGLOW=OFF` build exits 0.

### Task 2 — PASS

- [x] `src/ctl/main.cpp` PrintUsage adds six `"backglow ..."` lines.
- [x] `validCommand` accepts all five backglow subcommand forms; rejects `backglow` alone and unknown verbs.
- [x] `cmake --build build --config Release --target beyond_prox_ctl` exits 0.
- [x] CLI validation smoke on bare environment: five backglow forms pass validation; `bogus command` rejected.

### Task 3 — PASS (hardware-verified on MagWLED-1 COM11)

- [x] Criterion 1 (`backglow fill` red/green/blue/white) — colours render at ceiling brightness with double-send patch.
- [x] Criterion 2 (`backglow bri 255` → `OK bri=50 ceiling=50`) — clamp response confirmed; strip brightness unchanged vs fill FFFFFF.
- [x] Criterion 3 (auto-off on SteamVR exit) — magenta fill → `pwr=162 mA`; after `taskkill /PID <vrmonitor>` graceful exit → `pwr=0, live=False`. `ShutdownAllOff` via `DeviceProvider::Cleanup` confirmed end-to-end.
- [x] Criterion 4 (`backglow set <idx>`) — user visually confirmed single-LED addressing after repositioning HMD so full strip was in VDO.Ninja frame.
- [x] Criterion 5 (VRSettings read + log lines) — `Backglow: ceiling=50 com_port='COM11'` + `Backglow: online on COM11` observed.
- [x] D-03 polymorphic fill — per-LED rainbow confirmed; 2-arg `ERR fill expects 1 or 10 hex values` confirmed.
- [x] D-15 degraded mode — `backglow_com_port` set to `COM99`; log reported `Backglow: disabled (port 'COM99' unavailable); registering hotplug watcher`; pipe command returned `ERR backglow disabled (no port)`. Driver continued to load; HID/IPD paths unaffected. `backglow_com_port` restored to `COM11` after.

Full PASS grid + observed strings saved in `14-SMOKE.md`.

## Deviations from Plan

### Rule-3 blocker: `OnHotplugArrival` visibility

- **Found during:** Task 1 build. The static free function `HotplugWndProc` (declared at top of the backglow cpp section) tries to invoke `s_hotplugOwner->OnHotplugArrival()`. The plan's `<action>` block declared `OnHotplugArrival` inside the `private:` section of the class.
- **Fix:** Hoist `OnHotplugArrival` into a `public:` sub-section inside the `#ifdef ENABLE_BACKGLOW` block of `device_provider.h`.
- **Rationale:** Simpler than a friend declaration for a static function defined in the .cpp, and keeps the rest of the backglow surface private.
- **Impact:** Zero — `OnHotplugArrival` is a purely internal callback.

### Rule-2 correctness: fill-token overflow rejection

- **Found during:** Task 1 `HandleBackglowCommand` implementation review. The plan's polymorphic-fill sample code loops until it has consumed kBackglowMaxLeds tokens OR exhausted input. After consuming exactly 10 tokens, extra tokens would be silently dropped.
- **Fix:** After the loop, when `n == kBackglowMaxLeds`, probe `strtok_s(nullptr, ...)` one more time. If a non-null token is returned, reply with `ERR fill expects 1 or 10 hex values`.
- **Threat-model alignment:** T-14.3-01.
- **Commit:** `d696439`.

### Rule-1 bugfix: WLED first-frame quirk (double-send)

- **Found during:** Task 3 hardware UAT. See § "WLED first-frame quirk" above.
- **Fix:** `WledSerialTransport::SendRgbFrame` writes the Adalight buffer twice with a 20 ms gap.
- **Blast radius:** transport-only; `LedController` and callers unchanged.
- **Bandwidth cost:** 72 bytes per logical frame instead of 36 (2 × [6 header + 30 data]). At 115200 baud = 6.25 ms per doubled frame, well within the 33 ms writer-thread period. Worst-case sustained streaming occupies <20 % of USB CDC bandwidth.
- **Safety invariant preserved:** if the first write fails, the second is skipped and `WriteAllWithTimeout`'s Close()-on-fail path still triggers the usual reopen-gate flow.

### Plan-recommended layout change: forward-declare LedController

- `class LedController;` forward-declaration near `class HidDevice;` at header line 10.

### Deferred items discovered during build

- `C4005 SERIAL_IOC_*` macro redefinition warnings from `<Ntddser.h>` included after `<winioctl.h>`. Out of scope for Phase 14-03; tracked for Phase 15 cleanup.

## Authentication Gates

None — pure Win32/C++ code path.

## Carry-forwards

### Phase 15 (deferred from Phase 14 scope)

- **`status` pipe command** (DIAG-01) — extend existing `status` branch in `HandlePipeCommand` to emit `backglow=<state> port=<name> ceiling=<N>`.
- **VID/PID auto-detect** (LHWD-04) — `CM_Get_Device_Interface_List` / `SetupDiGetClassDevs` scan for VID 0x303A / PID 0x1001.
- **DDP fallback transport + selection** (TRNS-02, TRNS-03) — `WledDdpTransport` concrete impl; transport-type VRSettings key.
- **Live VRSettings reload on ceiling change** (D-12 follow-up).
- **WLED `if.live.mso=true` enforcement + neutral segment baseline** (14.1 spike carry-forward; surfaced again during 14-03 UAT) — POST `{"if":{"live":{"mso":true}}}` and `{"seg":[{"on":true,"fx":0,"col":[[0,0,0]]}]}` at first-connect.
- **Double-send revisit** — either file WLED upstream fix, expose a `double_send` flag, or move to sustained 30 fps streaming where the workaround becomes redundant.
- **LED count from VRSettings** — remove the `kBackglowMaxLeds=10` hard-code; accept strip length from a new `backglow_led_count` VRSettings key. Current implementation requires the physical strip to be configured for exactly 10 LEDs on the WLED side.

### Phase 16 (VRChat OSC bridge daemon)

- Pipe response strings above are **locked**. `beyond_backglow_ctl.exe` will consume them.
- `\\.\pipe\beyond_proximity_ctl` remains the single control surface.

### Phase 17 (avatar prefab + reference world)

- No direct carry-forward from this plan.

## Known Stubs

None. Every code path introduced in this plan is wired end-to-end.

## Threat Flags

All eight STRIDE entries (T-14.3-01..T-14.3-08) from the plan honoured. See the Plan's `<threat_model>` for the register; no new threats surfaced during UAT.

## Self-Check: PASSED

**Files verified to exist:**
- FOUND: `src/driver/device_provider.h`
- FOUND: `src/driver/device_provider.cpp`
- FOUND: `src/ctl/main.cpp`
- FOUND: `src/led/wled_serial.cpp` (double-send patch)
- FOUND: `.planning/phases/14-usb-serial-foundation-and-led-control/14-SMOKE.md` (PASS grid populated)
- FOUND: `scripts/deploy-backglow-dev.ps1`

**Build verified:**
- PASS: `cmake --build build --config Release` with ENABLE_BACKGLOW=ON exits 0
- PASS: `cmake --build build --config Release` with ENABLE_BACKGLOW=OFF exits 0
- FOUND: `build/driver/BeyondProximity/bin/win64/driver_BeyondProximity.dll`
- FOUND: `build/driver/BeyondProximity/bin/win64/beyond_prox_ctl.exe`

**Hardware UAT:**
- ALL 7 smoke items PASS (5 ROADMAP success criteria + D-03 + D-15).
- WLED `/json/info` confirmed `live=True lm="USB Adalight/TPM2" liveseg=0 leds.pwr=303 mA` during fills.
- Graceful SteamVR exit confirmed `leds.pwr=0 live=False`.
