Skip to content

Add Host Lighting add-on: live RGB LED control from host software over a vendor HID interface - #1691

Open
djGLiTCH wants to merge 10 commits into
OpenStickCommunity:mainfrom
djGLiTCH:20260811-host-lighting-protocol
Open

Add Host Lighting add-on: live RGB LED control from host software over a vendor HID interface#1691
djGLiTCH wants to merge 10 commits into
OpenStickCommunity:mainfrom
djGLiTCH:20260811-host-lighting-protocol

Conversation

@djGLiTCH

@djGLiTCH djGLiTCH commented Aug 11, 2026

Copy link
Copy Markdown

Summary

Host Lighting is an optional add-on exposing a second vendor HID interface
(usage page 0xFF47, fixed 64-byte reports) that lets host software drive
the board's RGB LEDs while the controller interface operates normally.
Hosts can address individual buttons, the case strip and player LEDs, in
whole-frame or overlay mode; the board's own animations resume when the
host releases control, disconnects, or goes quiet past a keepalive
timeout.

The board serves its own LED map, colour order, identity, runtime state
and animations over the same interface, read from the live configuration -
no per-board data exists on the host side, and user remaps are picked up
automatically. Discovery is by the vendor usage page, which is stable
across input modes and VID/PID overrides.

Disabled by default like all add-ons; while disabled, every mode presents
byte-identical descriptors to stock firmware.

Trying it

CI artifacts from this PR contain a UF2 per board config. Flash one,
enable the add-on under Configuration -> Add-Ons -> Host Lighting, and the
lighting interface enumerates alongside the controller.

