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>
This commit is contained in:
2026-10-07 17:59:42 +02:00
co-authored by Claude Opus 5.5
parent 5b7534560f
commit e454ae2478
10 changed files with 232 additions and 10 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # 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 ## 1.3.0 – 2026-10-07
### Added ### Added
+6
View File
@@ -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). 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 ## Predator key
Pressing the **Predator key** opens Predator Control, just like PredatorSense on Windows. Pressing the **Predator key** opens Predator Control, just like PredatorSense on Windows.
+1 -1
View File
@@ -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.3.0" VERSION="1.4.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
View File
@@ -1,5 +1,5 @@
PACKAGE_NAME="predator-wmi" PACKAGE_NAME="predator-wmi"
PACKAGE_VERSION="1.3.0" PACKAGE_VERSION="1.4.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}"
+2 -2
View File
@@ -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.3.0 pkgver=1.4.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.3.0 _modver=1.4.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.3.0", "Version": "1.4.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",
+1 -1
View File
@@ -2,7 +2,7 @@
import os import os
__version__ = "1.3.0" __version__ = "1.4.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")
+63 -2
View File
@@ -26,6 +26,7 @@ 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 .hotkey import HotkeyListener, launch_in_session
from .idle import IdleWatcher
from .keyboard import EFFECTS, NUM_CELLS, Keyboard from .keyboard import EFFECTS, NUM_CELLS, Keyboard
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
@@ -51,6 +52,13 @@ DEFAULT_CONFIG = {
"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 "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_info = None
self.update_error = None self.update_error = None
self._update_checking = False self._update_checking = False
self.idle = IdleWatcher(self._activity)
self.lights_idle = False
self.on_ac = True
# ---------------------------------------------------------- Configuration # ---------------------------------------------------------- Configuration
def _load_config(self): def _load_config(self):
@@ -132,8 +143,35 @@ class Daemon:
self.errors[key] = str(e) self.errors[key] = str(e)
return None 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): 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 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"]), self._try("keyboard", self.kbd.apply, k["effect"], hex_to_rgb(k["color"]),
k["brightness"], frame, int(k.get("effect_id") or 0)) k["brightness"], frame, int(k.get("effect_id") or 0))
@@ -142,8 +180,11 @@ class Daemon:
def lightbar_effective(self): def lightbar_effective(self):
lb = dict(self.config["lightbar"]) 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"): if lb.get("sync_keyboard"):
k = self.config["keyboard"] k = self.keyboard_effective()
lb["brightness"] = k["brightness"] lb["brightness"] = k["brightness"]
if k["effect"] == "off": if k["effect"] == "off":
lb["mode"] = "off" lb["mode"] = "off"
@@ -179,6 +220,9 @@ class Daemon:
def restore(self): def restore(self):
log("Restoring settings") log("Restoring settings")
bat = hw.battery() 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"): if self.config["profile"].get("auto_switch"):
self.apply_profile_for_power(bat.get("ac_online", True)) self.apply_profile_for_power(bat.get("ac_online", True))
self.apply_fans() self.apply_fans()
@@ -237,6 +281,7 @@ class Daemon:
"lightbar_effective": self.lightbar_effective(), "lightbar_effective": self.lightbar_effective(),
"fan_curve_applied": self.curve_applied, "fan_curve_applied": self.curve_applied,
"battery_limit": self.battery_limit, "battery_limit": self.battery_limit,
"lights_idle": self.lights_idle,
"update": self.update_info, "update": self.update_info,
"update_error": self.update_error, "update_error": self.update_error,
"update_status": updater.status(), "update_status": updater.status(),
@@ -308,7 +353,11 @@ class Daemon:
log("Power source changed:", "AC" if ac else "battery") log("Power source changed:", "AC" if ac else "battery")
if self.config["profile"].get("auto_switch"): if self.config["profile"].get("auto_switch"):
self.apply_profile_for_power(ac) 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.last_ac = ac
self.update_idle()
self.fan_curve_tick() self.fan_curve_tick()
self._ticks += 1 self._ticks += 1
if self._ticks % 20 == 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}) 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_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 cmd == "set_predator_key":
if a["action"] not in ("open", "cycle_profile", "none"): if a["action"] not in ("open", "cycle_profile", "none"):
raise ValueError("unknown action") 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()) 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() HotkeyListener(d.predator_key, log).start()
d.idle.start()
server.serve_forever() server.serve_forever()
+91 -2
View File
@@ -26,6 +26,7 @@ PAGES = [
("fans", "Fans"), ("fans", "Fans"),
("rgb", "RGB Studio"), ("rgb", "RGB Studio"),
("battery", "Battery"), ("battery", "Battery"),
("power", "Power"),
("system", "System"), ("system", "System"),
] ]
@@ -591,6 +592,92 @@ class RgbPage(Page):
self.lb_speed.setValue(lb["speed"]) 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): class BatteryPage(Page):
def __init__(self, win): def __init__(self, win):
super().__init__(win) super().__init__(win)
@@ -735,7 +822,7 @@ class SystemPage(Page):
def firmware(self, fw): def firmware(self, fw):
for name, info in fw.items(): 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 continue
if name == "usb_charging": if name == "usb_charging":
w = self.fw_boxes.get(name) w = self.fw_boxes.get(name)
@@ -843,7 +930,8 @@ class MainWindow(QMainWindow):
self.nav = QButtonGroup(self) self.nav = QButtonGroup(self)
self.pages = {} self.pages = {}
classes = {"home": HomePage, "performance": PerformancePage, "fans": FansPage, 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: for key, label in PAGES:
b = QPushButton(label) b = QPushButton(label)
b.setObjectName("nav") b.setObjectName("nav")
@@ -916,6 +1004,7 @@ class MainWindow(QMainWindow):
elif cmd == "get_firmware": elif cmd == "get_firmware":
self.pages["system"][0].firmware(result) self.pages["system"][0].firmware(result)
self.pages["battery"][0].firmware(result) self.pages["battery"][0].firmware(result)
self.pages["power"][0].firmware(result)
return return
else: else:
self.statusBar().showMessage("Applied", 2000) self.statusBar().showMessage("Applied", 2000)
+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()