Add utility to set the lights on a QMK keyboard
This is tested for the KeyChron K3 Max.
This commit is contained in:
605
qmk-lights.py
Executable file
605
qmk-lights.py
Executable 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 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=<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 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()
|
||||||
Reference in New Issue
Block a user