Files
utility-scripts/redragon.py

1127 lines
30 KiB
Python
Executable File

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