# Architecture Patterns: Backglow LED Integration

**Domain:** SteamVR sidecar driver extension -- LED control via USB serial (prototype) and abstracted protocol (production)
**Researched:** 2026-04-05
**Overall confidence:** HIGH (existing codebase well-understood, WLED protocols documented, VRChat OSC well-documented)

## System Overview: Current + New Components

```
                        External Callers
                +------------------+------------------+
                |                  |                  |
        beyond_prox_ctl.exe   beyond_backglow_ctl.exe
         (existing CLI,         (NEW: VRChat OSC
          extended w/ led        daemon, pipe client)
          commands)              |
                |                |
                +--------+-------+
                         |
              "proximity on"  "ipd 63.5"  "led set FF0000"
                         |
        ===============================================
          Named Pipe: \\.\pipe\beyond_proximity_ctl
        ===============================================
                         |
                         v
        +-------------------------------------------------+
        |                                                 |
        |   DeviceProvider (MODIFIED)                      |
        |     HandlePipeCommand()                          |
        |       +-- "proximity ..." -> SetHmdProximity()   |
        |       +-- "ipd ..."       -> ApplyIpd()          |
        |       +-- "led ..."  [NEW] -> LedController      |
        |     RunFrame()                                   |
        |       +-- PollPipe()                             |
        |       +-- Proximity algorithm                    |
        |       +-- IPD event handling                     |
        |                                                 |
        |   HidDevice (UNCHANGED)                          |
        |     Background reader thread                     |
        |     HMD proximity via HID                        |
        |                                                 |
        |   LedController (NEW)                            |
        |     +-- Brightness ceiling enforcement           |
        |     +-- Rate limiter (30fps default)             |
        |     +-- Double-buffered LED data                 |
        |     +-- Background writer thread                 |
        |         +-- ILedTransport interface               |
        |             +-- WledSerialTransport (prototype)   |
        |                 COM port -> ESP32-C3 USB CDC     |
        |                                                 |
        +-------------------------------------------------+
                         |
                    USB serial (COM port)
                         |
                  +------+------+
                  | ESP32-C3    |
                  | WLED 0.15.x |
                  | 10x WS2812B |
                  +-------------+
```

## Integration Point Analysis

### 1. LED Device Abstraction: IN-DRIVER, Not Separate Process

**Recommendation:** LED communication lives inside `driver_BeyondProximity.dll`, using a dedicated writer thread for serial I/O.
**Confidence:** HIGH

**Rationale:**
- The existing architecture already runs blocking I/O inside the driver DLL. HidDevice has its own reader thread within vrserver.exe. This is a proven, shipped pattern.
- Latency: In-process serial write avoids IPC round-trip. For 10 LEDs at 30fps the savings are modest but simplicity wins.
- Adding a separate LED bridge process would add deployment complexity (another exe, another pipe, another failure mode) for no architectural benefit at this scale.

**USB coexistence:** The ESP32-C3 WLED controller is a physically separate USB device from the Beyond 2 HMD. The HMD uses HID (VID `0x35BD` / PID `0x0101`) accessed via HIDAPI. The ESP32-C3 enumerates as USB CDC ACM with its own COM port (typically VID `0x303A` / PID `0x1001`). HIDAPI talks to one device; Win32 serial API (`CreateFileA` on `\\.\COMn`) talks to the other. Different USB device classes on different physical hardware. No contention, no shared-mode concerns.

### 2. Core/Prototype Separation: ILedTransport Interface

**Recommendation:** Abstract class `ILedTransport` with `WledSerialTransport` as the prototype implementation. Production swap requires only a new `ILedTransport` subclass.
**Confidence:** HIGH

```cpp
// src/led/led_transport.h
class ILedTransport
{
public:
    virtual ~ILedTransport() = default;

    // Lifecycle
    virtual bool Open(const char* address) = 0;  // COM port, IP, device path
    virtual void Close() = 0;
    virtual bool IsOpen() const = 0;

    // LED control (core protocol -- implementation-agnostic)
    virtual bool SetLeds(const uint8_t* rgb, int numLeds) = 0;  // flat RGB array
    virtual bool SetBrightness(uint8_t bri) = 0;                // 0-255 global
    virtual bool SetPower(bool on) = 0;
    virtual int  GetConnectionState() const = 0;  // 0=closed, 1=open, 2=reconnecting
};
```

**Prototype implementation:** `WledSerialTransport` sends Adalight frames for per-LED color data, JSON commands for brightness/power control.

