2 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 571cfcca2a Add power settings for the lighting, bump to 1.4.0
New Power page:
- toggle for the firmware's 30 s keyboard backlight timeout
- idle timeout for keyboard and lightbar, separately for AC and battery;
  predatord watches all input devices and restores the lighting on the
  next input
- battery rules: dimmed keyboard brightness, keyboard off, lightbar off

The rules only change what is sent to the hardware; the saved lighting
settings stay untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:59:42 +02:00
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
12 changed files with 368 additions and 12 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 1.4.0 – 2026-10-07
### Added
- **Power page** with energy settings for the lighting:
- toggle for the firmware's automatic keyboard backlight switch-off after 30 s
(moved here from the System page)
- turn keyboard and lightbar off after a configurable idle time, separately for AC and battery
(never, 30 s … 30 min); any keyboard, touchpad or mouse input turns them back on
- on battery: reduced keyboard brightness, keyboard lighting off, lightbar off
## 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
+11
View File
@@ -83,6 +83,17 @@ 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).
## Power settings
The **Power** page controls when the lighting switches off: the firmware's 30-second backlight
timeout can be disabled, a custom idle timeout can be set separately for AC and battery, and on
battery the keyboard can be dimmed or turned off and the lightbar disabled.
## 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
+1 -1
View File
@@ -4,7 +4,7 @@
# sudo ./install.sh --no-deps skip package installation (non-Arch distros)
set -euo pipefail
VERSION="1.2.0"
VERSION="1.4.0"
MODULE="predator-wmi"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFIX="/usr"
+1 -1
View File
@@ -1,5 +1,5 @@
PACKAGE_NAME="predator-wmi"
PACKAGE_VERSION="1.2.0"
PACKAGE_VERSION="1.4.0"
BUILT_MODULE_NAME[0]="predator_wmi"
DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86"
MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}"
+2 -2
View File
@@ -5,7 +5,7 @@
# sudo pacman -U predator-control-*.pkg.tar.zst
pkgname=predator-control
pkgver=1.2.0
pkgver=1.4.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.4.0
package() {
cd "$_src"
@@ -12,7 +12,7 @@
"Id": "org.predatorcontrol.panel",
"License": "GPL-2.0-only",
"Name": "Predator Control",
"Version": "1.2.0",
"Version": "1.4.0",
"Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
},
"X-Plasma-API-Minimum-Version": "6.0",
+1 -1
View File
@@ -2,7 +2,7 @@
import os
__version__ = "1.2.0"
__version__ = "1.4.0"
APP_NAME = "Predator Control"
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
+84 -2
View File
@@ -25,6 +25,8 @@ 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 .idle import IdleWatcher
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
DEFAULT_CONFIG = {
@@ -49,6 +51,14 @@ 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
"power": {
"idle_off_ac": 0, # seconds without input until lighting turns off, 0 = never
"idle_off_battery": 120,
"battery_brightness": 100, # keyboard brightness on battery, % of the normal value
"battery_lights_off": False,
"battery_lightbar_off": False,
},
}
@@ -98,6 +108,9 @@ class Daemon:
self.update_info = None
self.update_error = None
self._update_checking = False
self.idle = IdleWatcher(self._activity)
self.lights_idle = False
self.on_ac = True
# ---------------------------------------------------------- Configuration
def _load_config(self):
@@ -130,8 +143,35 @@ class Daemon:
self.errors[key] = str(e)
return None
# ---------------------------------------------------------- Power rules
def keyboard_effective(self):
k = dict(self.config["keyboard"])
p = self.config["power"]
if self.lights_idle or (not self.on_ac and p.get("battery_lights_off")):
k["effect"] = "off"
elif not self.on_ac and p.get("battery_brightness", 100) < 100:
k["brightness"] = int(k["brightness"] * p["battery_brightness"] / 100)
return k
def idle_timeout(self):
p = self.config["power"]
return int(p.get("idle_off_ac" if self.on_ac else "idle_off_battery") or 0)
def update_idle(self):
t = self.idle_timeout()
idle = t > 0 and self.idle.idle_seconds() >= t
if idle != self.lights_idle:
self.lights_idle = idle
log("Lighting %s" % ("off (idle)" if idle else "restored"))
self.apply_keyboard()
def _activity(self):
if self.lights_idle:
with self.lock:
self.update_idle()
def apply_keyboard(self, cfg=None):
k = cfg or self.config["keyboard"]
k = cfg or self.keyboard_effective()
frame = bytes.fromhex(k.get("frame") or "") if k["effect"] == "perkey" else None
self._try("keyboard", self.kbd.apply, k["effect"], hex_to_rgb(k["color"]),
k["brightness"], frame, int(k.get("effect_id") or 0))
@@ -140,8 +180,11 @@ class Daemon:
def lightbar_effective(self):
lb = dict(self.config["lightbar"])
if self.lights_idle or (not self.on_ac and self.config["power"].get("battery_lightbar_off")):
lb["mode"] = "off"
return lb
if lb.get("sync_keyboard"):
k = self.config["keyboard"]
k = self.keyboard_effective()
lb["brightness"] = k["brightness"]
if k["effect"] == "off":
lb["mode"] = "off"
@@ -177,6 +220,9 @@ class Daemon:
def restore(self):
log("Restoring settings")
bat = hw.battery()
self.on_ac = bat.get("ac_online", True)
self.idle.last_input = time.monotonic() # e.g. after resume: lights on
self.lights_idle = False
if self.config["profile"].get("auto_switch"):
self.apply_profile_for_power(bat.get("ac_online", True))
self.apply_fans()
@@ -235,6 +281,7 @@ class Daemon:
"lightbar_effective": self.lightbar_effective(),
"fan_curve_applied": self.curve_applied,
"battery_limit": self.battery_limit,
"lights_idle": self.lights_idle,
"update": self.update_info,
"update_error": self.update_error,
"update_status": updater.status(),
@@ -277,6 +324,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:
@@ -294,7 +353,11 @@ class Daemon:
log("Power source changed:", "AC" if ac else "battery")
if self.config["profile"].get("auto_switch"):
self.apply_profile_for_power(ac)
if ac != self.on_ac:
self.on_ac = ac
self.apply_keyboard() # battery lighting rules
self.last_ac = ac
self.update_idle()
self.fan_curve_tick()
self._ticks += 1
if self._ticks % 20 == 1:
@@ -328,6 +391,23 @@ 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_power":
p = self.config["power"]
for key in ("idle_off_ac", "idle_off_battery", "battery_brightness",
"battery_lights_off", "battery_lightbar_off"):
if key in a:
p[key] = a[key]
p["battery_brightness"] = max(0, min(100, int(p["battery_brightness"])))
self.save_config()
self.update_idle()
self.apply_keyboard()
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 +591,8 @@ 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()
d.idle.start()
server.serve_forever()
+106 -2
View File
@@ -26,6 +26,7 @@ PAGES = [
("fans", "Fans"),
("rgb", "RGB Studio"),
("battery", "Battery"),
("power", "Power"),
("system", "System"),
]
@@ -591,6 +592,92 @@ class RgbPage(Page):
self.lb_speed.setValue(lb["speed"])
IDLE_CHOICES = [(0, "Never"), (30, "30 seconds"), (60, "1 minute"), (120, "2 minutes"), (300, "5 minutes"),
(600, "10 minutes"), (900, "15 minutes"), (1800, "30 minutes")]
class PowerPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Power", "Energy settings for the keyboard lighting and lightbar"))
fw = Card("Firmware backlight timeout")
self.fw_timeout = QCheckBox("Turn the keyboard backlight off after 30 s without input (firmware)")
self.fw_timeout.clicked.connect(lambda v: (win.backend.run("set_firmware", name="kbd_timeout", value=bool(v)),
QTimer.singleShot(800, lambda: win.backend.run("get_firmware"))))
fw.body.addWidget(self.fw_timeout)
fw.body.addWidget(_muted("This is the built-in automatic switch-off of the laptop. Turn it off to keep the "
"lighting on permanently, or use the adjustable timeout below instead."))
self.lay.addWidget(fw)
idle = Card("Turn lighting off when idle")
g = QGridLayout()
self.idle_ac = QComboBox()
self.idle_bat = QComboBox()
for combo, key, label in ((self.idle_ac, "idle_off_ac", "On AC power"),
(self.idle_bat, "idle_off_battery", "On battery")):
for val, text in IDLE_CHOICES:
combo.addItem(text, val)
combo.activated.connect(lambda _=0, c=combo, k=key: win.backend.run("set_power", **{k: c.currentData()}))
g.addWidget(QLabel("On AC power"), 0, 0)
g.addWidget(self.idle_ac, 0, 1)
g.addWidget(QLabel("On battery"), 1, 0)
g.addWidget(self.idle_bat, 1, 1)
g.setColumnStretch(2, 1)
idle.body.addLayout(g)
idle.body.addWidget(_muted("Keyboard and lightbar turn off after this time without keyboard, touchpad or "
"mouse input, and come back on with the next input."))
self.idle_state = _muted("")
idle.body.addWidget(self.idle_state)
self.lay.addWidget(idle)
bat = Card("On battery")
h = QHBoxLayout()
h.addWidget(QLabel("Keyboard brightness"))
self.bat_bright = _slider(10, 100, 100)
self.bat_bright.setSingleStep(10)
self.bat_bright_l = QLabel("100 %")
self.bat_bright.valueChanged.connect(lambda v: self.bat_bright_l.setText("%d %%" % v))
self.bat_bright.sliderReleased.connect(lambda: win.backend.run("set_power", battery_brightness=self.bat_bright.value()))
h.addWidget(self.bat_bright, 1)
h.addWidget(self.bat_bright_l)
bat.body.addLayout(h)
bat.body.addWidget(_muted("Percentage of your normal keyboard brightness while running on battery."))
self.bat_off = QCheckBox("Turn the keyboard lighting off completely on battery")
self.bat_off.clicked.connect(lambda v: win.backend.run("set_power", battery_lights_off=bool(v)))
bat.body.addWidget(self.bat_off)
self.bat_lb = QCheckBox("Turn the lightbar off on battery")
self.bat_lb.clicked.connect(lambda v: win.backend.run("set_power", battery_lightbar_off=bool(v)))
bat.body.addWidget(self.bat_lb)
self.lay.addWidget(bat)
self.lay.addStretch(1)
def showEvent(self, e):
super().showEvent(e)
self.win.backend.run("get_firmware")
def firmware(self, fw):
v = fw.get("kbd_timeout", {})
self.fw_timeout.setEnabled(v.get("value") is not None)
self.fw_timeout.setChecked(bool(v.get("value")))
self.fw_timeout.setToolTip(v.get("error", "") or "")
def update_state(self, st):
p = st["config"].get("power", {})
for combo, key in ((self.idle_ac, "idle_off_ac"), (self.idle_bat, "idle_off_battery")):
if not combo.view().isVisible():
i = combo.findData(p.get(key, 0))
if i < 0:
combo.addItem("%d seconds" % p[key], p[key])
i = combo.count() - 1
combo.setCurrentIndex(i)
if not self.bat_bright.isSliderDown():
self.bat_bright.setValue(p.get("battery_brightness", 100))
self.bat_off.setChecked(bool(p.get("battery_lights_off")))
self.bat_lb.setChecked(bool(p.get("battery_lightbar_off")))
self.idle_state.setText("Lighting is currently off because the laptop is idle." if st.get("lights_idle") else "")
class BatteryPage(Page):
def __init__(self, win):
super().__init__(win)
@@ -674,6 +761,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")
@@ -722,7 +822,7 @@ class SystemPage(Page):
def firmware(self, fw):
for name, info in fw.items():
if "label" not in info:
if "label" not in info or name == "kbd_timeout": # shown on the Power page
continue
if name == "usb_charging":
w = self.fw_boxes.get(name)
@@ -782,6 +882,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 "✘"
@@ -828,7 +930,8 @@ class MainWindow(QMainWindow):
self.nav = QButtonGroup(self)
self.pages = {}
classes = {"home": HomePage, "performance": PerformancePage, "fans": FansPage,
"rgb": RgbPage, "battery": BatteryPage, "system": SystemPage}
"rgb": RgbPage, "battery": BatteryPage, "power": PowerPage,
"system": SystemPage}
for key, label in PAGES:
b = QPushButton(label)
b.setObjectName("nav")
@@ -901,6 +1004,7 @@ class MainWindow(QMainWindow):
elif cmd == "get_firmware":
self.pages["system"][0].firmware(result)
self.pages["battery"][0].firmware(result)
self.pages["power"][0].firmware(result)
return
else:
self.statusBar().showMessage("Applied", 2000)
+83
View File
@@ -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)
+56
View File
@@ -0,0 +1,56 @@
"""Input activity watcher used for the 'turn lighting off when idle' feature."""
import glob
import os
import select
import threading
import time
class IdleWatcher(threading.Thread):
"""Tracks the time of the last keyboard/touchpad/mouse event on any input device."""
RESCAN = 30 # seconds between scans for new devices (hotplug, resume)
def __init__(self, on_activity=None):
super().__init__(daemon=True)
self.last_input = time.monotonic()
self.on_activity = on_activity
def idle_seconds(self):
return time.monotonic() - self.last_input
def _open_all(self, fds):
for path in glob.glob("/dev/input/event*"):
if path in fds.values():
continue
try:
fds[os.open(path, os.O_RDONLY | os.O_NONBLOCK)] = path
except OSError:
pass
def run(self):
fds = {}
next_scan = 0.0
while True:
if time.monotonic() >= next_scan:
self._open_all(fds)
next_scan = time.monotonic() + self.RESCAN
if not fds:
time.sleep(5)
continue
ready, _, _ = select.select(list(fds), [], [], 5)
active = False
for fd in ready:
try:
if os.read(fd, 4096):
active = True
except BlockingIOError:
pass
except OSError:
os.close(fd)
fds.pop(fd, None)
if active:
self.last_input = time.monotonic()
if self.on_activity:
self.on_activity()
+3 -2
View File
@@ -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