1127 lines
30 KiB
Python
Executable File
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()
|