From 571cfcca2a89974c1320a46d2d7787007eafb3c0 Mon Sep 17 00:00:00 2001 From: Louis-Alexander Kerst Date: Wed, 7 Oct 2026 17:59:42 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 10 ++ README.md | 6 ++ 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 | 65 ++++++++++++- predator_control/gui/app.py | 93 ++++++++++++++++++- predator_control/idle.py | 56 +++++++++++ 10 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 predator_control/idle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f75e51e..b69ddc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 diff --git a/README.md b/README.md index 41cef50..c094018 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,12 @@ 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. diff --git a/install.sh b/install.sh index 6ce0a8b..eae5e66 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.3.0" +VERSION="1.4.0" MODULE="predator-wmi" SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PREFIX="/usr" diff --git a/kernel/dkms.conf b/kernel/dkms.conf index 7dbff00..5ab19eb 100644 --- a/kernel/dkms.conf +++ b/kernel/dkms.conf @@ -1,5 +1,5 @@ PACKAGE_NAME="predator-wmi" -PACKAGE_VERSION="1.3.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}" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 40b4060..3408785 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.3.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.3.0 +_modver=1.4.0 package() { cd "$_src" diff --git a/plasmoid/org.predatorcontrol.panel/metadata.json b/plasmoid/org.predatorcontrol.panel/metadata.json index f27939e..a24578d 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.3.0", + "Version": "1.4.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 3170e82..1c19c90 100644 --- a/predator_control/__init__.py +++ b/predator_control/__init__.py @@ -2,7 +2,7 @@ import os -__version__ = "1.3.0" +__version__ = "1.4.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 3f7918e..1ed8e15 100644 --- a/predator_control/daemon.py +++ b/predator_control/daemon.py @@ -26,6 +26,7 @@ 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 = { @@ -51,6 +52,13 @@ DEFAULT_CONFIG = { "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, + }, } @@ -100,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): @@ -132,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)) @@ -142,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" @@ -179,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() @@ -237,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(), @@ -308,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: @@ -342,6 +391,17 @@ 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") @@ -532,6 +592,7 @@ def main(): 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() diff --git a/predator_control/gui/app.py b/predator_control/gui/app.py index 9fc5449..5e3941f 100644 --- a/predator_control/gui/app.py +++ b/predator_control/gui/app.py @@ -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) @@ -735,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) @@ -843,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") @@ -916,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) diff --git a/predator_control/idle.py b/predator_control/idle.py new file mode 100644 index 0000000..bc2805d --- /dev/null +++ b/predator_control/idle.py @@ -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()