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>
This commit is contained in:
2026-10-07 17:37:03 +02:00
co-authored by Claude Opus 5.5
parent ea7ce49452
commit 2ef62e85cd
17 changed files with 315 additions and 25 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## 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.
+19 -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-1.1.0-1-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,7 @@ 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).
## Usage ## Usage
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

+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.1.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.1.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.1.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.1.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)."
}
@@ -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,7 @@ 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()
: "Service predatord not running" : "Service predatord not running"
P5Support.DataSource { P5Support.DataSource {
@@ -318,13 +334,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 %"
@@ -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.1.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.1.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")
+7 -1
View File
@@ -84,7 +84,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():
+1 -1
View File
@@ -214,7 +214,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(),
+20 -14
View File
@@ -17,7 +17,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"),
@@ -146,8 +146,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 +183,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):
@@ -595,14 +596,13 @@ class BatteryPage(Page):
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 +648,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))
@@ -826,6 +829,9 @@ 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" % ( self.footer.setText("%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"),
+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