Initial release of Predator Control 1.0.0

PredatorSense replacement for the Acer Predator Helios 16 (PH16-71) on Linux:

- predatord: root service for profiles, fans (incl. temperature curves),
  keyboard RGB, lightbar and battery health; restores settings at boot
  and after resume, switches profiles on AC/battery changes
- Qt desktop app with dashboard, fan curve editor and per-key RGB studio
- Plasma 6 panel widget with quick controls
- predatorctl command line tool
- predator_wmi kernel module (DKMS) as a minimal WMI method bridge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 18:38:14 +02:00
co-authored by Claude Opus 5.5
commit ea7ce49452
34 changed files with 3774 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
"""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
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():
"""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(LED_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)