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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import os
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
APP_NAME = "Predator Control"
|
||||
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
|
||||
|
||||
@@ -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("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("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"]
|
||||
print("Keyboard: %s %s brightness %s" % (k["effect"], k["color"], k["brightness"]))
|
||||
for k, v in st["errors"].items():
|
||||
|
||||
@@ -214,7 +214,7 @@ class Daemon:
|
||||
|
||||
def build_state(self):
|
||||
s = hw.sensors()
|
||||
bat = hw.battery()
|
||||
bat = hw.battery(limit_80=bool(self.battery_limit))
|
||||
st = {
|
||||
"version": __version__,
|
||||
"time": time.time(),
|
||||
|
||||
+20
-14
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox,
|
||||
from .. import APP_NAME, __version__, layout
|
||||
from ..client import Client, DaemonError, read_state
|
||||
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 = [
|
||||
("home", "Dashboard"),
|
||||
@@ -146,8 +146,9 @@ class HomePage(Page):
|
||||
quick.body.addWidget(self.l_profile)
|
||||
self.l_fans = _muted("")
|
||||
quick.body.addWidget(self.l_fans)
|
||||
self.l_bat = _muted("")
|
||||
quick.body.addWidget(self.l_bat)
|
||||
self.charge = ChargeIndicator(compact=True)
|
||||
self.charge.setFixedHeight(54)
|
||||
quick.body.addWidget(self.charge)
|
||||
b = QPushButton("Next profile")
|
||||
b.setObjectName("primary")
|
||||
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_fans.setText("Fans: %s" % st["config"]["fans"]["mode"].capitalize())
|
||||
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):
|
||||
@@ -595,14 +596,13 @@ class BatteryPage(Page):
|
||||
self.lay.addWidget(_title("Battery", "Battery status and health features"))
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(16)
|
||||
info = Card("Status")
|
||||
self.cap = QLabel("–")
|
||||
self.cap.setObjectName("big")
|
||||
info.body.addWidget(self.cap)
|
||||
info = Card("Charging")
|
||||
self.indicator = ChargeIndicator()
|
||||
info.body.addWidget(self.indicator)
|
||||
self.details = _muted("")
|
||||
info.body.addWidget(self.details)
|
||||
info.body.addStretch(1)
|
||||
row.addWidget(info, 1)
|
||||
row.addWidget(info, 3)
|
||||
health = Card("Battery health")
|
||||
self.limit = QCheckBox("Limit charging to 80 %")
|
||||
self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v))
|
||||
@@ -648,14 +648,17 @@ class BatteryPage(Page):
|
||||
def update_state(self, st):
|
||||
b = st["battery"]
|
||||
if not b.get("present"):
|
||||
self.cap.setText("No battery")
|
||||
self.details.setText("No battery found")
|
||||
return
|
||||
self.cap.setText("%s %%" % b.get("capacity"))
|
||||
lines = ["Status: %s" % b.get("status"),
|
||||
"Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"),
|
||||
"Power draw: %s W" % b.get("power_w"),
|
||||
self.indicator.set_battery(b)
|
||||
rate = "Charge rate" if b.get("charging") else "Power draw"
|
||||
lines = ["Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"),
|
||||
"%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"),
|
||||
"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))
|
||||
|
||||
|
||||
@@ -826,6 +829,9 @@ class MainWindow(QMainWindow):
|
||||
self.banner.hide()
|
||||
self.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"]
|
||||
self.footer.setText("%s\nCPU %s °C · GPU %s" % (
|
||||
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
|
||||
|
||||
@@ -388,3 +388,139 @@ class ColorRow(QWidget):
|
||||
self.current = QColor(c)
|
||||
self.preview.setStyleSheet("background:%s;border-radius:6px;border:1px solid %s;"
|
||||
% (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
|
||||
|
||||
def battery():
|
||||
def battery(limit_80=False):
|
||||
bat = next(iter(glob.glob("/sys/class/power_supply/BAT*")), None)
|
||||
ac = next((p for p in glob.glob("/sys/class/power_supply/*")
|
||||
if _read(p + "/type") == "Mains"), None)
|
||||
@@ -268,6 +268,19 @@ def battery():
|
||||
"cycles": _read_int(bat + "/cycle_count"),
|
||||
"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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user