#!/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()