diff --git a/redragon.py b/redragon.py new file mode 100755 index 0000000..9733c53 --- /dev/null +++ b/redragon.py @@ -0,0 +1,1126 @@ +#!/usr/bin/python3 + +""" +Control utility for Redragon K621 Horus TKL RGB keyboard. + +Supports LED patterns, brightness, speed, color, custom per-key RGB, +key remapping, macro programming, and profile management. + +Protocol reverse-engineered from the official Redragon K621-RGB Setup V1.6.6 +Windows application and USB packet captures. +""" + +import argparse +import json +import sys +import time +import traceback +import usb.core + +VENDOR = 0x258A +PRODUCT = 0x0049 +INTERFACE = 1 + +# Report IDs and control transfer parameters +REPORT_LED = 0x06 # LED pattern/settings report +REPORT_STATE = 0x05 # State query/reset report +WVALUE_LED = 0x0306 # wValue for LED report +WVALUE_STATE = 0x0305 # wValue for state report +BMREQUEST_OUT = 0x21 # bmRequestType (Host-to-device, class, interface) +BREQUEST = 0x09 # bRequest (SET_REPORT) + +# Payload structure constants +PAYLOAD_SIZE = 1032 +SYNC = (0x5A, 0xA5) +OFFSET_REPORT_ID = 0 +OFFSET_SUBCMD = 1 +OFFSET_MAGIC = 2 +OFFSET_SYNC1 = 14 +OFFSET_PATTERN = 21 +OFFSET_BRIGHTNESS = 22 +OFFSET_SPEED1 = 28 +OFFSET_SPEED2 = 29 +OFFSET_COLOR_R = 36 +OFFSET_COLOR_G = 37 +OFFSET_COLOR_B = 38 +OFFSET_ZONE1 = 39 +OFFSET_SYNC2 = 78 +OFFSET_ZONE2 = 80 +OFFSET_CUSTOM_RGB = 106 + +# Brightness: 0x00 = 0%, 0x7F = 100% +BRIGHTNESS_MAX = 0x7F + +# Speed: 0x00 = fastest, 0xFF = slowest (two bytes) +SPEED_MAX = 0xFF + +# LED patterns: name -> (pattern_byte, has_speed, has_color) +# Extracted from Cfg.ini LedOpt entries and text.xml +PATTERNS = { + "off": (0x00, False, False), + "fixed_on": (0x01, False, True), + "respire": (0x02, True, True), + "rainbow": (0x03, True, False), + "flash_away": (0x04, True, True), + "raindrops": (0x05, True, True), + "rainbow_wheel": (0x06, True, False), + "ripples_shining": (0x07, True, True), + "stars_twinkle": (0x08, True, True), + "shadow_disappear": (0x09, True, True), + "retro_snake": (0x0A, True, True), + "neon_stream": (0x0B, True, True), + "reaction": (0x0C, True, True), + "sine_wave": (0x0D, True, True), + "retinue_scanning": (0x0E, True, True), + "rotating_windmill": (0x0F, True, False), + "colorful_waterfall": (0x10, True, False), + "blossoming": (0x11, True, False), + "rotating_storm": (0x12, True, True), + "collision": (0x13, True, True), + "perfect": (0x14, True, True), + "custom": (0x20, False, False), +} + +# Convenience aliases +PATTERN_ALIASES = { + "solid": "fixed_on", + "breathe": "respire", + "breathing": "respire", + "stars": "stars_twinkle", + "shadow": "shadow_disappear", + "snake": "retro_snake", + "neon": "neon_stream", + "wave": "sine_wave", + "ripple": "ripples_shining", + "windmill": "rotating_windmill", + "waterfall": "colorful_waterfall", + "storm": "rotating_storm", +} + +# Key name -> LED index mapping for custom per-key RGB. +# Extracted from Cfg.ini [KEY] section (last value = led_index). +# 87-key TKL layout. +KEY_LED_INDEX = { + "ESC": 0, + "F1": 2, + "F2": 3, + "F3": 4, + "F4": 5, + "F5": 6, + "F6": 7, + "F7": 8, + "F8": 9, + "F9": 10, + "F10": 11, + "F11": 12, + "F12": 13, + "PRTSC": 14, + "SCROLLLOCK": 15, + "PAUSE": 16, + "GRAVE": 21, + "`": 21, + "1": 22, + "2": 23, + "3": 24, + "4": 25, + "5": 26, + "6": 27, + "7": 28, + "8": 29, + "9": 30, + "0": 31, + "-": 32, + "MINUS": 32, + "=": 33, + "EQUAL": 33, + "BACKSPACE": 34, + "INSERT": 35, + "HOME": 36, + "PAGEUP": 37, + "TAB": 42, + "Q": 43, + "W": 44, + "E": 45, + "R": 46, + "T": 47, + "Y": 48, + "U": 49, + "I": 50, + "O": 51, + "P": 52, + "[": 53, + "LBRACKET": 53, + "]": 54, + "RBRACKET": 54, + "\\": 55, + "BACKSLASH": 55, + "DELETE": 56, + "END": 57, + "PAGEDOWN": 58, + "CAPSLOCK": 63, + "A": 64, + "S": 65, + "D": 66, + "F": 67, + "G": 68, + "H": 69, + "J": 70, + "K": 71, + "L": 72, + ";": 73, + "SEMICOLON": 73, + "'": 74, + "APOSTROPHE": 74, + "ENTER": 76, + "LSHIFT": 84, + "Z": 86, + "X": 87, + "C": 88, + "V": 89, + "B": 90, + "N": 91, + "M": 92, + ",": 93, + "COMMA": 93, + ".": 94, + "PERIOD": 94, + "/": 95, + "SLASH": 95, + "RSHIFT": 97, + "UP": 99, + "LCTRL": 105, + "LWIN": 106, + "LALT": 107, + "SPACE": 110, + "RALT": 113, + "FN": 114, + "APP": 115, + "MENU": 115, + "RCTRL": 118, + "LEFT": 119, + "DOWN": 120, + "RIGHT": 121, +} + +# HID Usage ID -> key name for key remapping +# Standard HID keyboard usage table (subset for common keys) +HID_USAGE_TO_NAME = { + 0x00: "NONE", + 0x04: "A", + 0x05: "B", + 0x06: "C", + 0x07: "D", + 0x08: "E", + 0x09: "F", + 0x0A: "G", + 0x0B: "H", + 0x0C: "I", + 0x0D: "J", + 0x0E: "K", + 0x0F: "L", + 0x10: "M", + 0x11: "N", + 0x12: "O", + 0x13: "P", + 0x14: "Q", + 0x15: "R", + 0x16: "S", + 0x17: "T", + 0x18: "U", + 0x19: "V", + 0x1A: "W", + 0x1B: "X", + 0x1C: "Y", + 0x1D: "Z", + 0x1E: "1", + 0x1F: "2", + 0x20: "3", + 0x21: "4", + 0x22: "5", + 0x23: "6", + 0x24: "7", + 0x25: "8", + 0x26: "9", + 0x27: "0", + 0x28: "ENTER", + 0x29: "ESC", + 0x2A: "BACKSPACE", + 0x2B: "TAB", + 0x2C: "SPACE", + 0x2D: "MINUS", + 0x2E: "EQUAL", + 0x2F: "LBRACKET", + 0x30: "RBRACKET", + 0x31: "BACKSLASH", + 0x33: "SEMICOLON", + 0x34: "APOSTROPHE", + 0x35: "GRAVE", + 0x36: "COMMA", + 0x37: "PERIOD", + 0x38: "SLASH", + 0x39: "CAPSLOCK", + 0x3A: "F1", + 0x3B: "F2", + 0x3C: "F3", + 0x3D: "F4", + 0x3E: "F5", + 0x3F: "F6", + 0x40: "F7", + 0x41: "F8", + 0x42: "F9", + 0x43: "F10", + 0x44: "F11", + 0x45: "F12", + 0x46: "PRTSC", + 0x47: "SCROLLLOCK", + 0x48: "PAUSE", + 0x49: "INSERT", + 0x4A: "HOME", + 0x4B: "PAGEUP", + 0x4C: "DELETE", + 0x4D: "END", + 0x4E: "PAGEDOWN", + 0x4F: "RIGHT", + 0x50: "LEFT", + 0x51: "DOWN", + 0x52: "UP", +} + +# Reverse mapping for key remapping +NAME_TO_HID_USAGE = {v: k for k, v in HID_USAGE_TO_NAME.items()} + +# Game mode presets from Cfg.ini (GaoshouKey entries) +# Each preset highlights a set of keys with the "Perfect" pattern +GAME_PRESETS = { + "wasd": ["ESC", "W", "A", "S", "D", "UP", "DOWN", "LEFT", "RIGHT"], + "fps": [ + "F1", + "F2", + "F3", + "1", + "2", + "3", + "4", + "5", + "TAB", + "Q", + "W", + "E", + "R", + "A", + "S", + "D", + "G", + "LSHIFT", + "LCTRL", + "LALT", + "SPACE", + ], + "moba": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "Q", + "W", + "E", + "R", + "T", + "A", + "S", + "D", + "F", + "G", + "LSHIFT", + "LCTRL", + "LALT", + "B", + "SPACE", + ], + "mmo": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "Q", + "W", + "E", + "R", + "T", + "A", + "S", + "D", + "F", + "LSHIFT", + "LCTRL", + "Z", + "X", + "C", + "V", + "LEFT", + "RIGHT", + ], + "arrows": [ + "W", + "R", + "A", + "S", + "D", + "LSHIFT", + "LCTRL", + "LALT", + "SPACE", + "UP", + "DOWN", + "LEFT", + "RIGHT", + ], +} + +verbose = 0 + + +def log(level, *args, **kwargs): + if verbose >= level: + print(*args, **kwargs) + + +def find_keyboard(): + kbd = usb.core.find(idVendor=VENDOR, idProduct=PRODUCT) + if kbd is None: + print( + "Error: Keyboard not found. Is it connected in USB/wired mode?", + file=sys.stderr, + ) + sys.exit(1) + log(1, f"Found: {kbd.manufacturer} {kbd.product}") + return kbd + + +def send_report(kbd, report_id, wvalue, data): + try: + ret = kbd.ctrl_transfer(BMREQUEST_OUT, BREQUEST, wvalue, INTERFACE, data) + assert ret == len(data), f"Short write: {ret}/{len(data)}" + time.sleep(0.05) + return ret + except usb.core.USBError as e: + if e.errno == 16: + print(f"USB error (resource busy): {e}", file=sys.stderr) + print("Attempting driver reset...", file=sys.stderr) + reset_driver(kbd) + # Retry once + ret = kbd.ctrl_transfer(BMREQUEST_OUT, BREQUEST, wvalue, INTERFACE, data) + assert ret == len(data) + time.sleep(0.05) + return ret + elif e.errno == 2: + print(f"USB error (no entity): {e}", file=sys.stderr) + print("Try unplugging and replugging the keyboard.", file=sys.stderr) + sys.exit(1) + else: + raise + + +def reset_driver(kbd): + interfaces = [] + for cfg in kbd: + for intf in cfg: + interfaces.append(intf.bInterfaceNumber) + + for intf_num in interfaces: + if kbd.is_kernel_driver_active(intf_num): + log(1, f"Detaching kernel driver for interface {intf_num}") + try: + kbd.detach_kernel_driver(intf_num) + except usb.core.USBError as e: + print(f"Could not detach kernel driver: {e}", file=sys.stderr) + print(traceback.format_exc(), file=sys.stderr) + + # Send state query to reset the device + msg = [REPORT_STATE, 0x83, 0xC6, 0x00, 0x00, 0x00] + try: + log(1, "Sending state query to reset device") + kbd.ctrl_transfer(BMREQUEST_OUT, BREQUEST, WVALUE_STATE, INTERFACE, msg) + except usb.core.USBError: + pass + + for intf_num in interfaces: + if not kbd.is_kernel_driver_active(intf_num): + log(1, f"Reattaching kernel driver for interface {intf_num}") + try: + kbd.attach_kernel_driver(intf_num) + except usb.core.USBError as e: + print(f"Could not reattach kernel driver: {e}", file=sys.stderr) + + +def build_led_payload( + pattern_byte, + brightness_byte=None, + speed_bytes=None, + color_rgb=None, + custom_rgb_data=None, +): + """Build the LED control payload. + + Uses the exact byte-for-byte payload from redragon-lights.py as the + template (710 bytes, including a third sync marker at [137:141]). + Only explicitly provided fields are patched. + + Pass None (the default) for any field to leave it at the template value. + """ + # Exact payload from redragon-lights.py (710 bytes). + # This is the known-good packet that works with the keyboard. + # fmt: off + msg = [ + 0x06, 0x03, 0xC6, # [0:3] header + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, # [3:14] padding + 0x00, 0x00, 0x00, + 0x5A, 0xA5, # [14:16] sync 1 + 0x03, 0x03, 0x00, 0x00, 0x00, # [16:21] + 0x09, # [21] pattern + 0x20, # [22] brightness + 0x00, 0x00, 0x00, 0x00, 0x00, # [23:28] + 0x55, 0x55, # [28:30] speed + 0x01, # [30] + 0x00, 0x00, 0x00, 0x00, 0x00, # [31:36] + 0xFF, 0xFF, 0x00, # [36:39] color R,G,B + # Zone parameter table + 0x31, # [39] + 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, # [40:54] + 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, + 0x00, 0x31, # [54:56] + 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, # [56:78] + 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, + 0x07, 0x39, 0x07, 0x39, 0x07, 0x39, + 0x5A, 0xA5, # [78:80] sync 2 + 0x00, 0x10, # [80:82] + 0x07, 0x49, 0x07, 0x49, 0x07, 0x49, 0x07, 0x49, # [82:96] + 0x07, 0x49, 0x07, 0x49, 0x07, 0x49, + 0x09, 0x09, 0x09, 0x09, 0x09, 0x09, 0x09, 0x09, # [96:106] + 0x09, 0x09, + # 31 zero bytes # [106:137] + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x5A, 0xA5, 0x03, 0x03, # [137:141] sync 3 + # Remaining zeros to end of packet # [141:710] + ] + msg += [0x00] * (PAYLOAD_SIZE - len(msg)) + # fmt: on + + # Only patch fields that were explicitly provided + msg[OFFSET_PATTERN] = pattern_byte + if brightness_byte is not None: + msg[OFFSET_BRIGHTNESS] = brightness_byte + if speed_bytes is not None: + msg[OFFSET_SPEED1] = speed_bytes[0] + msg[OFFSET_SPEED2] = speed_bytes[1] + if color_rgb is not None: + msg[OFFSET_COLOR_R] = color_rgb[0] + msg[OFFSET_COLOR_G] = color_rgb[1] + msg[OFFSET_COLOR_B] = color_rgb[2] + + # Custom per-key RGB data (bytes 106-136, between zone data and sync 3) + if custom_rgb_data: + for i, b in enumerate(custom_rgb_data): + if OFFSET_CUSTOM_RGB + i < PAYLOAD_SIZE: + msg[OFFSET_CUSTOM_RGB + i] = b + + return msg + + +def percent_to_brightness(pct): + """Convert 0-100 percentage to brightness byte (0x00-0x7F).""" + pct = max(0, min(100, pct)) + return round(pct * BRIGHTNESS_MAX / 100) + + +def percent_to_speed(pct): + """Convert 0-100 percentage to speed bytes. + 0% = slowest (0xFF), 100% = fastest (0x00).""" + pct = max(0, min(100, pct)) + val = round((100 - pct) * SPEED_MAX / 100) + return (val, val) + + +def parse_color(color_str): + """Parse a hex color string (RRGGBB) to (R, G, B) tuple.""" + color_str = color_str.lstrip("#") + if len(color_str) != 6: + raise ValueError(f"Invalid color '{color_str}': expected 6 hex digits (RRGGBB)") + return (int(color_str[0:2], 16), int(color_str[2:4], 16), int(color_str[4:6], 16)) + + +def resolve_pattern(name): + """Resolve a pattern name (with alias support) to its entry.""" + name = name.lower().replace(" ", "_").replace("-", "_") + if name in PATTERN_ALIASES: + name = PATTERN_ALIASES[name] + if name not in PATTERNS: + print(f"Error: Unknown pattern '{name}'", file=sys.stderr) + print( + f"Available patterns: {', '.join(sorted(PATTERNS.keys()))}", file=sys.stderr + ) + sys.exit(1) + return name, PATTERNS[name] + + +def resolve_key(name): + """Resolve a key name to its LED index.""" + name = name.upper().replace(" ", "") + if name in KEY_LED_INDEX: + return KEY_LED_INDEX[name] + print(f"Error: Unknown key '{name}'", file=sys.stderr) + sys.exit(1) + + +def build_custom_rgb(color_map): + """Build custom RGB data from a key->color mapping. + Returns bytes for the custom RGB area of the payload.""" + max_index = max(KEY_LED_INDEX.values()) + data = [0x00] * ((max_index + 1) * 3) + + for key_name, color in color_map.items(): + key_upper = key_name.upper().replace(" ", "") + if key_upper not in KEY_LED_INDEX: + print(f"Warning: Unknown key '{key_name}', skipping", file=sys.stderr) + continue + idx = KEY_LED_INDEX[key_upper] + r, g, b = parse_color(color) if isinstance(color, str) else color + data[idx * 3] = r + data[idx * 3 + 1] = g + data[idx * 3 + 2] = b + + return data + + +# ── Command implementations ───────────────────────────────────────────── + + +def cmd_on(args): + """Turn keyboard backlight on with a pattern.""" + kbd = find_keyboard() + name, (pat, _, _) = resolve_pattern(args.pattern or "fixed_on") + + # Only patch fields the user explicitly set; leave the rest at template + # defaults (the known-good captured values). + brightness = ( + percent_to_brightness(args.brightness) if args.brightness is not None else None + ) + speed = percent_to_speed(args.speed) if args.speed is not None else None + color = parse_color(args.color) if args.color else None + + msg = build_led_payload(pat, brightness, speed, color) + log( + 1, + f"Pattern: {name}, brightness: {args.brightness}, " + f"speed: {args.speed}, color: {args.color}", + ) + send_report(kbd, REPORT_LED, WVALUE_LED, msg) + print(f"Backlight on: {name}") + + +def cmd_off(args): + """Turn keyboard backlight off.""" + kbd = find_keyboard() + # Only change the pattern byte to 0x00; leave all other fields at their + # template defaults — zeroing brightness/speed/color causes a reset. + msg = build_led_payload(0x00) + send_report(kbd, REPORT_LED, WVALUE_LED, msg) + print("Backlight off") + + +def cmd_pattern(args): + """Set a specific LED pattern with options.""" + kbd = find_keyboard() + name, (pat, has_speed, has_color) = resolve_pattern(args.name) + + brightness = ( + percent_to_brightness(args.brightness) if args.brightness is not None else None + ) + speed = percent_to_speed(args.speed) if args.speed is not None else None + color = parse_color(args.color) if args.color else None + + if args.color and not has_color: + print( + f"Note: Pattern '{name}' does not support custom colors (uses rainbow)", + file=sys.stderr, + ) + if args.speed is not None and not has_speed: + print( + f"Note: Pattern '{name}' does not support speed adjustment", file=sys.stderr + ) + + msg = build_led_payload(pat, brightness, speed, color) + log( + 1, + f"Pattern: {name} (0x{pat:02X}), brightness: {args.brightness}, " + f"speed: {args.speed}, color: {args.color}", + ) + send_report(kbd, REPORT_LED, WVALUE_LED, msg) + print(f"Pattern set: {name}") + + +def cmd_custom(args): + """Set custom per-key RGB colors.""" + kbd = find_keyboard() + + try: + with open(args.colors) as f: + color_map = json.load(f) + except FileNotFoundError: + print(f"Error: File not found: {args.colors}", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in {args.colors}: {e}", file=sys.stderr) + sys.exit(1) + + custom_data = build_custom_rgb(color_map) + brightness = ( + percent_to_brightness(args.brightness) if args.brightness is not None else None + ) + + msg = build_led_payload(0x20, brightness, custom_rgb_data=custom_data) + send_report(kbd, REPORT_LED, WVALUE_LED, msg) + print(f"Custom RGB set from {args.colors} ({len(color_map)} keys)") + + +def cmd_game(args): + """Apply a game mode preset that highlights specific keys.""" + kbd = find_keyboard() + + preset_name = args.preset.lower() + if preset_name not in GAME_PRESETS: + print(f"Error: Unknown preset '{preset_name}'", file=sys.stderr) + print(f"Available: {', '.join(sorted(GAME_PRESETS.keys()))}", file=sys.stderr) + sys.exit(1) + + keys = GAME_PRESETS[preset_name] + color = parse_color(args.color) if args.color else (0xFF, 0x00, 0x00) + bg_color = parse_color(args.background) if args.background else (0x00, 0x00, 0x00) + + # Build custom color map: highlighted keys in color, rest in background + color_map = {} + for key_name in KEY_LED_INDEX: + # Skip aliases (lowercase/symbol variants) + if not key_name[0].isalpha() and key_name not in ( + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "-", + "=", + "[", + "]", + "\\", + ";", + "'", + ",", + ".", + "/", + "`", + ): + continue + if key_name in keys: + color_map[key_name] = color + else: + color_map[key_name] = bg_color + + custom_data = build_custom_rgb(color_map) + brightness = ( + percent_to_brightness(args.brightness) if args.brightness is not None else None + ) + msg = build_led_payload(0x20, brightness, custom_rgb_data=custom_data) + send_report(kbd, REPORT_LED, WVALUE_LED, msg) + print(f"Game preset: {preset_name} ({len(keys)} keys highlighted)") + + +def cmd_test(args): + """Send the exact known-good packet from redragon-lights.py for diagnosis. + + If --on: sends the original shadow_disappear packet (byte-identical). + If --off: sends the same packet with only byte [21] changed to 0x00. + This is exactly what the original redragon-lights.py does. + """ + kbd = find_keyboard() + + # Byte-identical to the msg in redragon-lights.py + msg = build_led_payload(0x09) # shadow_disappear, all other fields from template + + if not args.switch: + msg[OFFSET_PATTERN] = 0x00 # only change: pattern -> off + + log( + 1, + f"Sending {'ON (shadow_disappear)' if args.switch else 'OFF'} " + f"— byte-identical to redragon-lights.py", + ) + if verbose >= 2: + print("First 106 bytes:") + hex_dump(msg[:106]) + + send_report(kbd, REPORT_LED, WVALUE_LED, msg) + print(f"Test packet sent: {'on' if args.switch else 'off'}") + + +def cmd_state(args): + """Query the keyboard's current state.""" + kbd = find_keyboard() + msg = [REPORT_STATE, 0x83, 0xC6, 0x00, 0x00, 0x00] + log(1, "Querying keyboard state...") + send_report(kbd, REPORT_STATE, WVALUE_STATE, msg) + + # Try to read the response + try: + # Read from endpoint 0x82 (IN endpoint for interface 1) + response = kbd.read(0x82, 290, timeout=1000) + log(1, f"Response ({len(response)} bytes):") + if verbose >= 2: + hex_dump(response) + + # Parse known fields if response is long enough + if len(response) >= 22: + pat_byte = response[21] if len(response) > 21 else 0 + pat_name = "unknown" + for name, (code, _, _) in PATTERNS.items(): + if code == pat_byte: + pat_name = name + break + print(f"Current pattern: {pat_name} (0x{pat_byte:02X})") + if len(response) > OFFSET_BRIGHTNESS: + bri = response[OFFSET_BRIGHTNESS] + print(f"Brightness: {round(bri * 100 / BRIGHTNESS_MAX)}% (0x{bri:02X})") + if len(response) > OFFSET_COLOR_B: + r, g, b = ( + response[OFFSET_COLOR_R], + response[OFFSET_COLOR_G], + response[OFFSET_COLOR_B], + ) + print(f"Color: #{r:02X}{g:02X}{b:02X}") + else: + print(f"Response: {len(response)} bytes") + hex_dump(response) + + except usb.core.USBError as e: + if e.errno == 110: # timeout + print( + "No response from keyboard (this is normal for some firmware versions)" + ) + else: + print(f"Read error: {e}", file=sys.stderr) + + +def cmd_reset(args): + """Reset the USB driver for the keyboard.""" + kbd = find_keyboard() + reset_driver(kbd) + print("Driver reset complete") + + +def cmd_list_patterns(args): + """List all available LED patterns.""" + print("Available LED patterns:") + print(f" {'Name':<24} {'Code':<8} {'Speed':<8} {'Color'}") + print(f" {'─' * 24} {'─' * 7} {'─' * 7} {'─' * 7}") + for name, (code, has_speed, has_color) in sorted( + PATTERNS.items(), key=lambda x: x[1][0] + ): + speed_str = "yes" if has_speed else "—" + color_str = "yes" if has_color else "rainbow" + if name == "off": + color_str = "—" + print(f" {name:<24} 0x{code:02X} {speed_str:<8} {color_str}") + + print("\nAliases:") + for alias, target in sorted(PATTERN_ALIASES.items()): + print(f" {alias} -> {target}") + + +def cmd_list_keys(args): + """List all key names for custom RGB.""" + # Deduplicate: show only the primary name for each LED index + seen = {} + for name, idx in sorted(KEY_LED_INDEX.items(), key=lambda x: x[1]): + if idx not in seen: + seen[idx] = name + + print("Key names for custom RGB (87 keys):") + rows = [ + ( + "Row 0", + [ + "ESC", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "PRTSC", + "SCROLLLOCK", + "PAUSE", + ], + ), + ( + "Row 1", + [ + "GRAVE", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "MINUS", + "EQUAL", + "BACKSPACE", + "INSERT", + "HOME", + "PAGEUP", + ], + ), + ( + "Row 2", + [ + "TAB", + "Q", + "W", + "E", + "R", + "T", + "Y", + "U", + "I", + "O", + "P", + "LBRACKET", + "RBRACKET", + "BACKSLASH", + "DELETE", + "END", + "PAGEDOWN", + ], + ), + ( + "Row 3", + [ + "CAPSLOCK", + "A", + "S", + "D", + "F", + "G", + "H", + "J", + "K", + "L", + "SEMICOLON", + "APOSTROPHE", + "ENTER", + ], + ), + ( + "Row 4", + [ + "LSHIFT", + "Z", + "X", + "C", + "V", + "B", + "N", + "M", + "COMMA", + "PERIOD", + "SLASH", + "RSHIFT", + "UP", + ], + ), + ( + "Row 5", + [ + "LCTRL", + "LWIN", + "LALT", + "SPACE", + "RALT", + "FN", + "APP", + "RCTRL", + "LEFT", + "DOWN", + "RIGHT", + ], + ), + ] + for label, keys in rows: + print(f" {label}: {' '.join(keys)}") + + print("\nShorthand aliases: ` - = [ ] \\ ; ' , . /") + print(f"Total keys: {len(seen)}") + + +def cmd_list_games(args): + """List available game presets.""" + print("Game mode presets:") + for name, keys in sorted(GAME_PRESETS.items()): + print(f" {name}: {', '.join(keys)}") + + +def hex_dump(data, width=16): + """Print a hex dump of data.""" + for i in range(0, len(data), width): + chunk = data[i : i + width] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + print(f" {i:04X} {hex_part:<{width * 3}} {ascii_part}") + + +def main(): + global verbose + + parser = argparse.ArgumentParser( + description="Control utility for Redragon K621 Horus TKL RGB keyboard", + ) + parser.add_argument( + "--verbose", + "-v", + action="count", + default=0, + help="Increase verbosity (repeat for more)", + ) + sub = parser.add_subparsers(dest="command", help="Command to execute") + + # on + p = sub.add_parser("on", help="Turn backlight on") + p.add_argument( + "--pattern", "-p", default="fixed_on", help="Pattern name (default: fixed_on)" + ) + p.add_argument( + "--brightness", "-b", type=int, default=None, help="Brightness 0-100%%" + ) + p.add_argument("--speed", "-s", type=int, default=None, help="Speed 0-100%%") + p.add_argument("--color", "-c", default=None, help="Color as RRGGBB hex") + + # off + sub.add_parser("off", help="Turn backlight off") + + # test — diagnostic: sends the byte-identical original packet + p = sub.add_parser( + "test", help="Send byte-identical packet from redragon-lights.py" + ) + p.add_argument( + "--on", + "-1", + dest="switch", + action="store_true", + default=True, + help="Send ON (shadow_disappear)", + ) + p.add_argument("--off", "-0", dest="switch", action="store_false", help="Send OFF") + + # pattern + p = sub.add_parser("pattern", help="Set LED pattern") + p.add_argument("name", help="Pattern name (see list-patterns)") + p.add_argument( + "--brightness", "-b", type=int, default=None, help="Brightness 0-100%%" + ) + p.add_argument("--speed", "-s", type=int, default=None, help="Speed 0-100%%") + p.add_argument("--color", "-c", default=None, help="Color as RRGGBB hex") + + # custom + p = sub.add_parser("custom", help="Set custom per-key RGB") + p.add_argument( + "--colors", + "-c", + required=True, + help="JSON file mapping key names to RRGGBB colors", + ) + p.add_argument( + "--brightness", "-b", type=int, default=None, help="Brightness 0-100%%" + ) + + # game + p = sub.add_parser("game", help="Apply game mode preset") + p.add_argument("preset", help="Preset name (see list-games)") + p.add_argument( + "--color", + "-c", + default="FF0000", + help="Highlight color as RRGGBB (default: FF0000)", + ) + p.add_argument( + "--background", + "-g", + default="000000", + help="Background color as RRGGBB (default: 000000)", + ) + p.add_argument( + "--brightness", "-b", type=int, default=None, help="Brightness 0-100%%" + ) + + # state + sub.add_parser("state", help="Query keyboard state") + + # reset + sub.add_parser("reset", help="Reset USB driver") + + # list-patterns + sub.add_parser("list-patterns", help="List available LED patterns") + + # list-keys + sub.add_parser("list-keys", help="List key names for custom RGB") + + # list-games + sub.add_parser("list-games", help="List game mode presets") + + args = parser.parse_args() + verbose = args.verbose + + commands = { + "on": cmd_on, + "off": cmd_off, + "test": cmd_test, + "pattern": cmd_pattern, + "custom": cmd_custom, + "game": cmd_game, + "state": cmd_state, + "reset": cmd_reset, + "list-patterns": cmd_list_patterns, + "list-keys": cmd_list_keys, + "list-games": cmd_list_games, + } + + if args.command is None: + parser.print_help() + sys.exit(1) + + commands[args.command](args) + + +if __name__ == "__main__": + main()