Prebuilt UF2s for every board, from this PR's exact commit, are also
published here if that is easier: https://github.com/djGLiTCH/GP2040-CE/releases/tag/HLP_v1.0
The same release carries a set built on top of the LED refactor (#1514)
for anyone testing that combination, since CI does not build those.

Python reference clients are attached below (discovery, capability decode,
streaming, per-button tests); hlp-ping.py runs a conformance pass
against a connected board. The attachment is self-contained for quick
testing against this PR; OpenStickCommunity/gp2040ce-binary-tools#12
proposes packaged, cross-platform versions of the everyday tools as
their permanent home.

HLP Python Reference Clients v1.0: hlp-tools_HLP-v1.0.zip
HLP Python Reference Clients v1.1: hlp-tools_HLP-v1.1.zip
HLP Python Reference Clients v1.2: hlp-tools_HLP-v1.2.zip
HLP Python Reference Clients v1.3: hlp-tools_HLP-v1.3.zip

Design notes

  • Protocol 1.0 with a written compatibility contract: command IDs sit in
    function ranges with reserved slots; from 1.0, IDs and payload layouts
    are frozen and minor versions are additive-only. Full reference in
    docs/host-lighting.md (in this PR).
  • Stock descriptors are untouched; descriptor variants are selected at
    runtime, and CFG_TUD_HID is already 2. Console modes (PS3/PS4/PS5,
    Switch, Xbox) never expose the interface.
  • XInput behaviour is configurable (AUTO/ON/OFF, default AUTO). AUTO
    boots with the stock console-identical identity and re-enumerates as
    the composite only if the host sends no console authentication within
    ~4 s; the composite binds the OS Xbox 360 driver (MS OS 1.0 compatible
    ID on Windows, xpad vendor match on Linux). Verified against a real
    Xbox 360 console (see Validation). ON forces the composite; OFF keeps
    XInput stock.
  • Hosts edit a staged frame; the COMMIT command publishes it atomically
    to the render core through a sequence-locked double buffer, so
    multi-report frames cannot tear.
  • Config writes go through the existing deferred GPStorageSaveEvent path;
    nothing writes flash from USB context. SET_INPUT_MODE validates against
    an explicit mode list; unknown modes are rejected rather than rebooted
    into.

LED refactor (#1514)

Ported experimentally onto #1514's head: 13 of the 15 modified files and
all new files applied verbatim; the adaptations needed were the
render-hook line and the function reading the light layout for
capabilities. The full test suite passed on that pipeline at the same
throughput. On the refactor, the positions capability page carries real
per-light coordinates (empty on the classic pipeline), and controls with
multiple wired LEDs light all of them. I can rebase this PR onto the
refactor once it is accepted into main, or hold this PR until then.

Validation

Hardware: Haute42 COSMOX (16 LEDs) and COSMOX M-Ultra (46 LEDs including
a 30-LED case strip), both render pipelines, plus a real Xbox 360 console.

  • Conformance: protocol 1.0 reported, all command IDs recognized, unknown
    commands rejected, capabilities matching each board's wiring (case
    strip included; per-light positions on the refactor pipeline).
  • Streaming: 300/300 commits acknowledged at a sustained 60 fps (three
    staging reports plus COMMIT per frame); overlay compositing over live
    animations; timeout fallback; input-mode round-trips.
  • Disabled state: stock 045E:028E identity on the OS driver, no lighting
    interface on the bus, auto-detect never fires.
  • Xbox 360 console: AUTO keeps the stock identity, no re-enumeration,
    inputs behave as stock.
  • Multi-board: two boards enumerate as independent devices with distinct
    factory IDs and were driven simultaneously without cross-talk.
  • Input latency versus stock main (5fb6404), same board and session,
    identical loop-counter probe: ~0.2 us added to the input sampling
    interval at 60 fps and ~0.3 us at 100 fps (~0.03 % of the 1 ms USB
    polling budget); no configuration measured slower than stock; the cost
    was unchanged on the board needing twice the USB traffic per frame;
    enabled-but-idle is not measurable. Rendering stays capped by the
    existing 100 Hz LED tick.
  • All board configs compile for RP2040 and RP2350, both pipelines.
  • Development approach and tooling were discussed with the team on
    Discord ahead of this PR.

Scope

New AddonOptions entry (field 31) with an enable flag and an XInput
lighting mode (OFF/ON/AUTO). Disabled by default like all add-ons;
defaults overridable per board via HOST_LIGHTING_ENABLED and
HOST_LIGHTING_XINPUT board config defines.
Vendor HID protocol (fixed 64-byte reports) letting host software drive
the RGB LEDs live: staged frame edits published atomically to the render
loop by COMMIT, whole-frame or overlay takeover, and an automatic return
to on-board animations on RELEASE or keepalive timeout. GET_CAPS serves
the board's LED map, identity, runtime state, animations and per-light
positions from the live configuration, so hosts need no per-board data.
Frames cross to the render core through a sequence-locked double buffer;
colour conversion is cached so steady-state render ticks reduce to a
copy. Config writes use the deferred GPStorageSaveEvent path.

The interface is not yet referenced by any USB descriptor; this commit
is inert until the transport commits.
Second configuration-descriptor variants append the lighting HID
interface next to the gamepad interface; the variant is selected at
runtime and the stock descriptors are untouched, so the add-on disabled
means byte-identical descriptors to stock firmware. usbdriver routes
HID class callbacks for the lighting instance to the protocol module.
A composite descriptor variant carries the XUSB gamepad interface plus
the lighting interface, bound to the OS Xbox 360 driver on Windows via
MS OS 1.0 compatible-ID descriptors and on Linux via the kernel xpad
vendor match. In AUTO mode the board boots with its stock
console-identical identity and only re-enumerates as the composite when
the host has sent no console authentication within the detection window
- consoles never see anything but stock behaviour. ON forces the
composite (PC-only); OFF keeps XInput fully stock.
Enable switch and XInput lighting mode select, following the existing
add-on section pattern; the two settings are exposed through the
get/setAddonsOptions API.
Feature overview, per-mode behaviour, discovery, the full protocol
reference, a typical host flow, and measured performance notes.
Capability page 5 serves a light table: one fixed-stride record per
light naming the control that owns it, the GPIO and action behind that
control, player index, case group and grid position. Page 2 carries one
range per button ID and so cannot describe a control that owns several
lights, which boards do when two physical buttons share an action. Page
1 gains a feature bitmask, the LED framework, the animation namespace
and the render rate, each reserving zero for "not reported", so a host
reads what a board offers instead of inferring it. Button IDs 18-19 and
30-41 name A3, A4 and E1-E12 in the light table; SET_BUTTONS still
stages only the IDs v1.0 could. Both per-record flags are positive
assertions, so a record asserting nothing reads as the weaker case: had
the second bit meant "synthesised", an all-zero record would have passed
as a genuine per-light table, itself a legal state and so
indistinguishable from one never filled in.

The map fingerprint now covers the whole pin map rather than per-control
lookups, so a profile switch that remaps pins invalidates a cached map
even though no stored LED index moved. Staged writes are bounds-checked
against the addressable space, and a case range whose configured index
falls outside it reports as absent rather than publishing a range no
pixel occupies. The takeover is released on USB unmount and suspend
instead of waiting out the keepalive.

Page 2 [6] reports the extent of the mapped range rather than the sum of
the ranges above it, which is the number a host needs to size a frame;
SET_MODE's timeout gains a 10 s ceiling. Both are documented in the
compatibility section, alongside a correction: SET_BUTTONS colours every
light of a control only on the LED-refactor pipeline, which the docs had
stated unconditionally. Command IDs and payload layouts are unchanged
and the caps format byte stays at 2, so v1.0 hosts are unaffected.
SET_LIGHT stages single lights by their page 5 ordinal, so a host can
colour one light of a control that owns several without deriving raw LED
indexes from the table first. Entries are batched like SET_BUTTONS and
the reply counts applied and skipped the same way; an ordinal at or past
the reported total is skipped, exactly as a control without a light is.

SET_BUTTONS entries naming the extended IDs - A3, A4 and E1-E12 - now
apply on boards that have such lights, through the same action lookup
the canonical IDs use, and count as skipped where there is none. They
were previously report-only, which made them the one part of the light
table a host could see but not act on semantically.

Both are additive: a new command in a reserved staging slot, and a skip
that becomes an apply where a light exists, which is the same observable
change as a user wiring a new light. Hosts detect the version by PING
minor >= 2; nothing existing moved and the caps format byte stays at 2.
SET_LIGHT replies now carry a per-entry outcome mask in bytes [5..6]:
bit n set means entry n applied, the popcount equals the applied count,
and bits at or above the entry count stay zero. A skipped entry names
the stale ordinal directly, so a host re-walks page 5 when its cached
table has actually moved rather than on every skip. Earlier firmware
zero-filled these bytes, so a host gates on the minor version before
reading them.

SET_LIGHT_RGBW stages up to twelve five-byte entries per report, the
RGBW pairing SET_RANGE already had; boards whose chain has no white
channel ignore the fifth byte, exactly as SET_RANGE_RGBW does, and the
reply carries the same counts and mask. Both staging commands share one
handler, so their ordinal handling cannot drift apart.

The render conversion honours a host-supplied white byte on chains that
have the channel. RGB::value()'s achromatic shortcut never reads W,
which would have rendered the textbook subtractive white (0,0,0,W)
black, so host pixels carrying W now compose their channel word
directly with the same per-channel arithmetic. Hosts that send no
white byte - every host predating these commands - render exactly as
before, achromatic colours mapped to the white emitter.

All additive: the mask occupies bytes v1.2 zero-filled, the new
command sits in a reserved staging slot, and hosts detect the version
by PING minor >= 3; nothing existing moved and the caps format byte
stays at 2.
@djGLiTCH

Copy link
Copy Markdown
Author

HLP v1.1, v1.2, and v1.3 are now on the branch, as one commit each. All are additive: no command ID or existing payload moved, the capability format byte stays at 2, and a v1.0 host keeps working against a v1.3 board unchanged.

HLP Version History

v1.0 v1.1 v1.2 v1.3
Released 11 Aug 2026 16 Aug 2026 16 Aug 2026 17 Aug 2026
Commands 14 14 15 (+ SET_LIGHT 0x15) 16 (+ SET_LIGHT_RGBW 0x16)
Capability pages 0-4 0-5 (+ light table) 0-5 0-5
Page 1 runtime state mode, profile, brightness, player, fingerprint, animation index + feature bitmask, LED framework, animation namespace, render Hz unchanged unchanged
Per-light visibility one range per control (page 2) every light named, with owner, GPIO, position and flags (page 5) unchanged unchanged
Per-light addressing raw index (SET_RANGE) raw index (SET_RANGE) by page 5 ordinal (SET_LIGHT) + RGBW by ordinal (SET_LIGHT_RGBW)
Staging reply detail applied/skipped counts unchanged unchanged + per-entry outcome mask naming each skip
Stageable button IDs 0-17 + specials 24-29 same (18-19, 30-41 report-only) every named ID, incl. A3/A4 and E1-E12 unchanged
Fingerprint scope per-control lookups whole pin map; also echoed on page 5 unchanged unchanged
Caps format byte 2 2 2 2
Detection PING magic GPHL minor >= 1 minor >= 2 minor >= 3
Round-trip / 60 fps stream 2.0 ms / 300 of 300 2.0 ms / 300 of 300 2.0 ms / 300 of 300 2.0 ms / 300 of 300

Test builds for every protocol version are published on my fork, each with Classic and Refactor images for all supported boards plus the matching host tools: HLP v1.0, HLP v1.1, HLP v1.2, and HLP v1.3 (v1.3 is the current one to test against).

Gate on >= v1.0 and read the capability pages; detect the newer surfaces by PING minor version, never an exact match.

v1.1:
Capability page 5, the light table: one 12-byte record per light (owning button ID, GPIO, action, player index, case group, grid position, flags), paged four per report, fingerprinted. It is the only page that can express a control owning several lights. Page 1 appends a feature bitmask, LED framework, animation namespace and render rate at [12..18], each reserving 0 for "not reported". Also: staged writes are bounds-checked, the fingerprint covers the whole pin map, and the takeover releases on USB disconnect/suspend instead of waiting out the keepalive.

v1.2:
SET_LIGHT (0x15): stage single lights by page 5 ordinal, up to 15 per report, reply counts applied/skipped; out-of-range ordinals skip, same rule as a control without a light. SET_BUTTONS entries naming A3/A4/E1-E12 now apply where such lights exist (previously report-only).

v1.3:
Staging replies define [5..6] as a per-entry outcome mask (bit n set = entry n applied; popcount equals the applied count), so a skip names the stale ordinal instead of forcing a page 5 re-walk. SET_LIGHT_RGBW (0x16): the RGBW pairing SET_RANGE already had, 12 entries per report, W ignored on chains without a white channel. On chains with one, host-supplied W now renders: the framework's achromatic shortcut never read W, so subtractive white (0,0,0,W) drew black - hosts send W = min(R,G,B) with RGB reduced, gated on minor >= 3.

Validation:
80 assertions per pipeline at v1.3, all passing - every command, every capability page, paged walks, out-of-range and mixed batches, mask correctness including a mid-batch skip in the mask's high byte, and older hosts reading newer boards. v1.1 was rig-tested on a Haute42 B16 and a COSMOX M Ultra Gen 2; v1.2/v1.3 on the M-Ultra, with extended-ID staging proven both ways via temporarily configured A3/E3 lights. Latency re-measured at each version: 2.0 ms median round-trip, 300/300 commits at a sustained 60 fps - nothing added runs per frame.

docs/host-lighting.md carries the full reference and changelog. Host tools: OpenStickCommunity/gp2040ce-binary-tools#12; the attached hlp-tools zip is updated for v1.3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant