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