Files
Cachy-Predator-Keyboard/predator_control/keyboard.py
T
MrBlakeandClaude Opus 5.5 5b7534560f Open the app with the Predator key, bump to 1.3.0
The Predator key sends keyboard usage 0xC9, which lies outside the
logical range the keyboard declares, so the kernel never emits an input
event for it. It is also reported on vendor report 4 of USB interface 2
(04 81 ff on press). predatord now listens there and opens the app in
the active graphical session via the user's systemd manager.

The action is configurable on the System page: open the app, switch to
the next performance mode, or do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:51:21 +02:00

141 lines
5.0 KiB
Python

"""Per-key RGB keyboard (Chicony MCU 04F2:0117/011A, interface 3, vendor page FF02) via hidraw.
Protocol (from PredatorSense USB captures, see the Venator project):
* 8-byte feature reports <op> p1..p6 <checksum>, checksum = 0xFF - (sum & 0xFF)
* 512-byte output report carrying the per-key framebuffer (128 cells x {0,R,G,B})
"""
import fcntl
import glob
import os
import threading
ACER_VID = 0x04F2
KNOWN_PIDS = (0x0117, 0x011A)
LED_INTERFACE = 3
HOTKEY_INTERFACE = 2 # vendor report 4 carries the Predator key
NUM_CELLS = 128
OP_BEGIN = 0x88
OP_MODE_ZONE = 0xB1
OP_MODE_PERKEY = 0x12
OP_SET_COLOR = 0x14
OP_APPLY = 0x08
SUB_APPLY = 0x02
MODE_TAG = 0x05
FLAG_DEFAULT = 0x01
SCOPE_ZONE = 0x01
SCOPE_PERKEY = 0x08
# name -> (EFF byte, uses colour?, display name)
EFFECTS = {
"static": (0x01, True, "Static"),
"breathing": (0x02, True, "Breathing"),
"rainbow": (0x03, False, "Rainbow wave"),
"snake": (0x05, True, "Snake"),
"ripple": (0x06, True, "Ripple (reactive)"),
"neon": (0x08, False, "Neon"),
"rain": (0x0A, True, "Rain"),
"explosion": (0x12, True, "Explosion"),
"pulse": (0x25, True, "Pulse (reactive)"),
"stars": (0x26, True, "Stars"),
"meteor": (0x27, True, "Meteor (reactive)"),
"aura": (0x28, True, "Aura (reactive)"),
"perkey": (0x33, False, "Custom (per key)"),
"off": (0x01, False, "Off"),
}
def _ioc(direction, type_, nr, size):
return (direction << 30) | (size << 16) | (ord(type_) << 8) | nr
def HIDIOCSFEATURE(length):
return _ioc(3, "H", 0x06, length) # _IOC_WRITE|_IOC_READ
def checksum(body7):
return (0xFF - (sum(body7) & 0xFF)) & 0xFF
def find_hidraw(interface=LED_INTERFACE):
"""Path of the hidraw node of the LED interface, or None."""
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
try:
with open(os.path.join(node, "device", "uevent")) as f:
uevent = f.read()
except OSError:
continue
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
if l.startswith("HID_ID=")), "")
parts = hid_id.split(":")
if len(parts) != 3:
continue
vid, pid = int(parts[1], 16), int(parts[2], 16)
if vid != ACER_VID or pid not in KNOWN_PIDS:
continue
devpath = os.path.realpath(os.path.join(node, "device"))
# .../1-7:1.3/0003:04F2:011A.0004 -> interface number from the parent
iface = os.path.basename(os.path.dirname(devpath)).rsplit(".", 1)[-1]
if iface == str(interface):
return "/dev/" + os.path.basename(node)
return None
class Keyboard:
def __init__(self):
self._lock = threading.Lock()
@property
def path(self):
return find_hidraw()
def available(self):
return self.path is not None
def _cmd(self, fd, op, p1=0, p2=0, p3=0, p4=0, p5=0, p6=0):
body = [op, p1, p2, p3, p4, p5, p6]
buf = bytearray([0] + body + [checksum(body)])
fcntl.ioctl(fd, HIDIOCSFEATURE(len(buf)), buf)
def apply(self, effect="static", color=(255, 255, 255), brightness=200,
frame=None, effect_id=0):
"""Apply a hardware effect or (effect='perkey') a per-key image.
frame: bytes/list with 128*3 RGB values (perkey only).
effect_id: optional raw EFF byte (effect variants with other speed/direction).
"""
path = self.path
if not path:
raise RuntimeError("RGB keyboard (hidraw) not found")
brightness = max(0, min(255, int(brightness)))
r, g, b = (max(0, min(255, int(c))) for c in color)
with self._lock:
fd = os.open(path, os.O_RDWR)
try:
self._cmd(fd, OP_BEGIN)
if effect == "perkey":
data = bytes(frame or bytes(NUM_CELLS * 3))
data = data[:NUM_CELLS * 3].ljust(NUM_CELLS * 3, b"\0")
self._cmd(fd, OP_MODE_PERKEY, 0, 0, 8, 0, 0, 0)
out = bytearray(1 + NUM_CELLS * 4)
for i in range(NUM_CELLS):
out[1 + i * 4 + 1:1 + i * 4 + 4] = data[i * 3:i * 3 + 3]
os.write(fd, bytes(out))
self._cmd(fd, OP_APPLY, SUB_APPLY, 0x33, MODE_TAG,
brightness, SCOPE_PERKEY, FLAG_DEFAULT)
return
self._cmd(fd, OP_MODE_ZONE)
if effect == "off" or brightness == 0:
self._cmd(fd, OP_APPLY, SUB_APPLY, 0x01, MODE_TAG, 0,
SCOPE_ZONE, FLAG_DEFAULT)
return
if effect not in EFFECTS:
raise ValueError("Unknown effect: %s" % effect)
self._cmd(fd, OP_SET_COLOR, 0, 0, r, g, b, 0)
eff = effect_id or EFFECTS[effect][0]
self._cmd(fd, OP_APPLY, SUB_APPLY, eff, MODE_TAG, brightness,
SCOPE_ZONE, FLAG_DEFAULT)
finally:
os.close(fd)