Files
utility-scripts/qmk-lights.py
Timothy Allen e88a889c21 Add utility to set the lights on a QMK keyboard
This is tested for the KeyChron K3 Max.
2026-03-29 15:15:46 +02:00

606 lines
22 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()