# Stack Research: v3.0 Backglow LED Control

**Domain:** SteamVR driver -- driving WS2812B LEDs via WLED/ESP32-C3 over USB serial, with WiFi/DDP fallback and VRChat control interfaces
**Researched:** 2026-04-05
**Confidence:** HIGH (protocols well-documented, libraries mature)

This document covers ONLY what is new for v3.0. The existing stack (C++17, OpenVR SDK v2.5.1, HIDAPI 0.14.0, MSVC 2022, CMake, Inno Setup 6, named pipe server) is validated and unchanged.

## Recommended Stack Additions

### Core: USB Serial Communication (WLED Adalight Protocol)

| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Windows Serial API (`CreateFile`/`WriteFile`) | Win32 | USB serial I/O to WLED ESP32-C3 | Zero dependencies. The driver already runs on Windows only. Win32 serial is the standard approach for COM port access in C++. No need for a library -- the protocol is trivial. |

**No serial library needed.** The Adalight protocol is 6-byte header + 30 bytes RGB data (10 LEDs x 3 bytes). Total frame is 36 bytes at 115200 baud = 0.3ms per frame. Win32 `CreateFile("COM3", ...)` with `SetCommState` for baud rate is ~30 lines of setup code.

### Fallback: DDP over UDP (WiFi path)

| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| Winsock2 (`ws2_32.lib`) | Win32 | UDP socket for DDP packets | Already available in Windows SDK. DDP is a 10-byte header + RGB payload sent as a single UDP datagram. No library needed for 10 LEDs. |

**No DDP library needed.** The protocol is simple enough to implement directly. Existing C++ DDP libraries are in Rust/Go/Python -- no mature C++ library exists. For 10 LEDs, a DDP frame is 10-byte header + 30 bytes payload = 40 bytes per UDP packet. Implementing from spec is less work than wrapping a foreign-language library.

### VRChat Control: OSC Library

| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| oscpp | latest (header-only) | OSC packet construction/parsing | Header-only C++11, zero allocation, 155 stars. No transport included -- pairs with our own UDP socket. Fits the project's minimal-dependency philosophy. ISC/MIT-compatible license. |

**Why oscpp over alternatives:**
- **tinyosc** (281 stars): Pure C, requires compilation of .c file. oscpp is header-only C++ which integrates more cleanly.
- **liblo** (SourceForge): Full networking stack included, POSIX-oriented, heavier than needed. We only need packet construction + parsing, not a server framework.
- **oscpack**: Unmaintained (last update years ago), includes its own networking layer.

oscpp handles ONLY packet encoding/decoding. We use Winsock2 UDP for transport (same socket as DDP, different port).

### Optional Future: HTTP/WebSocket Client

| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| cpp-httplib | v0.18+ | HTTP client for WAN relay | Header-only, single file, C++11. Only needed IF a WAN relay architecture is pursued. Do NOT add until that path is validated. |

**Defer this.** WAN relay is speculative. The primary control path (OSC) doesn't need HTTP. If WAN relay becomes real, cpp-httplib is the right choice -- single header, no dependencies, blocking I/O is fine for our use case (background thread sends LED updates).

## Protocol Specifications

### WLED Adalight Serial Protocol (PRIMARY -- USB path)

**Confidence: HIGH** -- verified against WLED source code (`wled_serial.cpp`)

The Adalight protocol sends raw RGB data to WLED over serial. WLED's serial parser auto-detects the protocol from the first byte.

#### Packet Format

```
Byte 0:    'A' (0x41)           -- magic word start
Byte 1:    'd' (0x64)           -- magic word middle
Byte 2:    'a' (0x61)           -- magic word end
Byte 3:    count_hi             -- (numLeds - 1) >> 8
Byte 4:    count_lo             -- (numLeds - 1) & 0xFF
Byte 5:    checksum             -- count_hi ^ count_lo ^ 0x55
Byte 6+:   R, G, B, R, G, B... -- 3 bytes per LED, numLeds times
```

**Note:** The count field is `numLeds - 1` (zero-indexed). For 10 LEDs: count_hi = 0x00, count_lo = 0x09, checksum = 0x00 ^ 0x09 ^ 0x55 = 0x5C.

#### Complete 10-LED Frame Example

```
41 64 61 00 09 5C [R0 G0 B0] [R1 G1 B1] ... [R9 G9 B9]
```

Total: 6 header + 30 data = 36 bytes.

#### Baud Rate

Default: 115200. More than sufficient for 10 LEDs.

At 115200 baud, 36 bytes takes ~0.31ms. Even at 60 FPS (16.6ms frame budget), serial overhead is negligible.

