From 5b7534560fc96727ebb59efc93aaa28fbbc71c3a Mon Sep 17 00:00:00 2001 From: Louis-Alexander Kerst Date: Wed, 7 Oct 2026 17:51:21 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 9 ++ README.md | 5 ++ install.sh | 2 +- kernel/dkms.conf | 2 +- packaging/arch/PKGBUILD | 4 +- .../org.predatorcontrol.panel/metadata.json | 2 +- predator_control/__init__.py | 2 +- predator_control/daemon.py | 21 +++++ predator_control/gui/app.py | 15 ++++ predator_control/hotkey.py | 83 +++++++++++++++++++ predator_control/keyboard.py | 5 +- 11 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 predator_control/hotkey.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c23dd..f75e51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.3.0 – 2026-10-07 + +### Added +- **Predator key opens the app.** The key produces no input event on Linux (its usage code is + outside the range the keyboard declares), so predatord listens on the keyboard's vendor HID + report instead and opens Predator Control in the active desktop session +- Choose the key's action on the System page: open the app, switch to the next performance mode, + or do nothing + ## 1.2.0 – 2026-10-07 ### Added diff --git a/README.md b/README.md index f46d869..41cef50 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,11 @@ Keyboard RGB and performance profiles work without the module. Uninstall with `sudo pacman -R predator-control` (package) or `sudo ./uninstall.sh` (add `--purge` to also delete your settings). +## Predator key + +Pressing the **Predator key** opens Predator Control, just like PredatorSense on Windows. +On the **System** page you can instead make it switch to the next performance mode, or disable it. + ## Updates Installed via the pacman package, Predator Control updates itself: the service checks for new diff --git a/install.sh b/install.sh index ab31bdf..6ce0a8b 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ # sudo ./install.sh --no-deps skip package installation (non-Arch distros) set -euo pipefail -VERSION="1.2.0" +VERSION="1.3.0" MODULE="predator-wmi" SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PREFIX="/usr" diff --git a/kernel/dkms.conf b/kernel/dkms.conf index 439c1d0..7dbff00 100644 --- a/kernel/dkms.conf +++ b/kernel/dkms.conf @@ -1,5 +1,5 @@ PACKAGE_NAME="predator-wmi" -PACKAGE_VERSION="1.2.0" +PACKAGE_VERSION="1.3.0" BUILT_MODULE_NAME[0]="predator_wmi" DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86" MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 9cbe9fa..40b4060 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -5,7 +5,7 @@ # sudo pacman -U predator-control-*.pkg.tar.zst pkgname=predator-control -pkgver=1.2.0 +pkgver=1.3.0 pkgrel=1 pkgdesc="PredatorSense replacement for Acer Predator laptops: performance modes, fans, keyboard RGB, lightbar, battery (Qt app + Plasma widget)" arch=('any') @@ -22,7 +22,7 @@ install=predator-control.install source=() _src="$(realpath "$startdir/../..")" -_modver=1.2.0 +_modver=1.3.0 package() { cd "$_src" diff --git a/plasmoid/org.predatorcontrol.panel/metadata.json b/plasmoid/org.predatorcontrol.panel/metadata.json index bb0e1a0..f27939e 100644 --- a/plasmoid/org.predatorcontrol.panel/metadata.json +++ b/plasmoid/org.predatorcontrol.panel/metadata.json @@ -12,7 +12,7 @@ "Id": "org.predatorcontrol.panel", "License": "GPL-2.0-only", "Name": "Predator Control", - "Version": "1.2.0", + "Version": "1.3.0", "Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard" }, "X-Plasma-API-Minimum-Version": "6.0", diff --git a/predator_control/__init__.py b/predator_control/__init__.py index 573d4b3..3170e82 100644 --- a/predator_control/__init__.py +++ b/predator_control/__init__.py @@ -2,7 +2,7 @@ import os -__version__ = "1.2.0" +__version__ = "1.3.0" APP_NAME = "Predator Control" RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control") diff --git a/predator_control/daemon.py b/predator_control/daemon.py index ff4cb71..3f7918e 100644 --- a/predator_control/daemon.py +++ b/predator_control/daemon.py @@ -25,6 +25,7 @@ import traceback from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__ from . import hardware as hw from . import updater +from .hotkey import HotkeyListener, launch_in_session from .keyboard import EFFECTS, NUM_CELLS, Keyboard DEFAULT_CONFIG = { @@ -49,6 +50,7 @@ DEFAULT_CONFIG = { "battery": {"limit": None}, # None = leave the firmware state alone "keyboard_presets": {}, "updates": {"auto_check": True, "interval_hours": 6}, + "predator_key": "open", # open | cycle_profile | none } @@ -277,6 +279,18 @@ class Daemon: self._last_try = time.time() self.check_update_async() + def predator_key(self): + action = self.config.get("predator_key", "open") + if action == "open": + if not launch_in_session(["predator-control"]): + log("Predator key: no active graphical session to open the app in") + elif action == "cycle_profile": + with self.lock: + ch = hw.profile_choices() + cur = hw.get_profile() + nxt = ch[(ch.index(cur) + 1) % len(ch)] if cur in ch else ch[0] + self._try("profile", hw.set_profile, nxt) + def loop(self): while True: try: @@ -328,6 +342,12 @@ class Daemon: merge(self.config["updates"], {k: a[k] for k in ("auto_check", "interval_hours") if k in a}) self.save_config() return {} + if cmd == "set_predator_key": + if a["action"] not in ("open", "cycle_profile", "none"): + raise ValueError("unknown action") + self.config["predator_key"] = a["action"] + self.save_config() + return {} if cmd == "restore": self.restore() return {} @@ -511,6 +531,7 @@ def main(): signal.signal(signal.SIGTERM, lambda *_: (server.server_close(), sys.exit(0))) signal.signal(signal.SIGUSR1, lambda *_: threading.Thread(target=lambda: (d.lock.acquire(), d.restore(), d.lock.release())).start()) threading.Thread(target=d.loop, daemon=True).start() + HotkeyListener(d.predator_key, log).start() server.serve_forever() diff --git a/predator_control/gui/app.py b/predator_control/gui/app.py index df79a02..9fc5449 100644 --- a/predator_control/gui/app.py +++ b/predator_control/gui/app.py @@ -674,6 +674,19 @@ class SystemPage(Page): self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS).")) self.lay.addWidget(self.fw_card) + key = Card("Predator key") + kr = QHBoxLayout() + kr.addWidget(QLabel("When the Predator key is pressed")) + self.key_action = QComboBox() + for val, text in (("open", "Open Predator Control"), ("cycle_profile", "Switch to the next performance mode"), + ("none", "Do nothing")): + self.key_action.addItem(text, val) + self.key_action.activated.connect(lambda: win.backend.run("set_predator_key", action=self.key_action.currentData())) + kr.addWidget(self.key_action) + kr.addStretch(1) + key.body.addLayout(kr) + self.lay.addWidget(key) + upd = Card("Updates") self.upd_head = QLabel("") self.upd_head.setObjectName("big") @@ -782,6 +795,8 @@ class SystemPage(Page): err = ("\n\n⚠ %s" % st["update_error"]) if st.get("update_error") else "" self.upd_text.setText("Installed version %s · last check %s%s%s" % (st.get("version"), when, err, extra)) self.upd_install.hide() + if not self.key_action.view().isVisible(): + self.key_action.setCurrentIndex(max(0, self.key_action.findData(st["config"].get("predator_key", "open")))) self.upd_auto.setChecked(st["config"].get("updates", {}).get("auto_check", True)) a = st["available"] yes = lambda v: "✔" if v else "✘" diff --git a/predator_control/hotkey.py b/predator_control/hotkey.py new file mode 100644 index 0000000..ea5c8f9 --- /dev/null +++ b/predator_control/hotkey.py @@ -0,0 +1,83 @@ +"""Predator key listener. + +The PH16-71 Predator key produces no input event: its keyboard usage (0xC9) is outside the +range the keyboard declares, so the kernel drops it. The key is also reported on the +vendor report 4 of USB interface 2: `04 81 ff` on press, `04 00 00` on release. +""" + +import os +import pwd +import select +import subprocess +import threading +import time + +from .keyboard import HOTKEY_INTERFACE, find_hidraw + +PRESS_PREFIX = b"\x04\x81" + + +def active_session_user(): + """(uid, name) of the active graphical session, or None.""" + out = subprocess.run(["loginctl", "list-sessions", "--no-legend"], + capture_output=True, text=True).stdout + for line in out.splitlines(): + sid = line.split()[0] if line.split() else None + if not sid: + continue + props = dict(l.split("=", 1) for l in subprocess.run( + ["loginctl", "show-session", sid, "-p", "Active", "-p", "Type", "-p", "User", "-p", "Name"], + capture_output=True, text=True).stdout.splitlines() if "=" in l) + if props.get("Active") == "yes" and props.get("Type") in ("wayland", "x11"): + return int(props["User"]), props["Name"] + return None + + +def launch_in_session(args): + sess = active_session_user() + if not sess: + return False + uid, name = sess + # runs in the user's systemd manager, which carries WAYLAND_DISPLAY/DISPLAY on Plasma + r = subprocess.run(["systemd-run", "--quiet", "--user", "--machine=%s@.host" % name, "--collect", + "--unit=predator-control-launch-%d" % int(time.time() * 1000)] + args, + capture_output=True, text=True) + return r.returncode == 0 + + +class HotkeyListener(threading.Thread): + def __init__(self, on_press, log): + super().__init__(daemon=True) + self.on_press = on_press + self.log = log + + def run(self): + while True: + path = find_hidraw(HOTKEY_INTERFACE) + if not path: + time.sleep(5) + continue + try: + fd = os.open(path, os.O_RDONLY) + except OSError as e: + self.log("hotkey: cannot open %s: %s" % (path, e)) + time.sleep(5) + continue + last = 0.0 + try: + while True: + select.select([fd], [], []) + data = os.read(fd, 64) + if not data: + break + if data.startswith(PRESS_PREFIX) and time.monotonic() - last > 0.4: + last = time.monotonic() + try: + self.on_press() + except Exception as e: + self.log("hotkey: %s" % e) + except OSError: + pass # device vanished (suspend/replug) – reopen + finally: + os.close(fd) + time.sleep(1) diff --git a/predator_control/keyboard.py b/predator_control/keyboard.py index cee4cd4..1495b11 100644 --- a/predator_control/keyboard.py +++ b/predator_control/keyboard.py @@ -13,6 +13,7 @@ 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 @@ -57,7 +58,7 @@ def checksum(body7): return (0xFF - (sum(body7) & 0xFF)) & 0xFF -def find_hidraw(): +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: @@ -76,7 +77,7 @@ def find_hidraw(): 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): + if iface == str(interface): return "/dev/" + os.path.basename(node) return None