From 2d13ffb6a453635050f284d1349a7d49169e9fe4 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:51:56 +1000 Subject: [PATCH 1/9] Add HostLightingOptions to the configuration 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. --- proto/config.proto | 7 +++++++ proto/enums.proto | 9 +++++++++ src/config_utils.cpp | 6 ++++++ 3 files changed, 22 insertions(+) diff --git a/proto/config.proto b/proto/config.proto index 860aaaf3e..1ac575021 100644 --- a/proto/config.proto +++ b/proto/config.proto @@ -908,6 +908,12 @@ message HETriggerOptions optional int32 smoothingFactor = 13; } +message HostLightingOptions +{ + optional bool enabled = 1; + optional HostLightingXInputMode xinputMode = 2; +} + message AddonOptions { optional BootselButtonOptions bootselButtonOptions = 1; @@ -940,6 +946,7 @@ message AddonOptions optional GamepadUSBHostOptions gamepadUSBHostOptions = 28; optional TG16Options tg16Options = 29; optional HETriggerOptions heTriggerOptions = 30; + optional HostLightingOptions hostLightingOptions = 31; } message MigrationHistory diff --git a/proto/enums.proto b/proto/enums.proto index db377d366..920452368 100644 --- a/proto/enums.proto +++ b/proto/enums.proto @@ -170,6 +170,15 @@ enum InputModeAuthType INPUT_MODE_AUTH_TYPE_I2C = 3; } +enum HostLightingXInputMode +{ + option (nanopb_enumopt).long_names = false; + + HOST_LIGHTING_XINPUT_MODE_OFF = 0; + HOST_LIGHTING_XINPUT_MODE_ON = 1; + HOST_LIGHTING_XINPUT_MODE_AUTO = 2; +} + enum InputModeDeviceType { option (nanopb_enumopt).long_names = false; diff --git a/src/config_utils.cpp b/src/config_utils.cpp index df4c67bd2..18f027ecf 100644 --- a/src/config_utils.cpp +++ b/src/config_utils.cpp @@ -10,6 +10,7 @@ #include "BoardConfig.h" #include "GamepadConfig.h" +#include "hostlighting.h" #include "version.h" #include "addons/analog.h" #include "addons/board_led.h" @@ -1129,6 +1130,11 @@ void ConfigUtils::initUnsetPropertiesWithDefaults(Config& config) INIT_UNSET_PROPERTY(config.addonOptions.focusModeOptions, macroLockEnabled, !!FOCUS_MODE_MACRO_LOCK_ENABLED); + // addonOptions.hostLightingOptions + INIT_UNSET_PROPERTY(config.addonOptions.hostLightingOptions, enabled, !!HOST_LIGHTING_ENABLED); + INIT_UNSET_PROPERTY(config.addonOptions.hostLightingOptions, xinputMode, + HOST_LIGHTING_XINPUT ? HOST_LIGHTING_XINPUT_MODE_AUTO : HOST_LIGHTING_XINPUT_MODE_OFF); + // addonOptions.gamepadUSBHostOptions INIT_UNSET_PROPERTY(config.addonOptions.gamepadUSBHostOptions, enabled, GAMEPAD_USB_HOST_ENABLED) From 2c840cf59ca601fb8a898e13b546efef2dd17217 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:51:56 +1000 Subject: [PATCH 2/9] Add Host Lighting Protocol module and render hook 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. --- CMakeLists.txt | 1 + headers/hostlighting.h | 166 +++++++++ src/addons/neopicoleds.cpp | 10 + src/hostlighting.cpp | 677 +++++++++++++++++++++++++++++++++++++ 4 files changed, 854 insertions(+) create mode 100644 headers/hostlighting.h create mode 100644 src/hostlighting.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a8f3e87a0..7be481011 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -260,6 +260,7 @@ src/display/GPGFX_UI.cpp src/drivermanager.cpp src/eventmanager.cpp src/layoutmanager.cpp +src/hostlighting.cpp src/peripheralmanager.cpp src/storagemanager.cpp src/system.cpp diff --git a/headers/hostlighting.h b/headers/hostlighting.h new file mode 100644 index 000000000..957c2ee0c --- /dev/null +++ b/headers/hostlighting.h @@ -0,0 +1,166 @@ +/* + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 OpenStickCommunity (gp2040-ce.info) + */ + +#ifndef _HOST_LIGHTING_H_ +#define _HOST_LIGHTING_H_ + +#include + +#include "tusb.h" +#include "enums.pb.h" + +// Host Lighting Protocol (HLP): lets a host application drive the board's RGB +// LEDs over a dedicated vendor HID interface, alongside the regular input +// interface, in XInput, Generic and Keyboard modes (runtime-gated by +// HostLightingOptions in the stored config). +// +// Wire protocol, in fixed 64-byte reports both directions: +// request: [0]=command [1]=sequence [2..]=payload +// reply: [0]=command|0x80 [1]=sequence [2]=status [3..]=payload +// Staging commands (SET_BUTTONS/SET_RANGE/SET_RANGE_RGBW/FILL/CLEAR) edit an +// off-screen frame that COMMIT publishes atomically to the render loop; +// RELEASE - or the host going quiet past the keepalive timeout - hands the +// LEDs back to the on-board animations. GET_CAPS serves five forward-only +// pages (identity, runtime state, LED map, animations, positions) built from +// the board's live configuration, so hosts need no per-board knowledge. +// Management commands select the on-board idle animation (SET_ANIMATION) and, +// magic-guarded, switch input mode or reboot into webconfig or the bootloader. +// +// Config default for enabling the add-on, overridable per board. Disabled by +// default like every other addon; while disabled all modes present +// byte-identical descriptors to stock firmware. +#ifndef HOST_LIGHTING_ENABLED +#define HOST_LIGHTING_ENABLED 0 +#endif + +// Config default for the XInput lighting mode (AUTO when 1, OFF when 0). +// XInput support carries extra weight: the device re-identifies as a composite +// (custom VID:PID + MS OS descriptors bind XUSB via compatible ID) and drops +// the console-only interfaces, so it is PC-only while the composite is active. +#ifndef HOST_LIGHTING_XINPUT +#define HOST_LIGHTING_XINPUT 1 +#endif + +#define HOST_LIGHTING_PROTOCOL_VERSION_MAJOR 1 +#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 0 + +// All transfers are fixed-size reports: [0]=command, [1]=sequence, [2..63]=payload. +// Replies echo the sequence and set bit 7 of the command byte. +#define HOST_LIGHTING_REPORT_SIZE 64 +#define HOST_LIGHTING_RESPONSE_FLAG 0x80 + +// Command IDs are grouped by function with room to grow; new commands are +// assigned within the range matching their purpose. 0x00 is never a command +// and bit 7 is the reply flag, so the command space is 0x01-0x7F. +// 0x01-0x0F: session and discovery +#define HOST_LIGHTING_CMD_PING 0x01 +#define HOST_LIGHTING_CMD_GET_CAPS 0x02 +#define HOST_LIGHTING_CMD_SET_MODE 0x03 +// 0x10-0x2F: frame staging +#define HOST_LIGHTING_CMD_SET_BUTTONS 0x10 +#define HOST_LIGHTING_CMD_SET_RANGE 0x11 +#define HOST_LIGHTING_CMD_SET_RANGE_RGBW 0x12 +#define HOST_LIGHTING_CMD_FILL 0x13 +#define HOST_LIGHTING_CMD_CLEAR 0x14 +// 0x30-0x3F: frame lifecycle +#define HOST_LIGHTING_CMD_COMMIT 0x30 +#define HOST_LIGHTING_CMD_RELEASE 0x31 +// 0x40-0x4F: board features +#define HOST_LIGHTING_CMD_SET_ANIMATION 0x40 +// 0x70-0x7F: privileged management, magic-guarded +#define HOST_LIGHTING_CMD_SET_INPUT_MODE 0x7B +#define HOST_LIGHTING_CMD_REBOOT_WEBCONFIG 0x7C +#define HOST_LIGHTING_CMD_REBOOT_BOOTSEL 0x7F + +#define HOST_LIGHTING_STATUS_OK 0x00 +#define HOST_LIGHTING_STATUS_UNSUPPORTED 0x01 +#define HOST_LIGHTING_STATUS_INVALID_ARG 0x02 + +// Addressable LED space and per-report payload capacities +#define HOST_LIGHTING_MAX_LEDS 100 +#define HOST_LIGHTING_RANGE_MAX_PIXELS 20 +#define HOST_LIGHTING_RANGE_RGBW_MAX_PIXELS 15 +#define HOST_LIGHTING_BUTTONS_MAX_ENTRIES 15 +#define HOST_LIGHTING_POSITIONS_PER_PAGE 19 +#define HOST_LIGHTING_DEFAULT_TIMEOUT_MS 2000 + +#define HOST_LIGHTING_FILL_SCOPE_ALL 0x00 +#define HOST_LIGHTING_FILL_SCOPE_BUTTONS 0x01 +#define HOST_LIGHTING_FILL_SCOPE_CASE 0x02 +#define HOST_LIGHTING_FILL_SCOPE_PLEDS 0x03 + +#define HOST_LIGHTING_TAKEOVER_WHOLE_FRAME 0x00 +#define HOST_LIGHTING_TAKEOVER_OVERLAY 0x01 + +// Button IDs: 0-17 in GP2040-CE canonical order, then addressable specials +#define HOST_LIGHTING_BUTTON_COUNT 18 +#define HOST_LIGHTING_BUTTON_PLED1 24 +#define HOST_LIGHTING_BUTTON_PLED4 27 +#define HOST_LIGHTING_BUTTON_TURBO 28 +#define HOST_LIGHTING_BUTTON_CASE 29 + +// The HID instance index the lighting interface occupies in HID-class modes +// (the gamepad interface enumerates first as instance 0). In XInput mode the +// gamepad interface is vendor-class, so lighting is the only HID instance (0); +// use HostLighting::lightingInstance() for the active mode's value. +#define HOST_LIGHTING_HID_INSTANCE 1 + +// Vendor-defined report descriptor: one 64-byte input and one 64-byte output +// report, no report IDs. Hosts identify the interface by this usage page. +static const uint8_t hostlighting_report_descriptor[] __attribute__((unused)) = +{ + 0x06, 0x47, 0xFF, // USAGE_PAGE (Vendor Defined 0xFF47) + 0x09, 0x4C, // USAGE (0x4C) + 0xA1, 0x01, // COLLECTION (Application) + 0x15, 0x00, // LOGICAL_MINIMUM (0) + 0x26, 0xFF, 0x00, // LOGICAL_MAXIMUM (255) + 0x75, 0x08, // REPORT_SIZE (8) + 0x95, 0x40, // REPORT_COUNT (64) + 0x09, 0x01, // USAGE (0x01) + 0x81, 0x02, // INPUT (Data,Var,Abs) + 0x09, 0x02, // USAGE (0x02) + 0x91, 0x02, // OUTPUT (Data,Var,Abs) + 0xC0 // END_COLLECTION +}; + +namespace HostLighting { + // Modes whose configuration descriptor includes the lighting interface + bool enabledForMode(InputMode mode); + + // Whether the given HID instance is the lighting interface in the active mode + bool isLightingInterface(uint8_t itf); + + // The HID instance index the lighting interface occupies in the active mode + uint8_t lightingInstance(); + + // Whether XInput mode should present the lighting composite identity this + // boot: config ON, or AUTO with a PC-host verdict recorded by auto-detect + bool xinputCompositeActive(); + + // XInput AUTO detection tick, called from the XInput driver's process loop. + // A host that enumerates the (console-identical) stock identity but sends no + // console auth traffic within the detection window is a PC: record the + // verdict in a watchdog scratch register and reboot into the composite. + // The verdict survives soft reboots and clears on power loss (unplug). + void xinputAutoDetectTask(bool consoleAuthSeen); + + const uint8_t * getReportDescriptor(); + + uint16_t getReport(uint8_t report_id, hid_report_type_t report_type, uint8_t * buffer, uint16_t reqlen); + void setReport(uint8_t report_id, hid_report_type_t report_type, const uint8_t * buffer, uint16_t bufsize); + + // Called from the LED render loop (core1) just before the frame is shown. + // While the host holds a fresh takeover, replaces frame[0..ledCount) with the + // host frame, converted to the strip's colour format. `format` is the LEDFormat + // enum value; `brightnessX` is the board's current brightness scale. + void applyToFrame(uint32_t * frame, uint32_t ledCount, float brightnessX, int format); + + // Consumes a pending SET_ANIMATION request; returns the requested on-board + // animation index, or -1 when none is pending. Called from the LED render + // loop (core1), which owns animation selection. + int16_t takeLocalAnimationRequest(); +} + +#endif diff --git a/src/addons/neopicoleds.cpp b/src/addons/neopicoleds.cpp index 1b7b68e5d..0a110d55e 100644 --- a/src/addons/neopicoleds.cpp +++ b/src/addons/neopicoleds.cpp @@ -15,6 +15,7 @@ #include "usbdriver.h" #include "enums.h" #include "helper.h" +#include "hostlighting.h" #define FRAME_MAX 100 #define AL_ROW 5 @@ -612,6 +613,15 @@ void NeoPicoLEDAddon::process() { } } + // A live host-lighting takeover replaces the whole frame; expires back to + // the animations above when the host goes quiet + HostLighting::applyToFrame(frame, (uint32_t)ledCount, as.GetBrightnessX(), neopico.GetFormat()); + + // Host-requested change of the on-board animation + int16_t requestedAnimation = HostLighting::takeLocalAnimationRequest(); + if (requestedAnimation >= 0) + as.SetMode((uint8_t)requestedAnimation); + neopico.SetFrame(frame); neopico.Show(); this->nextRunTime = make_timeout_time_ms(intervalMS); diff --git a/src/hostlighting.cpp b/src/hostlighting.cpp new file mode 100644 index 000000000..63ad173fc --- /dev/null +++ b/src/hostlighting.cpp @@ -0,0 +1,677 @@ +/* + * SPDX-License-Identifier: MIT + * SPDX-FileCopyrightText: Copyright (c) 2026 OpenStickCommunity (gp2040-ce.info) + */ + +#include "hostlighting.h" + +#include + +#include "hardware/watchdog.h" +#include "pico/platform.h" +#include "pico/time.h" +#include "pico/unique_id.h" + +#include "BoardConfig.h" +#include "drivermanager.h" +#include "eventmanager.h" +#include "events/GPStorageSaveEvent.h" +#include "storagemanager.h" +#include "system.h" +#include "usbdriver.h" +#include "version.h" +#include "animation.h" +#include "animationstation.h" + +// Watchdog scratch register holding the XInput AUTO host verdict (scratch[5] +// belongs to System::reboot's BootMode) +#define HOST_LIGHTING_VERDICT_SCRATCH 6 +#define HOST_LIGHTING_VERDICT_PC_HOST 0x484C5043 // "HLPC" +#define HOST_LIGHTING_XINPUT_DETECT_MS 4000 + +#ifndef BOARD_CONFIG_LABEL +#define BOARD_CONFIG_LABEL "Unknown" +#endif + +#define HOST_LIGHTING_CAPS_FORMAT 2 + +static uint8_t responseBuffer[HOST_LIGHTING_REPORT_SIZE] = {}; + +// Staged frame + per-pixel validity: written by SET_* on core0, published by COMMIT. +static uint32_t stagingPixels[HOST_LIGHTING_MAX_LEDS] = {}; +static uint32_t stagingValid[(HOST_LIGHTING_MAX_LEDS + 31) / 32] = {}; + +// Live frame shared with the core1 render loop, guarded by a sequence lock: +// core0 increments liveSeq to odd, writes, increments to even; core1 retries +// its copy until it reads the same even value on both sides. +static volatile uint32_t liveSeq = 0; +static uint32_t livePixels[HOST_LIGHTING_MAX_LEDS] = {}; +static uint32_t liveValid[(HOST_LIGHTING_MAX_LEDS + 31) / 32] = {}; +static volatile bool liveActive = false; +static volatile uint8_t liveTakeover = HOST_LIGHTING_TAKEOVER_WHOLE_FRAME; +static volatile uint16_t liveTimeoutMs = HOST_LIGHTING_DEFAULT_TIMEOUT_MS; +static volatile bool liveApplyBrightness = true; +static volatile uint32_t lastCommandMs = 0; + +// Pending on-board animation selection, applied by the render core +static volatile int16_t pendingAnimationMode = -1; + +struct LedRange { + int16_t first; + uint8_t count; +}; + +// Per-button LED index fields of LEDOptions, in protocol button ID order +static const int32_t LEDOptions::* const buttonIndexFields[HOST_LIGHTING_BUTTON_COUNT] = { + &LEDOptions::indexUp, &LEDOptions::indexDown, &LEDOptions::indexLeft, &LEDOptions::indexRight, + &LEDOptions::indexB1, &LEDOptions::indexB2, &LEDOptions::indexB3, &LEDOptions::indexB4, + &LEDOptions::indexL1, &LEDOptions::indexR1, &LEDOptions::indexL2, &LEDOptions::indexR2, + &LEDOptions::indexS1, &LEDOptions::indexS2, &LEDOptions::indexL3, &LEDOptions::indexR3, + &LEDOptions::indexA1, &LEDOptions::indexA2, +}; + +// Resolves a protocol button ID to its physical LED chain range from the live +// configuration; {-1, 0} when the control has no LED on this board. +static LedRange resolveButton(uint8_t buttonId) { + const LEDOptions & lo = Storage::getInstance().getLedOptions(); + LedRange none = { -1, 0 }; + + if (buttonId < HOST_LIGHTING_BUTTON_COUNT) { + int32_t index = lo.*buttonIndexFields[buttonId]; + uint32_t perButton = (lo.ledsPerButton > 0) ? lo.ledsPerButton : 1; + if ((index < 0) || ((uint32_t)index * perButton >= HOST_LIGHTING_MAX_LEDS)) + return none; + return { (int16_t)(index * perButton), (uint8_t)perButton }; + } + + if ((buttonId >= HOST_LIGHTING_BUTTON_PLED1) && (buttonId <= HOST_LIGHTING_BUTTON_PLED4)) { + if (lo.pledType != PLED_TYPE_RGB) + return none; + const int32_t pledIndexes[4] = { lo.pledIndex1, lo.pledIndex2, lo.pledIndex3, lo.pledIndex4 }; + int32_t index = pledIndexes[buttonId - HOST_LIGHTING_BUTTON_PLED1]; + if ((index < 0) || (index >= HOST_LIGHTING_MAX_LEDS)) + return none; + return { (int16_t)index, 1 }; + } + + if (buttonId == HOST_LIGHTING_BUTTON_TURBO) { + const TurboOptions & turbo = Storage::getInstance().getAddonOptions().turboOptions; + if ((turbo.turboLedType != PLED_TYPE_RGB) || (turbo.turboLedIndex < 0) || + (turbo.turboLedIndex >= HOST_LIGHTING_MAX_LEDS)) + return none; + return { (int16_t)turbo.turboLedIndex, 1 }; + } + + if (buttonId == HOST_LIGHTING_BUTTON_CASE) { + if ((lo.caseRGBIndex < 0) || (lo.caseRGBCount == 0)) + return none; + uint32_t count = lo.caseRGBCount; + if ((uint32_t)lo.caseRGBIndex + count > HOST_LIGHTING_MAX_LEDS) + count = HOST_LIGHTING_MAX_LEDS - lo.caseRGBIndex; + return { (int16_t)lo.caseRGBIndex, (uint8_t)count }; + } + + return none; +} + +static void stagePixel(uint32_t index, uint32_t colour) { + stagingPixels[index] = colour; + stagingValid[index / 32] |= (1u << (index % 32)); +} + +static void stageRange(const LedRange & range, uint32_t colour) { + for (uint8_t i = 0; i < range.count; i++) + stagePixel((uint32_t)range.first + i, colour); +} + +// GET_CAPS pages are ordered so hosts only ever read forward: page 0 identity +// (fetch once), page 1 runtime state (the cheap poll target), page 2 LED map +// (carries the same fingerprint as page 1, so a fetched map self-certifies +// against the state that prompted it), page 3 on-board animations, page 4 +// per-light positions (optional, spatial). + +// FNV-1a over the resolved LED ranges: changes exactly when the map a host +// would cache changes (profile pin remaps, webconfig LED edits) +static uint32_t ledMapFingerprint() { + uint32_t fingerprint = 2166136261u; + for (uint8_t id = 0; id <= HOST_LIGHTING_BUTTON_CASE; id++) { + LedRange range = resolveButton(id); + fingerprint = (fingerprint ^ (uint8_t)range.first) * 16777619u; + fingerprint = (fingerprint ^ range.count) * 16777619u; + } + return fingerprint; +} + +// Page 0 - identity, static for the life of the firmware: +// [3] caps format [4..11] factory-unique board ID +// [12..] two NUL-terminated strings: board label, firmware version +static void buildCapsIdentity(uint8_t * reply) { + reply[3] = HOST_LIGHTING_CAPS_FORMAT; + + pico_unique_board_id_t uniqueId; + pico_get_unique_board_id(&uniqueId); + memcpy(&reply[4], uniqueId.id, 8); + + const char * label = BOARD_CONFIG_LABEL; + const char * version = GP2040VERSION; + uint8_t pos = 12; + while (*label && (pos < HOST_LIGHTING_REPORT_SIZE - 2)) + reply[pos++] = (uint8_t)*label++; + reply[pos++] = 0; + while (*version && (pos < HOST_LIGHTING_REPORT_SIZE - 1)) + reply[pos++] = (uint8_t)*version++; + reply[pos] = 0; +} + +// Page 1 - runtime state, everything that changes without replugging: +// [3] current InputMode [4] profile number [5] brightness step +// [6] host-assigned player (0 = none) [7..10] LED-map fingerprint (LE) +// [11] current on-board animation index +static void buildCapsState(uint8_t * reply) { + reply[3] = (uint8_t)DriverManager::getInstance().getInputMode(); + + const GamepadOptions & gamepadOptions = Storage::getInstance().getGamepadOptions(); + reply[4] = (uint8_t)gamepadOptions.profileNumber; + + const AnimationOptions & animationOptions = Storage::getInstance().getAnimationOptions(); + reply[5] = (uint8_t)((animationOptions.brightness > 255) ? 255 : animationOptions.brightness); + + uint32_t player = Storage::getInstance().GetProcessedGamepad()->auxState.playerID.value; + reply[6] = (uint8_t)((player > 255) ? 255 : player); + + uint32_t fingerprint = ledMapFingerprint(); + memcpy(&reply[7], &fingerprint, 4); + + reply[11] = (uint8_t)animationOptions.baseAnimationIndex; +} + +// Page 2 - LED map; the trailing fingerprint matches page 1's for the same +// map, letting a host confirm a coherent snapshot without re-reading page 1: +// [3] ledsPerButton [4] LEDFormat [5] ButtonLayout +// [6] total LED count [7] brightness maximum +// [8..43] per-button {first LED, count} pairs, button IDs 0-17 (0xFF = unmapped) +// [44..47] player LED indexes [48] turbo LED index [49..50] case {first, count} +// [51..54] LED-map fingerprint (LE) +static void buildCapsLedMap(uint8_t * reply) { + const LEDOptions & lo = Storage::getInstance().getLedOptions(); + uint32_t perButton = (lo.ledsPerButton > 0) ? lo.ledsPerButton : 1; + + reply[3] = (uint8_t)perButton; + reply[4] = (uint8_t)lo.ledFormat; + reply[5] = (uint8_t)lo.ledLayout; + reply[7] = (uint8_t)((lo.brightnessMaximum > 255) ? 255 : lo.brightnessMaximum); + + uint32_t totalLeds = 0; + for (uint8_t b = 0; b < HOST_LIGHTING_BUTTON_COUNT; b++) { + LedRange range = resolveButton(b); + if (range.first < 0) { + reply[8 + b * 2] = 0xFF; + reply[9 + b * 2] = 0; + } else { + reply[8 + b * 2] = (uint8_t)range.first; + reply[9 + b * 2] = range.count; + totalLeds += range.count; + } + } + + for (uint8_t p = 0; p < 4; p++) { + LedRange range = resolveButton(HOST_LIGHTING_BUTTON_PLED1 + p); + reply[44 + p] = (range.first < 0) ? 0xFF : (uint8_t)range.first; + if (range.first >= 0) + totalLeds += 1; + } + + LedRange turbo = resolveButton(HOST_LIGHTING_BUTTON_TURBO); + reply[48] = (turbo.first < 0) ? 0xFF : (uint8_t)turbo.first; + if (turbo.first >= 0) + totalLeds += 1; + + LedRange caseRange = resolveButton(HOST_LIGHTING_BUTTON_CASE); + reply[49] = (caseRange.first < 0) ? 0xFF : (uint8_t)caseRange.first; + reply[50] = caseRange.count; + totalLeds += caseRange.count; + + reply[6] = (uint8_t)((totalLeds > 255) ? 255 : totalLeds); + + uint32_t fingerprint = ledMapFingerprint(); + memcpy(&reply[51], &fingerprint, 4); +} + +// Page 3 - on-board animation selection (the lighting shown outside host +// control): [3] current animation index [4] number of animations +static void buildCapsAnimations(uint8_t * reply) { + const AnimationOptions & animationOptions = Storage::getInstance().getAnimationOptions(); + reply[3] = (uint8_t)animationOptions.baseAnimationIndex; + reply[4] = (uint8_t)TOTAL_EFFECTS; +} + +// Page 4 - per-light grid positions, for spatially aware host effects. +// Reply: [3]=total entries, [4]=count here, then count x {first LED, x, y}. +// The classic render pipeline has no per-light positions, so it reports zero +// entries; hosts fall back to the layout enum from page 2. +static void buildCapsPositions(uint8_t * reply, uint8_t startEntry) { + (void)startEntry; + reply[3] = 0; + reply[4] = 0; +} + +bool HostLighting::enabledForMode(InputMode mode) { + const HostLightingOptions & options = Storage::getInstance().getAddonOptions().hostLightingOptions; + if (!options.enabled) + return false; + if ((mode == INPUT_MODE_GENERIC) || (mode == INPUT_MODE_KEYBOARD)) + return true; + if (mode == INPUT_MODE_XINPUT) + return xinputCompositeActive(); + return false; +} + +bool HostLighting::xinputCompositeActive() { + const HostLightingOptions & options = Storage::getInstance().getAddonOptions().hostLightingOptions; + if (!options.enabled) + return false; + if (options.xinputMode == HOST_LIGHTING_XINPUT_MODE_ON) + return true; + if (options.xinputMode == HOST_LIGHTING_XINPUT_MODE_AUTO) + return watchdog_hw->scratch[HOST_LIGHTING_VERDICT_SCRATCH] == HOST_LIGHTING_VERDICT_PC_HOST; + return false; +} + +void HostLighting::xinputAutoDetectTask(bool consoleAuthSeen) { + // The decision is final for the session, so the frequent process-loop calls + // reduce to a single branch once it is made + static bool settled = false; + if (settled) + return; + + const HostLightingOptions & options = Storage::getInstance().getAddonOptions().hostLightingOptions; + if (!options.enabled || (options.xinputMode != HOST_LIGHTING_XINPUT_MODE_AUTO) || + xinputCompositeActive() || consoleAuthSeen) { + settled = true; + return; + } + + static uint32_t enumeratedSinceMs = 0; + if (!get_usb_mounted() || get_usb_suspended()) { + enumeratedSinceMs = 0; + return; + } + + uint32_t now = to_ms_since_boot(get_absolute_time()); + if (enumeratedSinceMs == 0) { + enumeratedSinceMs = now; + return; + } + if ((now - enumeratedSinceMs) >= HOST_LIGHTING_XINPUT_DETECT_MS) { + watchdog_hw->scratch[HOST_LIGHTING_VERDICT_SCRATCH] = HOST_LIGHTING_VERDICT_PC_HOST; + System::reboot(System::BootMode::GAMEPAD); + } +} + +uint8_t HostLighting::lightingInstance() { + // XInput's gamepad interface is vendor-class, leaving lighting as the only + // HID interface; HID-class modes enumerate their gamepad as instance 0 first + return (DriverManager::getInstance().getInputMode() == INPUT_MODE_XINPUT) ? 0 : HOST_LIGHTING_HID_INSTANCE; +} + +bool HostLighting::isLightingInterface(uint8_t itf) { + return (itf == lightingInstance()) && + enabledForMode(DriverManager::getInstance().getInputMode()); +} + +const uint8_t * HostLighting::getReportDescriptor() { + return hostlighting_report_descriptor; +} + +uint16_t HostLighting::getReport(uint8_t report_id, hid_report_type_t report_type, uint8_t * buffer, uint16_t reqlen) { + uint16_t size = (reqlen < HOST_LIGHTING_REPORT_SIZE) ? reqlen : HOST_LIGHTING_REPORT_SIZE; + memcpy(buffer, responseBuffer, size); + return size; +} + +static void publishFrame() { + liveSeq = liveSeq + 1; // odd: write in progress + __mem_fence_release(); + memcpy(livePixels, stagingPixels, sizeof(livePixels)); + memcpy(liveValid, stagingValid, sizeof(liveValid)); + liveActive = true; + __mem_fence_release(); + liveSeq = liveSeq + 1; // even: stable +} + +void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, const uint8_t * buffer, uint16_t bufsize) { + // Reports arrive via the OUT endpoint (type 0) or a SET_REPORT control request + if ((report_type != HID_REPORT_TYPE_INVALID) && (report_type != HID_REPORT_TYPE_OUTPUT)) + return; + + if (bufsize < 2) + return; + + uint8_t command = buffer[0]; + uint8_t sequence = buffer[1]; + uint8_t status = HOST_LIGHTING_STATUS_OK; + bool doReboot = false; + System::BootMode rebootMode = System::BootMode::GAMEPAD; + + memset(responseBuffer, 0, sizeof(responseBuffer)); + responseBuffer[0] = command | HOST_LIGHTING_RESPONSE_FLAG; + responseBuffer[1] = sequence; + + switch (command) { + case HOST_LIGHTING_CMD_PING: + responseBuffer[3] = 'G'; + responseBuffer[4] = 'P'; + responseBuffer[5] = 'H'; + responseBuffer[6] = 'L'; + responseBuffer[7] = HOST_LIGHTING_PROTOCOL_VERSION_MAJOR; + responseBuffer[8] = HOST_LIGHTING_PROTOCOL_VERSION_MINOR; + break; + + case HOST_LIGHTING_CMD_GET_CAPS: + if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + if (buffer[2] == 0) + buildCapsIdentity(responseBuffer); + else if (buffer[2] == 1) + buildCapsState(responseBuffer); + else if (buffer[2] == 2) + buildCapsLedMap(responseBuffer); + else if (buffer[2] == 3) + buildCapsAnimations(responseBuffer); + else if (buffer[2] == 4) + buildCapsPositions(responseBuffer, (bufsize > 3) ? buffer[3] : 0); + else + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + + case HOST_LIGHTING_CMD_SET_MODE: { + if (bufsize < 6) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + if (buffer[2] > HOST_LIGHTING_TAKEOVER_OVERLAY) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + uint16_t timeoutMs = (uint16_t)buffer[3] | ((uint16_t)buffer[4] << 8); + if (timeoutMs == 0) + timeoutMs = HOST_LIGHTING_DEFAULT_TIMEOUT_MS; + if (timeoutMs < 100) + timeoutMs = 100; + liveTakeover = buffer[2]; + liveTimeoutMs = timeoutMs; + liveApplyBrightness = (buffer[5] != 0); + break; + } + + case HOST_LIGHTING_CMD_SET_BUTTONS: { + if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + uint8_t entries = buffer[2]; + if ((entries == 0) || (entries > HOST_LIGHTING_BUTTONS_MAX_ENTRIES) || + (bufsize < (uint16_t)(3 + entries * 4))) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + uint8_t applied = 0, skipped = 0; + for (uint8_t e = 0; e < entries; e++) { + const uint8_t * entry = &buffer[3 + e * 4]; + LedRange range = resolveButton(entry[0]); + if (range.first < 0) { + skipped++; + continue; + } + stageRange(range, ((uint32_t)entry[1] << 16) | ((uint32_t)entry[2] << 8) | entry[3]); + applied++; + } + responseBuffer[3] = applied; + responseBuffer[4] = skipped; + break; + } + + case HOST_LIGHTING_CMD_SET_RANGE: { + if (bufsize < 4) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + uint8_t start = buffer[2]; + uint8_t count = buffer[3]; + if ((count == 0) || (count > HOST_LIGHTING_RANGE_MAX_PIXELS) || + ((uint16_t)start + count > HOST_LIGHTING_MAX_LEDS) || + (bufsize < (uint16_t)(4 + count * 3))) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + for (uint8_t i = 0; i < count; i++) { + const uint8_t * rgb = &buffer[4 + i * 3]; + stagePixel((uint32_t)start + i, ((uint32_t)rgb[0] << 16) | ((uint32_t)rgb[1] << 8) | rgb[2]); + } + break; + } + + case HOST_LIGHTING_CMD_SET_RANGE_RGBW: { + // As SET_RANGE with a white component; boards whose colour format + // has no white channel simply ignore the fourth byte + if (bufsize < 4) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + uint8_t start = buffer[2]; + uint8_t count = buffer[3]; + if ((count == 0) || (count > HOST_LIGHTING_RANGE_RGBW_MAX_PIXELS) || + ((uint16_t)start + count > HOST_LIGHTING_MAX_LEDS) || + (bufsize < (uint16_t)(4 + count * 4))) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + for (uint8_t i = 0; i < count; i++) { + const uint8_t * rgbw = &buffer[4 + i * 4]; + stagePixel((uint32_t)start + i, + ((uint32_t)rgbw[3] << 24) | ((uint32_t)rgbw[0] << 16) | + ((uint32_t)rgbw[1] << 8) | rgbw[2]); + } + break; + } + + case HOST_LIGHTING_CMD_FILL: { + if (bufsize < 6) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + uint32_t colour = ((uint32_t)buffer[3] << 16) | ((uint32_t)buffer[4] << 8) | buffer[5]; + switch (buffer[2]) { + case HOST_LIGHTING_FILL_SCOPE_ALL: + for (uint32_t i = 0; i < HOST_LIGHTING_MAX_LEDS; i++) + stagePixel(i, colour); + break; + case HOST_LIGHTING_FILL_SCOPE_BUTTONS: + for (uint8_t b = 0; b < HOST_LIGHTING_BUTTON_COUNT; b++) + stageRange(resolveButton(b), colour); + break; + case HOST_LIGHTING_FILL_SCOPE_CASE: + stageRange(resolveButton(HOST_LIGHTING_BUTTON_CASE), colour); + break; + case HOST_LIGHTING_FILL_SCOPE_PLEDS: + for (uint8_t p = 0; p < 4; p++) + stageRange(resolveButton(HOST_LIGHTING_BUTTON_PLED1 + p), colour); + break; + default: + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + break; + } + + case HOST_LIGHTING_CMD_CLEAR: + memset(stagingPixels, 0, sizeof(stagingPixels)); + memset(stagingValid, 0, sizeof(stagingValid)); + break; + + case HOST_LIGHTING_CMD_COMMIT: + publishFrame(); + break; + + case HOST_LIGHTING_CMD_RELEASE: + liveActive = false; + break; + + case HOST_LIGHTING_CMD_SET_ANIMATION: { + // Selects the on-board animation shown outside host control; the + // render core applies it, and the choice persists like the hotkeys' + if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + if (buffer[2] >= TOTAL_EFFECTS) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + AnimationOptions & animationOptions = Storage::getInstance().getAnimationOptions(); + if (animationOptions.baseAnimationIndex != buffer[2]) { + animationOptions.baseAnimationIndex = buffer[2]; + EventManager::getInstance().triggerEvent(new GPStorageSaveEvent(false)); + } + pendingAnimationMode = buffer[2]; + break; + } + + case HOST_LIGHTING_CMD_SET_INPUT_MODE: { + // Guarded by a magic payload; persists the mode and reboots into it + if ((bufsize < 7) || (buffer[3] != 'M') || (buffer[4] != 'O') || + (buffer[5] != 'D') || (buffer[6] != 'E')) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + InputMode inputMode = (InputMode)buffer[2]; + switch (inputMode) { + case INPUT_MODE_XINPUT: + case INPUT_MODE_SWITCH: + case INPUT_MODE_PS3: + case INPUT_MODE_KEYBOARD: + case INPUT_MODE_PS4: + case INPUT_MODE_XBONE: + case INPUT_MODE_MDMINI: + case INPUT_MODE_NEOGEO: + case INPUT_MODE_PCEMINI: + case INPUT_MODE_EGRET: + case INPUT_MODE_ASTRO: + case INPUT_MODE_PSCLASSIC: + case INPUT_MODE_XBOXORIGINAL: + case INPUT_MODE_PS5: + case INPUT_MODE_GENERIC: + case INPUT_MODE_SWITCH_PRO: + case INPUT_MODE_P5GENERAL: + break; + default: + // INPUT_MODE_CONFIG is entered via REBOOT_WEBCONFIG instead + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + if (status != HOST_LIGHTING_STATUS_OK) + break; + GamepadOptions & gamepadOptions = Storage::getInstance().getGamepadOptions(); + if (gamepadOptions.inputMode != inputMode) { + gamepadOptions.inputMode = inputMode; + // Save and restart from the main loop rather than USB context + EventManager::getInstance().triggerEvent(new GPStorageSaveEvent(true, true)); + } else { + doReboot = true; + rebootMode = System::BootMode::GAMEPAD; + } + break; + } + + case HOST_LIGHTING_CMD_REBOOT_WEBCONFIG: + // Guarded by a magic payload so a stray report cannot reboot the board + if ((bufsize < 6) || (buffer[2] != 'W') || (buffer[3] != 'E') || + (buffer[4] != 'B') || (buffer[5] != 'C')) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + doReboot = true; + rebootMode = System::BootMode::WEBCONFIG; + break; + + case HOST_LIGHTING_CMD_REBOOT_BOOTSEL: + // Guarded by a magic payload so a stray report cannot reboot the board + if ((bufsize < 6) || (buffer[2] != 'B') || (buffer[3] != 'O') || + (buffer[4] != 'O') || (buffer[5] != 'T')) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + doReboot = true; + rebootMode = System::BootMode::USB; + break; + + default: + status = HOST_LIGHTING_STATUS_UNSUPPORTED; + break; + } + + responseBuffer[2] = status; + + // Every valid command is a keepalive for the takeover timeout + if (status == HOST_LIGHTING_STATUS_OK) + lastCommandMs = to_ms_since_boot(get_absolute_time()); + + uint8_t instance = HostLighting::lightingInstance(); + if (tud_hid_n_ready(instance)) { + tud_hid_n_report(instance, 0, responseBuffer, sizeof(responseBuffer)); + } + + if (doReboot) { + // The reply may not flush before reboot; hosts detect success by the + // requested identity appearing on the bus + System::reboot(rebootMode); + } +} + +void HostLighting::applyToFrame(uint32_t * frame, uint32_t ledCount, float brightnessX, int format) { + if (!liveActive) + return; + + uint32_t now = to_ms_since_boot(get_absolute_time()); + if ((now - lastCommandMs) > liveTimeoutMs) + return; // host went quiet: let the on-board animations show + + // Colour conversion is soft-float on the RP2040, so the converted frame is + // cached and only rebuilt when the host publishes or brightness/format + // changes; steady-state render ticks reduce to a word copy + static uint32_t convertedPixels[HOST_LIGHTING_MAX_LEDS]; + static uint32_t convertedValid[(HOST_LIGHTING_MAX_LEDS + 31) / 32]; + static uint32_t convertedSeq = 0xFFFFFFFF; + static uint32_t convertedBrightnessBits = 0; + static int convertedFormat = -1; + + float brightness = liveApplyBrightness ? brightnessX : 1.0f; + uint32_t brightnessBits; + memcpy(&brightnessBits, &brightness, sizeof(brightnessBits)); + + uint32_t seqNow = liveSeq; + if ((seqNow != convertedSeq) || (seqNow & 1) || + (brightnessBits != convertedBrightnessBits) || (format != convertedFormat)) { + uint32_t local[HOST_LIGHTING_MAX_LEDS]; + uint32_t seqBefore, seqAfter; + do { + seqBefore = liveSeq; + __mem_fence_acquire(); + memcpy(local, livePixels, sizeof(local)); + memcpy(convertedValid, liveValid, sizeof(convertedValid)); + __mem_fence_acquire(); + seqAfter = liveSeq; + } while ((seqBefore != seqAfter) || (seqBefore & 1)); + + for (uint32_t i = 0; i < HOST_LIGHTING_MAX_LEDS; i++) { + // Stored as 0xWWRRGGBB; the white byte only matters on RGBW formats + RGB colour((uint8_t)(local[i] >> 16), (uint8_t)(local[i] >> 8), + (uint8_t)local[i], (uint8_t)(local[i] >> 24)); + convertedPixels[i] = colour.value(static_cast(format), brightness); + } + convertedSeq = seqAfter; + convertedBrightnessBits = brightnessBits; + convertedFormat = format; + } + + if (ledCount > HOST_LIGHTING_MAX_LEDS) + ledCount = HOST_LIGHTING_MAX_LEDS; + + if (liveTakeover == HOST_LIGHTING_TAKEOVER_OVERLAY) { + // Untouched pixels keep the animation underneath + for (uint32_t i = 0; i < ledCount; i++) { + if (convertedValid[i / 32] & (1u << (i % 32))) + frame[i] = convertedPixels[i]; + } + } else { + memcpy(frame, convertedPixels, ledCount * sizeof(uint32_t)); + } +} + +int16_t HostLighting::takeLocalAnimationRequest() { + int16_t pending = pendingAnimationMode; + if (pending >= 0) + pendingAnimationMode = -1; + return pending; +} From 3bc81461fa949ddd8a56789fb2b77ff89fe4e5a2 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:51:56 +1000 Subject: [PATCH 3/9] Expose the lighting interface in Generic and Keyboard modes 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. --- headers/drivers/hid/HIDDescriptors.h | 81 +++++++++++++++++++ .../drivers/keyboard/KeyboardDescriptors.h | 19 +++++ src/drivers/hid/HIDDriver.cpp | 2 + src/drivers/keyboard/KeyboardDriver.cpp | 2 + src/usbdriver.cpp | 9 +++ 5 files changed, 113 insertions(+) diff --git a/headers/drivers/hid/HIDDescriptors.h b/headers/drivers/hid/HIDDescriptors.h index fedb71a1b..0166bca8b 100644 --- a/headers/drivers/hid/HIDDescriptors.h +++ b/headers/drivers/hid/HIDDescriptors.h @@ -7,6 +7,8 @@ #include +#include "hostlighting.h" + #define HID_ENDPOINT_SIZE 64 // Mac OS-X and Linux automatically load the correct drivers. On @@ -26,6 +28,9 @@ #define GAMEPAD_ENDPOINT 1 #define GAMEPAD_SIZE 64 +#define HOST_LIGHTING_INTERFACE 1 +#define HOST_LIGHTING_ENDPOINT 2 + #define LSB(n) (n & 255) #define MSB(n) ((n >> 8) & 255) @@ -175,3 +180,79 @@ static const uint8_t hid_configuration_descriptor[] = GAMEPAD_SIZE, 0, // wMaxPacketSize 1 // bInterval (1 ms) }; + +// Variant with the host lighting interface appended; selected at runtime by +// HostLighting::enabledForMode() +#define CONFIG1_HOST_LIGHTING_DESC_SIZE (9+9+9+7 + 9+9+7+7) +static const uint8_t hid_hostlighting_configuration_descriptor[] __attribute__((unused)) = +{ + // configuration descriptor, USB spec 9.6.3, page 264-266, Table 9-10 + 9, // bLength; + 2, // bDescriptorType; + LSB(CONFIG1_HOST_LIGHTING_DESC_SIZE), // wTotalLength + MSB(CONFIG1_HOST_LIGHTING_DESC_SIZE), + 2, // bNumInterfaces + 1, // bConfigurationValue + 0, // iConfiguration + 0x80, // bmAttributes + 50, // bMaxPower + // interface descriptor, USB spec 9.6.5, page 267-269, Table 9-12 + 9, // bLength + 4, // bDescriptorType + GAMEPAD_INTERFACE, // bInterfaceNumber + 0, // bAlternateSetting + 1, // bNumEndpoints + 0x03, // bInterfaceClass (0x03 = HID) + 0x00, // bInterfaceSubClass (0x00 = No Boot) + 0x00, // bInterfaceProtocol (0x00 = No Protocol) + 0, // iInterface + // HID interface descriptor, HID 1.11 spec, section 6.2.1 + 9, // bLength + 0x21, // bDescriptorType + 0x11, 0x01, // bcdHID + 0, // bCountryCode + 1, // bNumDescriptors + 0x22, // bDescriptorType + sizeof(hid_report_descriptor), // wDescriptorLength + 0, + // endpoint descriptor, USB spec 9.6.6, page 269-271, Table 9-13 + 7, // bLength + 5, // bDescriptorType + GAMEPAD_ENDPOINT | 0x80, // bEndpointAddress + 0x03, // bmAttributes (0x03=intr) + GAMEPAD_SIZE, 0, // wMaxPacketSize + 1, // bInterval (1 ms) + // host lighting interface descriptor + 9, // bLength + 4, // bDescriptorType + HOST_LIGHTING_INTERFACE, // bInterfaceNumber + 0, // bAlternateSetting + 2, // bNumEndpoints + 0x03, // bInterfaceClass (0x03 = HID) + 0x00, // bInterfaceSubClass (0x00 = No Boot) + 0x00, // bInterfaceProtocol (0x00 = No Protocol) + 0, // iInterface + // HID interface descriptor, HID 1.11 spec, section 6.2.1 + 9, // bLength + 0x21, // bDescriptorType + 0x11, 0x01, // bcdHID + 0, // bCountryCode + 1, // bNumDescriptors + 0x22, // bDescriptorType + sizeof(hostlighting_report_descriptor), // wDescriptorLength + 0, + // endpoint descriptor, USB spec 9.6.6, page 269-271, Table 9-13 + 7, // bLength + 5, // bDescriptorType + HOST_LIGHTING_ENDPOINT | 0x80, // bEndpointAddress + 0x03, // bmAttributes (0x03=intr) + HID_ENDPOINT_SIZE, 0, // wMaxPacketSize + 1, // bInterval (1 ms) + // endpoint descriptor, USB spec 9.6.6, page 269-271, Table 9-13 + 7, // bLength + 5, // bDescriptorType + HOST_LIGHTING_ENDPOINT, // bEndpointAddress + 0x03, // bmAttributes (0x03=intr) + HID_ENDPOINT_SIZE, 0, // wMaxPacketSize + 1 // bInterval (1 ms) +}; diff --git a/headers/drivers/keyboard/KeyboardDescriptors.h b/headers/drivers/keyboard/KeyboardDescriptors.h index a9b85fee7..b7a400915 100644 --- a/headers/drivers/keyboard/KeyboardDescriptors.h +++ b/headers/drivers/keyboard/KeyboardDescriptors.h @@ -2,6 +2,7 @@ #include #include "tusb.h" +#include "hostlighting.h" #define KEYBOARD_KEY_REPORT_ID 0x01 #define KEYBOARD_MULTIMEDIA_REPORT_ID 0x02 @@ -60,8 +61,12 @@ enum }; #define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) +#define CONFIG_TOTAL_LEN_LIGHTING (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN + TUD_HID_INOUT_DESC_LEN) #define EPNUM_HID 0x81 +#define ITF_NUM_HID_LIGHTING 1 +#define EPNUM_LIGHTING_OUT 0x02 +#define EPNUM_LIGHTING_IN 0x82 static const uint8_t keyboard_report_descriptor[] = { @@ -124,4 +129,18 @@ static const uint8_t keyboard_configuration_descriptor[] = // Interface number, string index, protocol, report descriptor len, EP Out & In address, size & polling interval TUD_HID_DESCRIPTOR(ITF_NUM_HID_KEYBOARD, 0, HID_ITF_PROTOCOL_KEYBOARD, sizeof(keyboard_report_descriptor), EPNUM_HID, CFG_TUD_HID_EP_BUFSIZE, 1) +}; + +// Variant with the host lighting interface appended; selected at runtime by +// HostLighting::enabledForMode() +static const uint8_t keyboard_hostlighting_configuration_descriptor[] __attribute__((unused)) = +{ + // Config number, interface count, string index, total length, attribute, power in mA + TUD_CONFIG_DESCRIPTOR(1, 2, 0, CONFIG_TOTAL_LEN_LIGHTING, 32, 100), + + // Interface number, string index, protocol, report descriptor len, EP Out & In address, size & polling interval + TUD_HID_DESCRIPTOR(ITF_NUM_HID_KEYBOARD, 0, HID_ITF_PROTOCOL_KEYBOARD, sizeof(keyboard_report_descriptor), EPNUM_HID, CFG_TUD_HID_EP_BUFSIZE, 1), + + // Interface number, string index, protocol, report descriptor len, EP Out & In address, size & polling interval + TUD_HID_INOUT_DESCRIPTOR(ITF_NUM_HID_LIGHTING, 0, HID_ITF_PROTOCOL_NONE, sizeof(hostlighting_report_descriptor), EPNUM_LIGHTING_OUT, EPNUM_LIGHTING_IN, CFG_TUD_HID_EP_BUFSIZE, 1) }; \ No newline at end of file diff --git a/src/drivers/hid/HIDDriver.cpp b/src/drivers/hid/HIDDriver.cpp index fada36307..fdb9e5365 100644 --- a/src/drivers/hid/HIDDriver.cpp +++ b/src/drivers/hid/HIDDriver.cpp @@ -178,6 +178,8 @@ const uint8_t * HIDDriver::get_hid_descriptor_report_cb(uint8_t itf) { } const uint8_t * HIDDriver::get_descriptor_configuration_cb(uint8_t index) { + if (HostLighting::enabledForMode(INPUT_MODE_GENERIC)) + return hid_hostlighting_configuration_descriptor; return hid_configuration_descriptor; } diff --git a/src/drivers/keyboard/KeyboardDriver.cpp b/src/drivers/keyboard/KeyboardDriver.cpp index 95a4b938c..1d5adcc17 100644 --- a/src/drivers/keyboard/KeyboardDriver.cpp +++ b/src/drivers/keyboard/KeyboardDriver.cpp @@ -186,6 +186,8 @@ const uint8_t * KeyboardDriver::get_hid_descriptor_report_cb(uint8_t itf) { } const uint8_t * KeyboardDriver::get_descriptor_configuration_cb(uint8_t index) { + if (HostLighting::enabledForMode(INPUT_MODE_KEYBOARD)) + return keyboard_hostlighting_configuration_descriptor; return keyboard_configuration_descriptor; } diff --git a/src/usbdriver.cpp b/src/usbdriver.cpp index 3710f1d3b..5bf514730 100644 --- a/src/usbdriver.cpp +++ b/src/usbdriver.cpp @@ -8,6 +8,7 @@ #include "tusb.h" #include "drivermanager.h" +#include "hostlighting.h" static bool usb_mounted; static bool usb_suspended; @@ -26,12 +27,18 @@ const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *driver_count) { } uint16_t tud_hid_get_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t report_type, uint8_t *buffer, uint16_t reqlen) { + if (HostLighting::isLightingInterface(itf)) + return HostLighting::getReport(report_id, report_type, buffer, reqlen); return DriverManager::getInstance().getDriver()->get_report(report_id, report_type, buffer, reqlen); } // Invoked when received SET_REPORT control request or // received data on OUT endpoint ( Report ID = 0, Type = 0 ) void tud_hid_set_report_cb(uint8_t itf, uint8_t report_id, hid_report_type_t report_type, uint8_t const *buffer, uint16_t bufsize) { + if (HostLighting::isLightingInterface(itf)) { + HostLighting::setReport(report_id, report_type, buffer, bufsize); + return; + } DriverManager::getInstance().getDriver()->set_report(report_id, report_type, buffer, bufsize); } @@ -85,6 +92,8 @@ uint8_t const *tud_descriptor_device_cb() { // Application return pointer to descriptor // Descriptor contents must exist long enough for transfer to complete uint8_t const *tud_hid_descriptor_report_cb(uint8_t itf) { + if (HostLighting::isLightingInterface(itf)) + return HostLighting::getReportDescriptor(); return DriverManager::getInstance().getDriver()->get_hid_descriptor_report_cb(itf); } From 101b358aa27fb046534af7c1a731e2669e265c37 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:51:57 +1000 Subject: [PATCH 4/9] Support the lighting interface in XInput mode 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. --- headers/drivers/xinput/XInputDescriptors.h | 147 +++++++++++++++++++++ src/drivers/xinput/XInputDriver.cpp | 45 ++++++- 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/headers/drivers/xinput/XInputDescriptors.h b/headers/drivers/xinput/XInputDescriptors.h index bbb8c5548..c87ff8a3c 100644 --- a/headers/drivers/xinput/XInputDescriptors.h +++ b/headers/drivers/xinput/XInputDescriptors.h @@ -8,6 +8,8 @@ #include #include +#include "hostlighting.h" + #define XINPUT_ENDPOINT_SIZE 20 // Buttons 1 (8 bits) @@ -94,6 +96,62 @@ static const uint8_t * xinput_get_string_descriptor(int index) { return xinput_string_descriptors[index]; } +// Composite variant: per-interface driver binding (XUSB via the MS OS compatible +// ID below), so the device class is 0 and the ID differs from the stock 045E:028E, +// which Windows would otherwise bind whole-device to its XUSB driver. Selected at +// runtime by HostLighting::xinputCompositeActive(). +static const uint8_t xinput_composite_device_descriptor[] __attribute__((unused)) = +{ + 0x12, // bLength + 0x01, // bDescriptorType (Device) + 0x00, 0x02, // bcdUSB 2.00 + 0x00, // bDeviceClass (per-interface) + 0x00, // bDeviceSubClass + 0x00, // bDeviceProtocol + 0x40, // bMaxPacketSize0 64 + // Vendor chosen from the Linux xpad driver's 360-vendor wildcard list so the + // composite still binds xpad there; PID deliberately not a known product so + // Windows has no device-level match and splits the composite (WCID binds XUSB) + 0xC6, 0x24, // idVendor 0x24C6 + 0xAD, 0x5B, // idProduct 0x5BAD + 0x14, 0x01, // bcdDevice 2.14 + 0x01, // iManufacturer (String Index) + 0x02, // iProduct (String Index) + 0x03, // iSerialNumber (String Index) + 0x01, // bNumConfigurations 1 +}; + +// MS OS 1.0 string descriptor (string index 0xEE): "MSFT100" + vendor request code +static const uint16_t xinput_ms_os_string_descriptor[] __attribute__((unused)) = +{ + 0x0312, // bLength 18, bDescriptorType string + 'M', 'S', 'F', 'T', '1', '0', '0', + 0x0020, // bMS_VendorCode 0x20, padding +}; + +// MS OS 1.0 Extended Compat ID descriptor: interface 0 binds the XUSB driver via +// compatible ID, interface 1 (lighting) stays plain HID +static const uint8_t xinput_wcid_compat_id_descriptor[] __attribute__((unused)) = +{ + 0x40, 0x00, 0x00, 0x00, // dwLength 64 + 0x00, 0x01, // bcdVersion 1.00 + 0x04, 0x00, // wIndex: extended compat ID + 0x02, // bCount: 2 functions + 0, 0, 0, 0, 0, 0, 0, // reserved + // function: interface 0 -> XUSB10 + 0x00, // bFirstInterfaceNumber + 0x01, // reserved + 'X', 'U', 'S', 'B', '1', '0', 0x00, 0x00, // compatibleID + 0, 0, 0, 0, 0, 0, 0, 0, // subCompatibleID + 0, 0, 0, 0, 0, 0, // reserved + // function: interface 1 -> none (HID class binding) + 0x01, // bFirstInterfaceNumber + 0x01, // reserved + 0, 0, 0, 0, 0, 0, 0, 0, // compatibleID + 0, 0, 0, 0, 0, 0, 0, 0, // subCompatibleID + 0, 0, 0, 0, 0, 0, // reserved +}; + static const uint8_t xinput_device_descriptor[] = { 0x12, // bLength @@ -112,6 +170,95 @@ static const uint8_t xinput_device_descriptor[] = 0x01, // bNumConfigurations 1 }; +// Composite variant: the XUSB control interface plus the host lighting HID +// interface. The console-only interfaces (audio, plug-in module, security) are +// omitted -- they serve no purpose on PC and would enumerate driverless. +// Selected at runtime by HostLighting::xinputCompositeActive(). +static const uint8_t xinput_composite_configuration_descriptor[] __attribute__((unused)) = +{ + 0x09, // bLength + 0x02, // bDescriptorType (Configuration) + 0x51, 0x00, // wTotalLength 81 + 0x02, // bNumInterfaces 2 + 0x01, // bConfigurationValue + 0x00, // iConfiguration (String Index) + 0xA0, // bmAttributes (remote wakeup) + 0xFA, // bMaxPower 500mA + + // Control Interface (0x5D 0xFF) + 0x09, // bLength + 0x04, // bDescriptorType (Interface) + 0x00, // bInterfaceNumber 0 + 0x00, // bAlternateSetting + 0x02, // bNumEndpoints 2 + 0xFF, // bInterfaceClass + 0x5D, // bInterfaceSubClass + 0x01, // bInterfaceProtocol + 0x00, // iInterface (String Index) + + // Gamepad Descriptor + 0x11, // bLength + 0x21, // bDescriptorType (HID) + 0x00, 0x01, // bcdHID 1.10 + 0x01, // SUB_TYPE + 0x25, // reserved2 + 0x81, // DEVICE_EPADDR_IN + 0x14, // bMaxDataSizeIn + 0x00, 0x00, 0x00, 0x00, 0x13, // reserved3 + 0x02, // DEVICE_EPADDR_OUT is this right? + 0x08, // bMaxDataSizeOut + 0x00, 0x00, // reserved4 + + // Report IN Endpoint 1.1 + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x81, // bEndpointAddress (IN/D2H) + 0x03, // bmAttributes (Interrupt) + 0x20, 0x00, // wMaxPacketSize 32 + 0x01, // bInterval 1 (unit depends on device speed) + + // Report OUT Endpoint 1.2 + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x02, // bEndpointAddress (OUT/H2D) + 0x03, // bmAttributes (Interrupt) + 0x20, 0x00, // wMaxPacketSize 32 + 0x08, // bInterval 8 (unit depends on device speed) + + // Host Lighting Interface + 0x09, // bLength + 0x04, // bDescriptorType (Interface) + 0x01, // bInterfaceNumber 1 + 0x00, // bAlternateSetting + 0x02, // bNumEndpoints + 0x03, // bInterfaceClass (HID) + 0x00, // bInterfaceSubClass (No Boot) + 0x00, // bInterfaceProtocol (No Protocol) + 0x00, // iInterface (String Index) + // HID interface descriptor, HID 1.11 spec, section 6.2.1 + 0x09, // bLength + 0x21, // bDescriptorType (HID) + 0x11, 0x01, // bcdHID 1.11 + 0x00, // bCountryCode + 0x01, // bNumDescriptors + 0x22, // bDescriptorType (Report) + sizeof(hostlighting_report_descriptor), 0x00, // wDescriptorLength + // Lighting IN Endpoint + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x83, // bEndpointAddress (IN/D2H) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x01, // bInterval 1 + // Lighting OUT Endpoint + 0x07, // bLength + 0x05, // bDescriptorType (Endpoint) + 0x03, // bEndpointAddress (OUT/H2D) + 0x03, // bmAttributes (Interrupt) + 0x40, 0x00, // wMaxPacketSize 64 + 0x01, // bInterval 1 +}; + // This needs to be: // 4 interfaces // remote wakeup enabled diff --git a/src/drivers/xinput/XInputDriver.cpp b/src/drivers/xinput/XInputDriver.cpp index 8ad7d318b..f0a76b1d9 100644 --- a/src/drivers/xinput/XInputDriver.cpp +++ b/src/drivers/xinput/XInputDriver.cpp @@ -5,6 +5,7 @@ #include "drivers/xinput/XInputDriver.h" #include "drivers/shared/driverhelper.h" +#include "hostlighting.h" #include "storagemanager.h" #define USB_SETUP_DEVICE_TO_HOST 0x80 @@ -31,6 +32,10 @@ static uint8_t endpoint_out = 0; static uint8_t xinput_out_buffer[XINPUT_OUT_SIZE] = {}; static XInputAuthData * xinputAuthData = nullptr; +// Console hosts run XSM3 authentication almost immediately; its absence after +// enumeration is the AUTO-mode signal that the host is a PC +static bool xinputConsoleAuthSeen = false; + // Move to Proto Enums typedef enum { @@ -393,6 +398,9 @@ bool XInputDriver::process(Gamepad * gamepad) { } } + // AUTO mode: a host that enumerates but never authenticates is a PC + HostLighting::xinputAutoDetectTask(xinputConsoleAuthSeen); + return reportSent; } @@ -410,6 +418,22 @@ uint16_t XInputDriver::get_report(uint8_t report_id, hid_report_type_t report_ty // Only respond to vendor control xfers if we have a mounted x360 device bool XInputDriver::vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) { + // MS OS 1.0 feature request: hand Windows the compatible IDs that bind the + // XUSB driver to interface 0 of the composite layout + if (HostLighting::xinputCompositeActive() && + (request->bmRequestType_bit.type == TUSB_REQ_TYPE_VENDOR) && + (request->bRequest == REQ_GET_OS_FEATURE_DESCRIPTOR) && + (request->wIndex == DESC_EXTENDED_COMPATIBLE_ID_DESCRIPTOR)) { + if (stage == CONTROL_STAGE_SETUP) { + return tud_control_xfer(rhport, request, + (void *)xinput_wcid_compat_id_descriptor, sizeof(xinput_wcid_compat_id_descriptor)); + } + return true; + } + // Any XSM3 authentication request marks the host as a console + if ((request->bRequest >= XSM360_GET_SERIAL) && (request->bRequest <= XSM360_AUTH_KEEPALIVE)) { + xinputConsoleAuthSeen = true; + } // Do nothing if we have no auth driver if ( xAuthDriver == nullptr || !xAuthDriver->available() ) { return false; @@ -488,6 +512,14 @@ bool XInputDriver::vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_co const uint16_t * XInputDriver::get_descriptor_string_cb(uint8_t index, uint16_t langid) { char *value; + // MS OS 1.0 string descriptor: advertises the vendor request code Windows + // uses to fetch the compatible ID descriptor + if (index == 0xEE) { + if (HostLighting::xinputCompositeActive()) { + return xinput_ms_os_string_descriptor; + } + return nullptr; // stall: stock identity has no MS OS descriptors + } // Check for override settings GamepadOptions & gamepadOptions = Storage::getInstance().getGamepadOptions(); if ( gamepadOptions.usbDescOverride == true ) { @@ -512,16 +544,20 @@ const uint16_t * XInputDriver::get_descriptor_string_cb(uint8_t index, uint16_t } const uint8_t * XInputDriver::get_descriptor_device_cb() { + const uint8_t * baseDescriptor = xinput_device_descriptor; + if (HostLighting::xinputCompositeActive()) { + baseDescriptor = xinput_composite_device_descriptor; + } // Check for override settings GamepadOptions & gamepadOptions = Storage::getInstance().getGamepadOptions(); if ( gamepadOptions.usbOverrideID == true ) { static uint8_t modified_device_descriptor[18]; - memcpy(modified_device_descriptor, xinput_device_descriptor, sizeof(xinput_device_descriptor)); + memcpy(modified_device_descriptor, baseDescriptor, 18); memcpy(&modified_device_descriptor[8], (uint8_t*)&gamepadOptions.usbVendorID, sizeof(uint16_t)); // Vendor ID memcpy(&modified_device_descriptor[10], (uint8_t*)&gamepadOptions.usbProductID, sizeof(uint16_t)); // Product ID return (const uint8_t*)modified_device_descriptor; } - return xinput_device_descriptor; + return baseDescriptor; } const uint8_t * XInputDriver::get_hid_descriptor_report_cb(uint8_t itf) { @@ -531,6 +567,11 @@ const uint8_t * XInputDriver::get_hid_descriptor_report_cb(uint8_t itf) { const uint8_t * XInputDriver::get_descriptor_configuration_cb(uint8_t index) { uint16_t configDescriptorSize = sizeof(xinput_configuration_descriptor); memcpy(configDescriptor, &xinput_configuration_descriptor, configDescriptorSize); + // The interface 0 block is identical in both layouts, so the subtype patch + // below lands at the same offset either way + if (HostLighting::xinputCompositeActive()) { + memcpy(configDescriptor, &xinput_composite_configuration_descriptor, sizeof(xinput_composite_configuration_descriptor)); + } // check subtype GamepadOptions & gamepadOptions = Storage::getInstance().getGamepadOptions(); From 4534273f40194b346266be58308da9eaf2b999f9 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:51:57 +1000 Subject: [PATCH 5/9] Add Host Lighting section to the web configurator Enable switch and XInput lighting mode select, following the existing add-on section pattern; the two settings are exposed through the get/setAddonsOptions API. --- src/webconfig.cpp | 8 +++ www/src/Addons/HostLighting.tsx | 83 +++++++++++++++++++++++++++++ www/src/Locales/en/AddonsConfig.jsx | 7 +++ www/src/Pages/AddonsConfigPage.tsx | 7 +++ 4 files changed, 105 insertions(+) create mode 100644 www/src/Addons/HostLighting.tsx diff --git a/src/webconfig.cpp b/src/webconfig.cpp index 83a6ef541..c27eecacb 100644 --- a/src/webconfig.cpp +++ b/src/webconfig.cpp @@ -1792,6 +1792,10 @@ std::string setAddonOptions() docToValue(focusModeOptions.macroLockEnabled, doc, "focusModeMacroLockEnabled"); docToValue(focusModeOptions.enabled, doc, "FocusModeAddonEnabled"); + HostLightingOptions& hostLightingOptions = Storage::getInstance().getAddonOptions().hostLightingOptions; + docToValue(hostLightingOptions.enabled, doc, "HostLightingAddonEnabled"); + docToValue(hostLightingOptions.xinputMode, doc, "hostLightingXInputMode"); + AnalogADS1219Options& analogADS1219Options = Storage::getInstance().getAddonOptions().analogADS1219Options; docToValue(analogADS1219Options.enabled, doc, "I2CAnalog1219InputEnabled"); @@ -2340,6 +2344,10 @@ std::string getAddonOptions() writeDoc(doc, "focusModeMacroLockEnabled", focusModeOptions.macroLockEnabled); writeDoc(doc, "FocusModeAddonEnabled", focusModeOptions.enabled); + const HostLightingOptions& hostLightingOptions = Storage::getInstance().getAddonOptions().hostLightingOptions; + writeDoc(doc, "HostLightingAddonEnabled", hostLightingOptions.enabled); + writeDoc(doc, "hostLightingXInputMode", hostLightingOptions.xinputMode); + RotaryOptions& rotaryOptions = Storage::getInstance().getAddonOptions().rotaryOptions; writeDoc(doc, "RotaryAddonEnabled", rotaryOptions.enabled); writeDoc(doc, "encoderOneEnabled", rotaryOptions.encoderOne.enabled); diff --git a/www/src/Addons/HostLighting.tsx b/www/src/Addons/HostLighting.tsx new file mode 100644 index 000000000..ec877b90e --- /dev/null +++ b/www/src/Addons/HostLighting.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next'; +import { FormCheck, Row } from 'react-bootstrap'; +import * as yup from 'yup'; + +import Section from '../Components/Section'; + +import FormSelect from '../Components/FormSelect'; +import { AddonPropTypes } from '../Pages/AddonsConfigPage'; + +export const hostLightingScheme = { + HostLightingAddonEnabled: yup + .number() + .required() + .label('Host Lighting Add-On Enabled'), + hostLightingXInputMode: yup + .number() + .label('Host Lighting XInput Mode') + .validateRangeWhenValue('HostLightingAddonEnabled', 0, 2), +}; + +export const hostLightingState = { + HostLightingAddonEnabled: 0, + hostLightingXInputMode: 2, +}; + +const XINPUT_MODES = [ + { labelKey: 'host-lighting-xinput-mode-off', value: 0 }, + { labelKey: 'host-lighting-xinput-mode-on', value: 1 }, + { labelKey: 'host-lighting-xinput-mode-auto', value: 2 }, +]; + +const HostLighting = ({ + values, + errors, + handleChange, + handleCheckbox, +}: AddonPropTypes) => { + const { t } = useTranslation(); + return ( +
+ + { + handleCheckbox('HostLightingAddonEnabled'); + handleChange(e); + }} + /> +
+ ); +}; + +export default HostLighting; diff --git a/www/src/Locales/en/AddonsConfig.jsx b/www/src/Locales/en/AddonsConfig.jsx index 03adbad95..895f35264 100644 --- a/www/src/Locales/en/AddonsConfig.jsx +++ b/www/src/Locales/en/AddonsConfig.jsx @@ -160,6 +160,13 @@ export default { 'snes-extension-desc-header': 'SNES Extension Mapping', 'focus-mode-header-text': 'Focus Mode Configuration', 'focus-mode-pin-label': 'Focus Mode GPIO Pin', + 'host-lighting-header-text': 'Host Lighting Configuration', + 'host-lighting-sub-header-text': + 'Lets host software drive the RGB LEDs over a dedicated USB interface in XInput, Generic and Keyboard modes. On-board animations resume automatically when the host goes quiet.', + 'host-lighting-xinput-mode-label': 'XInput Lighting', + 'host-lighting-xinput-mode-off': 'Off (stock identity, console-safe)', + 'host-lighting-xinput-mode-on': 'Always On (PC-only identity)', + 'host-lighting-xinput-mode-auto': 'Auto (console detected at plug-in)', 'keyboard-host-header-text': 'Keyboard/Mouse Host Configuration', 'keyboard-host-sub-header-text': 'Keyboard Buttons', 'keyboard-host-mouse-header-text': 'Mouse Buttons', diff --git a/www/src/Pages/AddonsConfigPage.tsx b/www/src/Pages/AddonsConfigPage.tsx index 0c147e689..80a1cd91e 100644 --- a/www/src/Pages/AddonsConfigPage.tsx +++ b/www/src/Pages/AddonsConfigPage.tsx @@ -41,6 +41,10 @@ import FocusMode, { focusModeScheme, focusModeState, } from '../Addons/FocusMode'; +import HostLighting, { + hostLightingScheme, + hostLightingState, +} from '../Addons/HostLighting'; import Keyboard, { keyboardScheme, keyboardState } from '../Addons/Keyboard'; import GamepadUSBHost, { gamepadUSBHostScheme, @@ -84,6 +88,7 @@ const schema = yup.object().shape({ ...socdScheme, ...wiiScheme, ...focusModeScheme, + ...hostLightingScheme, ...keyboardScheme, ...rotaryScheme, ...pcf8575Scheme, @@ -109,6 +114,7 @@ export const DEFAULT_VALUES = { ...snesState, ...tg16State, ...focusModeState, + ...hostLightingState, ...keyboardState, ...rotaryState, ...pcf8575State, @@ -134,6 +140,7 @@ const ADDONS = [ SNES, TG16, FocusMode, + HostLighting, Keyboard, GamepadUSBHost, Rotary, From 7081de75268b1ee821ea854f2bbfd91c1e1114d7 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:51:57 +1000 Subject: [PATCH 6/9] Add Host Lighting documentation Feature overview, per-mode behaviour, discovery, the full protocol reference, a typical host flow, and measured performance notes. --- docs/host-lighting.md | 274 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/host-lighting.md diff --git a/docs/host-lighting.md b/docs/host-lighting.md new file mode 100644 index 000000000..139909042 --- /dev/null +++ b/docs/host-lighting.md @@ -0,0 +1,274 @@ +# Host Lighting + +Host Lighting lets software on a connected PC drive a GP2040-CE board's RGB +LEDs in real time over USB, alongside the normal controller function. Game +state, per-button effects, ambient scenes - anything a host application can +compute, it can show on the board's lights, while the on-board animations +take over automatically the moment the host goes quiet. + +The feature is fully self-describing: hosts query each board's LED layout, +identity and state over the same interface, so host software needs no +per-board configuration or updates when new boards are released. + +## How it works + +The add-on is disabled by default, like all GP2040-CE add-ons. Enable it in +the web configurator (`Configuration -> Add-Ons -> Host Lighting`); board makers +can ship it on by defining `HOST_LIGHTING_ENABLED 1` in a board config. + +When enabled, supported input modes expose one extra vendor-defined HID +interface next to the regular controller interface. Hosts exchange fixed +64-byte reports on it: + +- Lighting commands write into a **staged frame** on the board; a `COMMIT` + publishes it atomically to the LED render loop, so multi-report frames never + tear. +- The render loop shows the host frame while it stays fresh. If the host + releases control - or simply stops talking for a configurable timeout + (default 2 s) - the board's own animations resume seamlessly. A crashed or + disconnected host can never leave the lights stuck. +- **Whole-frame** takeover replaces all lighting; **overlay** takeover only + replaces pixels the host has explicitly staged, compositing host effects + over the running animations. +- Everything the host needs to know - LED map, colour order, board identity, + current state - is served by `GET_CAPS` from the board's live configuration, + including user remaps. + +## Supported modes + +| Input mode | Lighting interface | +|---|---| +| Generic (DInput) | Yes | +| Keyboard | Yes | +| XInput | Yes - see below | +| PS3/PS4/PS5, Switch, Xbox, other console modes | Never | + +**XInput** carries a choice, configurable in the web configurator +(`Host Lighting` section): + +- **Auto** (default): the board boots with its stock, console-identical + identity. A console begins authentication immediately and the board stays + stock - consoles never see any difference. A PC sends no console + authentication, so after ~4 seconds the board re-enumerates as a composite + whose controller interface binds the operating system's own Xbox 360 driver + (Windows via MS OS descriptors, Linux via the kernel's xpad vendor match), + with the lighting interface alongside. Hosts should expect the lighting + device to appear a few seconds after plug-in in this mode. +- **Always on**: the composite identity from boot (PC-only while enabled). +- **Off**: stock XInput identity always; no lighting interface in XInput mode. + +With the add-on disabled (the default), every mode presents byte-identical +USB descriptors to stock firmware. + +## Discovery + +Do not match VID:PID - it varies by input mode and user override. Enumerate +HID devices and match the top-level collection: + +- **Usage page `0xFF47`, usage `0x4C`** +- One 64-byte input report and one 64-byte output report, no report IDs + (Windows buffers are 65 bytes with a leading `0x00` report-ID byte) + +Then send `PING` and require the `GPHL` magic and protocol version >= 1.0. +Bind boards persistently by the factory-unique ID from `GET_CAPS` page 0 - +never by device path, which changes with USB ports. + +Multiple boards connected at once appear as fully independent lighting +devices - one interface per board, each addressed separately with no +interaction between them. + +## Protocol reference + +The wire protocol carried on this interface is the Host Lighting Protocol +(HLP) - the `GPHL` magic in `PING` replies and the `hlp-` prefix on the +reference tools refer to it. + +All transfers are 64-byte reports. Requests: `[0]=command, [1]=sequence, +[2..]=payload`. Replies echo the command with bit 7 set: `[0]=cmd|0x80, +[1]=sequence, [2]=status, [3..]=payload`. Status: `0` OK, `1` unsupported +command, `2` invalid argument. + +### Commands + +| Cmd | Name | Payload | Notes | +|---|---|---|---| +*Session and discovery (0x01-0x0F)* +| 0x01 | PING | - | Reply `[3..6]="GPHL", [7..8]=version`; any OK command refreshes the takeover keepalive | +| 0x02 | GET_CAPS | `[2]=page` (+`[3]=start` for page 4) | See pages below | +| 0x03 | SET_MODE | `[2]=takeover (0 whole-frame, 1 overlay), [3..4]=timeout ms LE (0->2000, min 100), [5]=apply board brightness` | Send at connect; settings persist until reboot | + +*Frame staging (0x10-0x2F)* +| 0x10 | SET_BUTTONS | `[2]=n (1-15)`, n x `[buttonId,R,G,B]` | Stage by control; reply `[3]=applied, [4]=skipped` | +| 0x11 | SET_RANGE | `[2]=start, [3]=count (1-20)`, count x `[R,G,B]` | Stage raw pixels | +| 0x12 | SET_RANGE_RGBW | `[2]=start, [3]=count (1-15)`, count x `[R,G,B,W]` | Boards without a white channel ignore W | +| 0x13 | FILL | `[2]=scope (0 all, 1 buttons, 2 case, 3 player LEDs), [3..5]=RGB` | Stage a scope | +| 0x14 | CLEAR | - | Reset staged pixels and overlay validity | + +*Frame lifecycle (0x30-0x3F)* +| 0x30 | COMMIT | - | Atomically publish the staged frame | +| 0x31 | RELEASE | - | Immediately return to on-board animations | + +*Board features (0x40-0x4F)* +| 0x40 | SET_ANIMATION | `[2]=index` | Select the on-board animation; applied live and persisted | + +*Privileged management, magic-guarded (0x70-0x7F)* +| 0x7B | SET_INPUT_MODE | `[2]=InputMode, [3..6]="MODE"` | ACKs, saves, reboots into the new mode | +| 0x7C | REBOOT_WEBCONFIG | `[2..5]="WEBC"` | Reboot into the web configurator | +| 0x7F | REBOOT_BOOTSEL | `[2..5]="BOOT"` | Reboot into the UF2 bootloader | + +A control may own more than one light: some boards wire two physical buttons +to the same action (a second Up, for example), and each has its own light. +Colouring a control colours every light that belongs to it. The capability +map reports one LED range per control - the first - so a host that needs to +address the additional lights individually can do so by raw index with +`SET_RANGE`. + +Button IDs 0-17 follow GP2040-CE's canonical order: Up, Down, Left, Right, +B1-B4, L1, R1, L2, R2, S1, S2, L3, R3, A1, A2. Specials: 24-27 player LEDs +1-4, 28 turbo LED, 29 the whole case range. `SET_INPUT_MODE` values are the +firmware's `InputMode` enum (0 XINPUT, 1 SWITCH, 2 PS3, 3 KEYBOARD, 4 PS4, +5 XBONE, 6 MDMINI, 7 NEOGEO, 8 PCEMINI, 9 EGRET, 10 ASTRO, 11 PSCLASSIC, +12 XBOXORIGINAL, 13 PS5, 14 GENERIC, 15 SWITCH_PRO, 16 P5GENERAL); the config +mode is entered via `REBOOT_WEBCONFIG` instead. + +### Command round-trip + +A command and its reply typically complete in about **1 ms** - one USB frame, +as expected for a 1 ms interrupt endpoint in each direction. The round trip +is bounded by the host's polling, not by board processing, so it can stretch +to several milliseconds when the host machine is under load. Set host +timeouts generously (tens of milliseconds) rather than near the typical +figure, to avoid false failures. + +### Replies while streaming + +Every command is answered, and replies stay reliable at streaming rates: a +46-LED board driven at 60 fps - three staging reports plus a `COMMIT` every +frame - acknowledges 300 of 300 commits with none lost. + +Because commands can be pipelined, replies arrive interleaved: the +acknowledgements for a frame's staging reports land while the host is still +waiting for that frame's `COMMIT` reply. **Match each reply by its command +byte and sequence number, holding a small queue of recent replies.** A host +that keeps only the newest reply in a single slot will discard +acknowledgements it is still waiting for and stall until its own timeout, +losing frame rate to failures that never happened on the wire. + +The firmware skips a reply if the interrupt IN endpoint is still busy, so a +reply is best-effort by design; skips are rare in practice even at full +streaming rates. Either way the command executes before the reply is +attempted, so a missing acknowledgement never means a lost frame. + +### GET_CAPS pages + +Pages are ordered so hosts only ever read forward: fetch page 0 once, poll +page 1 cheaply, and read later pages when something changed. The LED-map +fingerprint appears in pages 1 and 2, so a fetched map self-certifies against +the state that prompted the fetch. + +**Page 0 - identity** (static): `[3]` caps format (2), `[4..11]` +factory-unique board ID, then two NUL-terminated strings: board label, +firmware version. + +**Page 1 - runtime state** (the poll target): `[3]` current InputMode, +`[4]` profile number, `[5]` brightness step, `[6]` host-assigned player +(the XInput slot granted by the OS; 0 = none), `[7..10]` LED-map fingerprint, +`[11]` current animation index. + +**Page 2 - LED map** (cache until the fingerprint changes): `[3]` +ledsPerButton, `[4]` colour format (0 GRB, 1 RGB, 2 GRBW, 3 RGBW), `[5]` +layout enum, `[6]` total LEDs, `[7]` brightness maximum, `[8..43]` 18 x +`[firstLED, count]` per button ID (0xFF = unmapped), `[44..47]` player LED +indexes, `[48]` turbo index, `[49..50]` case `[first, count]`, `[51..54]` +fingerprint. + +**Page 3 - animations**: `[3]` current index, `[4]` count. Pairs with +`SET_ANIMATION`. The count is what the board actually offers, which varies +by board and by how the user has configured it - do not assume a fixed +number. `SET_ANIMATION` rejects an index at or above the reported count +with an invalid-argument status. + +**Page 4 - per-light positions** (request `[3]=start entry`): `[3]` total, +`[4]` count in reply, then count x `[firstLED, gridX, gridY]`. Boards whose +render pipeline has no per-light position data report zero entries; hosts +fall back to the layout enum. + +## Typical host flow + +1. Discover by usage page; `PING`; require version >= 1.0. +2. `GET_CAPS` 0 (bind by unique ID), 1 (state), 2 (map), optionally 3 and 4. +3. `SET_MODE` with the desired takeover, timeout and brightness policy. +4. Stream: stage changes (`SET_BUTTONS`/`SET_RANGE`/`FILL`) and `COMMIT` once + per frame; send `PING` on quiet frames to hold the takeover. +5. Poll page 1 periodically; if the fingerprint changes, re-read page 2. +6. `RELEASE` on shutdown - or simply stop; the timeout restores animations. + +## Compatibility and versioning + +- The protocol version is reported by `PING` (major, minor). From 1.0, + command IDs and existing payload layouts are frozen. A minor version adds + things - new commands in reserved slots, new capability pages, new optional + trailing payload fields; only a major version may change existing + behaviour. +- Hosts send unused payload bytes as zero; the board ignores unrecognized + trailing payload bytes. New optional fields can therefore be appended to + existing commands without breaking older hosts. +- Unknown commands are rejected with status `1` (unsupported); unknown + capability pages with status `2` (invalid argument). Hosts should treat + both as "not supported by this firmware", not as errors. Capability pages + 5 and above are reserved. +- Command IDs are grouped by function with room to grow: `0x01-0x0F` session + and discovery, `0x10-0x2F` frame staging, `0x30-0x3F` frame lifecycle, + `0x40-0x4F` board features, `0x70-0x7F` privileged management. New commands + are assigned within the matching range; `0x00` is never a command and bit 7 + is reserved for the reply flag. +- The wire format addresses up to 255 LEDs (all indexes are single bytes); + the current limit of 100 tracks the render pipeline's ceiling, so raising + the pipeline limit needs no protocol change. + +## Performance + +The LED render loop runs at 100 Hz, which is the effective ceiling for +visible updates; 60 fps host streaming is recommended. + +Input latency was measured on an RP2040 by instrumenting the input loop with +a counter and comparing against an identically instrumented build of stock +firmware on the same board: + +| Firmware | Input sampling interval | +|---|---| +| Stock upstream | 16.70 us | +| Host Lighting, idle | 16.32 us | +| Host Lighting, 60 fps streaming | 16.51 us | +| Host Lighting, 100 fps streaming | 16.62 us | + +Driving the lights costs about 0.2 us of sampling interval at 60 fps and +0.3 us at 100 fps - roughly 0.03 % of the 1 ms USB polling budget - with zero +missed commands across sustained runs. The cost does not scale with board +size: a 46-LED board, which needs four USB commands per frame instead of two, +measured the same 0.19 us at 60 fps and 0.29 us at 100 fps. The absolute figures sit slightly +below stock here, which is a binary-layout effect in the flash cache rather +than a real speed-up; the meaningful result is that no configuration measured +slower than stock. Merely enabling the add-on, with no host connected, costs +nothing measurable. With the add-on disabled the lighting code never runs at +all. + +## Host implementations + +**MESH** supports Host Lighting out of the box: boards are discovered +automatically, per-button LED maps are seeded from `GET_CAPS`, and game +state drives per-control lighting with the board's own animations as the +idle fallback. Any application that can read and write HID reports can +implement the protocol; small Python reference clients covering discovery, +capability decoding, streaming and board management accompany the pull +request that introduced this feature. + +## Notes + +- Takeover settings persist until the board reboots; hosts should always send + `SET_MODE` at connect rather than assuming defaults. +- Switching input modes with `SET_INPUT_MODE` re-enumerates the device; modes + without the lighting interface can only be left via the web configurator or + boot-time button holds. +- `SET_RANGE_RGBW` may be sent to any board; the white component only renders + on GRBW/RGBW strips (see page 2's colour format). From 1a523f36fc89433d6fb3b818a10f185a866a4a85 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:49:55 +1000 Subject: [PATCH 7/9] Extend Host Lighting to protocol v1.1 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. --- docs/host-lighting.md | 294 +++++++++++++++++++++++++++++++------ headers/hostlighting.h | 117 ++++++++++++++- src/addons/neopicoleds.cpp | 5 + src/hostlighting.cpp | 260 +++++++++++++++++++++++++++++--- src/usbdriver.cpp | 3 + 5 files changed, 612 insertions(+), 67 deletions(-) diff --git a/docs/host-lighting.md b/docs/host-lighting.md index 139909042..67300faf5 100644 --- a/docs/host-lighting.md +++ b/docs/host-lighting.md @@ -69,7 +69,7 @@ HID devices and match the top-level collection: - One 64-byte input report and one 64-byte output report, no report IDs (Windows buffers are 65 bytes with a leading `0x00` report-ID byte) -Then send `PING` and require the `GPHL` magic and protocol version >= 1.0. +Then send `PING` and require the `GPHL` magic and protocol version >= v1.0. Bind boards persistently by the factory-unique ID from `GET_CAPS` page 0 - never by device path, which changes with USB ports. @@ -94,14 +94,14 @@ command, `2` invalid argument. |---|---|---|---| *Session and discovery (0x01-0x0F)* | 0x01 | PING | - | Reply `[3..6]="GPHL", [7..8]=version`; any OK command refreshes the takeover keepalive | -| 0x02 | GET_CAPS | `[2]=page` (+`[3]=start` for page 4) | See pages below | -| 0x03 | SET_MODE | `[2]=takeover (0 whole-frame, 1 overlay), [3..4]=timeout ms LE (0->2000, min 100), [5]=apply board brightness` | Send at connect; settings persist until reboot | +| 0x02 | GET_CAPS | `[2]=page` (+`[3]=start` for pages 4 and 5) | See pages below | +| 0x03 | SET_MODE | `[2]=takeover (0 whole-frame, 1 overlay), [3..4]=timeout ms LE (0->2000, min 100, max 10000), [5]=apply board brightness` | Send at connect; settings persist until reboot | *Frame staging (0x10-0x2F)* | 0x10 | SET_BUTTONS | `[2]=n (1-15)`, n x `[buttonId,R,G,B]` | Stage by control; reply `[3]=applied, [4]=skipped` | | 0x11 | SET_RANGE | `[2]=start, [3]=count (1-20)`, count x `[R,G,B]` | Stage raw pixels | | 0x12 | SET_RANGE_RGBW | `[2]=start, [3]=count (1-15)`, count x `[R,G,B,W]` | Boards without a white channel ignore W | -| 0x13 | FILL | `[2]=scope (0 all, 1 buttons, 2 case, 3 player LEDs), [3..5]=RGB` | Stage a scope | +| 0x13 | FILL | `[2]=scope (0 all, 1 buttons, 2 case, 3 player LEDs), [3..5]=RGB` | Stage a scope. The buttons scope covers every button light, including any on the extended controls | | 0x14 | CLEAR | - | Reset staged pixels and overlay validity | *Frame lifecycle (0x30-0x3F)* @@ -118,27 +118,45 @@ command, `2` invalid argument. A control may own more than one light: some boards wire two physical buttons to the same action (a second Up, for example), and each has its own light. -Colouring a control colours every light that belongs to it. The capability -map reports one LED range per control - the first - so a host that needs to -address the additional lights individually can do so by raw index with -`SET_RANGE`. - -Button IDs 0-17 follow GP2040-CE's canonical order: Up, Down, Left, Right, +How many of them `SET_BUTTONS` colours depends on the board's LED framework. +The LED-refactor pipeline colours every light belonging to the control, so +one entry lights both Up buttons. The classic pipeline colours the single +light its per-control configuration names, and reaches the other under +whichever button ID that configuration parked it on. `FILL`'s buttons scope +reaches every button light on either. + +Page 2 reports one LED range per control regardless. A host that needs all of +a control's lights, on any board, reads page 5 - which lists every light and +names its owner - and colours the extras by raw index with `SET_RANGE`. + +Button IDs 0-17 follow GP2040-CE's gamepad bit order: Up, Down, Left, Right, B1-B4, L1, R1, L2, R2, S1, S2, L3, R3, A1, A2. Specials: 24-27 player LEDs -1-4, 28 turbo LED, 29 the whole case range. `SET_INPUT_MODE` values are the -firmware's `InputMode` enum (0 XINPUT, 1 SWITCH, 2 PS3, 3 KEYBOARD, 4 PS4, +1-4, 28 turbo LED, 29 the whole case range. 18 and 19 name A3 and A4, and +30-41 name E1-E12; page 2 has no slot for those, so they appear only in page 5 +and a host learns them from there. 20-23 are permanently unassigned - those +gamepad bits are the dpad in a second encoding, not four more controls. +`0xFF` means no button ID names this light. + +The extended IDs are report-only. `SET_BUTTONS` stages 0-17 and 24-29; an entry +naming A3, A4 or an E-button is counted as skipped, exactly as any control the +board has no light for. Colour those lights with `SET_RANGE` instead, using the +first LED and count page 5 gives for the record - which is why the record +carries both. + +`SET_INPUT_MODE` values are the firmware's `InputMode` enum (0 XINPUT, +1 SWITCH, 2 PS3, 3 KEYBOARD, 4 PS4, 5 XBONE, 6 MDMINI, 7 NEOGEO, 8 PCEMINI, 9 EGRET, 10 ASTRO, 11 PSCLASSIC, 12 XBOXORIGINAL, 13 PS5, 14 GENERIC, 15 SWITCH_PRO, 16 P5GENERAL); the config mode is entered via `REBOOT_WEBCONFIG` instead. ### Command round-trip -A command and its reply typically complete in about **1 ms** - one USB frame, -as expected for a 1 ms interrupt endpoint in each direction. The round trip -is bounded by the host's polling, not by board processing, so it can stretch -to several milliseconds when the host machine is under load. Set host -timeouts generously (tens of milliseconds) rather than near the typical -figure, to avoid false failures. +A command and its reply typically complete in about **2 ms** - one USB frame +out and one back, as expected for a 1 ms interrupt endpoint in each direction. +Measured over 300 pings the median is 2.0 ms on both pipelines and both test +boards. + +Set host timeouts in the tens of milliseconds rather than near this figure. ### Replies while streaming @@ -148,8 +166,8 @@ frame - acknowledges 300 of 300 commits with none lost. Because commands can be pipelined, replies arrive interleaved: the acknowledgements for a frame's staging reports land while the host is still -waiting for that frame's `COMMIT` reply. **Match each reply by its command -byte and sequence number, holding a small queue of recent replies.** A host +waiting for that frame's `COMMIT` reply. Match each reply by its command +byte and sequence number, holding a small queue of recent replies. A host that keeps only the newest reply in a single slot will discard acknowledgements it is still waiting for and stall until its own timeout, losing frame rate to failures that never happened on the wire. @@ -173,16 +191,49 @@ firmware version. **Page 1 - runtime state** (the poll target): `[3]` current InputMode, `[4]` profile number, `[5]` brightness step, `[6]` host-assigned player (the XInput slot granted by the OS; 0 = none), `[7..10]` LED-map fingerprint, -`[11]` current animation index. +`[11]` current animation index (0xFF = none selected), `[12..15]` feature +bitmask (bit 0 per-light positions are real, bit 1 the light table returns +entries, remaining bits zero), `[16]` LED framework (0 not reported, +1 classic, 2 LED-refactor), `[17]` animation namespace (0 not reported, +1 built-in effects, 2 stored profiles), `[18]` render rate in Hz +(0 = not stated). + +A cleared feature bit is a promise the corresponding page returns nothing, not +merely that it might. Both bits describe the board as it is at the moment of +the read: the light registry is populated on the render core during LED setup, +so a host that enumerates early can legitimately see them clear and should +re-read when the fingerprint changes. The framework byte is diagnostic - it +exists so a support question can be answered in one read. Branch on the feature +bits and the per-record flags, never on it. + +Read `[18]` rather than assuming a rate: classic renders at 100 Hz and the +LED-refactor at 40 Hz today, both are free to change, and a host streaming +faster than the board renders simply discards the difference with nothing to +tell it why. **Page 2 - LED map** (cache until the fingerprint changes): `[3]` ledsPerButton, `[4]` colour format (0 GRB, 1 RGB, 2 GRBW, 3 RGBW), `[5]` -layout enum, `[6]` total LEDs, `[7]` brightness maximum, `[8..43]` 18 x -`[firstLED, count]` per button ID (0xFF = unmapped), `[44..47]` player LED -indexes, `[48]` turbo index, `[49..50]` case `[first, count]`, `[51..54]` -fingerprint. - -**Page 3 - animations**: `[3]` current index, `[4]` count. Pairs with +layout enum, `[6]` LED count as the extent of the mapped range, `[7]` +brightness maximum, `[8..43]` 18 x `[firstLED, count]` per button ID +(0xFF = unmapped), `[44..47]` player LED indexes, `[48]` turbo index, +`[49..50]` case `[first, count]`, `[51..54]` fingerprint. + +Page 2 answers "where do I write this control", for the eighteen canonical +controls. It is not an inventory of the board's lights. Absence from page 2 +means the control has no slot here, not that no light exists. A board may carry +lights on controls page 2 cannot name, and lights with no owning control at +all. Where the page 1 light-table bit is set, page 5 is authoritative and +page 2's bindings are best-effort - some board configurations assign a +control's LED index to a light that control does not drive, because it is the +only way that pipeline can make the light work at all. + +`[6]` is the extent - the highest LED index in use, plus one - which is the +number a host sizes a frame buffer from. It is deliberately not the sum of the +ranges above it, because that sum undercounts by exactly the lights page 2 +cannot name. + +**Page 3 - animations**: `[3]` current index (0xFF = none selected), +`[4]` count. Pairs with `SET_ANIMATION`. The count is what the board actually offers, which varies by board and by how the user has configured it - do not assume a fixed number. `SET_ANIMATION` rejects an index at or above the reported count @@ -191,32 +242,93 @@ with an invalid-argument status. **Page 4 - per-light positions** (request `[3]=start entry`): `[3]` total, `[4]` count in reply, then count x `[firstLED, gridX, gridY]`. Boards whose render pipeline has no per-light position data report zero entries; hosts -fall back to the layout enum. +fall back to the layout enum. Page 4 is a projection of page 5's columns and +is kept because it fits 19 entries per read against page 5's 4, so a host that +only wants positions keeps a cheap path. + +**Page 5 - light table** (request `[3]=start entry`): `[3]` total records, +`[4]` start entry echoed, `[5]` count in this reply, `[6]` record stride in +bytes, `[7..]` the records, `[60..63]` the LED-map fingerprint. + +Each record is 12 bytes: + +| off | field | meaning | +|---|---|---| +| +0 | first LED | first LED index on the chain. **Not a unique key** - boards exist with two lights starting at the same index | +| +1 | LED count | LEDs in this light, which may differ from page 2's global `ledsPerButton` | +| +2 | light kind | 0 button, 1 case, 2 turbo, 3-6 player 1-4, `0xFF` unknown | +| +3 | button ID | the owning control, or `0xFF` if none names it | +| +4 | GPIO pin | the pin the control sits on, or `0xFF` when unknown or not applicable | +| +5..6 | GPIO action | the pin's action, signed 16-bit little-endian, verbatim. `0x8000` means no owning action | +| +7 | player index | 0-3, or `0xFF` | +| +8 | case group | the light's slot in the profile's non-button palette. Opaque, not unique, `0xFF` for none | +| +9..10 | grid X, Y | valid only when the position flag is set | +| +11 | flags | bit 0 position is real, bit 1 record came from a per-light table, rest zero. A clear bit claims nothing | + +A light's identity is its **record ordinal**, which is why the reply echoes the +start entry. Records appear in the same order as page 4's, so ordinal *n* on +one page is ordinal *n* on the other. + +Where a control owns several lights, several records carry the same button ID. +That is the page's purpose, and it needs no special case in a host. + +Read the stride from `[6]` rather than assuming 12. A record can never be +widened in place - that would move every record after the first, and nothing +in the reply would let a parser notice - so future per-light fields go on a new +page. The stride exists so that rule is enforced by the format rather than +remembered. + +Both flags are positive assertions: a set bit is the board vouching for +something, so a record asserting nothing reads as the weaker case. A board +with no per-light table rebuilds these records from its per-control +configuration and leaves bit 1 clear. Those rows are per-*control*, so a board +wiring two buttons to one action cannot be represented, and each action is +definitional rather than read from a live light. The layout is identical +either way: a board fills in what it knows and writes the sentinels where it +does not, so a host parses one way and reads the flags to learn what is real. +Availability is a property of the board, never of the protocol version. + +The fingerprint in the tail lets a host notice the map changing underneath a +walk that takes several reads. If it changes mid-walk, discard the partial +table and start again. ## Typical host flow -1. Discover by usage page; `PING`; require version >= 1.0. -2. `GET_CAPS` 0 (bind by unique ID), 1 (state), 2 (map), optionally 3 and 4. -3. `SET_MODE` with the desired takeover, timeout and brightness policy. +1. Discover by usage page; `PING`; require version >= v1.0. +2. `GET_CAPS` 0 (bind by unique ID), 1 (state), 2 (map), optionally 3, and 4 + or 5. Page 5 is the full light inventory; page 2 alone suits a host that + only colours canonical controls. +3. `SET_MODE` with the desired takeover, timeout and brightness policy. The + timeout is clamped to the range in the command table; send `PING` to idle + longer. 4. Stream: stage changes (`SET_BUTTONS`/`SET_RANGE`/`FILL`) and `COMMIT` once per frame; send `PING` on quiet frames to hold the takeover. -5. Poll page 1 periodically; if the fingerprint changes, re-read page 2. +5. Poll page 1 periodically; if the fingerprint changes, re-read whichever of + pages 2, 4 and 5 you cache - one fingerprint certifies all three, and a + change mid-walk means discarding the partial table. 6. `RELEASE` on shutdown - or simply stop; the timeout restores animations. ## Compatibility and versioning -- The protocol version is reported by `PING` (major, minor). From 1.0, - command IDs and existing payload layouts are frozen. A minor version adds - things - new commands in reserved slots, new capability pages, new optional - trailing payload fields; only a major version may change existing - behaviour. +- The protocol version is reported by `PING` (major, minor). Command IDs and + existing payload layouts are frozen. A minor version adds things - new + commands in reserved slots, new capability pages, new optional trailing + payload fields; only a major version may change existing behaviour. - Hosts send unused payload bytes as zero; the board ignores unrecognized - trailing payload bytes. New optional fields can therefore be appended to - existing commands without breaking older hosts. + trailing payload bytes. Boards zero every unused reply byte and hosts ignore + unrecognized trailing reply bytes, so new optional fields can be appended to + existing commands and pages without breaking older hosts. An entry in a + fixed-stride array is not a trailing field: an entry's width is frozen once + the page ships, because widening it moves every entry after the first and + nothing in the reply would let a parser notice. Further per-light fields go + on a new page. +- The caps format byte (page 0 `[3]`) versions page 0's own layout, not the + feature set. It stays at 2 for all of v1.x. Feature discovery is the page 1 + bitmask. Hosts must not require equality on it. - Unknown commands are rejected with status `1` (unsupported); unknown capability pages with status `2` (invalid argument). Hosts should treat both as "not supported by this firmware", not as errors. Capability pages - 5 and above are reserved. + 6 and above are reserved. - Command IDs are grouped by function with room to grow: `0x01-0x0F` session and discovery, `0x10-0x2F` frame staging, `0x30-0x3F` frame lifecycle, `0x40-0x4F` board features, `0x70-0x7F` privileged management. New commands @@ -228,8 +340,14 @@ fall back to the layout enum. ## Performance -The LED render loop runs at 100 Hz, which is the effective ceiling for -visible updates; 60 fps host streaming is recommended. +The LED render loop's rate is the effective ceiling for visible updates, and it +differs by LED framework - the classic pipeline renders at 100 Hz, the +LED-refactor pipeline at 40 Hz. **Read the actual rate from page 1 `[18]` +rather than assuming one.** A host streaming faster than the board renders is +not an error and nothing reports it; the extra frames are simply never shown. +60 fps streaming suits a 100 Hz board. + +### Input sampling cost Input latency was measured on an RP2040 by instrumenting the input loop with a counter and comparing against an identically instrumented build of stock @@ -253,6 +371,26 @@ slower than stock. Merely enabling the add-on, with no host connected, costs nothing measurable. With the add-on disabled the lighting code never runs at all. +Building the light table and computing the map fingerprint happen only when a +host requests a capability page, never on the render path. + +### Protocol throughput + +Measured on both pipelines and both test boards, streaming whole frames of +`SET_RANGE` plus one `COMMIT`: + +| | round-trip median | 60 fps commits | unthrottled | +|---|---|---|---| +| classic, 46 LEDs | 2.00 ms | 300/300, 0 lost | 122 fps | +| classic, 16 LEDs | 2.00 ms | 300/300, 0 lost | 247 fps | +| LED-refactor, 46 LEDs | 2.00 ms | 300/300, 0 lost | 122 fps | +| LED-refactor, 16 LEDs | 2.00 ms | 300/300, 0 lost | 249 fps | + +Both pipelines hold a full 60 fps with no lost acknowledgements. Unthrottled +throughput scales with the number of reports a frame needs: four for a 46-LED +board, two for a 16-LED board. Both figures exceed either render rate, so +streaming faster than the rate page 1 `[18]` reports gains nothing. + ## Host implementations **MESH** supports Host Lighting out of the box: boards are discovered @@ -260,8 +398,8 @@ automatically, per-button LED maps are seeded from `GET_CAPS`, and game state drives per-control lighting with the board's own animations as the idle fallback. Any application that can read and write HID reports can implement the protocol; small Python reference clients covering discovery, -capability decoding, streaming and board management accompany the pull -request that introduced this feature. +capability decoding, streaming and board management live in the +`gp2040ce-binary-tools` repository. ## Notes @@ -272,3 +410,71 @@ request that introduced this feature. boot-time button holds. - `SET_RANGE_RGBW` may be sent to any board; the white component only renders on GRBW/RGBW strips (see page 2's colour format). + +## Changelog + +Everything above describes the protocol as it is now. This section records +what changed between versions, for hosts written against an earlier one. + +Versions are the protocol version `PING` reports, not the firmware version. +A host should gate on `>= v1.0` and read the capability pages, never require +an exact version. + +### v1.1 - 16 August 2026 + +Additive. No command ID and no existing payload layout changed: every field a +v1.0 host reads is still at the offset and width it was, so a v1.0 host keeps +working unchanged. Two values it can observe are more accurate than they were, +both listed under **Changed**. Input sampling and per-frame render cost are +unaffected - the new work runs only on capability reads. + +**Added** + +- **Capability page 5, the light table.** One 12-byte record per light, + paged four to a report, naming the control that owns each light. It is the + first page that can express a control owning several lights, which page 2 + structurally cannot. Records carry the owning button ID, GPIO, action, + player index, case group, grid position and flags. +- **Page 1 appended fields** at `[12..18]`: a feature bitmask, the LED + framework, the animation namespace, and the render rate in Hz. All sit + after every v1.0 field, and each reserves 0 for "not reported". +- **Button IDs 18-19 (A3, A4) and 30-41 (E1-E12).** Report-only: page 5 names + them, `SET_BUTTONS` does not stage them. Colour those lights with + `SET_RANGE`. +- **A fingerprint on page 5**, matching pages 1 and 2, so one value certifies + pages 2, 4 and 5 together and guards a paged walk. +- **Per-record flags** written as positive assertions, so a record asserting + nothing reads as the weaker case. + +**Changed** + +- `FILL`'s buttons scope now covers every button light, including lights on + controls page 2 cannot name. +- Page 2 `[6]` reports the extent of the mapped range rather than the sum of + the ranges above it. On a board with a gap in its chain this is a larger, + and correct, number: it is what a host needs to size a frame. +- `SET_MODE`'s timeout gained a 10 s ceiling. A host wanting to idle longer + should send `PING`. +- The map fingerprint now covers the whole pin map, so a profile switch that + remaps pins changes it even though no stored LED index moved. + +**Fixed** + +- Staged writes are bounds-checked against the addressable LED space, so a + stored configuration naming an index past the end of the chain is a + lighting fault rather than a memory fault. +- A case strip whose configured index falls outside the addressable space now + reports as having no light, matching every other control. It previously + clamped only the length, which could publish a zero-width or absurdly wide + range through page 2 and the light table. +- The takeover is released when the USB host disconnects or suspends, rather + than waiting for the keepalive to expire. +- This document previously said `SET_BUTTONS` colours every light belonging to + a control. That is true only on the LED-refactor pipeline; the classic + pipeline colours the one light its per-control configuration names. No + firmware behaviour changed - the description was wrong. A host that needs + every light on any board should read page 5. + +### v1.0 - 11 August 2026 + +Initial public release for review. diff --git a/headers/hostlighting.h b/headers/hostlighting.h index 957c2ee0c..90836b2a3 100644 --- a/headers/hostlighting.h +++ b/headers/hostlighting.h @@ -22,9 +22,12 @@ // Staging commands (SET_BUTTONS/SET_RANGE/SET_RANGE_RGBW/FILL/CLEAR) edit an // off-screen frame that COMMIT publishes atomically to the render loop; // RELEASE - or the host going quiet past the keepalive timeout - hands the -// LEDs back to the on-board animations. GET_CAPS serves five forward-only -// pages (identity, runtime state, LED map, animations, positions) built from -// the board's live configuration, so hosts need no per-board knowledge. +// LEDs back to the on-board animations. GET_CAPS serves six forward-only +// pages (identity, runtime state, LED map, animations, positions, light table) +// built from the board's live configuration, so hosts need no per-board +// knowledge. Page 2 answers "where do I write this control" for the eighteen +// canonical controls; page 5 is the full inventory, and where a control owns +// several lights it is page 5 that says so. // Management commands select the on-board idle animation (SET_ANIMATION) and, // magic-guarded, switch input mode or reboot into webconfig or the bootloader. // @@ -43,8 +46,23 @@ #define HOST_LIGHTING_XINPUT 1 #endif +// v1.1 adds capability page 5, appends fields to page 1, and widens FILL's +// buttons scope to every button light. No command ID and no existing payload +// layout changes, so every field a v1.0 host reads is still where it was. +// +// What a v1.0 host can observe did change in places, and the spec lists every +// case rather than claiming otherwise: page 2 [6] reports the extent of the +// mapped range instead of the sum of the ranges above it, SET_MODE's timeout +// gained a ceiling, FILL's buttons scope widened, and the map fingerprint now +// also moves on profile pin remaps. All are the board describing itself more +// accurately at the same offsets, which is what keeps this a minor bump; the +// changelog in docs/host-lighting.md is the authority. +// +// The caps format byte on page 0 stays at 2: it versions that page's layout, +// which is untouched, and moving it would force every host to re-derive a +// parser for a surface that did not move. #define HOST_LIGHTING_PROTOCOL_VERSION_MAJOR 1 -#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 0 +#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 1 // All transfers are fixed-size reports: [0]=command, [1]=sequence, [2..63]=payload. // Replies echo the sequence and set bit 7 of the command byte. @@ -85,6 +103,11 @@ #define HOST_LIGHTING_BUTTONS_MAX_ENTRIES 15 #define HOST_LIGHTING_POSITIONS_PER_PAGE 19 #define HOST_LIGHTING_DEFAULT_TIMEOUT_MS 2000 +// Upper bound on the keepalive a host may ask for. The field is sixteen bits, +// so without one a dead host could hold the lights for over a minute. Not +// configurable: a host that wants to idle longer sends PING, which refreshes +// the keepalive and is how the protocol expects a quiet host to behave. +#define HOST_LIGHTING_MAX_TIMEOUT_MS 10000 #define HOST_LIGHTING_FILL_SCOPE_ALL 0x00 #define HOST_LIGHTING_FILL_SCOPE_BUTTONS 0x01 @@ -94,12 +117,82 @@ #define HOST_LIGHTING_TAKEOVER_WHOLE_FRAME 0x00 #define HOST_LIGHTING_TAKEOVER_OVERLAY 0x01 -// Button IDs: 0-17 in GP2040-CE canonical order, then addressable specials +// Button IDs follow GP2040-CE's gamepad bit order - the four dpad masks, then +// the button masks from B1 upward (GamepadState.h). That is deliberately not +// GpioAction's declaration order, whose tail runs A1, A2, L3, R3; the bridge +// between the two is a table, never arithmetic. +// +// 0-17 are the canonical set page 2 can express and are frozen from v1.0. +// 18-19 name A3 and A4, and 30-41 name E1-E12, both added in v1.1: page 2 has +// no slot for them, so they appear only in the light table, and a host learns +// them from there. 20-23 stay permanently unassigned - those mask bits are +// GAMEPAD_MASK_DU..DR, which are the dpad in a second encoding rather than +// four more controls, and giving Up a second ID would be a lasting mistake. #define HOST_LIGHTING_BUTTON_COUNT 18 +#define HOST_LIGHTING_BUTTON_A3 18 +#define HOST_LIGHTING_BUTTON_A4 19 #define HOST_LIGHTING_BUTTON_PLED1 24 #define HOST_LIGHTING_BUTTON_PLED4 27 #define HOST_LIGHTING_BUTTON_TURBO 28 #define HOST_LIGHTING_BUTTON_CASE 29 +#define HOST_LIGHTING_BUTTON_E1 30 +#define HOST_LIGHTING_BUTTON_E12 41 +#define HOST_LIGHTING_BUTTON_NONE 0xFF + +// Page 5 - the light table. One record per light, the same twelve bytes on +// every pipeline: a board fills in what it knows and writes the sentinels +// where it does not, so a host parses one way and reads flags to learn what +// is real. Availability is a property of the board, never of the version. +#define HOST_LIGHTING_LIGHT_STRIDE 12 +#define HOST_LIGHTING_LIGHTS_PER_PAGE 4 + +// Light kinds as HLP defines them. Numerically equal to the LED-refactor's +// LightType today, but owned here: that enum exists in only one tree and is +// itself hand-mirrored from pixel.h, so the wire cannot depend on it. +#define HOST_LIGHTING_LIGHT_ACTION 0x00 +#define HOST_LIGHTING_LIGHT_CASE 0x01 +#define HOST_LIGHTING_LIGHT_TURBO 0x02 +#define HOST_LIGHTING_LIGHT_PLAYER1 0x03 +#define HOST_LIGHTING_LIGHT_PLAYER4 0x06 +#define HOST_LIGHTING_LIGHT_UNKNOWN 0xFF + +// Per-record flags, both positive assertions: a set bit is the board vouching +// for something, so a record asserting nothing reads as the weaker case. +// POSITION says the grid coordinates are real - it cannot be inferred from +// (0,0), because positions are origin-normalised so some light always sits +// there. PER_LIGHT says the record was read from a per-light table and +// describes exactly one light; clear means it was rebuilt from per-control +// configuration, where duplicates are structurally invisible. +#define HOST_LIGHTING_LIGHT_FLAG_POSITION 0x01 +#define HOST_LIGHTING_LIGHT_FLAG_PER_LIGHT 0x02 + +// GpioAction travels verbatim as a signed 16-bit value so it never needs an +// HLP allocation when GP2040-CE adds one. This sentinel means no owning action +// exists, which is distinct from the pin's action genuinely being NONE (-10). +#define HOST_LIGHTING_ACTION_NONE ((int16_t)0x8000) + +// Page 1 feature bits. A cleared bit is a promise that the corresponding page +// returns nothing, not merely that it might. +#define HOST_LIGHTING_FEATURE_POSITIONS (1u << 0) +#define HOST_LIGHTING_FEATURE_LIGHT_TABLE (1u << 1) + +// Page 1 LED-framework byte. CLASSIC is the AnimationStation the released +// firmware ships; REFACTOR is the Lights/Light rewrite on the LED-refactor +// branch. Zero is reserved for "not reported", so a board that never wrote the +// byte cannot be read as naming a framework. Diagnostic only: hosts must branch +// on the feature bits and the per-record flags, never on this, because the two +// differ in more ways than one byte can carry. +#define HOST_LIGHTING_FRAMEWORK_UNREPORTED 0x00 +#define HOST_LIGHTING_FRAMEWORK_CLASSIC 0x01 +#define HOST_LIGHTING_FRAMEWORK_REFACTOR 0x02 + +// Which universe page 3's animation index selects. Classic indexes its +// built-in effects; the refactor indexes the user's stored animation profiles. +// Those are genuinely different objects, so a host that assumes one silently +// selects the wrong thing on a board running the other. Naming the universe +// costs a byte and means neither has to pretend to be the other. +#define HOST_LIGHTING_ANIM_EFFECTS 0x01 +#define HOST_LIGHTING_ANIM_PROFILES 0x02 // The HID instance index the lighting interface occupies in HID-class modes // (the gamepad interface enumerates first as instance 0). In XInput mode the @@ -146,6 +239,14 @@ namespace HostLighting { // The verdict survives soft reboots and clears on power loss (unplug). void xinputAutoDetectTask(bool consoleAuthSeen); + // Publishes the LED render rate for GET_CAPS; called by the LED addon, which + // owns the interval. Reported rather than asserted by the specification, + // because the frameworks tick at different rates - classic at 100 Hz, the + // refactor at 40 Hz, both derived from the LED addon's intervalMS - and a + // host that streams faster than the board renders simply discards the + // difference. + void setRenderRate(uint8_t hz); + const uint8_t * getReportDescriptor(); uint16_t getReport(uint8_t report_id, hid_report_type_t report_type, uint8_t * buffer, uint16_t reqlen); @@ -161,6 +262,12 @@ namespace HostLighting { // animation index, or -1 when none is pending. Called from the LED render // loop (core1), which owns animation selection. int16_t takeLocalAnimationRequest(); + + // Hands the LEDs back to the on-board animations, as RELEASE does. Called + // when the bus goes away: takeover is session state, and without this it + // outlives the session that set it, so the next host to send anything at + // all revives the previous host's frame before saying what it wants. + void releaseTakeover(); } #endif diff --git a/src/addons/neopicoleds.cpp b/src/addons/neopicoleds.cpp index 0a110d55e..512ccadaf 100644 --- a/src/addons/neopicoleds.cpp +++ b/src/addons/neopicoleds.cpp @@ -293,6 +293,11 @@ void NeoPicoLEDAddon::setup() { as.SetMode(animationOptions.baseAnimationIndex); as.SetBrightness(animationOptions.brightness); + // Tell Host Lighting how fast we actually render, so it can report a rate + // rather than have the protocol assert one. A host streaming faster than + // this simply discards the difference, with nothing to tell it why. + HostLighting::setRenderRate((uint8_t)((intervalMS > 0) ? (1000 / intervalMS) : 0)); + // Next Run nextRunTime = make_timeout_time_ms(0); // Reset timeout diff --git a/src/hostlighting.cpp b/src/hostlighting.cpp index 63ad173fc..3149feb72 100644 --- a/src/hostlighting.cpp +++ b/src/hostlighting.cpp @@ -70,6 +70,39 @@ static const int32_t LEDOptions::* const buttonIndexFields[HOST_LIGHTING_BUTTON_ &LEDOptions::indexA1, &LEDOptions::indexA2, }; +// The GPIO action each protocol button ID stands for. Referenced by name so a +// renumbering of the enum cannot silently change what the wire means; the tail +// is a permutation, since protocol order is the gamepad bit order while +// GpioAction declares A1 and A2 ahead of L3 and R3. +static const GpioAction canonicalAction[HOST_LIGHTING_BUTTON_COUNT] = { + GpioAction::BUTTON_PRESS_UP, GpioAction::BUTTON_PRESS_DOWN, + GpioAction::BUTTON_PRESS_LEFT, GpioAction::BUTTON_PRESS_RIGHT, + GpioAction::BUTTON_PRESS_B1, GpioAction::BUTTON_PRESS_B2, + GpioAction::BUTTON_PRESS_B3, GpioAction::BUTTON_PRESS_B4, + GpioAction::BUTTON_PRESS_L1, GpioAction::BUTTON_PRESS_R1, + GpioAction::BUTTON_PRESS_L2, GpioAction::BUTTON_PRESS_R2, + GpioAction::BUTTON_PRESS_S1, GpioAction::BUTTON_PRESS_S2, + GpioAction::BUTTON_PRESS_L3, GpioAction::BUTTON_PRESS_R3, + GpioAction::BUTTON_PRESS_A1, GpioAction::BUTTON_PRESS_A2, +}; + +// The GPIO a control sits on, for the light table's diagnostic column. A unique +// match or nothing: a board that maps one action across several pins has no +// single answer, and here an honest "unknown" beats picking the first. +static uint8_t pinForAction(GpioAction action) { + GpioMappingInfo * pinMappings = Storage::getInstance().getProfilePinMappings(); + uint8_t found = 0xFF; + + for (uint8_t pin = 0; pin < (uint8_t)NUM_BANK0_GPIOS; pin++) { + if (pinMappings[pin].action != action) + continue; + if (found != 0xFF) + return 0xFF; + found = pin; + } + return found; +} + // Resolves a protocol button ID to its physical LED chain range from the live // configuration; {-1, 0} when the control has no LED on this board. static LedRange resolveButton(uint8_t buttonId) { @@ -103,7 +136,15 @@ static LedRange resolveButton(uint8_t buttonId) { } if (buttonId == HOST_LIGHTING_BUTTON_CASE) { - if ((lo.caseRGBIndex < 0) || (lo.caseRGBCount == 0)) + // The index is rejected outright when it lands outside the addressable + // space, as every branch above does. Clamping the count instead is not + // enough: the subtraction below is signed, so an index past the end + // yields a negative width that becomes a huge one as an unsigned count, + // and an index of exactly the maximum yields a zero-width range at an + // address no pixel occupies. Neither is a range, and both would be + // published to a host through page 2 and the light table. + if ((lo.caseRGBIndex < 0) || (lo.caseRGBIndex >= HOST_LIGHTING_MAX_LEDS) || + (lo.caseRGBCount == 0)) return none; uint32_t count = lo.caseRGBCount; if ((uint32_t)lo.caseRGBIndex + count > HOST_LIGHTING_MAX_LEDS) @@ -119,26 +160,58 @@ static void stagePixel(uint32_t index, uint32_t colour) { stagingValid[index / 32] |= (1u << (index % 32)); } +// Clamped against the staging buffer rather than trusting the caller: a stored +// configuration can name an LED index past the end of the chain, and the ranges +// resolveButton derives from one are only as sound as the values behind them. +// Bounding the write here keeps a bad config a lighting bug instead of a +// memory-corruption bug, for every present and future caller. static void stageRange(const LedRange & range, uint32_t colour) { - for (uint8_t i = 0; i < range.count; i++) - stagePixel((uint32_t)range.first + i, colour); + if (range.first < 0) + return; + + uint32_t end = (uint32_t)range.first + range.count; + if (end > HOST_LIGHTING_MAX_LEDS) + end = HOST_LIGHTING_MAX_LEDS; + + for (uint32_t index = (uint32_t)range.first; index < end; index++) + stagePixel(index, colour); } // GET_CAPS pages are ordered so hosts only ever read forward: page 0 identity // (fetch once), page 1 runtime state (the cheap poll target), page 2 LED map // (carries the same fingerprint as page 1, so a fetched map self-certifies // against the state that prompted it), page 3 on-board animations, page 4 -// per-light positions (optional, spatial). - -// FNV-1a over the resolved LED ranges: changes exactly when the map a host -// would cache changes (profile pin remaps, webconfig LED edits) +// per-light positions, page 5 the light table. + +// FNV-1a over everything the capability pages say about the map, so one value +// certifies pages 2, 4 and 5 together and a host knows from a single poll that +// anything it cached is still true. +// +// The pin map is hashed wholesale rather than through per-control reverse +// lookups. It is both cheaper - one pass instead of one scan per control - and +// the honest scope: a profile switch remaps pins underneath the board without +// touching a single stored LED index, which changes what page 5 reports about +// ownership while every LED range stays exactly as it was. static uint32_t ledMapFingerprint() { uint32_t fingerprint = 2166136261u; + for (uint8_t id = 0; id <= HOST_LIGHTING_BUTTON_CASE; id++) { LedRange range = resolveButton(id); fingerprint = (fingerprint ^ (uint8_t)range.first) * 16777619u; fingerprint = (fingerprint ^ range.count) * 16777619u; } + + GpioMappingInfo * pinMappings = Storage::getInstance().getProfilePinMappings(); + for (uint8_t pin = 0; pin < (uint8_t)NUM_BANK0_GPIOS; pin++) + fingerprint = (fingerprint ^ (uint32_t)pinMappings[pin].action) * 16777619u; + + // Board shape page 2 carries and the range loop above never reaches + const LEDOptions & lo = Storage::getInstance().getLedOptions(); + fingerprint = (fingerprint ^ (uint32_t)lo.ledsPerButton) * 16777619u; + fingerprint = (fingerprint ^ (uint32_t)lo.ledFormat) * 16777619u; + fingerprint = (fingerprint ^ (uint32_t)lo.ledLayout) * 16777619u; + fingerprint = (fingerprint ^ (uint32_t)lo.brightnessMaximum) * 16777619u; + return fingerprint; } @@ -163,10 +236,27 @@ static void buildCapsIdentity(uint8_t * reply) { reply[pos] = 0; } +// Published by the LED addon, which owns the render interval; zero until it +// has, which a host reads as "not stated" rather than as a rate of nothing +static uint8_t renderRateHz = 0; + +void HostLighting::setRenderRate(uint8_t hz) { + renderRateHz = hz; +} + +void HostLighting::releaseTakeover() { + liveActive = false; +} + // Page 1 - runtime state, everything that changes without replugging: // [3] current InputMode [4] profile number [5] brightness step // [6] host-assigned player (0 = none) [7..10] LED-map fingerprint (LE) // [11] current on-board animation index +// [12..15] feature bitmask (LE) [16] LED framework [17] animation namespace +// [18] render rate in Hz (0 = not stated) +// Everything from [12] was appended in v1.1. Replies are zero-filled before they +// are built, so firmware predating those fields reports them as zero, which +// reads correctly as no optional features and nothing stated. static void buildCapsState(uint8_t * reply) { reply[3] = (uint8_t)DriverManager::getInstance().getInputMode(); @@ -183,12 +273,43 @@ static void buildCapsState(uint8_t * reply) { memcpy(&reply[7], &fingerprint, 4); reply[11] = (uint8_t)animationOptions.baseAnimationIndex; + + // This pipeline has no per-light positions, so bit 0 stays clear. Bit 1 is + // set whenever page 5 has anything to say, which here means the board has at + // least one control with a light. + uint32_t features = 0; + for (uint8_t id = 0; id <= HOST_LIGHTING_BUTTON_CASE; id++) { + if (resolveButton(id).first >= 0) { + features |= HOST_LIGHTING_FEATURE_LIGHT_TABLE; + break; + } + } + memcpy(&reply[12], &features, 4); + + reply[16] = HOST_LIGHTING_FRAMEWORK_CLASSIC; + reply[17] = HOST_LIGHTING_ANIM_EFFECTS; + reply[18] = renderRateHz; +} + +// Tracks the highest LED index any reported range reaches, for page 2's [6]. +// An extent rather than a sum of counts: a sum only equals the strip length +// when every LED belongs to a control this page names, and it undercounts by +// exactly the lights a host most needs to know about on a board that has more +// lights than named controls. An extent is also a number a host can size a +// frame buffer from, which a sum is not. +static void noteExtent(uint32_t & extent, const LedRange & range) { + if (range.first < 0) + return; + + uint32_t end = (uint32_t)range.first + range.count; + if (end > extent) + extent = end; } // Page 2 - LED map; the trailing fingerprint matches page 1's for the same // map, letting a host confirm a coherent snapshot without re-reading page 1: // [3] ledsPerButton [4] LEDFormat [5] ButtonLayout -// [6] total LED count [7] brightness maximum +// [6] LED count, as the extent of the mapped range [7] brightness maximum // [8..43] per-button {first LED, count} pairs, button IDs 0-17 (0xFF = unmapped) // [44..47] player LED indexes [48] turbo LED index [49..50] case {first, count} // [51..54] LED-map fingerprint (LE) @@ -201,7 +322,7 @@ static void buildCapsLedMap(uint8_t * reply) { reply[5] = (uint8_t)lo.ledLayout; reply[7] = (uint8_t)((lo.brightnessMaximum > 255) ? 255 : lo.brightnessMaximum); - uint32_t totalLeds = 0; + uint32_t ledExtent = 0; for (uint8_t b = 0; b < HOST_LIGHTING_BUTTON_COUNT; b++) { LedRange range = resolveButton(b); if (range.first < 0) { @@ -210,39 +331,49 @@ static void buildCapsLedMap(uint8_t * reply) { } else { reply[8 + b * 2] = (uint8_t)range.first; reply[9 + b * 2] = range.count; - totalLeds += range.count; } + noteExtent(ledExtent, range); } for (uint8_t p = 0; p < 4; p++) { LedRange range = resolveButton(HOST_LIGHTING_BUTTON_PLED1 + p); reply[44 + p] = (range.first < 0) ? 0xFF : (uint8_t)range.first; - if (range.first >= 0) - totalLeds += 1; + noteExtent(ledExtent, range); } LedRange turbo = resolveButton(HOST_LIGHTING_BUTTON_TURBO); reply[48] = (turbo.first < 0) ? 0xFF : (uint8_t)turbo.first; - if (turbo.first >= 0) - totalLeds += 1; + noteExtent(ledExtent, turbo); LedRange caseRange = resolveButton(HOST_LIGHTING_BUTTON_CASE); reply[49] = (caseRange.first < 0) ? 0xFF : (uint8_t)caseRange.first; reply[50] = caseRange.count; - totalLeds += caseRange.count; + noteExtent(ledExtent, caseRange); - reply[6] = (uint8_t)((totalLeds > 255) ? 255 : totalLeds); + reply[6] = (uint8_t)((ledExtent > 255) ? 255 : ledExtent); uint32_t fingerprint = ledMapFingerprint(); memcpy(&reply[51], &fingerprint, 4); } +// How many on-board animations a host may actually select. AnimationStation +// counts the custom theme as an extra effect once the user has configured one +// (see its constructor in animationstation.cpp), and the board's own hotkeys +// will cycle onto it, so reporting the bare TOTAL_EFFECTS hides an animation +// the board really has and makes SET_ANIMATION refuse a valid index. Derived +// from stored config rather than read from AnimationStation because HLP holds +// no handle on the LED addon by design; keep this in step with that constructor. +static uint8_t onBoardAnimationCount() { + const AnimationOptions & animationOptions = Storage::getInstance().getAnimationOptions(); + return (uint8_t)(TOTAL_EFFECTS + (animationOptions.hasCustomTheme ? 1 : 0)); +} + // Page 3 - on-board animation selection (the lighting shown outside host // control): [3] current animation index [4] number of animations static void buildCapsAnimations(uint8_t * reply) { const AnimationOptions & animationOptions = Storage::getInstance().getAnimationOptions(); reply[3] = (uint8_t)animationOptions.baseAnimationIndex; - reply[4] = (uint8_t)TOTAL_EFFECTS; + reply[4] = onBoardAnimationCount(); } // Page 4 - per-light grid positions, for spatially aware host effects. @@ -255,6 +386,92 @@ static void buildCapsPositions(uint8_t * reply, uint8_t startEntry) { reply[4] = 0; } +// Page 5 - the light table: one twelve-byte record per light, the same layout +// on every pipeline, so a host parses one way and reads the flags and sentinels +// to learn which fields carry real data on this board. +// +// This pipeline keeps no per-light records - all it knows is a scalar config +// slot per control. Rather than answer with an empty page, which would leave +// every host maintaining a page 2 inference path forever for the firmware most +// people actually run, it rebuilds those slots into records and leaves the +// PER_LIGHT flag clear. That absence carries the honest part: these rows are +// per-control, so a board wiring two buttons to one action cannot be +// represented here, and each action is definitional rather than read back +// from a live light. +// +// Reply: [3] total records [4] start entry, echoed [5] count in this reply +// [6] record stride [7..54] records [60..63] LED-map fingerprint +// The echo makes a paged walk self-identifying, which page 4 is not, and the +// fingerprint lets a host notice the map changing underneath a multi-read walk. +static void buildCapsLights(uint8_t * reply, uint8_t startEntry) { + // Every control that has a light, in protocol ID order + uint8_t ids[HOST_LIGHTING_BUTTON_COUNT + 6]; + uint8_t total = 0; + + for (uint8_t id = 0; id < HOST_LIGHTING_BUTTON_COUNT; id++) { + if (resolveButton(id).first >= 0) + ids[total++] = id; + } + for (uint8_t id = HOST_LIGHTING_BUTTON_PLED1; id <= HOST_LIGHTING_BUTTON_CASE; id++) { + if (resolveButton(id).first >= 0) + ids[total++] = id; + } + + reply[3] = total; + reply[4] = startEntry; + reply[6] = HOST_LIGHTING_LIGHT_STRIDE; + + uint8_t count = 0; + for (uint8_t i = startEntry; + (i < total) && (count < HOST_LIGHTING_LIGHTS_PER_PAGE); + i++, count++) { + const uint8_t id = ids[i]; + const LedRange range = resolveButton(id); + uint8_t * record = &reply[7 + count * HOST_LIGHTING_LIGHT_STRIDE]; + + uint8_t type = HOST_LIGHTING_LIGHT_ACTION; + uint8_t player = 0xFF; + uint8_t pin = 0xFF; + int16_t action = HOST_LIGHTING_ACTION_NONE; + + if (id < HOST_LIGHTING_BUTTON_COUNT) { + action = (int16_t)canonicalAction[id]; + pin = pinForAction(canonicalAction[id]); + } else if (id <= HOST_LIGHTING_BUTTON_PLED4) { + type = (uint8_t)(HOST_LIGHTING_LIGHT_PLAYER1 + (id - HOST_LIGHTING_BUTTON_PLED1)); + player = (uint8_t)(id - HOST_LIGHTING_BUTTON_PLED1); + } else if (id == HOST_LIGHTING_BUTTON_TURBO) { + // The turbo light is addressed by its LED index, never by a pin: the + // action named here is the turbo button, a different thing entirely + type = HOST_LIGHTING_LIGHT_TURBO; + action = (int16_t)GpioAction::BUTTON_PRESS_TURBO; + } else { + type = HOST_LIGHTING_LIGHT_CASE; + } + + record[0] = (uint8_t)range.first; + record[1] = range.count; + record[2] = type; + record[3] = id; + record[4] = pin; + record[5] = (uint8_t)(action & 0xFF); + record[6] = (uint8_t)((action >> 8) & 0xFF); + record[7] = player; + record[8] = 0xFF; // no case-group index exists in this pipeline + record[9] = 0; // no positions either, so the POSITION flag stays clear + record[10] = 0; + // Both flags clear: this pipeline has no per-light table and no + // positions, so it asserts neither. The record is rebuilt from + // per-control configuration, which is exactly what a clear PER_LIGHT + // tells a host. + record[11] = 0; + } + reply[5] = count; + + uint32_t fingerprint = ledMapFingerprint(); + memcpy(&reply[60], &fingerprint, 4); +} + bool HostLighting::enabledForMode(InputMode mode) { const HostLightingOptions & options = Storage::getInstance().getAddonOptions().hostLightingOptions; if (!options.enabled) @@ -379,6 +596,8 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c buildCapsAnimations(responseBuffer); else if (buffer[2] == 4) buildCapsPositions(responseBuffer, (bufsize > 3) ? buffer[3] : 0); + else if (buffer[2] == 5) + buildCapsLights(responseBuffer, (bufsize > 3) ? buffer[3] : 0); else status = HOST_LIGHTING_STATUS_INVALID_ARG; break; @@ -394,6 +613,11 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c timeoutMs = HOST_LIGHTING_DEFAULT_TIMEOUT_MS; if (timeoutMs < 100) timeoutMs = 100; + // Clamped rather than refused: a host asking for longer than we are + // willing to hold the lights still gets a working session, just a + // shorter leash than it asked for + if (timeoutMs > HOST_LIGHTING_MAX_TIMEOUT_MS) + timeoutMs = HOST_LIGHTING_MAX_TIMEOUT_MS; liveTakeover = buffer[2]; liveTimeoutMs = timeoutMs; liveApplyBrightness = (buffer[5] != 0); @@ -505,7 +729,7 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c // Selects the on-board animation shown outside host control; the // render core applies it, and the choice persists like the hotkeys' if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } - if (buffer[2] >= TOTAL_EFFECTS) { + if (buffer[2] >= onBoardAnimationCount()) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } diff --git a/src/usbdriver.cpp b/src/usbdriver.cpp index 5bf514730..aedbf20ce 100644 --- a/src/usbdriver.cpp +++ b/src/usbdriver.cpp @@ -54,6 +54,7 @@ void tud_umount_cb(void) { usb_mounted = false; usb_suspended = false; + HostLighting::releaseTakeover(); } // Invoked when usb bus is suspended @@ -62,6 +63,8 @@ void tud_umount_cb(void) void tud_suspend_cb(bool remote_wakeup_en) { (void)remote_wakeup_en; usb_suspended = true; + // The host holding the takeover is, by definition, no longer talking + HostLighting::releaseTakeover(); } // Invoked when usb bus is resumed From 16792afb3687a2f24fa4887f4a475145ec0ad126 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:51:50 +1000 Subject: [PATCH 8/9] Extend Host Lighting to protocol v1.2 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. --- docs/host-lighting.md | 31 +++++++++++++++++++++-------- headers/hostlighting.h | 9 ++++++++- src/hostlighting.cpp | 44 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/docs/host-lighting.md b/docs/host-lighting.md index 67300faf5..c1d40b98a 100644 --- a/docs/host-lighting.md +++ b/docs/host-lighting.md @@ -103,6 +103,7 @@ command, `2` invalid argument. | 0x12 | SET_RANGE_RGBW | `[2]=start, [3]=count (1-15)`, count x `[R,G,B,W]` | Boards without a white channel ignore W | | 0x13 | FILL | `[2]=scope (0 all, 1 buttons, 2 case, 3 player LEDs), [3..5]=RGB` | Stage a scope. The buttons scope covers every button light, including any on the extended controls | | 0x14 | CLEAR | - | Reset staged pixels and overlay validity | +| 0x15 | SET_LIGHT | `[2]=n (1-15)`, n x `[ordinal,R,G,B]` | Stage single lights by page 5 ordinal; reply `[3]=applied, [4]=skipped` | *Frame lifecycle (0x30-0x3F)* | 0x30 | COMMIT | - | Atomically publish the staged frame | @@ -137,11 +138,10 @@ and a host learns them from there. 20-23 are permanently unassigned - those gamepad bits are the dpad in a second encoding, not four more controls. `0xFF` means no button ID names this light. -The extended IDs are report-only. `SET_BUTTONS` stages 0-17 and 24-29; an entry -naming A3, A4 or an E-button is counted as skipped, exactly as any control the -board has no light for. Colour those lights with `SET_RANGE` instead, using the -first LED and count page 5 gives for the record - which is why the record -carries both. +Every named button ID stages: an entry naming a control the board has no +light for is counted as skipped, the extended IDs included. To colour one +specific light when a control owns several, stage it by its page 5 ordinal +with `SET_LIGHT`. `SET_INPUT_MODE` values are the firmware's `InputMode` enum (0 XINPUT, 1 SWITCH, 2 PS3, 3 KEYBOARD, 4 PS4, @@ -265,9 +265,10 @@ Each record is 12 bytes: | +9..10 | grid X, Y | valid only when the position flag is set | | +11 | flags | bit 0 position is real, bit 1 record came from a per-light table, rest zero. A clear bit claims nothing | -A light's identity is its **record ordinal**, which is why the reply echoes the -start entry. Records appear in the same order as page 4's, so ordinal *n* on -one page is ordinal *n* on the other. +A light's identity is its **record ordinal**, which is why the reply echoes +the start entry; the ordinal is also the address `SET_LIGHT` stages by. +Records appear in the same order as page 4's, so ordinal *n* on one page is +ordinal *n* on the other. Where a control owns several lights, several records carry the same button ID. That is the page's purpose, and it needs no special case in a host. @@ -420,6 +421,20 @@ Versions are the protocol version `PING` reports, not the firmware version. A host should gate on `>= v1.0` and read the capability pages, never require an exact version. +### v1.2 - 16 August 2026 + +Additive. A host detects it by minor version >= 2; nothing existing moved. + +**Added** + +- **`SET_LIGHT` (0x15)**: stage single lights by their page 5 ordinal, so a + host can colour one light of a control that owns several without raw LED + arithmetic. +- **Extended button IDs stage.** `SET_BUTTONS` entries naming A3, A4 or + E1-E12 now apply on boards with such lights, and count as skipped where + there is none - the same rule as every other control. They were previously + report-only. + ### v1.1 - 16 August 2026 Additive. No command ID and no existing payload layout changed: every field a diff --git a/headers/hostlighting.h b/headers/hostlighting.h index 90836b2a3..4b59b996e 100644 --- a/headers/hostlighting.h +++ b/headers/hostlighting.h @@ -61,8 +61,13 @@ // The caps format byte on page 0 stays at 2: it versions that page's layout, // which is untouched, and moving it would force every host to re-derive a // parser for a surface that did not move. +// +// v1.2 adds SET_LIGHT, staging one light by its page 5 ordinal, and lets +// SET_BUTTONS stage the extended IDs on boards that have such lights. Both are +// additive - a new command in a reserved slot and a skip that becomes an apply +// where a light exists - and a host detects them by minor version >= 2. #define HOST_LIGHTING_PROTOCOL_VERSION_MAJOR 1 -#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 1 +#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 2 // All transfers are fixed-size reports: [0]=command, [1]=sequence, [2..63]=payload. // Replies echo the sequence and set bit 7 of the command byte. @@ -82,6 +87,7 @@ #define HOST_LIGHTING_CMD_SET_RANGE_RGBW 0x12 #define HOST_LIGHTING_CMD_FILL 0x13 #define HOST_LIGHTING_CMD_CLEAR 0x14 +#define HOST_LIGHTING_CMD_SET_LIGHT 0x15 // 0x30-0x3F: frame lifecycle #define HOST_LIGHTING_CMD_COMMIT 0x30 #define HOST_LIGHTING_CMD_RELEASE 0x31 @@ -101,6 +107,7 @@ #define HOST_LIGHTING_RANGE_MAX_PIXELS 20 #define HOST_LIGHTING_RANGE_RGBW_MAX_PIXELS 15 #define HOST_LIGHTING_BUTTONS_MAX_ENTRIES 15 +#define HOST_LIGHTING_SET_LIGHT_MAX_ENTRIES 15 #define HOST_LIGHTING_POSITIONS_PER_PAGE 19 #define HOST_LIGHTING_DEFAULT_TIMEOUT_MS 2000 // Upper bound on the keepalive a host may ask for. The field is sixteen bits, diff --git a/src/hostlighting.cpp b/src/hostlighting.cpp index 3149feb72..e6d98e256 100644 --- a/src/hostlighting.cpp +++ b/src/hostlighting.cpp @@ -403,11 +403,11 @@ static void buildCapsPositions(uint8_t * reply, uint8_t startEntry) { // [6] record stride [7..54] records [60..63] LED-map fingerprint // The echo makes a paged walk self-identifying, which page 4 is not, and the // fingerprint lets a host notice the map changing underneath a multi-read walk. -static void buildCapsLights(uint8_t * reply, uint8_t startEntry) { - // Every control that has a light, in protocol ID order - uint8_t ids[HOST_LIGHTING_BUTTON_COUNT + 6]; +// Every control that has a light, in protocol ID order. This list IS the +// page 5 record order, so a light's ordinal - the index SET_LIGHT stages by - +// is its position here. +static uint8_t collectLightIds(uint8_t * ids) { uint8_t total = 0; - for (uint8_t id = 0; id < HOST_LIGHTING_BUTTON_COUNT; id++) { if (resolveButton(id).first >= 0) ids[total++] = id; @@ -416,6 +416,12 @@ static void buildCapsLights(uint8_t * reply, uint8_t startEntry) { if (resolveButton(id).first >= 0) ids[total++] = id; } + return total; +} + +static void buildCapsLights(uint8_t * reply, uint8_t startEntry) { + uint8_t ids[HOST_LIGHTING_BUTTON_COUNT + 6]; + uint8_t total = collectLightIds(ids); reply[3] = total; reply[4] = startEntry; @@ -648,6 +654,36 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c break; } + case HOST_LIGHTING_CMD_SET_LIGHT: { + // Stages one light per entry by its page 5 ordinal. On this + // pipeline the ordinal space is the collected control list, + // rebuilt here so the numbering always matches what page 5 most + // recently reported. + if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + uint8_t entries = buffer[2]; + if ((entries == 0) || (entries > HOST_LIGHTING_SET_LIGHT_MAX_ENTRIES) || + (bufsize < (uint16_t)(3 + entries * 4))) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; + } + uint8_t ids[HOST_LIGHTING_BUTTON_COUNT + 6]; + uint8_t total = collectLightIds(ids); + uint8_t applied = 0, skipped = 0; + for (uint8_t e = 0; e < entries; e++) { + const uint8_t * entry = &buffer[3 + e * 4]; + if (entry[0] >= total) { + skipped++; + continue; + } + stageRange(resolveButton(ids[entry[0]]), + ((uint32_t)entry[1] << 16) | ((uint32_t)entry[2] << 8) | entry[3]); + applied++; + } + responseBuffer[3] = applied; + responseBuffer[4] = skipped; + break; + } + case HOST_LIGHTING_CMD_SET_RANGE: { if (bufsize < 4) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } uint8_t start = buffer[2]; From 4a5f2070fc08b43345a256a320d1b5fac299c634 Mon Sep 17 00:00:00 2001 From: Jacob Simpson <28767380+djGLiTCH@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:34:44 +1000 Subject: [PATCH 9/9] Extend Host Lighting to protocol v1.3 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. --- docs/host-lighting.md | 71 +++++++++++++++++++++++----- headers/hostlighting.h | 11 ++++- src/hostlighting.cpp | 104 +++++++++++++++++++++++++++++++---------- 3 files changed, 149 insertions(+), 37 deletions(-) diff --git a/docs/host-lighting.md b/docs/host-lighting.md index c1d40b98a..7b5351192 100644 --- a/docs/host-lighting.md +++ b/docs/host-lighting.md @@ -57,6 +57,15 @@ interface next to the regular controller interface. Hosts exchange fixed - **Always on**: the composite identity from boot (PC-only while enabled). - **Off**: stock XInput identity always; no lighting interface in XInput mode. +The composite presents its own USB identity, chosen so Windows queries the +MS OS descriptors that bind its Xbox 360 driver. A user USB ID override +replaces that identity, and Windows caches the query's verdict per VID:PID: +an ID the PC has already seen on a device without those descriptors can +leave the controller interface unbound there. The lighting interface is +HID-class and enumerates regardless - the protocol suite passes in full +under an overridden ID. If the controller side fails after an override in +this mode, choose an ID that PC has not seen before. + With the add-on disabled (the default), every mode presents byte-identical USB descriptors to stock firmware. @@ -103,7 +112,8 @@ command, `2` invalid argument. | 0x12 | SET_RANGE_RGBW | `[2]=start, [3]=count (1-15)`, count x `[R,G,B,W]` | Boards without a white channel ignore W | | 0x13 | FILL | `[2]=scope (0 all, 1 buttons, 2 case, 3 player LEDs), [3..5]=RGB` | Stage a scope. The buttons scope covers every button light, including any on the extended controls | | 0x14 | CLEAR | - | Reset staged pixels and overlay validity | -| 0x15 | SET_LIGHT | `[2]=n (1-15)`, n x `[ordinal,R,G,B]` | Stage single lights by page 5 ordinal; reply `[3]=applied, [4]=skipped` | +| 0x15 | SET_LIGHT | `[2]=n (1-15)`, n x `[ordinal,R,G,B]` | Stage single lights by page 5 ordinal; reply `[3]=applied, [4]=skipped, [5..6]=outcome mask LE (bit n set = entry n applied)` | +| 0x16 | SET_LIGHT_RGBW | `[2]=n (1-12)`, n x `[ordinal,R,G,B,W]` | As SET_LIGHT with a white component; boards without a white channel ignore W | *Frame lifecycle (0x30-0x3F)* | 0x30 | COMMIT | - | Atomically publish the staged frame | @@ -141,7 +151,10 @@ gamepad bits are the dpad in a second encoding, not four more controls. Every named button ID stages: an entry naming a control the board has no light for is counted as skipped, the extended IDs included. To colour one specific light when a control owns several, stage it by its page 5 ordinal -with `SET_LIGHT`. +with `SET_LIGHT`, or `SET_LIGHT_RGBW` where the chain has a white channel. +A skipped ordinal means the light table changed underneath the host; the +reply's outcome mask names the entry, so the host knows what to re-read +without walking page 5 on speculation. `SET_INPUT_MODE` values are the firmware's `InputMode` enum (0 XINPUT, 1 SWITCH, 2 PS3, 3 KEYBOARD, 4 PS4, @@ -411,15 +424,48 @@ capability decoding, streaming and board management live in the boot-time button holds. - `SET_RANGE_RGBW` may be sent to any board; the white component only renders on GRBW/RGBW strips (see page 2's colour format). +- **White handling.** On a white-format chain a host-supplied W byte renders + on the white emitter alongside the RGB emitters; send the subtractive + conversion (W = min(R,G,B), RGB reduced by W) and each colour lands on the + emitters that produce it. A host that sends no W - any RGB-only host - gets + achromatic colours (R=G=B) mapped to the white emitter by the board itself, + the same mapping the on-board animations use. Firmware before v1.3 applied + that mapping even when a W byte was supplied, so a subtractive white + (0,0,0,W) rendered dark: gate host-supplied W on `PING` minor >= 3 and send + plain RGB to older boards. ## Changelog -Everything above describes the protocol as it is now. This section records -what changed between versions, for hosts written against an earlier one. +Everything above describes the Host Lighting Protocol (HLP) as it is now. +This section records what changed between HLP versions, for hosts written +against an earlier one. + +Versions are the Host Lighting Protocol version `PING` reports, not the +GP2040-CE firmware version. A host should gate on `>= v1.0` and read the +capability pages for maximum compatibility, rather than require an exact +version. + +### v1.3 - 17 August 2026 + +Additive. A host detects it by minor version >= 3; nothing existing moved. + +**Added** -Versions are the protocol version `PING` reports, not the firmware version. -A host should gate on `>= v1.0` and read the capability pages, never require -an exact version. +- **Per-entry outcome mask.** `SET_LIGHT` replies define `[5..6]` as a + little-endian mask, bit n set meaning entry n applied. Its popcount equals + the applied count, and bits at or above the entry count are zero. A host + can now name the stale ordinal behind a skip instead of re-walking page 5. + Earlier firmware zero-fills these bytes, so gate on the minor version + before reading them. +- **`SET_LIGHT_RGBW` (0x16)**: as `SET_LIGHT` with a white component, up to + 12 entries per report, the pairing `SET_RANGE` already had. Boards whose + chain has no white channel ignore W, exactly as `SET_RANGE_RGBW` does. + The reply carries the same counts and outcome mask. +- **Host-supplied W is honoured on white-format chains.** Earlier firmware + routed achromatic colours to the white emitter without reading the host's + W byte, so a subtractive white (0,0,0,W) rendered dark. From v1.3 a host + pixel carrying W composes all four channels; pixels without W render + exactly as before. See White handling under Compatibility. ### v1.2 - 16 August 2026 @@ -437,11 +483,12 @@ Additive. A host detects it by minor version >= 2; nothing existing moved. ### v1.1 - 16 August 2026 -Additive. No command ID and no existing payload layout changed: every field a -v1.0 host reads is still at the offset and width it was, so a v1.0 host keeps -working unchanged. Two values it can observe are more accurate than they were, -both listed under **Changed**. Input sampling and per-frame render cost are -unaffected - the new work runs only on capability reads. +Additive. A host detects it by minor version >= 1; no command ID and no +existing payload layout changed: every field a v1.0 host reads is still at +the offset and width it was, so a v1.0 host keeps working unchanged. Two +values it can observe are more accurate than they were, both listed under +**Changed**. Input sampling and per-frame render cost are unaffected - the +new work runs only on capability reads. **Added** diff --git a/headers/hostlighting.h b/headers/hostlighting.h index 4b59b996e..68ce416c0 100644 --- a/headers/hostlighting.h +++ b/headers/hostlighting.h @@ -66,8 +66,15 @@ // SET_BUTTONS stage the extended IDs on boards that have such lights. Both are // additive - a new command in a reserved slot and a skip that becomes an apply // where a light exists - and a host detects them by minor version >= 2. +// +// v1.3 defines reply bytes [5..6] of the per-light staging commands as a +// per-entry outcome mask (bit n set = entry n applied), so a skip names the +// stale entry instead of forcing a page 5 re-walk, and adds SET_LIGHT_RGBW, +// the RGBW pairing SET_RANGE already has. Both are additive - the mask +// occupies bytes v1.2 zero-filled and the command sits in a reserved slot - +// and a host detects them by minor version >= 3. #define HOST_LIGHTING_PROTOCOL_VERSION_MAJOR 1 -#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 2 +#define HOST_LIGHTING_PROTOCOL_VERSION_MINOR 3 // All transfers are fixed-size reports: [0]=command, [1]=sequence, [2..63]=payload. // Replies echo the sequence and set bit 7 of the command byte. @@ -88,6 +95,7 @@ #define HOST_LIGHTING_CMD_FILL 0x13 #define HOST_LIGHTING_CMD_CLEAR 0x14 #define HOST_LIGHTING_CMD_SET_LIGHT 0x15 +#define HOST_LIGHTING_CMD_SET_LIGHT_RGBW 0x16 // 0x30-0x3F: frame lifecycle #define HOST_LIGHTING_CMD_COMMIT 0x30 #define HOST_LIGHTING_CMD_RELEASE 0x31 @@ -108,6 +116,7 @@ #define HOST_LIGHTING_RANGE_RGBW_MAX_PIXELS 15 #define HOST_LIGHTING_BUTTONS_MAX_ENTRIES 15 #define HOST_LIGHTING_SET_LIGHT_MAX_ENTRIES 15 +#define HOST_LIGHTING_SET_LIGHT_RGBW_MAX_ENTRIES 12 #define HOST_LIGHTING_POSITIONS_PER_PAGE 19 #define HOST_LIGHTING_DEFAULT_TIMEOUT_MS 2000 // Upper bound on the keepalive a host may ask for. The field is sixteen bits, diff --git a/src/hostlighting.cpp b/src/hostlighting.cpp index e6d98e256..e68c82a01 100644 --- a/src/hostlighting.cpp +++ b/src/hostlighting.cpp @@ -562,6 +562,39 @@ static void publishFrame() { liveSeq = liveSeq + 1; // even: stable } +// Shared by SET_LIGHT and SET_LIGHT_RGBW: stages one light per entry by its +// page 5 ordinal. On this pipeline the ordinal space is the collected control +// list, rebuilt here so the numbering always matches what page 5 most +// recently reported. The reply carries the counts in [3..4] and a per-entry +// outcome mask in [5..6] (bit n set = entry n applied), so a skip names the +// stale entry instead of forcing a page 5 re-walk. +static void stageLightEntries(const uint8_t * buffer, uint8_t entries, + bool withWhite, uint8_t * responseBuffer) { + uint8_t ids[HOST_LIGHTING_BUTTON_COUNT + 6]; + uint8_t total = collectLightIds(ids); + uint8_t stride = withWhite ? 5 : 4; + uint8_t applied = 0, skipped = 0; + uint16_t mask = 0; + for (uint8_t e = 0; e < entries; e++) { + const uint8_t * entry = &buffer[3 + e * stride]; + if (entry[0] >= total) { + skipped++; + continue; + } + uint32_t colour = ((uint32_t)entry[1] << 16) | + ((uint32_t)entry[2] << 8) | entry[3]; + if (withWhite) + colour |= (uint32_t)entry[4] << 24; + stageRange(resolveButton(ids[entry[0]]), colour); + mask |= (uint16_t)1 << e; + applied++; + } + responseBuffer[3] = applied; + responseBuffer[4] = skipped; + responseBuffer[5] = (uint8_t)(mask & 0xFF); + responseBuffer[6] = (uint8_t)(mask >> 8); +} + void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, const uint8_t * buffer, uint16_t bufsize) { // Reports arrive via the OUT endpoint (type 0) or a SET_REPORT control request if ((report_type != HID_REPORT_TYPE_INVALID) && (report_type != HID_REPORT_TYPE_OUTPUT)) @@ -655,10 +688,6 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c } case HOST_LIGHTING_CMD_SET_LIGHT: { - // Stages one light per entry by its page 5 ordinal. On this - // pipeline the ordinal space is the collected control list, - // rebuilt here so the numbering always matches what page 5 most - // recently reported. if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } uint8_t entries = buffer[2]; if ((entries == 0) || (entries > HOST_LIGHTING_SET_LIGHT_MAX_ENTRIES) || @@ -666,21 +695,21 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } - uint8_t ids[HOST_LIGHTING_BUTTON_COUNT + 6]; - uint8_t total = collectLightIds(ids); - uint8_t applied = 0, skipped = 0; - for (uint8_t e = 0; e < entries; e++) { - const uint8_t * entry = &buffer[3 + e * 4]; - if (entry[0] >= total) { - skipped++; - continue; - } - stageRange(resolveButton(ids[entry[0]]), - ((uint32_t)entry[1] << 16) | ((uint32_t)entry[2] << 8) | entry[3]); - applied++; + stageLightEntries(buffer, entries, false, responseBuffer); + break; + } + + case HOST_LIGHTING_CMD_SET_LIGHT_RGBW: { + // As SET_LIGHT with a white component; boards whose colour format + // has no white channel simply ignore the fifth byte + if (bufsize < 3) { status = HOST_LIGHTING_STATUS_INVALID_ARG; break; } + uint8_t entries = buffer[2]; + if ((entries == 0) || (entries > HOST_LIGHTING_SET_LIGHT_RGBW_MAX_ENTRIES) || + (bufsize < (uint16_t)(3 + entries * 5))) { + status = HOST_LIGHTING_STATUS_INVALID_ARG; + break; } - responseBuffer[3] = applied; - responseBuffer[4] = skipped; + stageLightEntries(buffer, entries, true, responseBuffer); break; } @@ -869,6 +898,36 @@ void HostLighting::setReport(uint8_t report_id, hid_report_type_t report_type, c } } +// Host pixels are stored as 0xWWRRGGBB and convert through the framework's +// RGB::value(), so host colours and on-board animations render identically - +// with one carve-out. value()'s achromatic shortcut routes r==g==b colours to +// the white emitter and never reads W, which would render a host's +// subtractive white (0,0,0,W) black on a white-format chain. When the host +// supplied a white byte on such a chain, compose the channel word directly +// with value()'s own per-channel arithmetic instead; with no white byte the +// shortcut applies as always, so RGB-only hosts render exactly as before. +// Formats without a white channel take value() regardless, which ignores W. +// The compositions below must be kept byte-for-byte in step with the general +// GRBW/RGBW paths of RGB::value() in headers/animationstation/animation.h. +static uint32_t convertHostPixel(uint32_t stored, LEDFormat format, float brightnessX) { + uint8_t r = (uint8_t)(stored >> 16), g = (uint8_t)(stored >> 8); + uint8_t b = (uint8_t)stored, w = (uint8_t)(stored >> 24); + if (w != 0) { + if (format == LED_FORMAT_GRBW) + return ((uint32_t)(g * brightnessX) << 24) + | ((uint32_t)(r * brightnessX) << 16) + | ((uint32_t)(b * brightnessX) << 8) + | (uint32_t)(w * brightnessX); + if (format == LED_FORMAT_RGBW) + return ((uint32_t)(r * brightnessX) << 24) + | ((uint32_t)(g * brightnessX) << 16) + | ((uint32_t)(b * brightnessX) << 8) + | (uint32_t)(w * brightnessX); + } + RGB colour(r, g, b, w); + return colour.value(format, brightnessX); +} + void HostLighting::applyToFrame(uint32_t * frame, uint32_t ledCount, float brightnessX, int format) { if (!liveActive) return; @@ -904,12 +963,9 @@ void HostLighting::applyToFrame(uint32_t * frame, uint32_t ledCount, float brigh seqAfter = liveSeq; } while ((seqBefore != seqAfter) || (seqBefore & 1)); - for (uint32_t i = 0; i < HOST_LIGHTING_MAX_LEDS; i++) { - // Stored as 0xWWRRGGBB; the white byte only matters on RGBW formats - RGB colour((uint8_t)(local[i] >> 16), (uint8_t)(local[i] >> 8), - (uint8_t)local[i], (uint8_t)(local[i] >> 24)); - convertedPixels[i] = colour.value(static_cast(format), brightness); - } + for (uint32_t i = 0; i < HOST_LIGHTING_MAX_LEDS; i++) + convertedPixels[i] = convertHostPixel(local[i], + static_cast(format), brightness); convertedSeq = seqAfter; convertedBrightnessBits = brightnessBits; convertedFormat = format;