Baud rate can be changed by sending a single command byte:

| Byte | Rate |
|------|------|
| 0xB0 | 115200 |
| 0xB1 | 230400 |
| 0xB2 | 460800 |
| 0xB3 | 500000 |
| 0xB4 | 576000 |
| 0xB5 | 921600 |
| 0xB6 | 1000000 |
| 0xB7 | 1500000 |

**Recommendation:** Stay at 115200. For 10 LEDs there is no benefit to higher rates, and it avoids needing to coordinate baud rate changes.

#### Other Useful Serial Commands

| Command | Response | Purpose |
|---------|----------|---------|
| `'v'` (0x76) | WLED version string | Detect WLED device on COM port |
| `'{'` + JSON | JSON state | Full WLED JSON API (brightness, effects, etc.) |
| `'o'` (0x6F) | None | Disable serial streaming mode |
| `'O'` (0x4F) | None | Enable continuous serial streaming |

**JSON API over serial** is the path for global brightness control: `{"bri":128}` sets master brightness 0-255. This coexists with Adalight pixel data.

#### WLED Configuration Required

In WLED Sync Settings, "Serial" must be enabled. The "Realtime" mode must allow serial input. This is default-on in stock WLED firmware.

### DDP Protocol (FALLBACK -- WiFi path)

**Confidence: HIGH** -- protocol spec from 3waylabs.com, verified against WLED implementation

DDP sends pixel data over UDP to port 4048. WLED accepts DDP natively with no configuration.

#### Packet Format

```
Byte 0:  Flags
         [7:6] = version (01 = v1)
         [5]   = timecode present (0 = no)
         [4]   = storage (0 = not stored)
         [3]   = reply requested (0 = no)
         [2]   = query (0 = no)
         [1]   = push/final packet (1 = yes, display now)
         [0]   = reserved
Byte 1:  [7:4] = reserved, [3:0] = sequence number (1-15, or 0)
Byte 2:  Data type
         [7:3] = reserved
         [2:0] = bits per channel minus 1 (7 = 8-bit)
Byte 3:  Source/destination ID (0 = default)
Bytes 4-7:  Data offset (uint32 big-endian, byte offset into buffer)
Bytes 8-9:  Data length (uint16 big-endian, number of data bytes)
Bytes 10+:  Pixel data (R, G, B, R, G, B...)
```

#### Complete 10-LED DDP Packet

```
Header: 41 01 07 00 00000000 001E
Data:   [R0 G0 B0] ... [R9 G9 B9]
```

- Flags 0x41: version 1, push=1 (final packet, display immediately)
- Sequence: 0x01
- Data type: 0x07 (8 bits per channel = 0b111)
- ID: 0x00
- Offset: 0x00000000
- Length: 0x001E (30 bytes = 10 LEDs x 3)
- Total: 10 header + 30 data = 40 bytes

**WLED limitation:** Does not read timecodes in DDP headers. Do not include timecode field.

### ESP32-C3 USB Communication (MagWLED-1)

**Confidence: HIGH** -- ESP32-C3 datasheet + MagWLED-1 product page

The ESP32-C3 has a built-in USB Serial/JTAG Controller. This is a hardware-fixed CDC-ACM device -- it appears as a standard COM port on Windows with no special driver.

#### Key Facts

- **Interface:** USB Serial/JTAG Controller (hardware CDC-ACM, not USB-OTG)
- **Windows driver:** Built-in. Windows 10/11 auto-detects as a COM port. No driver installation needed.
- **MagWLED-1 connector:** USB-C with Power Delivery negotiation (5V/12V via DIP switch)
- **WLED pre-installed:** MagWLED-1 ships with WLED firmware. Adalight serial input works out of the box.
- **LED capacity:** 512 pixels at good frame rates (we need 10, so zero concern)
- **Power draw:** 0.35W idle (ESP32-C3 + switching regulator)
- **Board size:** 48x32mm, 7.6mm max height -- fits inside facial interface

#### COM Port Detection Strategy

To find the WLED device:
1. Enumerate COM ports via `SetupDiGetClassDevs(GUID_DEVINTERFACE_COMPORT)`
2. Check VID/PID for ESP32-C3 USB Serial/JTAG: VID=0x303A, PID=0x1001
3. Open port, send `'v'` command, verify WLED version response
4. If not found, fall back to WiFi/DDP path

### VRChat OSC Interface (Avatar Parameters)

**Confidence: HIGH** -- well-documented official API

VRChat exposes bidirectional OSC for avatar parameters. This is the viable control path.

#### How It Works

| Direction | Port | Address Format | Types |
|-----------|------|----------------|-------|
| External -> VRChat | UDP 9000 | `/avatar/parameters/{name}` | int, float, bool |
| VRChat -> External | UDP 9001 | `/avatar/parameters/{name}` | int, float, bool |

**For backglow control, VRChat SENDS parameter values OUT on port 9001.** Our driver listens on 9001 for parameter changes.

#### Architecture

```
VRChat World (Udon) -> Avatar Contact Receiver -> Avatar Parameter change
    -> VRChat OSC output (port 9001) -> bey-closer driver -> WLED LEDs
```

A VRChat world can influence avatar parameters via:
- **Contact Senders/Receivers:** World objects with VRCContactSender collide with avatar's VRCContactReceiver, driving animator parameters
- **Animator override:** World-placed animator controllers can set avatar parameter values

The avatar must have parameters named to match (e.g., `BackglowR`, `BackglowG`, `BackglowB`, `BackglowBrightness`). The world triggers parameter changes via contacts; VRChat's OSC system exports them to port 9001.

**This is an indirect path:** World -> Contact -> Avatar Parameter -> OSC -> Driver. It requires the user's avatar to have the matching parameters configured. This is the standard pattern used by VRChat hardware integrations (face tracking, haptics, etc.).

### VRChat World-External Communication (Direct)

**Confidence: HIGH** -- confirmed NOT feasible for real-time

| Method | Real-time? | Viable? | Why |
|--------|-----------|---------|-----|
| OSC from worlds (Udon) | N/A | NO | Not implemented. Feature request pending. VRChat explicitly does not support OSC in Udon. |
| VRCStringDownloader | No (5s rate limit) | NO | One download per 5 seconds, queued randomly. Completely unusable for LED control. |
| MIDI | Technically yes | MAYBE | Udon has MIDI input. External helper apps exist (Udon-MIDI-Web-Helper). Extremely hacky. |
| WebSocket/HTTP | N/A | NO | Not available in Udon. Sandboxed. |

**Conclusion:** There is NO direct world-to-external real-time communication path in VRChat. The avatar parameter -> OSC path is the only viable approach. This is actually fine -- it's the same pattern every VRChat hardware integration uses.

## What NOT To Add

| Technology | Why Not |
|------------|---------|
| libserialport / Boost.Asio serial | Overkill. Win32 serial API is 30 lines. We only support Windows. |
| Any DDP library | None exist in C++. Protocol is 10-byte header construction. |
| liblo / oscpack | Too heavy. oscpp is header-only and does exactly what we need. |
| WebSocket library | No use case until WAN relay is validated. Defer. |
| cpp-httplib | No use case until WAN relay is validated. Defer. |
| HIDAPI for WLED | WLED is not an HID device. It's CDC-ACM serial. |
| Boost anything | Project has zero Boost dependencies. Keep it that way. |

## Integration Points with Existing Code

### Named Pipe Extensions

The existing pipe server (`\\.\pipe\beyond_proximity_ctl`) gets new commands:

| Command | Purpose |
|---------|---------|
| `backglow <r> <g> <b>` | Set all 10 LEDs to uniform color (0-255 each) |
| `backglow led <n> <r> <g> <b>` | Set individual LED (0-9) |
| `backglow brightness <0-255>` | Set global max brightness ceiling |
| `backglow off` | All LEDs off |
| `backglow status` | Report connection state, current colors, brightness |

### New Source Files (Estimated)

| File | Purpose |
|------|---------|
| `src/driver/backglow_transport.h` | Abstract interface: `SetPixel(n, r, g, b)`, `Show()`, `SetBrightness(b)` |
| `src/driver/serial_transport.cpp/h` | Adalight-over-serial implementation (primary) |
| `src/driver/ddp_transport.cpp/h` | DDP-over-UDP implementation (fallback) |
| `src/driver/osc_listener.cpp/h` | UDP listener on port 9001 for VRChat OSC parameters |
| `src/driver/backglow_controller.cpp/h` | Orchestration: transport selection, parameter mapping, brightness ceiling |

### Thread Architecture

```
Existing threads:
  - RunFrame (main driver loop, ~1ms tick)
  - HID reader (background, USB read loop)
  - Pipe server (background, named pipe accept loop)

New threads:
  - OSC listener (background, UDP recv loop on port 9001)
  - Serial writer (could be synchronous in RunFrame -- 0.3ms per frame is fine)
```