**Production swap:** Replace with `CustomPcbTransport` implementing whatever protocol the production PCB uses. `DeviceProvider` and `LedController` only touch `ILedTransport*`, never WLED-specific code.

**Why this boundary:**
- `SetLeds(rgb, n)` is the minimal contract. Any LED controller (WLED, custom firmware, direct SPI) can implement this.
- Brightness clamping (max ceiling) lives in `LedController` above the transport, so it's protocol-independent.
- Address format varies naturally: COM port string for serial, IP:port for UDP/DDP, device path for custom USB.

**File organization:**
```
src/led/
  led_transport.h        -- ILedTransport interface
  wled_serial.h/.cpp     -- WledSerialTransport (prototype)
  led_controller.h/.cpp  -- Orchestrator: brightness ceiling, rate limiting, writer thread
```

### 3. Named Pipe Extension: Single Pipe, New Command Prefix

**Recommendation:** Add `led` commands to the existing `\\.\pipe\beyond_proximity_ctl` pipe. Do NOT create a separate pipe.
**Confidence:** HIGH

**Rationale:**
- The pipe is already PIPE_NOWAIT with message mode, polled in RunFrame. Adding more command prefixes is trivial -- identical to how `ipd` was added to the existing `proximity`/`status` commands.
- One pipe = one control surface. Operators and tools have a single entry point for all driver functions.
- The driver only supports 1 pipe instance. A second pipe would need a second polling path or a dedicated thread.

**New commands:**

| Command | Description | Response |
|---------|-------------|----------|
| `led set <hex>...` | Set per-LED colors. e.g. `led set FF0000 00FF00 0000FF` | `OK leds=3` |
| `led bri <0-255>` | Set global brightness | `OK bri=128` |
| `led on` | Power on LEDs | `OK led=on` |
| `led off` | Power off LEDs | `OK led=off` |
| `led status` | Query LED subsystem state | `led=on bri=128 leds=10 transport=serial port=COM5 conn=open` |
| `led fill <hex>` | Fill all LEDs with one color | `OK fill=FF8800 leds=10` |

**Implementation follows existing pattern:**
```cpp
// In HandlePipeCommand, new else-if branch:
else if (strncmp(cmd, "led ", 4) == 0)
{
    HandleLedCommand(cmd + 4, response, sizeof(response));
}
```

The `status` command response should also gain LED fields so a single `status` call reports everything.

**CLI extension:** `beyond_prox_ctl.exe` gains validation for `led` command prefixes. No new executable needed for pipe-based control.

### 4. Threading Model: Dedicated Serial Writer Thread

**Recommendation:** Dedicated background thread for serial writes. RunFrame only queues updates via atomic flag.
**Confidence:** HIGH

**Why not RunFrame:**
- RunFrame runs on vrserver.exe's main server thread. Serial `WriteFile` on USB CDC can block for 1-50ms if the device buffer is full or USB scheduling delays occur. Any stall in RunFrame blocks ALL SteamVR drivers sharing the server thread.
- The existing HidDevice pattern proves the correct approach: blocking I/O runs on a background thread; RunFrame only reads/writes atomics. Follow the same pattern.

**Architecture:**

```
RunFrame thread (vrserver main):
  - Calls m_pLedController->QueueUpdate(rgb, n) on pipe command
  - Sets atomic flag + copies data under mutex

LedWriterThread (background, owned by LedController):
  - Sleeps at target framerate (33ms for 30fps) or waits on condition variable
  - Checks m_bPendingUpdate atomic flag
  - If set: locks mutex, copies buffer, unlocks, sends via ILedTransport::SetLeds()
  - Handles reconnection if serial port lost (same pattern as HidDevice reconnect)
```

**Data transfer mechanism:**
```cpp
class LedController {
    std::thread m_writerThread;
    std::atomic<bool> m_bStopRequested{false};
    std::atomic<bool> m_bPendingUpdate{false};
    std::atomic<int>  m_connectionState{0};

    // Double-buffer: RunFrame writes to pending, thread reads from pending
    std::mutex m_dataMutex;
    uint8_t m_pendingRgb[MAX_LEDS * 3];  // MAX_LEDS = 10 for prototype
    int m_pendingCount = 0;

    // Rate limiting
    uint32_t m_maxFps = 30;
    uint8_t m_maxBrightness = 255;  // ceiling enforced before transport
};
```

**Rate limiting:** LedController enforces max update rate (30fps default, configurable via VRSettings). VRChat OSC can fire at 90Hz but LED updates above 30fps are imperceptible and waste USB bandwidth.

