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>
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 1.2.0 – 2026-10-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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).
|
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
|
## Updates
|
||||||
|
|
||||||
Installed via the pacman package, Predator Control updates itself: the service checks for new
|
Installed via the pacman package, Predator Control updates itself: the service checks for new
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# sudo ./install.sh --no-deps skip package installation (non-Arch distros)
|
# sudo ./install.sh --no-deps skip package installation (non-Arch distros)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="1.2.0"
|
VERSION="1.3.0"
|
||||||
MODULE="predator-wmi"
|
MODULE="predator-wmi"
|
||||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PREFIX="/usr"
|
PREFIX="/usr"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
PACKAGE_NAME="predator-wmi"
|
PACKAGE_NAME="predator-wmi"
|
||||||
PACKAGE_VERSION="1.2.0"
|
PACKAGE_VERSION="1.3.0"
|
||||||
BUILT_MODULE_NAME[0]="predator_wmi"
|
BUILT_MODULE_NAME[0]="predator_wmi"
|
||||||
DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86"
|
DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86"
|
||||||
MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}"
|
MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# sudo pacman -U predator-control-*.pkg.tar.zst
|
# sudo pacman -U predator-control-*.pkg.tar.zst
|
||||||
|
|
||||||
pkgname=predator-control
|
pkgname=predator-control
|
||||||
pkgver=1.2.0
|
pkgver=1.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="PredatorSense replacement for Acer Predator laptops: performance modes, fans, keyboard RGB, lightbar, battery (Qt app + Plasma widget)"
|
pkgdesc="PredatorSense replacement for Acer Predator laptops: performance modes, fans, keyboard RGB, lightbar, battery (Qt app + Plasma widget)"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
@@ -22,7 +22,7 @@ install=predator-control.install
|
|||||||
source=()
|
source=()
|
||||||
|
|
||||||
_src="$(realpath "$startdir/../..")"
|
_src="$(realpath "$startdir/../..")"
|
||||||
_modver=1.2.0
|
_modver=1.3.0
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$_src"
|
cd "$_src"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"Id": "org.predatorcontrol.panel",
|
"Id": "org.predatorcontrol.panel",
|
||||||
"License": "GPL-2.0-only",
|
"License": "GPL-2.0-only",
|
||||||
"Name": "Predator Control",
|
"Name": "Predator Control",
|
||||||
"Version": "1.2.0",
|
"Version": "1.3.0",
|
||||||
"Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
|
"Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
|
||||||
},
|
},
|
||||||
"X-Plasma-API-Minimum-Version": "6.0",
|
"X-Plasma-API-Minimum-Version": "6.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
__version__ = "1.2.0"
|
__version__ = "1.3.0"
|
||||||
|
|
||||||
APP_NAME = "Predator Control"
|
APP_NAME = "Predator Control"
|
||||||
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
|
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import traceback
|
|||||||
from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__
|
from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__
|
||||||
from . import hardware as hw
|
from . import hardware as hw
|
||||||
from . import updater
|
from . import updater
|
||||||
|
from .hotkey import HotkeyListener, launch_in_session
|
||||||
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
|
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
@@ -49,6 +50,7 @@ DEFAULT_CONFIG = {
|
|||||||
"battery": {"limit": None}, # None = leave the firmware state alone
|
"battery": {"limit": None}, # None = leave the firmware state alone
|
||||||
"keyboard_presets": {},
|
"keyboard_presets": {},
|
||||||
"updates": {"auto_check": True, "interval_hours": 6},
|
"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._last_try = time.time()
|
||||||
self.check_update_async()
|
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):
|
def loop(self):
|
||||||
while True:
|
while True:
|
||||||
try:
|
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})
|
merge(self.config["updates"], {k: a[k] for k in ("auto_check", "interval_hours") if k in a})
|
||||||
self.save_config()
|
self.save_config()
|
||||||
return {}
|
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":
|
if cmd == "restore":
|
||||||
self.restore()
|
self.restore()
|
||||||
return {}
|
return {}
|
||||||
@@ -511,6 +531,7 @@ def main():
|
|||||||
signal.signal(signal.SIGTERM, lambda *_: (server.server_close(), sys.exit(0)))
|
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())
|
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()
|
threading.Thread(target=d.loop, daemon=True).start()
|
||||||
|
HotkeyListener(d.predator_key, log).start()
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -674,6 +674,19 @@ class SystemPage(Page):
|
|||||||
self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
|
self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
|
||||||
self.lay.addWidget(self.fw_card)
|
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")
|
upd = Card("Updates")
|
||||||
self.upd_head = QLabel("")
|
self.upd_head = QLabel("")
|
||||||
self.upd_head.setObjectName("big")
|
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 ""
|
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_text.setText("Installed version %s · last check %s%s%s" % (st.get("version"), when, err, extra))
|
||||||
self.upd_install.hide()
|
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))
|
self.upd_auto.setChecked(st["config"].get("updates", {}).get("auto_check", True))
|
||||||
a = st["available"]
|
a = st["available"]
|
||||||
yes = lambda v: "✔" if v else "✘"
|
yes = lambda v: "✔" if v else "✘"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -13,6 +13,7 @@ import threading
|
|||||||
ACER_VID = 0x04F2
|
ACER_VID = 0x04F2
|
||||||
KNOWN_PIDS = (0x0117, 0x011A)
|
KNOWN_PIDS = (0x0117, 0x011A)
|
||||||
LED_INTERFACE = 3
|
LED_INTERFACE = 3
|
||||||
|
HOTKEY_INTERFACE = 2 # vendor report 4 carries the Predator key
|
||||||
NUM_CELLS = 128
|
NUM_CELLS = 128
|
||||||
|
|
||||||
OP_BEGIN = 0x88
|
OP_BEGIN = 0x88
|
||||||
@@ -57,7 +58,7 @@ def checksum(body7):
|
|||||||
return (0xFF - (sum(body7) & 0xFF)) & 0xFF
|
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."""
|
"""Path of the hidraw node of the LED interface, or None."""
|
||||||
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
|
||||||
try:
|
try:
|
||||||
@@ -76,7 +77,7 @@ def find_hidraw():
|
|||||||
devpath = os.path.realpath(os.path.join(node, "device"))
|
devpath = os.path.realpath(os.path.join(node, "device"))
|
||||||
# .../1-7:1.3/0003:04F2:011A.0004 -> interface number from the parent
|
# .../1-7:1.3/0003:04F2:011A.0004 -> interface number from the parent
|
||||||
iface = os.path.basename(os.path.dirname(devpath)).rsplit(".", 1)[-1]
|
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 "/dev/" + os.path.basename(node)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user