Reverse Engineering the ITE 8910 Keyboard RGB Protocol for OpenRGB
Table of Contents
TL;DR
My XMG PRO E23 laptop has a per-key RGB keyboard powered by an ITE 8910 controller (USB 048d:8910). No Linux support existed. I reverse-engineered the complete USB HID protocol from the Windows XMG Control Center binaries and contributed a full controller to OpenRGB with 14 modes, multi-color effects, and per-key control. This is the first complete implementation for this chip on any open-source platform.
| Field | Value |
|---|---|
| Device | ITE 8910 (USB VID 048d, PID 8910) |
| Laptops | XMG PRO E23, Clevo PD5x, TUXEDO, Schenker |
| MR | OpenRGB !3236 |
| Modes | 14 (Direct + 12 firmware animations + Off) |
| Protocol | 6-byte HID feature reports, report ID 0xCC |
Background
The XMG PRO E23 is a Clevo PD5x barebone with a per-key RGB keyboard. On Windows, XMG provides a Control Center application with full RGB control - per-key colors, animations (wave, breathing, snake, etc.), brightness, speed. On Linux, nothing worked. The keyboard was stuck on whatever color was last set on Windows.
All I wanted was my keyboard LEDs working properly on Linux. Like most people who buy these laptops and switch to Linux, I expected something to exist. OpenRGB only added Clevo keyboard support in January 2026, but for the older ITE 8291 model, not mine. The few tools I found online were either unstable or incomplete. So I decided to dig deeper. Years of CTFs and vulnerability research made this kind of reverse engineering feel natural, and honestly pretty fun.
Why Nothing Existed
The ITE 8910 is a 2023 chip. It’s too new for the community to have caught up.
OpenRGB had zero support for PID 0x8910. The existing Clevo controller only handles the older ITE 8291 (PID 0x600B) which uses a completely different protocol. An open MR (!3177) has been stuck in review since January 2026 with 59 comments.
keyRGB has experimental 8910 support but sends 7-byte packets instead of the correct 6, causing instability and saccaded animations. It only supports basic mode switching with no custom colors.
tuxedo-drivers provides an ite_829x kernel module with sysfs LED control, but it only exposes individual LEDs with no GUI, no animation modes, and no per-key color management.
And ITE datasheets? Not public. ITE distributes documentation only under NDA to OEMs like Clevo. No open protocol specification exists.
The fundamental problem: every previous implementation was based on the tuxedo-drivers C code which only documents per-key color commands and brightness. The animation modes with custom colors were never reverse-engineered because they only exist in the Windows application.
The XMG Control Center
Since I bought the laptop, the XMG Control Center came pre-installed on the Windows partition. It’s literally software I own. So instead of guessing the protocol or probing the hardware blind, I went straight to the source: the Windows binaries that already know how to talk to this keyboard.
Quick Glossary
I’d never done hardware RE before this project. If you’re in the same boat, here’s what everything means:
USB HID is the standard protocol for peripherals like keyboards and mice. Your keyboard doesn’t just send keypresses, it also exposes a control channel where you can send commands back to it, like “set this LED to red”. These commands are called feature reports, small packets of bytes with a specific structure.
hidapi is a C library that lets you talk to HID devices from userspace (no kernel driver needed). You open a device, send bytes, receive bytes. OpenRGB uses it for almost every controller it supports.
hidraw is the Linux kernel interface that exposes raw HID devices as files (/dev/hidraw0, /dev/hidraw1…). hidapi uses it under the hood on Linux.
Report ID is the first byte of a HID packet that identifies what type of data follows. Our keyboard uses 0xCC as report ID for all LED commands.
HID descriptor is a data structure the device sends to the host that describes the format of its reports (field sizes, value ranges). The ITE 8910’s descriptor is intentionally bogus, which caused us problems.
IL (Intermediate Language) is the bytecode format for .NET executables, like Java bytecode but for C#. Instead of reading x86 assembly, you read stack-based instructions like ldc.i4.s 10 (push the value 10) and call SetLEDStatus (call a function). It’s more readable than raw assembly but still requires manual analysis to reconstruct the logic.
KLM (KeyboardLayoutManager) is OpenRGB’s internal system for mapping hardware LED IDs to visual key positions. It handles regional layouts (QWERTY, AZERTY, QWERTZ) so the same code works everywhere. Not a standard, just an OpenRGB thing.
Embedded Controller (EC) is a separate chip in the laptop that manages power, fans, and communicates with the keyboard controller. Some features (like Ripple mode) require configuring the EC through the BIOS, which we couldn’t do from Linux.
The ITE 829x Family
ITE makes embedded controllers and keyboard controllers for laptop OEMs. The 829x family has evolved over the years:
- ITE 8291 Rev 0.02 (PID
0xCE00, ~2018) - First generation, basic protocol - ITE 8291 Rev 0.03 (PID
0x600B, ~2020) - Row-based color data, already supported in OpenRGB - ITE 8910 (PID
0x8910, ~2023) - Latest generation, per-key commands, new protocol
The 8910 uses a fundamentally different protocol from the 8291, with per-key color commands instead of row-based bulk transfers.
Reverse Engineering Process
Step 1: Extracting the Windows Binaries
I extracted the relevant binaries from the Windows partition using ntfscat without mounting the full NTFS volume:
perkey_api.dll- Native x64 DLL that talks to the USB HID deviceLedKeyboardSetting.exe- .NET WPF application with the mode/color logicDataAddress.dll- Key address mapping (turned out to be a trivial pass-through)
Step 2: Reverse Engineering perkey_api.dll
Using radare2 for static analysis of the native DLL, I identified 4 exports:
InitPerkeyIo - Opens the HID device (VID 048d, PID 8910, Usage 0xFF89)
SetLEDStatus - Sends a 6-byte command: [0xCC, arg1, arg2, arg3, arg4, arg5]
SetLEDStatusAll - Sends arbitrary data with report ID 0xCC
GetLedData - Queries device state
The key finding: all communication uses 6-byte HID feature reports with report ID 0xCC. The SetLEDStatus function builds the packet as [0xCC, cmd, d0, d1, d2, d3] and calls HidD_SetFeature.
Step 3: Decompiling the .NET Executable
The real protocol logic lives in the PerkeyKB class inside LedKeyboardSetting.exe. Since monodis crashed on missing PresentationCore dependencies, I used ikdasm which dumped the complete IL (42,000 lines).
From the IL I extracted:
The mode dispatch table (SetMode method with a switch on get_Mode()):
Mode 0: Wave - Preset color (iWave 0-7) or custom color (iWave 8-15)
Mode 1: Breathing - Random (iBreath=0) or custom color (iBreath=1)
Mode 2: Blink - Random (iBlink=0) or custom color (iBlink=1)
Mode 3: Random - Multicolor only
Mode 4: Scan - Random only (no color variant here)
Mode 5: Ripple - Random (iRipple=0), color (1), FIAO random (2), FIAO color (3)
Mode 6: Snake - Preset color (iSnake 0-3) or custom color (iSnake 4-7)
Mode 7: Wave Custom - Wave with iWave index selecting color slot
Mode 8: Scan Color - Random (iScan=0) or 2 custom colors (iScan=1)
Mode 9: Random Color - Random (iRandom=0) or custom color (iRandom=1)
Mode 10: Snake Custom - Snake with iSnake index selecting color slot
Mode 11: Fn Color - Highlight Fn-layer keys with custom color
The brightness/speed command:
SetBrightnessValue(brightness):
if status == ON:
SetLEDStatus(0x09, brightness, speed, 0, 0)
else:
SetLEDStatus(0x09, 0, 0, 0, 0) // off
The color variant commands:
Breathing Color: SetLEDStatus(0x0A, 0xAA, R, G, B)
Flashing Color: SetLEDStatus(0x0B, 0xAA, R, G, B)
Random Color: SetLEDStatus(0x18, 0xA1, R, G, B)
Wave Color: SetLEDStatus(0x15, 0xA1+slot, R, G, B) // 8 slots
Snake Color: SetLEDStatus(0x16, 0xA1+slot, R, G, B) // 4 slots
Scan Color: SetLEDStatus(0x17, 0xA1/0xA2, R, G, B) // 2 colors
Step 4: The HID Descriptor Problem
When I first tried sending commands from userspace, nothing worked. Reads succeeded but all writes were silently ignored. The HID descriptor was the culprit:
Report 0xCC: Report Size = 3 bits, Report Count = 16
3-bit fields for values that go up to 255. The descriptor is intentionally malformed. The firmware doesn’t care about it, but the Linux HID stack does. Various packet sizes (7, 8, 16, 65 bytes) all failed.
The solution: the hid-generic kernel driver performs hid_hw_start() during probe, which initializes the device. After that, writes via hidraw work correctly with exactly 6 bytes. Without the kernel driver loaded, the device ignores all SET_FEATURE requests.
Step 5: ClearColor Discovery
Even after solving the HID issue, per-key color updates from OpenRGB didn’t work correctly. Setting a single key had no visible effect. Through protocol analysis, I discovered that the firmware requires a ClearColor command ([CC, 00, 0C]) before sending per-key colors. Without it, the firmware retains previous LED states and new black (0,0,0) values are ignored.
Step 6: LED ID Mapping
The 8910 uses a non-trivial LED ID encoding: ((row & 0x07) << 5) | (col & 0x1F), where rows go top-to-bottom (row 0 = F-keys, row 5 = modifiers). This creates IDs with gaps (stride 32 per row instead of 20), which required careful buffer mapping in the OpenRGB integration.
The LED mapping was verified against the EzyUser mapping from a concurrent (but incomplete) MR, and validated on hardware.
The Complete Protocol
[0xCC, cmd, d0, d1, d2, d3]
Per-key: [CC, 01, led_id, R, G, B] Set single key color
Clear: [CC, 00, 0C] Reset all LEDs
Brightness: [CC, 09, brightness, speed] Global brightness (0-10) and speed
Animations (cmd=0x00):
Wave: [CC, 00, 04]
Random: [CC, 00, 09]
Scan: [CC, 00, 0A]
Snake: [CC, 00, 0B]
Spectrum Cycle: [CC, 00, 02]
Color effects:
Breathing: [CC, 0A, 00] (random) / [CC, 0A, AA, R, G, B] (custom)
Flashing: [CC, 0B, 00] (random) / [CC, 0B, AA, R, G, B] (custom)
Random Color: [CC, 18, A1, R, G, B]
Multi-color slots:
Wave: [CC, 15, A1-A8, R, G, B] (8 colors)
Snake: [CC, 16, A1-A4, R, G, B] (4 colors)
Scan: [CC, 17, A1-A2, R, G, B] (2 colors)
Bugs and Quirks in the XMG Control Center
Reverse engineering someone else’s code means you also find their bugs. A few things stood out:
Fn Highlight hardcodes red. The SetFnColorMode method takes a Color parameter and stores it via set_Fn(color), but TriggerFnEffect completely ignores it and sends SetLEDStatus(0x01, key, 0xFF, 0x00, 0x00) for every Fn key. The color picker exists in the UI but the selected color is never sent to the firmware. In our OpenRGB implementation, we actually use the selected color.
Ripple and FIAO are identical at the HID level. SetRippleRandomMode, SetRippleColorMode, SetFIAORandomMode, and SetFIAOColorMode all send the exact same command: [CC, 07, 00, 00, 00, 00]. The “color” variant stores the color in the .NET class but never sends it to the firmware. The differentiation only exists in the BIOS settings via ACPI, not in the HID protocol.
Brightness mapping is non-linear. The Windows UI has 4 brightness levels that map to hardware values 2, 4, 6, 10 (not 1, 2, 3, 4 or 0, 3, 7, 10). There’s no apparent logic to the mapping, it’s just hardcoded in SetBrightnessLevel.
OpenRGB Integration
Rather than creating a separate controller (as attempted in !3177), I extended the existing Clevo keyboard controller originally written by Kyle Cascade to handle both ITE 8291 and ITE 829x variants. This turned out to be harder than the RE itself.
The KLM Trap
OpenRGB’s KeyboardLayoutManager consumes LED values in visual order (row by row, left to right, including navigation cluster and numpad inline). I initially used KEYBOARD_SIZE_TKL with edit_keys to add the numpad, like Kyle did for the 8291. But TKL interleaves the navigation cluster values between main rows, which shifted all LED IDs by 3+ positions. Clicking “Key: 5” would light up the minus key.
The fix was switching to KEYBOARD_SIZE_FULL where all values are declared in visual order with no interleaving. This is the approach used by the Cooler Master keyboard controller, and it works correctly.
The Buffer Map Problem
The 8291 uses sequential LED IDs (row * 21 + col, range 0-125). The 829x uses encoded IDs ((row << 5) | col, range 0-179 with gaps). The buffer that maps LED IDs to colors needed to be 180 entries instead of 120, and the color data construction loop needed to translate between sequential positions and encoded IDs. Getting this wrong meant keys would light up in the wrong order or not at all.
Brightness Overflow
During testing, I accidentally sent brightness=50 (the 8291’s max) to the 829x which only accepts 0-10. The keyboard went completely dark and OpenRGB segfaulted. A manual reset via Python got the keyboard back. The fix was a simple clamp in the code to stay within 0-10.
The implementation includes:
- KeyboardLayoutManager with
KEYBOARD_SIZE_FULLlayout for proper key names and regional layout support - 14 modes: Direct, Wave, Breathing, Breathing Color, Flashing, Flashing Color, Random, Random Color, Scan, Snake, Spectrum Cycle, Rainbow Wave, Fn Highlight, Off
- Multi-color support: Wave (8 colors), Snake (4 colors), Scan (2 colors)
- ClearColor before per-key updates
- Configurable brightness and speed on all animation modes
The merge request: OpenRGB !3236
What’s Not Supported
Ripple mode exists in the Windows Control Center but requires the Embedded Controller (EC) to be configured via the proprietary Insyde DCHU BIOS interface (WriteAppSettings -> DeviceIoControl with IOCTL 0x32240C). The EC tells the keyboard chip to enter reactive mode and forwards keypress coordinates. This BIOS interface is not accessible from Linux userspace without significant additional reverse engineering of the Insyde DCHU driver.
Impact
This is the first complete per-key RGB implementation for the ITE 8910 on Linux. It covers all Clevo-based laptops from 2023+ that use this controller, including models from XMG, TUXEDO, Schenker, Eluktronics, and others. Users no longer need to dual-boot Windows just to change their keyboard colors.
On Legality
Reverse engineering for interoperability is explicitly protected by law. In the EU, Directive 2009/24/EC Article 6 allows RE when the information is not otherwise available. In the US, DMCA Section 1201(f) provides the same exception. No DRM was bypassed, no source code was redistributed. I analyzed the communication protocol between software I own and hardware I own, and wrote a clean-room implementation. This is the same legal basis that projects like Wine, Samba, and ReactOS operate on.
Timeline
The entire reverse engineering, implementation, debugging, and PR submission was done in a single overnight session. From “my keyboard doesn’t work on Linux” to a merge request with 14 modes, KLM layout, and zero compiler warnings.
- 2023: Bought the XMG PRO E23, keyboard RGB didn’t work on Linux
- March 27, 2026: Reverse-engineered the complete protocol and submitted OpenRGB MR !3236
Tools Used
- radare2 - x64 DLL disassembly
- ikdasm - .NET IL decompilation
- OpenRGB - RGB lighting control framework
- Claude Code - AI-assisted reverse engineering and C++ implementation