**Bandwidth check:** For 10 LEDs at 115200 baud using Adalight: 36 bytes/frame = 1,080 bytes/sec at 30fps. At 115200 baud (~11,520 bytes/sec effective), this uses ~9% of available bandwidth. No concern.

### 5. VRChat Control Daemon: SEPARATE Process

**Recommendation:** `beyond_backglow_ctl.exe` -- a standalone process that listens for VRChat OSC and sends pipe commands to the driver.
**Confidence:** HIGH on architecture, MEDIUM on VRChat world->OSC path

**Why separate, not in-driver:**
- OSC requires a UDP listener on localhost. Running a UDP server inside vrserver.exe risks firewall prompts during VR, port conflicts with other OSC tools, and UDP errors potentially crashing the VR session.
- VRChat OSC is an app-level concern, not a driver-level concern. The driver's job is LED I/O. The daemon's job is translating VR application intent into LED commands.
- The daemon can be restarted, updated, or replaced without touching the driver. Different VR apps (VRChat, Resonite, custom) could have different daemons.
- Follows the existing pattern: separate process communicates via named pipe.
- The daemon has NO OpenVR dependency -- it's pure Win32 (UDP socket + named pipe client).

**Daemon architecture:**
```
beyond_backglow_ctl.exe
  +-- OSC UDP listener (port 9001, VRChat default output port)
  +-- Parameter mapper (avatar params -> LED colors)
  +-- Named pipe client (sends "led set ..." / "led fill ..." to driver)
  +-- Optional: config file for parameter mapping
```

### 6. Full Data Flow Pipeline

#### Path A: CLI/Manual Control (simplest, build first)

```
User runs:  beyond_prox_ctl "led set FF0000 00FF00 0000FF"
  -> Named pipe write to driver (connect, send, read response, disconnect)
  -> DeviceProvider::HandlePipeCommand parses "led set ..."
  -> LedController::QueueUpdate(rgb, 3)
  -> LedWriterThread wakes (~0-33ms latency from rate limiter)
  -> WledSerialTransport::SetLeds sends Adalight frame over COM port
  -> ESP32-C3 WLED receives Adalight, updates WS2812B LEDs
End-to-end latency: ~5-50ms (pipe + queue wait + serial)
```

#### Path B: VRChat Avatar Parameter -> OSC -> Daemon -> Pipe -> Driver

```
VRChat avatar parameter changes (user gesture, world contact, menu toggle)
  -> VRChat OSC output: /avatar/parameters/BackglowR float 0.8
  -> beyond_backglow_ctl.exe receives UDP on port 9001
  -> Maps parameters to RGB: R=0.8,G=0.0,B=0.0 -> "CC0000"
  -> Sends pipe command: "led fill CC0000"
  -> DeviceProvider::HandlePipeCommand -> LedController::QueueUpdate
  -> Writer thread -> WledSerialTransport -> ESP32 -> LEDs
End-to-end latency: ~15-50ms (OSC + pipe + queue + serial)
```

**VRChat world-to-LED path:** VRChat worlds cannot directly send OSC (Udon has no OSC API as of 2026-04). The established workaround:
- **World uses Contact Sender** to drive avatar parameters (Contact Receiver on avatar sets a parameter)
- Avatar parameter change emits OSC to external listeners
- This is the standard VRChat ecosystem pattern for world-to-external communication (used by haptics devices like OpenShock, face tracking tools, etc.)

Both paths require avatar setup (custom float parameters like `BackglowR`, `BackglowG`, `BackglowB`, `BackglowBri`), but this is well-understood by the VRChat creator community.

#### Path C: Future WAN/Remote Control (out of scope for v3.0)

```
Remote app -> HTTP/WebSocket -> relay client on PC -> pipe command -> same as A
```

The pipe is the universal control surface. Any new control source only needs to implement a pipe client.

### 7. Build Order for Fastest End-to-End Demo

| Phase | What | Deliverable | Dependencies |
|-------|------|-------------|--------------|
| **1a** | `ILedTransport` + `WledSerialTransport` | Serial communication to WLED | None |
| **1b** | `LedController` with writer thread | Thread-safe LED state management | 1a |
| **1c** | Pipe command extensions (`led set/bri/on/off/fill/status`) | CLI-controllable LEDs | 1a, 1b |
| **1d** | CLI extension in `beyond_prox_ctl.exe` | `beyond_prox_ctl "led set FF0000"` works | 1c |
| **2** | `beyond_backglow_ctl.exe` OSC daemon | VRChat avatar -> LEDs | 1c |
| **3** | VRSettings config + COM auto-detect | UX polish | 1a |

