Add utility to set the lights on a QMK keyboard

This is tested for the KeyChron K3 Max.
This commit is contained in:
2026-03-29 15:15:46 +02:00
parent 815723390d
commit e88a889c21

605
qmk-lights.py Executable file
View File

@@ -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 0255 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=<any>, 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 0255."""
n = int(s, 0)
if not 0 <= n <= 255:
raise argparse.ArgumentTypeError("value must be 0255, 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()