4 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
MrBlakeandClaude Opus 5.5 cc4fdb9cd0 Add built-in updater, bump to 1.2.0
- predatord checks the Gitea releases every 6 hours, downloads the
  newest pacman package, verifies its SHA-256 from the release notes
  and installs it with `pacman -U` in a separate systemd unit
- Update notice and install button on the System page, in the sidebar
  and in the panel widget; `predatorctl update [--check] [--yes]`
- Optional API token for private repositories
- packaging/release.py builds and publishes releases in the format
  the updater expects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:42:02 +02:00
MrBlakeandClaude Opus 5.5 2ef62e85cd Add charging indicator and pacman package, bump to 1.1.0
- Show charge/discharge power in watts, a battery graphic with charge
  bolt, a power history graph and the estimated time until full or
  remaining (respecting the 80 % charge limit) in the app, the panel
  widget, its tooltip and `predatorctl status`
- Add packaging/arch/PKGBUILD that builds a pacman package; the kernel
  module ships as DKMS source and is rebuilt for every kernel
- Bump version to 1.1.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:37:03 +02:00
23 changed files with 1046 additions and 33 deletions
+38
View File
@@ -1,5 +1,43 @@
# 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
### 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
- **Built-in updater**: predatord checks the release page every 6 hours (configurable), downloads the
new pacman package, verifies its SHA-256 and installs it with `pacman -U` in a separate systemd unit
- Update notice and *Install* button on the System page, in the sidebar and in the panel widget
- `predatorctl update [--check] [--yes]`
- Optional API token in `/etc/predator-control/update-token` for private repositories
- `packaging/release.py` builds the package and publishes a release in the format the updater expects
## 1.1.0 – 2026-10-07
### Added
- **Charging indicator**: live charge/discharge power in watts, battery graphic with charge bolt,
power history graph and estimated time until full / time remaining (respects the 80 % limit)
– on the Battery page, the Dashboard, in the panel widget (popup and tooltip) and in `predatorctl status`
- **pacman package**: `packaging/arch/PKGBUILD` builds `predator-control-<version>-any.pkg.tar.zst`;
the kernel module is shipped as DKMS source and rebuilt automatically for every kernel
## 1.0.0 – 2026-10-04 ## 1.0.0 – 2026-10-04
First release. First release.
+41 -2
View File
@@ -18,7 +18,7 @@ desktop app, a **Plasma panel widget**, and a command line tool.
| **Keyboard RGB** | 12 hardware effects (static, breathing, rainbow wave, ripple, neon, rain, explosion, pulse, stars, meteor, aura, …), colour, brightness, hidden effect variants, presets | | **Keyboard RGB** | 12 hardware effects (static, breathing, rainbow wave, ripple, neon, rain, explosion, pulse, stars, meteor, aura, …), colour, brightness, hidden effect variants, presets |
| **RGB Studio** | Paint individual keys with the mouse, live preview on the keyboard, quick fills (WASD, arrows, F-row, numpad …), German ISO and US ANSI layouts | | **RGB Studio** | Paint individual keys with the mouse, live preview on the keyboard, quick fills (WASD, arrows, F-row, numpad …), German ISO and US ANSI layouts |
| **Lightbar** | All rear lightbar effects with colour, brightness and speed – or let it follow the keyboard | | **Lightbar** | All rear lightbar effects with colour, brightness and speed – or let it follow the keyboard |
| **Battery** | 80 % charge limit, calibration, live health / cycles / power draw | | **Battery** | live charging indicator in watts with power graph and time until full / remaining, 80 % charge limit, calibration, health and cycles |
| **Firmware options** | Boot animation & sound, LCD overdrive, keyboard backlight timeout, USB charging while off | | **Firmware options** | Boot animation & sound, LCD overdrive, keyboard backlight timeout, USB charging while off |
| **Monitoring** | CPU/GPU temperatures, fan RPM, CPU load and clock, memory – without waking the dGPU | | **Monitoring** | CPU/GPU temperatures, fan RPM, CPU load and clock, memory – without waking the dGPU |
| **Panel widget** | Click the icon in the Plasma panel to change everything quickly; buttons open the full app directly on the right page (e.g. RGB Studio). Middle-click cycles the performance mode | | **Panel widget** | Click the icon in the Plasma panel to change everything quickly; buttons open the full app directly on the right page (e.g. RGB Studio). Middle-click cycles the performance mode |
@@ -41,6 +41,23 @@ interfaces will likely work. Reports are welcome – please include the output o
## Installation ## Installation
### pacman package (Arch, CachyOS, Manjaro, EndeavourOS …) – recommended
Download `predator-control-<version>-any.pkg.tar.zst` from the
[releases page](https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard/releases) and install it:
```bash
sudo pacman -S --needed linux-cachyos-headers clang lld llvm # headers for your kernel (stock Arch: linux-headers)
sudo pacman -U predator-control-*-any.pkg.tar.zst
```
The service starts automatically and the kernel module is built by DKMS for every installed kernel.
To build the package yourself: `cd packaging/arch && makepkg -f`.
> Switching from `install.sh` to the package? Run `sudo ./uninstall.sh` first (your settings are kept).
### Install script (any distribution)
Requirements: a kernel with `acer-wmi` (any recent kernel), Python 3, PySide6, Requirements: a kernel with `acer-wmi` (any recent kernel), Python 3, PySide6,
DKMS and kernel headers. On Arch / CachyOS / Manjaro the installer pulls in everything. DKMS and kernel headers. On Arch / CachyOS / Manjaro the installer pulls in everything.
@@ -64,7 +81,28 @@ On other distributions install the dependencies yourself and run `sudo ./install
signatures you have to sign `predator_wmi` yourself (e.g. with `sbctl` or a MOK key). signatures you have to sign `predator_wmi` yourself (e.g. with `sbctl` or a MOK key).
Keyboard RGB and performance profiles work without the module. Keyboard RGB and performance profiles work without the module.
Uninstall with `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
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
releases every 6 hours, and a notice with an *Install* button appears on the **System** page, in the
sidebar and in the panel widget. Downloads are verified with the SHA-256 from the release notes and
installed with `pacman -U`. From the terminal: `predatorctl update` (or `--check`).
Automatic checks can be turned off on the System page.
If you installed with `install.sh`, update with `git pull && sudo ./install.sh`.
## Usage ## Usage
@@ -91,6 +129,7 @@ predatorctl kbd-brightness +25
predatorctl lightbar wave --color 00d4ff --speed 7 predatorctl lightbar wave --color 00d4ff --speed 7
predatorctl lightbar --sync on # lightbar follows the keyboard predatorctl lightbar --sync on # lightbar follows the keyboard
predatorctl battery-limit on predatorctl battery-limit on
predatorctl update # check for and install updates
predatorctl open rgb # open the app on a page predatorctl open rgb # open the app on a page
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+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.0.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.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}"
+3
View File
@@ -0,0 +1,3 @@
*.pkg.tar.*
src/
pkg/
+57
View File
@@ -0,0 +1,57 @@
# Maintainer: Louis-Alexander Kerst <louis@alexander-kerst.de>
#
# Builds the pacman package from this source checkout:
# cd packaging/arch && makepkg -f
# sudo pacman -U predator-control-*.pkg.tar.zst
pkgname=predator-control
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')
url="https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
license=('GPL-2.0-only')
depends=('python' 'pyside6' 'dkms')
optdepends=('plasma-workspace: panel widget'
'linux-headers: build the kernel module for the stock Arch kernel'
'linux-cachyos-headers: build the kernel module for CachyOS kernels'
'clang: needed by DKMS for clang-built kernels (CachyOS)'
'lld: needed by DKMS for clang-built kernels (CachyOS)'
'llvm: needed by DKMS for clang-built kernels (CachyOS)')
install=predator-control.install
source=()
_src="$(realpath "$startdir/../..")"
_modver=1.4.0
package() {
cd "$_src"
# Python package and launchers
install -d "$pkgdir/usr/lib/predator-control/predator_control/gui"
install -m644 predator_control/*.py "$pkgdir/usr/lib/predator-control/predator_control/"
install -m644 predator_control/gui/*.py "$pkgdir/usr/lib/predator-control/predator_control/gui/"
python -m compileall -q -d /usr/lib/predator-control "$pkgdir/usr/lib/predator-control"
for b in predatord predatorctl predator-control; do
install -Dm755 "bin/$b" "$pkgdir/usr/bin/$b"
done
# Kernel module as DKMS source (built automatically by the dkms pacman hook)
install -d "$pkgdir/usr/src/predator-wmi-$_modver"
install -m644 kernel/{predator_wmi.c,Makefile,dkms.conf} "$pkgdir/usr/src/predator-wmi-$_modver/"
install -Dm644 data/modules-load-predator-control.conf "$pkgdir/usr/lib/modules-load.d/predator-control.conf"
# Service, resume hook, desktop integration
install -Dm644 data/predatord.service "$pkgdir/usr/lib/systemd/system/predatord.service"
install -Dm755 data/system-sleep-predator-control "$pkgdir/usr/lib/systemd/system-sleep/predator-control"
install -Dm644 data/predator-control.desktop "$pkgdir/usr/share/applications/predator-control.desktop"
install -Dm644 data/predator-control.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/predator-control.svg"
# Plasma panel widget
local p="$pkgdir/usr/share/plasma/plasmoids/org.predatorcontrol.panel"
install -Dm644 plasmoid/org.predatorcontrol.panel/metadata.json "$p/metadata.json"
install -Dm644 plasmoid/org.predatorcontrol.panel/contents/ui/main.qml "$p/contents/ui/main.qml"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 CHANGELOG.md "$pkgdir/usr/share/doc/$pkgname/CHANGELOG.md"
}
+25
View File
@@ -0,0 +1,25 @@
post_install() {
systemctl daemon-reload
modprobe predator_wmi 2>/dev/null || echo ":: predator_wmi could not be loaded yet (kernel headers installed? Secure Boot?)"
systemctl enable --now predatord.service
echo ":: Predator Control installed."
echo ":: Add the panel widget: right-click the panel -> Add or Manage Widgets -> 'Predator Control'"
echo ":: Only members of the 'wheel' group can change settings."
}
post_upgrade() {
systemctl daemon-reload
modprobe predator_wmi 2>/dev/null
systemctl try-restart predatord.service
}
pre_remove() {
systemctl disable --now predatord.service 2>/dev/null
modprobe -r predator_wmi 2>/dev/null
true
}
post_remove() {
systemctl daemon-reload
echo ":: Settings were kept in /etc/predator-control (delete manually if no longer needed)."
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Build the pacman package and publish a Gitea release for the current version.
Usage: GITEA_TOKEN=... packaging/release.py
Requires a clean working tree and the tag v<version> already pushed.
The release notes are taken from CHANGELOG.md and contain the package's SHA-256,
which the built-in updater uses to verify downloads.
"""
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
API = "https://git.mrblake.cc/api/v1/repos/MrBlake/Cachy-Predator-Keyboard"
def api(method, path, data=None, ctype="application/json"):
headers = {"Authorization": "token " + os.environ["GITEA_TOKEN"]}
if data is not None:
headers["Content-Type"] = ctype
req = urllib.request.Request(API + path, data=data, method=method, headers=headers)
return json.load(urllib.request.urlopen(req))
def main():
if "GITEA_TOKEN" not in os.environ:
sys.exit("set GITEA_TOKEN")
version = re.search(r'__version__ = "([^"]+)"', open(os.path.join(ROOT, "predator_control/__init__.py")).read())[1]
pkgbuild = open(os.path.join(ROOT, "packaging/arch/PKGBUILD")).read()
if "pkgver=%s\n" % version not in pkgbuild:
sys.exit("PKGBUILD pkgver does not match version %s" % version)
pkgrel = re.search(r"^pkgrel=(\d+)", pkgbuild, re.M)[1]
arch_dir = os.path.join(ROOT, "packaging/arch")
subprocess.run(["makepkg", "-f", "--cleanbuild"], cwd=arch_dir, check=True)
pkg = "predator-control-%s-%s-any.pkg.tar.zst" % (version, pkgrel)
data = open(os.path.join(arch_dir, pkg), "rb").read()
sha = hashlib.sha256(data).hexdigest()
changelog = open(os.path.join(ROOT, "CHANGELOG.md")).read()
notes = changelog.split("## %s" % version, 1)[1].split("\n## ", 1)[0].split("\n", 1)[1].strip()
body = notes + f"""
### Install (Arch / CachyOS / Manjaro)
```bash
sudo pacman -S --needed linux-cachyos-headers clang lld llvm # stock Arch kernel: linux-headers
sudo pacman -U {pkg}
```
Already installed via pacman? Use the built-in updater (System page, panel widget or `predatorctl update`).
SHA-256 `{pkg}`: `{sha}`
Not affiliated with Acer Inc."""
rel = api("POST", "/releases", json.dumps({
"tag_name": "v" + version, "name": "Predator Control " + version,
"body": body, "draft": False, "prerelease": False}).encode())
boundary = "----predator" + sha[:16]
mp = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{pkg}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode() + data + f"\r\n--{boundary}--\r\n".encode()
api("POST", "/releases/%d/assets?name=%s" % (rel["id"], pkg), mp, "multipart/form-data; boundary=" + boundary)
print("Released", rel["html_url"])
print("SHA-256", sha)
if __name__ == "__main__":
main()
@@ -40,6 +40,21 @@ PlasmoidItem {
return names[key] || key || "–" return names[key] || key || "–"
} }
function temp(v) { return v ? Math.round(v) + " °C" : "off" } function temp(v) { return v ? Math.round(v) + " °C" : "off" }
function duration(m) {
if (m === null || m === undefined) return ""
return m >= 60 ? Math.floor(m / 60) + " h " + (m % 60) + " min" : m + " min"
}
function batteryText() {
if (!ok || !st.battery.present) return ""
const b = st.battery
let t = b.capacity + " %"
if (b.charging) t += " · charging" + (b.power_w ? " at " + b.power_w.toFixed(1) + " W" : "")
else if (b.status === "Discharging") t += " · on battery" + (b.power_w ? ", " + b.power_w.toFixed(1) + " W" : "")
else t += b.ac_online ? " · plugged in" : ""
if (b.minutes_left !== null && b.minutes_left !== undefined)
t += " · " + duration(b.minutes_left) + (b.charging ? " until full" : " left")
return t
}
function run(args) { function run(args) {
// unique suffix so the executable engine runs identical commands again // unique suffix so the executable engine runs identical commands again
@@ -56,6 +71,8 @@ PlasmoidItem {
toolTipSubText: ok toolTipSubText: ok
? profileLabel(st.profile) + " · CPU " + temp(st.sensors.cpu_temp) + " · GPU " + temp(st.sensors.gpu_temp) ? profileLabel(st.profile) + " · CPU " + temp(st.sensors.cpu_temp) + " · GPU " + temp(st.sensors.gpu_temp)
+ "\nFans " + (st.sensors.cpu_fan || 0) + " / " + (st.sensors.gpu_fan || 0) + " RPM" + "\nFans " + (st.sensors.cpu_fan || 0) + " / " + (st.sensors.gpu_fan || 0) + " RPM"
+ "\nBattery " + batteryText()
+ (st.update && st.update.available ? "\nUpdate " + st.update.latest + " available" : "")
: "Service predatord not running" : "Service predatord not running"
P5Support.DataSource { P5Support.DataSource {
@@ -318,13 +335,15 @@ PlasmoidItem {
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing Layout.topMargin: Kirigami.Units.smallSpacing
Kirigami.Icon { Kirigami.Icon {
source: root.ok && root.st.battery.ac_online ? "battery-charging" : "battery" source: root.ok && root.st.battery.charging ? "battery-charging" : (root.ok && root.st.battery.ac_online ? "battery-full-charging" : "battery")
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
} }
PC3.Label { PC3.Label {
Layout.fillWidth: true Layout.fillWidth: true
text: root.ok ? root.st.battery.capacity + " % · " + (root.st.battery.ac_online ? "plugged in" : "on battery") : "" text: root.batteryText()
wrapMode: Text.Wrap
color: root.ok && root.st.battery.charging ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.textColor
} }
PC3.CheckBox { PC3.CheckBox {
text: "Limit to 80 %" text: "Limit to 80 %"
@@ -334,6 +353,27 @@ PlasmoidItem {
} }
} }
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: root.ok && root.st.update !== null && root.st.update !== undefined && root.st.update.available
type: Kirigami.MessageType.Positive
text: root.ok && root.st.update ? (root.st.update_status === "running"
? "Installing update " + root.st.update.latest + "…"
: "Update " + root.st.update.latest + " available") : ""
actions: [
Kirigami.Action {
text: "Install"
icon.name: "update-none"
visible: root.ok && root.st.update && root.st.update.can_install && root.st.update_status !== "running"
onTriggered: root.run("update --yes")
},
Kirigami.Action {
text: "Details"
onTriggered: root.openPage("system")
}
]
}
PC3.Label { PC3.Label {
Layout.fillWidth: true Layout.fillWidth: true
visible: root.lastError !== "" visible: root.lastError !== ""
@@ -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.0.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.0.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")
+26 -1
View File
@@ -61,6 +61,10 @@ def main(argv=None):
sp = sub.add_parser("battery-limit", help="80 %% charge limit") sp = sub.add_parser("battery-limit", help="80 %% charge limit")
sp.add_argument("value", choices=["on", "off"]) sp.add_argument("value", choices=["on", "off"])
sp = sub.add_parser("update", help="check for and install updates")
sp.add_argument("--check", action="store_true", help="only check, do not install")
sp.add_argument("--yes", "-y", action="store_true", help="install without asking")
sp = sub.add_parser("open", help="open the main window") sp = sub.add_parser("open", help="open the main window")
sp.add_argument("page", nargs="?", default="home") sp.add_argument("page", nargs="?", default="home")
@@ -84,7 +88,13 @@ def main(argv=None):
print("CPU: %s °C %s %% fan %s RPM" % (s["cpu_temp"], st["cpu_load"], s["cpu_fan"])) print("CPU: %s °C %s %% fan %s RPM" % (s["cpu_temp"], st["cpu_load"], s["cpu_fan"]))
print("GPU: %s fan %s RPM" % ("%s °C" % s["gpu_temp"] if s["gpu_temp"] else "asleep", s["gpu_fan"])) print("GPU: %s fan %s RPM" % ("%s °C" % s["gpu_temp"] if s["gpu_temp"] else "asleep", s["gpu_fan"]))
print("Fan mode: %s" % st["config"]["fans"]["mode"]) print("Fan mode: %s" % st["config"]["fans"]["mode"])
print("Battery: %s %% (%s), AC: %s" % (b.get("capacity"), b.get("status"), "yes" if b["ac_online"] else "no")) line = "Battery: %s %% (%s), AC: %s" % (b.get("capacity"), b.get("status"), "yes" if b["ac_online"] else "no")
if b.get("power_w"):
line += ", %s %.1f W" % ("charging at" if b.get("charging") else "drawing", b["power_w"])
if b.get("minutes_left") is not None:
h, m = divmod(b["minutes_left"], 60)
line += ", %d:%02d h %s" % (h, m, "until full" if b.get("charging") else "left")
print(line)
k = st["config"]["keyboard"] k = st["config"]["keyboard"]
print("Keyboard: %s %s brightness %s" % (k["effect"], k["color"], k["brightness"])) print("Keyboard: %s %s brightness %s" % (k["effect"], k["color"], k["brightness"]))
for k, v in st["errors"].items(): for k, v in st["errors"].items():
@@ -133,6 +143,21 @@ def main(argv=None):
if a.sync: if a.sync:
args["sync_keyboard"] = a.sync == "on" args["sync_keyboard"] = a.sync == "on"
c.call("set_lightbar", **args) c.call("set_lightbar", **args)
elif a.cmd == "update":
u = c.call("check_update")
if not u["available"]:
print("Predator Control %s is up to date." % u["current"])
return 0
print("Update available: %s -> %s\n%s" % (u["current"], u["latest"], u["url"]))
if a.check:
return 0
if not u["can_install"]:
print("Automatic install needs the pacman package. Update with: git pull && sudo ./install.sh")
return 1
if not a.yes and sys.stdin.isatty() and input("Install now? [Y/n] ").strip().lower() not in ("", "y", "yes"):
return 0
c.call("install_update")
print("Installing %s in the background (journalctl -fu predator-control-update)." % u["asset"])
elif a.cmd == "battery-limit": elif a.cmd == "battery-limit":
c.call("set_battery_limit", enabled=_bool(a.value)) c.call("set_battery_limit", enabled=_bool(a.value))
except DaemonError as e: except DaemonError as e:
+134 -3
View File
@@ -24,6 +24,9 @@ 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 .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 = {
@@ -47,6 +50,15 @@ 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},
"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,
},
} }
@@ -93,6 +105,12 @@ class Daemon:
self.state = {} self.state = {}
self.battery_limit = None self.battery_limit = None
self._ticks = 0 self._ticks = 0
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 # ---------------------------------------------------------- Configuration
def _load_config(self): def _load_config(self):
@@ -125,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))
@@ -135,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"
@@ -172,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()
@@ -214,7 +265,7 @@ class Daemon:
def build_state(self): def build_state(self):
s = hw.sensors() s = hw.sensors()
bat = hw.battery() bat = hw.battery(limit_80=bool(self.battery_limit))
st = { st = {
"version": __version__, "version": __version__,
"time": time.time(), "time": time.time(),
@@ -230,6 +281,10 @@ 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_error": self.update_error,
"update_status": updater.status(),
"available": { "available": {
"keyboard": self.kbd_present, "keyboard": self.kbd_present,
"wmi": hw.wmi_available(hw.GUID_GAMING), "wmi": hw.wmi_available(hw.GUID_GAMING),
@@ -241,6 +296,46 @@ class Daemon:
} }
return st return st
def check_update_async(self):
if self._update_checking:
return
self._update_checking = True
def work():
try:
info = updater.check()
if info["available"] and (self.update_info or {}).get("latest") != info["latest"]:
log("Update available: %s -> %s" % (info["current"], info["latest"]))
self.update_info, self.update_error = info, None
except Exception as e:
self.update_error = str(e)
finally:
self._update_checking = False
threading.Thread(target=work, daemon=True).start()
def maybe_check_update(self):
u = self.config.get("updates", {})
if not u.get("auto_check", True):
return
last = (self.update_info or {}).get("checked", 0)
if time.time() - last > float(u.get("interval_hours", 6)) * 3600:
if self.update_error and time.time() - getattr(self, "_last_try", 0) < 900:
return # offline: retry every 15 min
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): def loop(self):
while True: while True:
try: try:
@@ -258,9 +353,15 @@ 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:
self.maybe_check_update()
if self._ticks % 40 == 0: if self._ticks % 40 == 0:
self.refresh_battery_limit() self.refresh_battery_limit()
self.state = self.build_state() self.state = self.build_state()
@@ -279,6 +380,34 @@ class Daemon:
if cmd == "state": if cmd == "state":
self.state = self.build_state() self.state = self.build_state()
return self.state return self.state
if cmd == "check_update":
self.update_info, self.update_error = updater.check(), None
return self.update_info
if cmd == "install_update":
info = updater.check()
self.update_info = info
return updater.install(info)
if cmd == "set_updates":
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": if cmd == "restore":
self.restore() self.restore()
return {} return {}
@@ -462,6 +591,8 @@ 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()
d.idle.start()
server.serve_forever() server.serve_forever()
+189 -18
View File
@@ -4,9 +4,10 @@ import argparse
import os import os
import sys import sys
import threading import threading
import time
from PySide6.QtCore import QObject, Qt, QTimer, Signal from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QIcon from PySide6.QtGui import QColor, QIcon, QPalette
from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame, from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget,
@@ -17,7 +18,7 @@ from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox,
from .. import APP_NAME, __version__, layout from .. import APP_NAME, __version__, layout
from ..client import Client, DaemonError, read_state from ..client import Client, DaemonError, read_state
from . import theme from . import theme
from .widgets import Bar, Card, ColorRow, CurveEditor, Gauge, KeyboardPainter from .widgets import Bar, Card, ChargeIndicator, ColorRow, CurveEditor, Gauge, KeyboardPainter, format_minutes
PAGES = [ PAGES = [
("home", "Dashboard"), ("home", "Dashboard"),
@@ -25,6 +26,7 @@ PAGES = [
("fans", "Fans"), ("fans", "Fans"),
("rgb", "RGB Studio"), ("rgb", "RGB Studio"),
("battery", "Battery"), ("battery", "Battery"),
("power", "Power"),
("system", "System"), ("system", "System"),
] ]
@@ -146,8 +148,9 @@ class HomePage(Page):
quick.body.addWidget(self.l_profile) quick.body.addWidget(self.l_profile)
self.l_fans = _muted("") self.l_fans = _muted("")
quick.body.addWidget(self.l_fans) quick.body.addWidget(self.l_fans)
self.l_bat = _muted("") self.charge = ChargeIndicator(compact=True)
quick.body.addWidget(self.l_bat) self.charge.setFixedHeight(54)
quick.body.addWidget(self.charge)
b = QPushButton("Next profile") b = QPushButton("Next profile")
b.setObjectName("primary") b.setObjectName("primary")
b.clicked.connect(self._cycle) b.clicked.connect(self._cycle)
@@ -182,7 +185,7 @@ class HomePage(Page):
self.l_profile.setText(PROFILE_INFO.get(st["profile"], (st["profile"],))[0]) self.l_profile.setText(PROFILE_INFO.get(st["profile"], (st["profile"],))[0])
self.l_fans.setText("Fans: %s" % st["config"]["fans"]["mode"].capitalize()) self.l_fans.setText("Fans: %s" % st["config"]["fans"]["mode"].capitalize())
b = st["battery"] b = st["battery"]
self.l_bat.setText("Battery %s %% · %s" % (b.get("capacity"), "AC" if b.get("ac_online") else "on battery")) self.charge.set_battery(b)
class PerformancePage(Page): class PerformancePage(Page):
@@ -589,20 +592,105 @@ 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)
self.lay.addWidget(_title("Battery", "Battery status and health features")) self.lay.addWidget(_title("Battery", "Battery status and health features"))
row = QHBoxLayout() row = QHBoxLayout()
row.setSpacing(16) row.setSpacing(16)
info = Card("Status") info = Card("Charging")
self.cap = QLabel("–") self.indicator = ChargeIndicator()
self.cap.setObjectName("big") info.body.addWidget(self.indicator)
info.body.addWidget(self.cap)
self.details = _muted("") self.details = _muted("")
info.body.addWidget(self.details) info.body.addWidget(self.details)
info.body.addStretch(1) info.body.addStretch(1)
row.addWidget(info, 1) row.addWidget(info, 3)
health = Card("Battery health") health = Card("Battery health")
self.limit = QCheckBox("Limit charging to 80 %") self.limit = QCheckBox("Limit charging to 80 %")
self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v)) self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v))
@@ -648,14 +736,17 @@ class BatteryPage(Page):
def update_state(self, st): def update_state(self, st):
b = st["battery"] b = st["battery"]
if not b.get("present"): if not b.get("present"):
self.cap.setText("No battery") self.details.setText("No battery found")
return return
self.cap.setText("%s %%" % b.get("capacity")) self.indicator.set_battery(b)
lines = ["Status: %s" % b.get("status"), rate = "Charge rate" if b.get("charging") else "Power draw"
"Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"), lines = ["Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"),
"Power draw: %s W" % b.get("power_w"), "%s: %s W" % (rate, b.get("power_w") if b.get("power_w") is not None else "–"),
"Health: %s %% of design capacity" % b.get("health"), "Health: %s %% of design capacity" % b.get("health"),
"Charge cycles: %s" % b.get("cycles")] "Charge cycles: %s" % b.get("cycles")]
if b.get("minutes_left") is not None:
lines.insert(2, "%s: %s" % ("Time until full" + (" (80 % limit)" if st.get("battery_limit") else "")
if b.get("charging") else "Time remaining", format_minutes(b["minutes_left"])))
self.details.setText("\n".join(lines)) self.details.setText("\n".join(lines))
@@ -670,6 +761,42 @@ 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")
self.upd_head = QLabel("")
self.upd_head.setObjectName("big")
upd.body.addWidget(self.upd_head)
self.upd_text = _muted("")
self.upd_text.setTextFormat(Qt.MarkdownText)
self.upd_text.setOpenExternalLinks(True)
upd.body.addWidget(self.upd_text)
h = QHBoxLayout()
self.upd_check = QPushButton("Check now")
self.upd_check.clicked.connect(lambda: (self.upd_check.setEnabled(False), win.backend.run("check_update")))
h.addWidget(self.upd_check)
self.upd_install = QPushButton("Install update")
self.upd_install.setObjectName("primary")
self.upd_install.clicked.connect(self._install)
h.addWidget(self.upd_install)
self.upd_auto = QCheckBox("Check for updates automatically")
self.upd_auto.clicked.connect(lambda v: win.backend.run("set_updates", auto_check=bool(v)))
h.addWidget(self.upd_auto)
h.addStretch(1)
upd.body.addLayout(h)
self.lay.addWidget(upd)
svc = Card("Status") svc = Card("Status")
self.status = _muted("") self.status = _muted("")
svc.body.addWidget(self.status) svc.body.addWidget(self.status)
@@ -695,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)
@@ -725,7 +852,39 @@ class SystemPage(Page):
box.setChecked(bool(info.get("value"))) box.setChecked(bool(info.get("value")))
box.setToolTip(info.get("error", "") or "") box.setToolTip(info.get("error", "") or "")
def _install(self):
u = self.win.state.get("update") or {}
if QMessageBox.question(self, "Install update",
"Install Predator Control %s now?\n\nThe background service restarts briefly; "
"reopen the app afterwards." % u.get("latest")) == QMessageBox.Yes:
self.upd_install.setEnabled(False)
self.win.backend.run("install_update")
def update_state(self, st): def update_state(self, st):
u = st.get("update") or {}
status = st.get("update_status")
self.upd_check.setEnabled(True)
if status == "running":
self.upd_head.setText("Installing update…")
self.upd_text.setText("pacman is installing the new version.")
self.upd_install.hide()
elif u.get("available"):
self.upd_head.setText("Version %s available" % u["latest"])
text = "Installed: %s · [Release page](%s)\n\n%s" % (u["current"], u.get("url"), u.get("notes", "").split("### Install")[0].strip())
if not u.get("can_install"):
text += "\n\n*Automatic install needs the pacman package – otherwise run `git pull && sudo ./install.sh`.*"
self.upd_text.setText(text)
self.upd_install.setVisible(bool(u.get("can_install")))
else:
self.upd_head.setText("Up to date" if u else "Not checked yet")
when = time.strftime("%d.%m.%Y %H:%M", time.localtime(u["checked"])) if u.get("checked") else "–"
extra = "\n\nLast update failed – see: journalctl -u predator-control-update" if status == "failed" 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_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"] a = st["available"]
yes = lambda v: "✔" if v else "✘" yes = lambda v: "✔" if v else "✘"
lines = ["%s Service predatord %s" % (yes(True), st.get("version")), lines = ["%s Service predatord %s" % (yes(True), st.get("version")),
@@ -771,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")
@@ -826,17 +986,25 @@ class MainWindow(QMainWindow):
self.banner.hide() self.banner.hide()
self.state = st self.state = st
self.stack.currentWidget().update_state(st) self.stack.currentWidget().update_state(st)
bat_page = self.pages["battery"][0]
if self.stack.currentWidget() is not bat_page:
bat_page.indicator.set_battery(st["battery"]) # keep the power history going
s = st["sensors"] s = st["sensors"]
self.footer.setText("%s\nCPU %s °C · GPU %s" % ( u = st.get("update") or {}
badge = ("⬆ Update %s available\n" % u["latest"]) if u.get("available") else ""
self.footer.setText(badge + "%s\nCPU %s °C · GPU %s" % (
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"), PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off")) ("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
def _done(self, cmd, result, err): def _done(self, cmd, result, err):
if err: if err:
self.statusBar().showMessage("Error: %s" % err, 8000) self.statusBar().showMessage("Error: %s" % err, 8000)
elif cmd == "install_update":
self.statusBar().showMessage("Update is being installed…", 8000)
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)
@@ -854,6 +1022,9 @@ def main(argv=None):
app.setDesktopFileName("predator-control") app.setDesktopFileName("predator-control")
app.setStyle("Fusion") app.setStyle("Fusion")
app.setStyleSheet(theme.QSS) app.setStyleSheet(theme.QSS)
pal = app.palette()
pal.setColor(QPalette.Link, QColor(theme.ACCENT))
app.setPalette(pal)
# single instance: forward the requested page to a running window # single instance: forward the requested page to a running window
name = "predator-control-%d" % os.getuid() name = "predator-control-%d" % os.getuid()
+136
View File
@@ -388,3 +388,139 @@ class ColorRow(QWidget):
self.current = QColor(c) self.current = QColor(c)
self.preview.setStyleSheet("background:%s;border-radius:6px;border:1px solid %s;" self.preview.setStyleSheet("background:%s;border-radius:6px;border:1px solid %s;"
% (self.current.name(), theme.BORDER)) % (self.current.name(), theme.BORDER))
def format_minutes(m):
if m is None:
return ""
return "%d h %02d min" % divmod(m, 60) if m >= 60 else "%d min" % m
class ChargeIndicator(QWidget):
"""Battery graphic with fill level, charge bolt, wattage and a power history sparkline."""
HISTORY = 80 # samples (~2 min at 1.5 s)
def __init__(self, compact=False, parent=None):
super().__init__(parent)
self.compact = compact
self.bat = {}
self.history = []
self.setMinimumHeight(54 if compact else 190)
self.setMinimumWidth(300 if compact else 420)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def set_battery(self, bat):
self.bat = bat or {}
w = self.bat.get("power_w")
if w is not None:
self.history = (self.history + [w])[-self.HISTORY:]
self.update()
def _state_text(self):
b = self.bat
st = b.get("status") or ""
if st == "Charging":
return "Charging"
if st == "Discharging":
return "On battery"
if b.get("ac_online"):
return "Plugged in · not charging"
return st or "Unknown"
def paintEvent(self, _):
b = self.bat
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
h = self.height() - 8 if self.compact else min(self.height() * 0.42, 86)
bw, bh = h * 2.0, h
r = QRectF(4, 4, bw, bh)
cap = b.get("capacity") or 0
charging = b.get("charging")
accent = QColor(theme.ACCENT if charging else (theme.DANGER if cap <= 15 else theme.TEXT))
# battery body + tip
p.setPen(QPen(QColor(theme.MUTED), 2.5))
p.setBrush(Qt.NoBrush)
p.drawRoundedRect(r, h * 0.14, h * 0.14)
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.MUTED))
p.drawRoundedRect(QRectF(r.right() + 2, r.top() + bh * 0.3, bh * 0.1, bh * 0.4), 2, 2)
inner = r.adjusted(5, 5, -5, -5)
fill = QColor(accent)
fill.setAlpha(170)
p.setBrush(fill)
p.drawRoundedRect(QRectF(inner.left(), inner.top(), inner.width() * cap / 100, inner.height()),
h * 0.08, h * 0.08)
if charging:
c = inner.center()
s = inner.height() * 0.42
bolt = QPainterPath(QPointF(c.x() + s * 0.15, c.y() - s))
bolt.lineTo(c.x() - s * 0.45, c.y() + s * 0.12)
bolt.lineTo(c.x() - s * 0.02, c.y() + s * 0.12)
bolt.lineTo(c.x() - s * 0.15, c.y() + s)
bolt.lineTo(c.x() + s * 0.45, c.y() - s * 0.12)
bolt.lineTo(c.x() + s * 0.02, c.y() - s * 0.12)
bolt.closeSubpath()
p.setBrush(QColor(theme.TEXT))
p.setPen(QPen(QColor(theme.BG), 1.5))
p.drawPath(bolt)
else:
f = QFont(self.font())
f.setBold(True)
f.setPointSizeF(max(8, h * 0.22))
p.setFont(f)
p.setPen(QColor(theme.TEXT))
p.drawText(inner, Qt.AlignCenter, "%d %%" % cap)
# text block
tx = r.right() + bh * 0.1 + 16
f = QFont(self.font())
w = b.get("power_w")
f.setBold(True)
f.setPointSizeF(max(10, h * (0.36 if self.compact else 0.34)))
p.setFont(f)
p.setPen(accent if charging else QColor(theme.TEXT))
big = ("%.1f W" % w) if w else ("%d %%" % cap)
p.drawText(QRectF(tx, r.top(), self.width() - tx, bh * 0.55), Qt.AlignLeft | Qt.AlignVCenter, big)
f.setBold(False)
f.setPointSizeF(max(8, h * (0.17 if self.compact else 0.14)))
p.setFont(f)
p.setPen(QColor(theme.MUTED))
sub = self._state_text()
if w:
sub = "%d %% · %s" % (cap, sub)
t = format_minutes(b.get("minutes_left"))
if t:
sub += " · %s %s" % (t, "until full" if charging else "left")
p.drawText(QRectF(tx, r.top() + bh * 0.5, self.width() - tx, bh * 0.5), Qt.AlignLeft | Qt.AlignVCenter, sub)
if self.compact or len(self.history) < 2:
return
# power history sparkline
g = QRectF(4, r.bottom() + 22, self.width() - 8, self.height() - r.bottom() - 30)
if g.height() < 30:
return
p.setPen(QColor(theme.MUTED))
f.setPointSizeF(8)
p.setFont(f)
top = max(10.0, max(self.history) * 1.15)
p.drawText(QRectF(g.left(), g.top() - 18, g.width(), 16), Qt.AlignLeft,
"Power over the last %d min" % max(1, round(len(self.history) * 1.5 / 60)))
p.drawText(QRectF(g.left(), g.top() - 18, g.width(), 16), Qt.AlignRight, "max %.0f W" % top)
p.setPen(QPen(QColor(theme.GRID), 1))
p.drawLine(g.bottomLeft(), g.bottomRight())
n = self.HISTORY - 1
pts = [QPointF(g.left() + g.width() * (n - (len(self.history) - 1 - i)) / n,
g.bottom() - g.height() * v / top) for i, v in enumerate(self.history)]
path = QPainterPath(pts[0])
for pt in pts[1:]:
path.lineTo(pt)
area = QPainterPath(path)
area.lineTo(pts[-1].x(), g.bottom())
area.lineTo(pts[0].x(), g.bottom())
c = QColor(theme.ACCENT)
c.setAlpha(45)
p.fillPath(area, c)
p.setPen(QPen(QColor(theme.ACCENT), 2))
p.drawPath(path)
+14 -1
View File
@@ -247,7 +247,7 @@ def set_lightbar(mode="static", color=(0, 200, 255), brightness=200, speed=5, di
# --------------------------------------------------------------- Battery # --------------------------------------------------------------- Battery
def battery(): def battery(limit_80=False):
bat = next(iter(glob.glob("/sys/class/power_supply/BAT*")), None) bat = next(iter(glob.glob("/sys/class/power_supply/BAT*")), None)
ac = next((p for p in glob.glob("/sys/class/power_supply/*") ac = next((p for p in glob.glob("/sys/class/power_supply/*")
if _read(p + "/type") == "Mains"), None) if _read(p + "/type") == "Mains"), None)
@@ -268,6 +268,19 @@ def battery():
"cycles": _read_int(bat + "/cycle_count"), "cycles": _read_int(bat + "/cycle_count"),
"health": round(100.0 * full / design, 1) if full and design else None, "health": round(100.0 * full / design, 1) if full and design else None,
}) })
# remaining time in minutes (to full while charging, to empty while discharging)
now = _read_int(bat + "/charge_now") or _read_int(bat + "/energy_now")
rate = cur if _read_int(bat + "/charge_now") is not None else pwr
minutes = None
if now is not None and full and rate:
rate = abs(rate)
if out["status"] == "Charging":
target = full * 0.8 if limit_80 else full
minutes = int(max(0, target - now) / rate * 60)
elif out["status"] == "Discharging":
minutes = int(now / rate * 60)
out["minutes_left"] = minutes
out["charging"] = out["status"] == "Charging"
return out return out
+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 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
+123
View File
@@ -0,0 +1,123 @@
"""Self-updater: checks the Gitea releases for a newer pacman package and installs it.
The release must carry a `predator-control-<ver>-<rel>-any.pkg.tar.zst` asset and its
SHA-256 in the release notes. Downloads are verified before `pacman -U` runs.
An optional API token (for private repositories) is read from
/etc/predator-control/update-token.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import time
import urllib.request
from . import CONFIG_DIR, __version__
API = "https://git.mrblake.cc/api/v1/repos/MrBlake/Cachy-Predator-Keyboard"
CACHE_DIR = "/var/cache/predator-control"
TOKEN_PATH = CONFIG_DIR + "/update-token"
PKG_RE = re.compile(r"^predator-control-[\w.+]+-\d+-any\.pkg\.tar\.zst$")
UNIT = "predator-control-update"
class UpdateError(RuntimeError):
pass
def parse_version(v):
return tuple(int(x) for x in re.findall(r"\d+", v.lstrip("v"))[:3])
def _request(url, binary=False):
headers = {"Accept": "application/json", "User-Agent": "predator-control/" + __version__}
try:
with open(TOKEN_PATH) as f:
token = f.read().strip()
if token:
headers["Authorization"] = "token " + token
except OSError:
pass
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as r:
data = r.read()
except OSError as e:
raise UpdateError("cannot reach the update server: %s" % e) from e
return data if binary else json.loads(data)
def installed_as_package():
"""True if the app was installed through pacman (only then can it update itself)."""
if not shutil.which("pacman"):
return False
return subprocess.run(["pacman", "-Qq", "predator-control"], capture_output=True).returncode == 0
def check():
"""Return information about the latest release."""
releases = [r for r in _request(API + "/releases?limit=50")
if not r.get("draft") and not r.get("prerelease") and parse_version(r.get("tag_name", ""))]
if not releases:
raise UpdateError("no releases found")
rel = max(releases, key=lambda r: parse_version(r["tag_name"]))
latest = rel.get("tag_name", "").lstrip("v")
asset = next((a for a in rel.get("assets", []) if PKG_RE.match(a.get("name", ""))), None)
sha = None
if asset:
m = re.search(re.escape(asset["name"]) + r"\W+([0-9a-f]{64})", rel.get("body", ""))
sha = m.group(1) if m else None
return {
"current": __version__,
"latest": latest,
"available": parse_version(latest) > parse_version(__version__),
"notes": rel.get("body", ""),
"url": rel.get("html_url"),
"asset": asset["name"] if asset else None,
"asset_url": asset["browser_download_url"] if asset else None,
"sha256": sha,
"can_install": installed_as_package() and bool(asset and sha),
"checked": time.time(),
}
def install(info):
"""Download, verify and install the package in a separate systemd unit.
pacman's post_upgrade restarts predatord, so the installation must not run
inside the daemon's own process.
"""
if not info.get("available"):
raise UpdateError("already up to date")
if not installed_as_package():
raise UpdateError("not installed via pacman – update with git pull && sudo ./install.sh")
if not info.get("asset_url") or not info.get("sha256"):
raise UpdateError("release has no verifiable package")
os.makedirs(CACHE_DIR, exist_ok=True)
path = os.path.join(CACHE_DIR, info["asset"])
data = _request(info["asset_url"], binary=True)
digest = hashlib.sha256(data).hexdigest()
if digest != info["sha256"]:
raise UpdateError("checksum mismatch – download rejected")
with open(path, "wb") as f:
f.write(data)
subprocess.run(["systemctl", "reset-failed", UNIT + ".service"], capture_output=True)
r = subprocess.run(["systemd-run", "--unit=" + UNIT, "--description=Predator Control update",
"pacman", "-U", "--noconfirm", "--needed", path],
capture_output=True, text=True)
if r.returncode != 0:
raise UpdateError("could not start the update: %s" % r.stderr.strip())
return {"started": True, "package": info["asset"]}
def status():
"""State of the installer unit: 'running', 'failed' or 'idle'."""
r = subprocess.run(["systemctl", "is-active", UNIT + ".service"], capture_output=True, text=True)
s = r.stdout.strip()
if s in ("active", "activating"):
return "running"
if s == "failed":
return "failed"
return "idle"