**Serial writes can be synchronous in RunFrame.** At 36 bytes / 115200 baud = 0.31ms, this is well within the ~1ms RunFrame budget. No dedicated writer thread needed unless latency proves problematic.

## Dependencies Summary

### New compile-time dependencies

| Dependency | Type | Files | License |
|------------|------|-------|---------|
| oscpp | Header-only | Drop into `extern/oscpp/` | ISC (permissive) |

### New link-time dependencies

| Library | Type | Notes |
|---------|------|-------|
| `ws2_32.lib` | Windows SDK | For UDP sockets (DDP + OSC). May already be linked via OpenVR. |
| `setupapi.lib` | Windows SDK | For COM port enumeration. May already be linked. |

### No new external downloads

Everything else is Win32 API or existing project dependencies.

## Installation

```bash
# Add oscpp header-only library
git subtree add --prefix extern/oscpp https://github.com/kaoskorobase/oscpp.git master --squash
# Or simply download and copy include/oscpp/ into extern/oscpp/include/oscpp/

# CMake addition:
# target_include_directories(driver_beyond_proximity PRIVATE extern/oscpp/include)
# target_link_libraries(driver_beyond_proximity ws2_32 setupapi)
```

## Alternatives Considered

| Category | Recommended | Alternative | Why Not |
|----------|-------------|-------------|---------|
| Serial library | Win32 API | libserialport, Boost.Asio | Windows-only project; Win32 is simpler and zero-dependency |
| DDP library | Hand-rolled (10-byte header) | ddp-rs (Rust), ddp (Go) | No C++ library exists; protocol is trivial to implement |
| OSC library | oscpp (header-only) | tinyosc (C), liblo (full stack) | Header-only fits project; no networking baggage |
| VRChat control | Avatar OSC parameters | World OSC (unavailable), String loading (5s delay) | Only avatar OSC works for real-time; world OSC doesn't exist |
| HTTP client | Defer (cpp-httplib when needed) | libcurl, Boost.Beast | No current use case; don't add speculative deps |
| Transport abstraction | Interface class + 2 impls | Single monolithic class | Clean separation enables testing and future transports |

## Sources

- [WLED Serial Documentation](https://kno.wled.ge/interfaces/serial/) -- Adalight/TPM2 protocol support, baud rate commands, JSON API over serial (HIGH confidence)
- [WLED Serial Source Code](https://github.com/Aircoookie/WLED/blob/main/wled00/wled_serial.cpp) -- Exact Adalight parser state machine, protocol detection logic (HIGH confidence)
- [Adalight Protocol Header Format](https://www.partsnotincluded.com/visualizing-adalight-header-information/) -- Magic word "Ada", count field, checksum formula (HIGH confidence)
- [WLED DDP Documentation](https://kno.wled.ge/interfaces/ddp/) -- Port 4048, no timecode support (HIGH confidence)
- [DDP Protocol Specification](http://www.3waylabs.com/ddp/) -- Header format, flags, data types (HIGH confidence, authoritative spec)
- [ESP32-C3 Serial Connection Guide](https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/get-started/establish-serial-connection.html) -- USB Serial/JTAG controller, CDC-ACM, Windows auto-detection (HIGH confidence)
- [MagWLED-1 Product Page](https://magwled.com/pages/about-magwled-1) -- ESP32-C3, USB-C PD, 512 pixel capacity, board dimensions (HIGH confidence)
- [VRChat OSC Avatar Parameters](https://docs.vrchat.com/docs/osc-avatar-parameters) -- Port 9000/9001, address format, bidirectional parameter flow (HIGH confidence)
- [VRChat OSC Overview](https://docs.vrchat.com/docs/osc-overview) -- Port configuration, command line override (HIGH confidence)
- [VRChat String Loading](https://creators.vrchat.com/worlds/udon/string-loading/) -- 5-second rate limit, trusted URLs only (HIGH confidence)
- [VRChat Udon OSC Feature Request](https://feedback.vrchat.com/udon/p/osc-for-worlds-udon) -- Confirmed: world OSC not available, planned for future (HIGH confidence)
- [oscpp GitHub](https://github.com/kaoskorobase/oscpp) -- Header-only C++11, 155 stars, ISC license (HIGH confidence)
- [tinyosc GitHub](https://github.com/mhroth/tinyosc) -- Pure C alternative, 281 stars, ISC license (HIGH confidence)
- [cpp-httplib GitHub](https://github.com/yhirose/cpp-httplib) -- Header-only HTTP client/server, deferred recommendation (HIGH confidence)

---
*Stack research for: v3.0 Backglow LED Control*
*Researched: 2026-04-05*
