From e88a889c2138c05f2002e99bc709f35cdfb8967b Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sun, 29 Mar 2026 15:15:46 +0200 Subject: [PATCH] Add utility to set the lights on a QMK keyboard This is tested for the KeyChron K3 Max. --- qmk-lights.py | 605 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100755 qmk-lights.py diff --git a/qmk-lights.py b/qmk-lights.py new file mode 100755 index 0000000..063a146 --- /dev/null +++ b/qmk-lights.py @@ -0,0 +1,605 @@ +#!/usr/bin/python3 +"""qmk-lights.py — QMK/VIA backlight controller for Keychron K3 Max RGB.""" + +import argparse +import ctypes +import ctypes.util +import dbus +import dbus.mainloop.glib +import gi.repository.GLib # type: ignore[import-untyped] +import pprint +import sys +import time + +# Must preload libhidapi-hidraw before importing hid. +# The Debian default (libhidapi-libusb) does not populate usage_page/usage, +# which are required to identify the VIA Raw HID interface. +_hidraw = ctypes.util.find_library("hidapi-hidraw") +if _hidraw: + ctypes.CDLL(_hidraw, mode=ctypes.RTLD_GLOBAL) + +import hid # noqa: E402 # type: ignore[import-untyped] + +# ── USB / HID ────────────────────────────────────────────────────────────────── +VID = 0x3434 +PID = 0x0A30 +RAW_HID_USAGE_PAGE = 0xFF60 # VIA Raw HID interface +RAW_HID_USAGE = 0x61 +REPORT_SIZE = 32 # payload bytes (firmware sees these) + +# ── VIA protocol command IDs (quantum/via.h) ─────────────────────────────────── +VIA_CUSTOM_SET_VALUE = 0x07 # id_custom_set_value +VIA_CUSTOM_GET_VALUE = 0x08 # id_custom_get_value +VIA_CUSTOM_SAVE = 0x09 # id_custom_save + +# ── VIA channel IDs (via.h: via_channel_id enum) ────────────────────────────── +RGB_MATRIX_CHANNEL = 0x03 # id_qmk_rgb_matrix_channel + +# ── QMK RGB Matrix value IDs (via.h: via_qmk_rgb_matrix_value enum) ─────────── +QMK_BRIGHTNESS = 0x01 # id_qmk_rgb_matrix_brightness +QMK_EFFECT = 0x02 # id_qmk_rgb_matrix_effect +QMK_SPEED = 0x03 # id_qmk_rgb_matrix_effect_speed +QMK_COLOR = 0x04 # id_qmk_rgb_matrix_color (hue byte, saturation byte) + +# ── Preset defaults ──────────────────────────────────────────────────────────── +# --on : solid white +ON_EFFECT = 1 # RGB_MATRIX_SOLID_COLOR +ON_BRIGHTNESS = 128 +ON_HUE = 0 # irrelevant when saturation = 0 +ON_SATURATION = 0 # 0 = white; 255 = fully saturated colour +ON_SPEED = 128 # mid speed (unused for solid, but set anyway) + +# --per-key : per_key_rgb — effect 23 (confirmed via VIA web app effect list). +PERKEY_EFFECT = 23 # per_key_rgb +PERKEY_BRIGHTNESS = 127 +PERKEY_HUE = 0 +PERKEY_SATURATION = 0 +PERKEY_SPEED = 128 + +# --program : reactive_multiwide — effect 19 (confirmed via VIA web app effect list). +PROGRAMMED_EFFECT = 19 # reactive_multiwide +PROGRAMMED_BRIGHTNESS = 255 +PROGRAMMED_HUE = 0 +PROGRAMMED_SATURATION = 0 # full saturation makes reactive effects visible +PROGRAMMED_SPEED = 128 + +# ── Effect name table — confirmed from VIA web app effect list ───────────────── +EFFECT_NAMES = { + 0: "none", + 1: "solid_color", + 2: "breathing", + 3: "band_spiral_val", + 4: "cycle_all", + 5: "cycle_left_right", + 6: "cycle_up_down", + 7: "rainbow_moving_chevron", + 8: "cycle_out_in", + 9: "cycle_out_in_dual", + 10: "cycle_pinwheel", + 11: "cycle_spiral", + 12: "dual_beacon", + 13: "rainbow_beacon", + 14: "jellybean_raindrops", + 15: "pixel_rain", + 16: "typing_heatmap", + 17: "digital_rain", + 18: "reactive_simple", + 19: "reactive_multiwide", + 20: "reactive_multinexus", + 21: "splash", + 22: "solid_splash", + 23: "per_key_rgb", + 24: "mix_pgb", +} + +# ── Screensaver DBus interfaces ──────────────────────────────────────────────── +SCREENSAVER_INTERFACES = [ + "org.gnome.ScreenSaver", + "org.cinnamon.ScreenSaver", + "org.kde.screensaver", + "org.freedesktop.ScreenSaver", +] + +HELP_EPILOG = """\ +━━━ Effect IDs ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +All IDs confirmed from the VIA web app effect list for this firmware. + + ID Name +─── ────────────────────────────────────────────────────── + 0 none All LEDs off + 1 solid_color Solid colour (see --hue, --saturation) + 2 breathing Whole-board pulsing brightness + 3 band_spiral_val Spiral brightness band + 4 cycle_all All keys cycle through hues together + 5 cycle_left_right Hue sweeps left → right + 6 cycle_up_down Hue sweeps top → bottom + 7 rainbow_moving_chevron Chevron-shaped rainbow wave + 8 cycle_out_in Hue cycle from edges inward + 9 cycle_out_in_dual Dual cycle from edges inward + 10 cycle_pinwheel Pinwheel of cycling hues + 11 cycle_spiral Spiral of cycling hues + 12 dual_beacon Two-colour spinning beacon + 13 rainbow_beacon Rainbow spinning beacon + 14 jellybean_raindrops Coloured jellybean drops + 15 pixel_rain Pixel colour rain + 16 typing_heatmap Keys glow where you type most + 17 digital_rain Matrix-style digital rain + 18 reactive_simple Solid + flash on keypress + 19 reactive_multiwide Wide ripple on keypress + 20 reactive_multinexus Nexus ripple on keypress + 21 splash Colour splash on keypress + 22 solid_splash Solid + colour splash on keypress + 23 per_key_rgb Per-key RGB (stored layout) + 24 mix_pgb Mixed PGB pattern + +━━━ Colour (HSV) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +--hue 0–255 maps to the colour wheel: + 0=red 21=orange 43=yellow 64=chartreuse 85=green + 106=spring_green 128=cyan 149=azure 170=blue 192=violet + 213=magenta 234=rose 255=red (wraps) + +--saturation: 0 = white (no colour), 255 = fully saturated colour. + +Note: solid white is hue=, saturation=0. + To get solid blue: --effect 1 --hue 170 --saturation 255 + +━━━ Examples ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + qmk-lights.py --on # solid white at full brightness + qmk-lights.py --on --brightness 128 # solid white at half brightness + qmk-lights.py --on --hue 170 --saturation 255 # solid blue + qmk-lights.py --effect 13 # cycle_left_right (if compiled in) + qmk-lights.py --brightness 200 --speed 64 # dim and slow, keep current effect + qmk-lights.py --off # turn off, saved to EEPROM + qmk-lights.py --query # read current settings from keyboard + qmk-lights.py --screensaver # auto off/on with screen lock +""" + +debug = 0 + + +# ── Device access ────────────────────────────────────────────────────────────── + + +def find_device(): + """Enumerate HID devices and return an opened handle to the VIA Raw HID interface.""" + for info in hid.enumerate(VID, 0): + if info["product_id"] != PID: + continue + if info.get("usage_page") != RAW_HID_USAGE_PAGE: + continue + if info.get("usage") != RAW_HID_USAGE: + continue + if debug > 0: + print( + "Found: {} (usage_page=0x{:04x} usage=0x{:02x})".format( + info.get("path"), info.get("usage_page"), info.get("usage") + ) + ) + kbd = hid.device() + kbd.open_path(info["path"]) + if debug > 0: + print("Manufacturer: {}".format(kbd.get_manufacturer_string())) + print("Product: {}".format(kbd.get_product_string())) + return kbd + raise ValueError( + "QMK keyboard not found (VID=0x{:04x} PID=0x{:04x}). " + "Is it connected and in USB mode?".format(VID, PID) + ) + + +# ── VIA communication ────────────────────────────────────────────────────────── + + +def send_via_command(kbd, command_id, channel_id, value_id=0x00, *extra_bytes): + """Send one VIA command (33 bytes: report-ID 0x00 + 32-byte payload). + + VIA protocol v12 layout: [command_id, channel_id, value_id, value_data...] + The report-ID byte (buf[0] = 0x00) is stripped by the HID layer before + the firmware receives the remaining 32 bytes. + """ + buf = bytearray(REPORT_SIZE + 1) + buf[0] = 0x00 # report ID + buf[1] = command_id + buf[2] = channel_id + buf[3] = value_id + for i, b in enumerate(extra_bytes): + buf[4 + i] = b + if debug > 1: + print("HID write: {}".format(buf[: max(6, 4 + len(extra_bytes) + 1)].hex())) + kbd.write(bytes(buf)) + time.sleep(0.05) + + +def get_via_value(kbd, channel_id, value_id, response_bytes=1): + """Query one VIA value; returns a list of response_bytes ints, or None on error.""" + buf = bytearray(REPORT_SIZE + 1) + buf[0] = 0x00 + buf[1] = VIA_CUSTOM_GET_VALUE + buf[2] = channel_id + buf[3] = value_id + if debug > 1: + print("HID get query: {}".format(buf[:6].hex())) + kbd.write(bytes(buf)) + time.sleep(0.05) + resp = kbd.read(REPORT_SIZE, timeout_ms=500) + if debug > 1: + print("HID get resp: {}".format(bytes(resp).hex() if resp else "(none)")) + if resp and resp[0] != 0xFF: + return list(resp[3 : 3 + response_bytes]) + return None + + +# ── High-level lighting functions ────────────────────────────────────────────── + + +def lights_off(kbd): + """Set effect to 0 (RGB_MATRIX_NONE) and save — proper off, not just dim.""" + send_via_command(kbd, VIA_CUSTOM_SET_VALUE, RGB_MATRIX_CHANNEL, QMK_EFFECT, 0x00) + send_via_command(kbd, VIA_CUSTOM_SAVE, RGB_MATRIX_CHANNEL) + if debug > 0: + print("Lights off") + + +def apply_settings( + kbd, *, effect=None, brightness=None, hue=None, saturation=None, speed=None +): + """Send whichever settings are not None, then save to EEPROM. + + If only one of hue/saturation is supplied, the other is read from the + keyboard so the colour command always sends both bytes. + """ + if effect is not None: + send_via_command( + kbd, VIA_CUSTOM_SET_VALUE, RGB_MATRIX_CHANNEL, QMK_EFFECT, effect + ) + if brightness is not None: + send_via_command( + kbd, VIA_CUSTOM_SET_VALUE, RGB_MATRIX_CHANNEL, QMK_BRIGHTNESS, brightness + ) + if hue is not None or saturation is not None: + if hue is None or saturation is None: + current = get_via_value( + kbd, RGB_MATRIX_CHANNEL, QMK_COLOR, response_bytes=2 + ) + if current: + if hue is None: + hue = current[0] + if saturation is None: + saturation = current[1] + else: + if hue is None: + hue = 0 + if saturation is None: + saturation = 0 + send_via_command( + kbd, VIA_CUSTOM_SET_VALUE, RGB_MATRIX_CHANNEL, QMK_COLOR, hue, saturation + ) + if speed is not None: + send_via_command( + kbd, VIA_CUSTOM_SET_VALUE, RGB_MATRIX_CHANNEL, QMK_SPEED, speed + ) + send_via_command(kbd, VIA_CUSTOM_SAVE, RGB_MATRIX_CHANNEL) + if debug > 0: + applied = { + k: v + for k, v in dict( + effect=effect, + brightness=brightness, + hue=hue, + saturation=saturation, + speed=speed, + ).items() + if v is not None + } + print("Applied: {}".format(applied)) + + +def lights_on(kbd): + """Solid white preset (effect=1, brightness=255, saturation=0).""" + apply_settings( + kbd, + effect=ON_EFFECT, + brightness=ON_BRIGHTNESS, + hue=ON_HUE, + saturation=ON_SATURATION, + speed=ON_SPEED, + ) + + +def lights_per_key(kbd): + """Per-key RGB preset — per_key_rgb (effect 23, uses PERKEY_* constants).""" + apply_settings( + kbd, + effect=PERKEY_EFFECT, + brightness=PERKEY_BRIGHTNESS, + hue=PERKEY_HUE, + saturation=PERKEY_SATURATION, + speed=PERKEY_SPEED, + ) + + +def lights_programmed(kbd): + """Programmed preset — reactive_multiwide (effect 19, uses PROGRAMMED_* constants).""" + apply_settings( + kbd, + effect=PROGRAMMED_EFFECT, + brightness=PROGRAMMED_BRIGHTNESS, + hue=PROGRAMMED_HUE, + saturation=PROGRAMMED_SATURATION, + speed=PROGRAMMED_SPEED, + ) + + +def lights_query(kbd): + """Read and display current RGB matrix settings from the keyboard.""" + effect = get_via_value(kbd, RGB_MATRIX_CHANNEL, QMK_EFFECT) + brightness = get_via_value(kbd, RGB_MATRIX_CHANNEL, QMK_BRIGHTNESS) + speed = get_via_value(kbd, RGB_MATRIX_CHANNEL, QMK_SPEED) + color = get_via_value(kbd, RGB_MATRIX_CHANNEL, QMK_COLOR, response_bytes=2) + + if effect: + eff_name = EFFECT_NAMES.get(effect[0], "(firmware-specific — see --help)") + print("Effect: {} — {}".format(effect[0], eff_name)) + else: + print("Effect: ?") + print("Brightness: {}".format(brightness[0] if brightness else "?")) + if color: + sat_desc = ( + "white" if color[1] == 0 else "fully saturated" if color[1] == 255 else "" + ) + sat_str = "{}{}".format(color[1], " ({})".format(sat_desc) if sat_desc else "") + print("Hue: {}".format(color[0])) + print("Saturation: {}".format(sat_str)) + else: + print("Hue: ?") + print("Saturation: ?") + print("Speed: {}".format(speed[0] if speed else "?")) + + +# ── Screensaver integration ──────────────────────────────────────────────────── + + +def screensaver_message_callback(session, message): + global debug + for screensaver in SCREENSAVER_INTERFACES: + if message.get_interface() != screensaver: + continue + if debug > 2: + pprint.pprint(message) + if message.get_member() in ("ActiveChanged", "WakeUpScreen"): + try: + if debug > 1: + print( + "{} ActiveChanged/WakeUpScreen: args={}".format( + screensaver, message.get_args_list() + ) + ) + except Exception: + pass + # Wrap individually so a non-supporting screensaver doesn't + # block one that does support GetActive. + try: + path = "/{}".format(screensaver.replace(".", "/")) + obj = session.get_object(screensaver, path) + iface = dbus.Interface(obj, screensaver) + time.sleep(0.25) + active = bool(iface.GetActive()) + if debug > 1: + print("{} GetActive → {}".format(screensaver, active)) + if active: + kbd = find_device() + lights_off(kbd) + kbd.close() + except Exception: + continue + + if message.get_member() == "WakeUpScreen": + try: + time.sleep(0.25) + if debug > 1: + print("{} WakeUpScreen → restore lights".format(screensaver)) + kbd = find_device() + lights_on(kbd) + kbd.close() + except Exception: + continue + + +# ── Argument parsing and main ────────────────────────────────────────────────── + + +def byte_value(s): + """argparse type: accept decimal or 0x hex, validate 0–255.""" + n = int(s, 0) + if not 0 <= n <= 255: + raise argparse.ArgumentTypeError("value must be 0–255, got {}".format(n)) + return n + + +def main(): + global debug + + parser = argparse.ArgumentParser( + description="Control RGB backlight on Keychron K3 Max (QMK/VIA protocol v12)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=HELP_EPILOG, + ) + + # ── Mode flags (mutually exclusive) ─────────────────────────────────────── + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument( + "--on", + "-1", + dest="mode", + action="store_const", + const="on", + help="Preset: solid white (effect=1, brightness=255, hue=0, saturation=0)", + ) + mode_group.add_argument( + "--off", + "-0", + dest="mode", + action="store_const", + const="off", + help="Turn backlight off (sets effect=0, saves to EEPROM)", + ) + mode_group.add_argument( + "--per-key", + "-k", + dest="mode", + action="store_const", + const="per_key", + help="Preset: per_key_rgb (effect 23, uses PERKEY_* constants)", + ) + mode_group.add_argument( + "--program", + "-p", + dest="mode", + action="store_const", + const="program", + help="Preset: reactive_multiwide (effect 19, uses PROGRAMMED_* constants)", + ) + mode_group.add_argument( + "--query", + "-q", + dest="mode", + action="store_const", + const="query", + help="Read and display current effect, brightness, hue, saturation and speed", + ) + mode_group.add_argument( + "--screensaver", + "-s", + dest="mode", + action="store_const", + const="screensaver", + help="Watch screensaver DBus signals: off on lock, solid white on wake", + ) + + # ── Individual settings (can augment a mode or stand alone) ─────────────── + parser.add_argument( + "--effect", + "-e", + type=byte_value, + metavar="0-255", + help=( + "Set effect by ID. 0=off, 1=solid_color; higher IDs are " + "firmware-specific (see effect table in --help)." + ), + ) + parser.add_argument( + "--brightness", + "-b", + type=byte_value, + metavar="0-255", + help="LED brightness. 0=off, 128=half, 255=full.", + ) + parser.add_argument( + "--hue", + type=byte_value, + metavar="0-255", + help=( + "Colour hue on the HSV wheel. " + "0=red 43=yellow 85=green 128=cyan 170=blue 213=magenta. " + "Has no visible effect when --saturation is 0 (white)." + ), + ) + parser.add_argument( + "--saturation", + "--sat", + type=byte_value, + metavar="0-255", + help="Colour saturation. 0=white, 255=fully saturated colour.", + ) + parser.add_argument( + "--speed", + type=byte_value, + metavar="0-255", + help="Animation speed. 0=slowest, 128=mid, 255=fastest.", + ) + + parser.add_argument( + "--verbose", + "-v", + action="count", + default=0, + help="Increase verbosity (repeat for more detail: -v, -vv, -vvv).", + ) + + args = parser.parse_args() + + individual = dict( + effect=args.effect, + brightness=args.brightness, + hue=args.hue, + saturation=args.saturation, + speed=args.speed, + ) + has_individual = any(v is not None for v in individual.values()) + + if args.mode is None and not has_individual: + parser.print_help() + sys.exit(1) + + debug = args.verbose + + # ── Screensaver mode: long-running DBus loop ─────────────────────────────── + if args.mode == "screensaver": + dbus_loop = dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + session = dbus.SessionBus(mainloop=dbus_loop) + for iface in SCREENSAVER_INTERFACES: + session.add_match_string_non_blocking("interface='{}'".format(iface)) + session.add_message_filter(screensaver_message_callback) + gi.repository.GLib.MainLoop().run() + return + + # ── All other modes: open device, act, close ─────────────────────────────── + kbd = find_device() + + if args.mode == "query": + lights_query(kbd) + + elif args.mode == "off": + lights_off(kbd) + + else: + # Start with profile defaults, then override with explicit args. + settings = {} + if args.mode == "on": + settings = dict( + effect=ON_EFFECT, + brightness=ON_BRIGHTNESS, + hue=ON_HUE, + saturation=ON_SATURATION, + speed=ON_SPEED, + ) + elif args.mode == "per_key": + settings = dict( + effect=PERKEY_EFFECT, + brightness=PERKEY_BRIGHTNESS, + hue=PERKEY_HUE, + saturation=PERKEY_SATURATION, + speed=PERKEY_SPEED, + ) + elif args.mode == "program": + settings = dict( + effect=PROGRAMMED_EFFECT, + brightness=PROGRAMMED_BRIGHTNESS, + hue=PROGRAMMED_HUE, + saturation=PROGRAMMED_SATURATION, + speed=PROGRAMMED_SPEED, + ) + # Individual args override profile defaults. + for key, val in individual.items(): + if val is not None: + settings[key] = val + apply_settings(kbd, **settings) + + kbd.close() + + +if __name__ == "__main__": + main()