**Phase 1 rationale:** Proves hardware I/O works with minimal code. End-to-end demo from CLI to physical LEDs.
**Phase 2 rationale:** Adds VRChat integration on top of proven I/O layer.
**Phase 3 rationale:** UX polish that doesn't affect core functionality.

Each phase is independently testable and demo-able.

## Protocol Selection for WLED Serial

### Adalight (recommended for per-LED streaming)

**Format:** `'A' 'd' 'a' <count_hi> <count_lo> <checksum> <R G B>...`
- Header: 6 bytes (`Ada` + 2-byte LED count + XOR checksum)
- Payload: 3 bytes per LED (RGB)
- For 10 LEDs: 36 bytes per frame
- Checksum: `count_hi XOR count_lo XOR 0x55`
- At 115200 baud: ~3.1ms per frame

**Why Adalight over alternatives:**
- **vs TPM2:** Both work for 10 LEDs. Adalight is simpler to implement and more widely documented.
- **vs JSON:** JSON is 2-4x less bandwidth efficient (WLED docs). JSON parsing also adds latency on ESP32.
- **vs UDP/DDP:** Requires WiFi. USB serial is simpler, lower latency, no network config.

### JSON API (for non-realtime control only)

**Use for:** Brightness (`{"bri":128}`), power (`{"on":true}`), querying state (`{"v":true}`)
**Send over:** Same serial port, same baud rate. WLED auto-detects JSON vs Adalight by first byte.

### WLED Serial Configuration Notes

- **Default baud:** 115200 (sufficient for 10 LEDs at 30fps)
- **ESP32-C3 USB CDC:** Baud rate setting in software is ignored by USB CDC (data rate is USB 2.0 full-speed regardless) but must match WLED's configured baud for protocol framing.
- **GPIO conflict:** If WLED uses GPIO1/GPIO3 for LED data, serial is disabled. ESP32-C3 with native USB uses the internal USB peripheral (GPIO18/19), not UART pins. No conflict.

## Component Boundaries: New vs Modified

| Component | Change Type | Location | Description |
|-----------|-------------|----------|-------------|
| `DeviceProvider` | **MODIFIED** | `src/driver/device_provider.h/.cpp` | Add `LedController` ownership, `HandleLedCommand()`, LED fields in `status` |
| `HidDevice` | **UNCHANGED** | `src/hid/` | No changes needed |
| `ILedTransport` | **NEW** | `src/led/led_transport.h` | Abstract interface for LED hardware communication |
| `WledSerialTransport` | **NEW** | `src/led/wled_serial.h/.cpp` | Adalight + JSON over COM port |
| `LedController` | **NEW** | `src/led/led_controller.h/.cpp` | Writer thread, rate limiter, brightness ceiling |
| `beyond_backglow_ctl.exe` | **NEW** | `src/backglow_ctl/main.cpp` | VRChat OSC daemon + pipe client |
| `beyond_prox_ctl.exe` | **MODIFIED** | `src/ctl/main.cpp` | Add `led` command validation |
| `CMakeLists.txt` | **MODIFIED** | root | Add `src/led/` sources to driver, new `beyond_backglow_ctl` target |

## Patterns to Follow

### Pattern 1: Background Thread with Atomic State (from HidDevice)

**What:** Background thread does blocking I/O; RunFrame reads results via atomics.
**Existing proof:** `HidDevice::m_lastProxDistance` is `atomic<uint16_t>` read from RunFrame, written from reader thread. `m_connectionState` is `atomic<int>`. No locks on hot path.

LedController follows the same pattern in reverse (RunFrame writes intent, background thread reads and sends).

### Pattern 2: Pipe Command Prefix Routing

**What:** Commands are routed by string prefix (`proximity`, `ipd`, `led`).
**Existing proof:** `HandlePipeCommand` uses `strcmp`/`strncmp` for `proximity`, `ipd`, `status`, `load_lh_config`. New `led` prefix slots in identically.

### Pattern 3: Compile-Time Feature Flag

**What:** `#ifdef ENABLE_BACKGLOW` wraps all backglow code for compile-out capability.
**Existing proof:** `ENABLE_IPD_PERSIST` wraps IPD persistence code. Same pattern.

### Pattern 4: Reconnect Loop in Background Thread

**What:** Background thread detects device loss and retries connection periodically.
**Existing proof:** HidDevice's reader thread handles HID reconnect. WledSerialTransport writer thread should handle COM port reconnect the same way.

## Anti-Patterns to Avoid

### Anti-Pattern 1: WiFi-First Communication

**What:** Using WLED's HTTP/UDP API over WiFi as the primary transport.
**Why bad:** WiFi adds 5-50ms jitter, requires network configuration, may conflict with VR streaming bandwidth, and ESP32-C3 WiFi can interfere with USB CDC stability. The headset is physically tethered via USB already.
**Instead:** USB serial first. WiFi/DDP only as future fallback for untethered setups.

### Anti-Pattern 2: Blocking Serial in RunFrame

**What:** Calling `WriteFile` on the COM port directly from `DeviceProvider::RunFrame()`.
**Why bad:** USB CDC serial can block for 1-50ms if device buffer is full. This stalls ALL SteamVR drivers sharing the server thread.
**Instead:** Queue update via atomic flag, let dedicated writer thread handle I/O.

### Anti-Pattern 3: WLED JSON API for Per-LED Realtime Updates

**What:** Sending `{"seg":{"i":["FF0000","00FF00",...]}}\n` over serial for every frame.
**Why bad:** JSON parsing overhead on ESP32, larger payload, WLED docs note JSON is 2-4x less bandwidth-efficient than binary protocols. Also, WLED JSON via serial is limited to state object only.
**Instead:** Adalight protocol for per-LED color streaming (binary, compact). JSON only for non-realtime operations (brightness, power).

### Anti-Pattern 4: OSC Listener in the Driver DLL

**What:** Running a UDP socket server inside vrserver.exe to receive VRChat OSC.
**Why bad:** Firewall pop-ups during VR, port conflicts with other OSC tools, UDP error = potential vrserver crash. Violates single-responsibility.
**Instead:** Separate `beyond_backglow_ctl.exe` daemon. Crashes don't affect VR session.

### Anti-Pattern 5: Separate Named Pipe for Backglow

**What:** Creating `\\.\pipe\beyond_backglow_ctl` as a second pipe in the driver.
**Why bad:** Requires second polling path or dedicated thread in RunFrame. Splits the control surface. Adds complexity for no benefit.
**Instead:** Extend existing pipe with `led` command prefix. One pipe, one control surface.

## COM Port Discovery

**Phase 1 (build first):** Manual configuration. COM port string in VRSettings (`driver_BeyondProximity.led_com_port`).

**Phase 3 (polish):** Auto-detection.
1. Enumerate COM ports via `SetupDiGetClassDevs` with `GUID_DEVINTERFACE_COMPORT`
2. Match by USB VID/PID: ESP32-C3 USB CDC is typically VID `0x303A` / PID `0x1001` (Espressif)
3. If multiple matches, try each and send `{"v":true}` -- WLED responds with version info
4. Cache discovered port in VRSettings

## Scalability Considerations

| Concern | 10 LEDs (v3.0) | 50 LEDs (future) | 100+ LEDs |
|---------|----------------|-------------------|-----------|
| Bandwidth | 36 bytes/frame, trivial | 156 bytes/frame, fine at 115200 | Consider 230400+ baud or DDP over UDP |
| Update rate | 30fps fine | 30fps fine | May need per-segment updates |
| Protocol | Adalight over serial | Adalight over serial | Switch to DDP over WiFi/UDP |
| Threading | Single writer thread | Single writer thread | Single writer thread still fine |

## Sources

- WLED Serial Interface: https://kno.wled.ge/interfaces/serial/
- WLED JSON API: https://kno.wled.ge/interfaces/json-api/
- WLED UDP Realtime: https://kno.wled.ge/interfaces/udp-realtime/
- WLED DDP Protocol: https://kno.wled.ge/interfaces/ddp/
- WLED Compatible Controllers: https://kno.wled.ge/basics/compatible-controllers/
- VRChat OSC Avatar Parameters: https://docs.vrchat.com/docs/osc-avatar-parameters
- VRChat OSC Overview: https://docs.vrchat.com/docs/osc-overview
- VRChat OSC DIY: https://docs.vrchat.com/docs/osc-diy
- VRChat Contacts: https://creators.vrchat.com/common-components/contacts/
- ESP32-C3 Serial Connection: https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/get-started/establish-serial-connection.html
- HyperSerialWLED (AWA protocol): https://github.com/awawa-dev/HyperSerialWLED
- OpenShock VRChat Setup (OSC pattern reference): https://wiki.openshock.org/guides/shockosc/avatar-setup-vrc

---
*Architecture research for: Backglow LED control integration with existing sidecar driver*
*Researched: 2026-04-05*
