|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +TUXEDO Keyboard LED Interface Helper for OpenRGB Profiles |
| 4 | +
|
| 5 | +============================================================================ |
| 6 | +IMPORTANT: This is NOT part of OpenRGB itself! |
| 7 | +============================================================================ |
| 8 | +
|
| 9 | +This script is a helper utility specifically for TUXEDO/Clevo laptops |
| 10 | +with per-key RGB keyboards using the tuxedo_keyboard kernel driver. |
| 11 | +
|
| 12 | +Purpose: |
| 13 | +-------- |
| 14 | +Convert OpenRGB .orp profile files to the Linux kernel LED interface format |
| 15 | +used by the tuxedo_keyboard driver. This allows applying OpenRGB profiles |
| 16 | +through the kernel's /sys/class/leds interface instead of USB HID. |
| 17 | +
|
| 18 | +Why This Exists: |
| 19 | +--------------- |
| 20 | +On TUXEDO laptops, the Embedded Controller (EC) can turn off the keyboard |
| 21 | +backlight after idle periods to save power. When it wakes up, it uses firmware |
| 22 | +default colors, not OpenRGB's USB HID settings. |
| 23 | +
|
| 24 | +The kernel LED interface has better integration with the EC and may provide |
| 25 | +more persistent color storage. This script bridges the gap between OpenRGB |
| 26 | +profiles and the kernel interface. |
| 27 | +
|
| 28 | +Hardware Compatibility: |
| 29 | +---------------------- |
| 30 | +- TUXEDO laptops with Clevo per-key RGB keyboards (ITE 8291 controller) |
| 31 | +- Linux with tuxedo_keyboard driver (kernel module) |
| 32 | +- NOT compatible with other RGB hardware |
| 33 | +
|
| 34 | +Usage: |
| 35 | +------ |
| 36 | + python3 tuxedo_keyboard_interface_helper.py |
| 37 | +
|
| 38 | +The script will: |
| 39 | +1. Read ~/.config/OpenRGB/<profile>.orp (default: ddd.orp) |
| 40 | +2. Convert colors using the hardware LED mapping from OpenRGB's ClevoKeyboard controller |
| 41 | +3. Apply colors to /sys/class/leds/rgb:kbd_backlight_*/multi_intensity |
| 42 | +
|
| 43 | +File Location: |
| 44 | +-------------- |
| 45 | +Place this script in your OpenRGB repository root or in a tools/ directory. |
| 46 | +It is NOT meant to be integrated into OpenRGB's build system. |
| 47 | +
|
| 48 | +Notes: |
| 49 | +------ |
| 50 | +- This uses the SAME LED mapping as OpenRGB's RGBController_ClevoKeyboard |
| 51 | +- Hardware LED indices (0-125) map directly to kernel zone numbers |
| 52 | +- The kernel interface is: /sys/class/leds/rgb:kbd_backlight_<zone>/multi_intensity |
| 53 | +- Each zone accepts "R G B" format (e.g., "255 128 0") |
| 54 | +
|
| 55 | +For more information on the EC timeout issue, see: |
| 56 | +https://gitlab.com/tuxedocomputers/development/packages/tuxedo-keyboard |
| 57 | +
|
| 58 | +Author: Helper script for TUXEDO hardware community |
| 59 | +License: GPL-2.0-or-later (same as OpenRGB) |
| 60 | +============================================================================ |
| 61 | +""" |
| 62 | + |
| 63 | +import sys |
| 64 | +import os |
| 65 | + |
| 66 | +# Hardware LED mapping from OpenRGB's RGBController_ClevoKeyboard |
| 67 | +# Source: Controllers/ClevoKeyboardController/RGBController_ClevoKeyboard.cpp |
| 68 | +# This mapping translates key names to hardware LED indices (0-125) |
| 69 | +# The kernel LED zones use the SAME numbering as hardware LEDs |
| 70 | +HW_LED_MAP = { |
| 71 | + # F-row |
| 72 | + "Key: Escape": 105, |
| 73 | + "Key: F1": 106, "Key: F2": 107, "Key: F3": 108, "Key: F4": 109, |
| 74 | + "Key: F5": 110, "Key: F6": 111, "Key: F7": 112, "Key: F8": 113, |
| 75 | + "Key: F9": 114, "Key: F10": 115, "Key: F11": 116, "Key: F12": 117, |
| 76 | + "Key: Print Screen": 118, |
| 77 | + # Number row |
| 78 | + "Key: `": 84, |
| 79 | + "Key: 1": 85, "Key: 2": 86, "Key: 3": 87, "Key: 4": 88, |
| 80 | + "Key: 5": 89, "Key: 6": 90, "Key: 7": 91, "Key: 8": 92, |
| 81 | + "Key: 9": 93, "Key: 0": 94, |
| 82 | + "Key: -": 95, "Key: =": 96, "Key: Backspace": 98, |
| 83 | + "Key: Insert": 119, "Key: Home": 121, "Key: Page Up": 123, |
| 84 | + "Key: Delete": 120, "Key: End": 122, "Key: Page Down": 124, |
| 85 | + # QWERTY row |
| 86 | + "Key: Tab": 63, |
| 87 | + "Key: Q": 65, "Key: W": 66, "Key: E": 67, "Key: R": 68, |
| 88 | + "Key: T": 69, "Key: Y": 70, "Key: U": 71, "Key: I": 72, |
| 89 | + "Key: O": 73, "Key: P": 74, |
| 90 | + "Key: [": 75, "Key: ]": 76, |
| 91 | + # ASDF row |
| 92 | + "Key: Caps Lock": 42, |
| 93 | + "Key: A": 44, "Key: S": 45, "Key: D": 46, "Key: F": 47, |
| 94 | + "Key: G": 48, "Key: H": 49, "Key: J": 50, "Key: K": 51, |
| 95 | + "Key: L": 52, "Key: ;": 53, "Key: '": 54, "Key: #": 55, |
| 96 | + "Key: Enter": 77, |
| 97 | + # ZXCV row |
| 98 | + "Key: Left Shift": 22, |
| 99 | + "Key: \\ (ISO)": 23, |
| 100 | + "Key: Z": 24, "Key: X": 25, "Key: C": 26, "Key: V": 27, |
| 101 | + "Key: B": 28, "Key: N": 29, "Key: M": 30, |
| 102 | + "Key: ,": 31, "Key: .": 32, "Key: /": 33, |
| 103 | + "Key: Right Shift": 35, |
| 104 | + # Modifiers |
| 105 | + "Key: Left Control": 0, "Key: Left Fn": 2, "Key: Left Windows": 3, |
| 106 | + "Key: Left Alt": 4, "Key: Space": 7, "Key: Right Alt": 10, |
| 107 | + "Key: Right Control": 12, |
| 108 | + # Arrows |
| 109 | + "Key: Up Arrow": 14, "Key: Left Arrow": 13, |
| 110 | + "Key: Down Arrow": 18, "Key: Right Arrow": 15, |
| 111 | + # Numpad |
| 112 | + "Key: Num Lock": 99, |
| 113 | + "Key: Number Pad /": 100, "Key: Number Pad *": 101, "Key: Number Pad -": 102, |
| 114 | + "Key: Number Pad +": 81, |
| 115 | + "Key: Number Pad 7": 78, "Key: Number Pad 8": 79, "Key: Number Pad 9": 80, |
| 116 | + "Key: Number Pad 6": 59, |
| 117 | + "Key: Number Pad 4": 57, "Key: Number Pad 5": 58, |
| 118 | + "Key: Number Pad 1": 36, "Key: Number Pad 2": 37, "Key: Number Pad 3": 38, |
| 119 | + "Key: Number Pad Enter": 39, |
| 120 | + "Key: Number Pad 0": 16, "Key: Number Pad .": 17 |
| 121 | +} |
| 122 | + |
| 123 | +# OpenRGB LED list order from RGBController_ClevoKeyboard |
| 124 | +# This is the order LEDs appear in OpenRGB's GUI and .orp files |
| 125 | +OPENRGB_LED_NAMES = [ |
| 126 | + "Key: Escape", "Key: F1", "Key: F2", "Key: F3", "Key: F4", "Key: F5", "Key: F6", |
| 127 | + "Key: F7", "Key: F8", "Key: F9", "Key: F10", "Key: F11", "Key: F12", "Key: Print Screen", |
| 128 | + "Key: `", "Key: 1", "Key: 2", "Key: 3", "Key: 4", "Key: 5", "Key: 6", "Key: 7", "Key: 8", |
| 129 | + "Key: 9", "Key: 0", "Key: -", "Key: =", "Key: Backspace", |
| 130 | + "Key: Insert", "Key: Num Lock", "Key: Number Pad /", "Key: Number Pad *", "Key: Number Pad -", |
| 131 | + "Key: Home", "Key: Page Up", |
| 132 | + "Key: Tab", "Key: Q", "Key: W", "Key: E", "Key: R", "Key: T", "Key: Y", "Key: U", |
| 133 | + "Key: I", "Key: O", "Key: P", "Key: [", "Key: ]", "Key: Delete", |
| 134 | + "Key: Number Pad 7", "Key: Number Pad 8", "Key: Number Pad 9", "Key: Number Pad +", |
| 135 | + "Key: End", "Key: Page Down", |
| 136 | + "Key: Caps Lock", "Key: A", "Key: S", "Key: D", "Key: F", "Key: G", "Key: H", "Key: J", |
| 137 | + "Key: K", "Key: L", "Key: ;", "Key: '", "Key: #", "Key: Enter", |
| 138 | + "Key: Number Pad 4", "Key: Number Pad 5", "Key: Number Pad 6", |
| 139 | + "Key: Left Shift", "Key: \\ (ISO)", "Key: Z", "Key: X", "Key: C", "Key: V", "Key: B", |
| 140 | + "Key: N", "Key: M", "Key: ,", "Key: .", "Key: /", "Key: Right Shift", |
| 141 | + "Key: Number Pad 1", "Key: Number Pad 2", "Key: Number Pad 3", "Key: Number Pad Enter", "Key: Up Arrow", |
| 142 | + "Key: Left Control", "Key: Left Fn", "Key: Left Windows", "Key: Left Alt", "Key: Space", |
| 143 | + "Key: Right Alt", "Key: Right Control", "Key: Left Arrow", "Key: Down Arrow", |
| 144 | + "Key: Number Pad 0", "Key: Number Pad .", "Key: Right Arrow" |
| 145 | +] |
| 146 | + |
| 147 | +def parse_orp_file(filepath): |
| 148 | + """ |
| 149 | + Parse OpenRGB .orp profile file and extract colors |
| 150 | +
|
| 151 | + The .orp file format: |
| 152 | + - Color data starts at offset 0x0e44 |
| 153 | + - Format per LED: [0x00, R, G, B] (4 bytes) |
| 154 | + - LEDs are in OpenRGB's display order (OPENRGB_LED_NAMES) |
| 155 | +
|
| 156 | + Returns: list of (R, G, B) tuples for each LED |
| 157 | + """ |
| 158 | + with open(filepath, "rb") as f: |
| 159 | + data = f.read() |
| 160 | + |
| 161 | + # Color data offset determined from binary analysis |
| 162 | + color_offset = 0x0e44 |
| 163 | + |
| 164 | + colors = [] |
| 165 | + for i in range(color_offset, min(len(data), color_offset + 126 * 4), 4): |
| 166 | + if i + 3 >= len(data): |
| 167 | + break |
| 168 | + |
| 169 | + marker = data[i] |
| 170 | + r = data[i + 1] |
| 171 | + g = data[i + 2] |
| 172 | + b = data[i + 3] |
| 173 | + |
| 174 | + if marker != 0x00: |
| 175 | + break |
| 176 | + |
| 177 | + colors.append((r, g, b)) |
| 178 | + |
| 179 | + return colors |
| 180 | + |
| 181 | +def convert_openrgb_to_kernel(orp_file, profile_name="ddd"): |
| 182 | + """ |
| 183 | + Convert OpenRGB profile to kernel LED interface format |
| 184 | +
|
| 185 | + Args: |
| 186 | + orp_file: Path to OpenRGB .orp profile file |
| 187 | + profile_name: Name of the profile (used for finding the file) |
| 188 | +
|
| 189 | + Returns: |
| 190 | + dict mapping kernel_zone -> (R, G, B) |
| 191 | + """ |
| 192 | + if not os.path.exists(orp_file): |
| 193 | + print(f"❌ Profile not found: {orp_file}") |
| 194 | + print(f" Looking for: {profile_name}.orp in ~/.config/OpenRGB/") |
| 195 | + return None |
| 196 | + |
| 197 | + print(f"📂 Reading profile: {orp_file}") |
| 198 | + |
| 199 | + # Parse OpenRGB profile |
| 200 | + openrgb_colors = parse_orp_file(orp_file) |
| 201 | + print(f"✅ Extracted {len(openrgb_colors)} colors from profile") |
| 202 | + print() |
| 203 | + |
| 204 | + # Build mapping: OpenRGB LED index -> Kernel zone |
| 205 | + openrgb_to_kernel = {} |
| 206 | + |
| 207 | + for openrgb_idx, key_name in enumerate(OPENRGB_LED_NAMES): |
| 208 | + if openrgb_idx >= len(openrgb_colors): |
| 209 | + break |
| 210 | + |
| 211 | + if key_name in HW_LED_MAP: |
| 212 | + kernel_zone = HW_LED_MAP[key_name] |
| 213 | + openrgb_to_kernel[openrgb_idx] = kernel_zone |
| 214 | + |
| 215 | + print(f"✅ Mapped {len(openrgb_to_kernel)} LEDs to kernel zones") |
| 216 | + print() |
| 217 | + |
| 218 | + # Convert colors: kernel_zone -> (R, G, B) |
| 219 | + kernel_colors = {} |
| 220 | + |
| 221 | + for openrgb_idx, kernel_zone in openrgb_to_kernel.items(): |
| 222 | + if openrgb_idx < len(openrgb_colors): |
| 223 | + kernel_colors[kernel_zone] = openrgb_colors[openrgb_idx] |
| 224 | + |
| 225 | + return kernel_colors |
| 226 | + |
| 227 | +def apply_to_kernel_interface(kernel_colors, brightness=50): |
| 228 | + """ |
| 229 | + Apply colors to the kernel LED interface |
| 230 | +
|
| 231 | + Args: |
| 232 | + kernel_colors: dict mapping kernel_zone -> (R, G, B) |
| 233 | + brightness: Brightness value (0-50, default 50) |
| 234 | +
|
| 235 | + Returns: |
| 236 | + Number of zones successfully applied |
| 237 | + """ |
| 238 | + leds_path = "/sys/class/leds" |
| 239 | + applied = 0 |
| 240 | + errors = 0 |
| 241 | + |
| 242 | + print(f"💡 Applying {len(kernel_colors)} colors with brightness {brightness}...") |
| 243 | + print() |
| 244 | + |
| 245 | + for kernel_zone, (r, g, b) in sorted(kernel_colors.items()): |
| 246 | + if kernel_zone == 0: |
| 247 | + zone = "rgb:kbd_backlight" |
| 248 | + else: |
| 249 | + zone = f"rgb:kbd_backlight_{kernel_zone}" |
| 250 | + |
| 251 | + intensity_file = os.path.join(leds_path, zone, "multi_intensity") |
| 252 | + brightness_file = os.path.join(leds_path, zone, "brightness") |
| 253 | + |
| 254 | + try: |
| 255 | + with open(intensity_file, 'w') as f: |
| 256 | + f.write(f"{r} {g} {b}") |
| 257 | + with open(brightness_file, 'w') as f: |
| 258 | + f.write(str(brightness)) |
| 259 | + applied += 1 |
| 260 | + except FileNotFoundError: |
| 261 | + errors += 1 |
| 262 | + except Exception as e: |
| 263 | + print(f" ⚠️ Zone {kernel_zone}: {e}") |
| 264 | + errors += 1 |
| 265 | + |
| 266 | + if errors > 0: |
| 267 | + print(f"⚠️ {errors} zones had errors (might not exist on this hardware)") |
| 268 | + |
| 269 | + return applied |
| 270 | + |
| 271 | +def main(): |
| 272 | + print("=" * 70) |
| 273 | + print(" TUXEDO Keyboard LED Interface Helper for OpenRGB Profiles") |
| 274 | + print("=" * 70) |
| 275 | + print() |
| 276 | + print("⚠️ This is a helper utility for TUXEDO/Clevo laptops only!") |
| 277 | + print(" It converts OpenRGB profiles to the kernel LED interface.") |
| 278 | + print() |
| 279 | + |
| 280 | + # Default profile location |
| 281 | + config_dir = os.path.expanduser("~/.config/OpenRGB") |
| 282 | + default_profile = "ddd" |
| 283 | + |
| 284 | + # Allow command-line profile name override |
| 285 | + if len(sys.argv) > 1: |
| 286 | + default_profile = sys.argv[1] |
| 287 | + |
| 288 | + orp_file = os.path.join(config_dir, f"{default_profile}.orp") |
| 289 | + |
| 290 | + # Convert profile |
| 291 | + kernel_colors = convert_openrgb_to_kernel(orp_file, default_profile) |
| 292 | + |
| 293 | + if kernel_colors is None: |
| 294 | + sys.exit(1) |
| 295 | + |
| 296 | + if len(kernel_colors) == 0: |
| 297 | + print("❌ No colors to apply!") |
| 298 | + sys.exit(1) |
| 299 | + |
| 300 | + # Show sample conversions for verification |
| 301 | + print("🔍 Sample conversions (WASD + hjkl):") |
| 302 | + test_keys = { |
| 303 | + "Key: W": "W", "Key: A": "A", "Key: S": "S", "Key: D": "D", |
| 304 | + "Key: H": "H", "Key: J": "J", "Key: K": "K", "Key: L": "L" |
| 305 | + } |
| 306 | + |
| 307 | + for key_name, short_name in test_keys.items(): |
| 308 | + openrgb_idx = OPENRGB_LED_NAMES.index(key_name) |
| 309 | + if key_name in HW_LED_MAP and openrgb_idx < len(orp_file): |
| 310 | + kernel_zone = HW_LED_MAP[key_name] |
| 311 | + if kernel_zone in kernel_colors: |
| 312 | + r, g, b = kernel_colors[kernel_zone] |
| 313 | + is_lit = (r > 0 or g > 0 or b > 0) |
| 314 | + if is_lit: |
| 315 | + print(f" {short_name}: OpenRGB {openrgb_idx} → Kernel zone {kernel_zone} → #{r:02x}{g:02x}{b:02x}") |
| 316 | + |
| 317 | + print() |
| 318 | + print("=" * 70) |
| 319 | + |
| 320 | + # Apply to kernel interface |
| 321 | + applied = apply_to_kernel_interface(kernel_colors) |
| 322 | + |
| 323 | + print() |
| 324 | + print(f"✅ Successfully applied {applied} LED colors!") |
| 325 | + print() |
| 326 | + print("📋 Test Summary:") |
| 327 | + print(" Your OpenRGB profile is now active via the kernel LED interface.") |
| 328 | + print(" Check if the keyboard shows your expected colors.") |
| 329 | + print() |
| 330 | + print("⏳ EC Timeout Test:") |
| 331 | + print(" 1. Don't touch the keyboard for 5-10 minutes") |
| 332 | + print(" 2. Wait for the EC to turn off the backlight") |
| 333 | + print(" 3. Press any key to wake it") |
| 334 | + print(" 4. Verify: Do your profile colors return?") |
| 335 | + print(" - If YES → Success! Kernel interface persists through EC wake") |
| 336 | + print(" - If NO → Need periodic reapplication (systemd timer)") |
| 337 | + print() |
| 338 | + print("=" * 70) |
| 339 | + |
| 340 | +if __name__ == "__main__": |
| 341 | + main() |
0 commit comments