Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571cfcca2a | ||
|
|
5b7534560f | ||
|
|
cc4fdb9cd0 | ||
|
|
2ef62e85cd |
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*.pkg.tar.*
|
||||||
|
src/
|
||||||
|
pkg/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)."
|
||||||
|
}
|
||||||
Executable
+72
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user