- 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>
894 lines
35 KiB
Python
894 lines
35 KiB
Python
"""Predator Control main window."""
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import threading
|
||
|
||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||
from PySide6.QtGui import QColor, QIcon
|
||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
|
||
QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget,
|
||
QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
|
||
QScrollArea, QSlider, QSpinBox, QStackedWidget, QTabWidget,
|
||
QVBoxLayout, QWidget)
|
||
|
||
from .. import APP_NAME, __version__, layout
|
||
from ..client import Client, DaemonError, read_state
|
||
from . import theme
|
||
from .widgets import Bar, Card, ChargeIndicator, ColorRow, CurveEditor, Gauge, KeyboardPainter, format_minutes
|
||
|
||
PAGES = [
|
||
("home", "Dashboard"),
|
||
("performance", "Performance"),
|
||
("fans", "Fans"),
|
||
("rgb", "RGB Studio"),
|
||
("battery", "Battery"),
|
||
("system", "System"),
|
||
]
|
||
|
||
PROFILE_INFO = {
|
||
"low-power": ("Eco", "Maximum battery life, lowest power limits."),
|
||
"quiet": ("Quiet", "Low noise for office work and video."),
|
||
"balanced": ("Balanced", "Everyday mix of performance and acoustics."),
|
||
"balanced-performance": ("Performance", "Higher power limits for gaming."),
|
||
"performance": ("Turbo", "Maximum CPU/GPU power, fans run loud."),
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------ backend
|
||
|
||
class Backend(QObject):
|
||
"""Runs daemon commands in worker threads so the UI never blocks."""
|
||
|
||
finished = Signal(str, object, str) # command, result, error
|
||
|
||
def run(self, cmd, **args):
|
||
def work():
|
||
c = Client()
|
||
try:
|
||
res, err = c.call(cmd, **args), ""
|
||
except (DaemonError, OSError, ValueError) as e:
|
||
res, err = None, str(e)
|
||
finally:
|
||
c.close()
|
||
self.finished.emit(cmd, res, err)
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
def call(self, cmd, **args):
|
||
c = Client()
|
||
try:
|
||
return c.call(cmd, **args)
|
||
finally:
|
||
c.close()
|
||
|
||
|
||
def _title(text, sub=None):
|
||
w = QWidget()
|
||
lay = QVBoxLayout(w)
|
||
lay.setContentsMargins(0, 0, 0, 8)
|
||
lay.setSpacing(2)
|
||
t = QLabel(text)
|
||
t.setObjectName("pageTitle")
|
||
lay.addWidget(t)
|
||
if sub:
|
||
s = QLabel(sub)
|
||
s.setObjectName("pageSub")
|
||
s.setWordWrap(True)
|
||
lay.addWidget(s)
|
||
return w
|
||
|
||
|
||
def _slider(lo, hi, val=0):
|
||
s = QSlider(Qt.Horizontal)
|
||
s.setRange(lo, hi)
|
||
s.setValue(val)
|
||
return s
|
||
|
||
|
||
def _muted(text):
|
||
l = QLabel(text)
|
||
l.setObjectName("muted")
|
||
l.setWordWrap(True)
|
||
return l
|
||
|
||
|
||
class Page(QScrollArea):
|
||
def __init__(self, win):
|
||
super().__init__()
|
||
self.win = win
|
||
self.setWidgetResizable(True)
|
||
inner = QWidget()
|
||
inner.setObjectName("page")
|
||
self.lay = QVBoxLayout(inner)
|
||
self.lay.setContentsMargins(32, 28, 32, 28)
|
||
self.lay.setSpacing(16)
|
||
self.setWidget(inner)
|
||
|
||
def update_state(self, st):
|
||
pass
|
||
|
||
|
||
# ------------------------------------------------------------------ pages
|
||
|
||
class HomePage(Page):
|
||
def __init__(self, win):
|
||
super().__init__(win)
|
||
self.lay.addWidget(_title("Dashboard", "Live system monitoring"))
|
||
grid = QGridLayout()
|
||
grid.setSpacing(16)
|
||
self.g_cpu = Gauge("CPU", "°C", 105, warn=90)
|
||
self.g_gpu = Gauge("GPU", "°C", 100, warn=85)
|
||
self.g_cfan = Gauge("CPU fan", "RPM", 7000)
|
||
self.g_gfan = Gauge("GPU fan", "RPM", 7000)
|
||
for i, g in enumerate((self.g_cpu, self.g_gpu, self.g_cfan, self.g_gfan)):
|
||
c = Card()
|
||
c.body.addWidget(g)
|
||
c.setMinimumHeight(210)
|
||
grid.addWidget(c, 0, i)
|
||
self.lay.addLayout(grid)
|
||
|
||
row = QHBoxLayout()
|
||
row.setSpacing(16)
|
||
load = Card("System")
|
||
self.b_load = Bar("CPU load")
|
||
self.b_freq = Bar("CPU clock (avg)")
|
||
self.b_mem = Bar("Memory")
|
||
self.b_sys = Bar("Chassis temperature")
|
||
for b in (self.b_load, self.b_freq, self.b_mem, self.b_sys):
|
||
load.body.addWidget(b)
|
||
row.addWidget(load, 3)
|
||
|
||
quick = Card("Mode")
|
||
self.l_profile = QLabel("–")
|
||
self.l_profile.setObjectName("big")
|
||
quick.body.addWidget(self.l_profile)
|
||
self.l_fans = _muted("")
|
||
quick.body.addWidget(self.l_fans)
|
||
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)
|
||
quick.body.addWidget(b)
|
||
quick.body.addStretch(1)
|
||
row.addWidget(quick, 2)
|
||
self.lay.addLayout(row)
|
||
self.lay.addStretch(1)
|
||
|
||
def _cycle(self):
|
||
st = self.win.state
|
||
if not st:
|
||
return
|
||
ch = st["profile_choices"]
|
||
nxt = ch[(ch.index(st["profile"]) + 1) % len(ch)] if st["profile"] in ch else ch[0]
|
||
self.win.backend.run("set_profile", profile=nxt)
|
||
|
||
def update_state(self, st):
|
||
s = st["sensors"]
|
||
self.g_cpu.set_value(s.get("cpu_temp"))
|
||
self.g_gpu.set_value(s.get("gpu_temp"), "" if s.get("gpu_temp") else "dGPU asleep")
|
||
self.g_cfan.set_value(s.get("cpu_fan"))
|
||
self.g_gfan.set_value(s.get("gpu_fan"))
|
||
load = st.get("cpu_load") or 0
|
||
self.b_load.set_value(load / 100, "%.0f %%" % load)
|
||
f = st.get("cpu_freq") or 0
|
||
self.b_freq.set_value(f / 5500, "%.2f GHz" % (f / 1000))
|
||
m = st.get("memory") or {}
|
||
self.b_mem.set_value((m.get("percent") or 0) / 100, "%s / %s GB" % (m.get("used_gb"), m.get("total_gb")))
|
||
t = s.get("sys_temp") or 0
|
||
self.b_sys.set_value(t / 100, "%s °C" % (t or "–"))
|
||
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.charge.set_battery(b)
|
||
|
||
|
||
class PerformancePage(Page):
|
||
def __init__(self, win):
|
||
super().__init__(win)
|
||
self.lay.addWidget(_title("Performance", "Choose an operating mode. Uses the firmware's ACPI platform profiles."))
|
||
card = Card("Operating mode")
|
||
row = QHBoxLayout()
|
||
row.setSpacing(12)
|
||
self.group = QButtonGroup(self)
|
||
self.group.setExclusive(True)
|
||
self.buttons = {}
|
||
for key, (name, desc) in PROFILE_INFO.items():
|
||
b = QPushButton(name)
|
||
b.setObjectName("profileTurbo" if key == "performance" else "profile")
|
||
b.setCheckable(True)
|
||
b.setToolTip(desc)
|
||
b.clicked.connect(lambda _=False, k=key: win.backend.run("set_profile", profile=k))
|
||
self.group.addButton(b)
|
||
self.buttons[key] = b
|
||
row.addWidget(b)
|
||
card.body.addLayout(row)
|
||
self.desc = _muted("")
|
||
card.body.addWidget(self.desc)
|
||
self.lay.addWidget(card)
|
||
|
||
pol = Card("Automatic switching")
|
||
self.auto = QCheckBox("Switch the profile automatically when plugging in / unplugging the charger")
|
||
self.auto.toggled.connect(self._policy)
|
||
pol.body.addWidget(self.auto)
|
||
g = QGridLayout()
|
||
self.ac = QComboBox()
|
||
self.bat = QComboBox()
|
||
for k, (name, _d) in PROFILE_INFO.items():
|
||
self.ac.addItem(name, k)
|
||
self.bat.addItem(name, k)
|
||
self.ac.activated.connect(self._policy)
|
||
self.bat.activated.connect(self._policy)
|
||
g.addWidget(QLabel("On AC power"), 0, 0)
|
||
g.addWidget(self.ac, 0, 1)
|
||
g.addWidget(QLabel("On battery"), 1, 0)
|
||
g.addWidget(self.bat, 1, 1)
|
||
g.setColumnStretch(2, 1)
|
||
pol.body.addLayout(g)
|
||
pol.body.addWidget(_muted("Choosing a mode above also stores it for the current power source. "
|
||
"Tip: the Predator/Turbo key cycles the firmware profiles as well."))
|
||
self.lay.addWidget(pol)
|
||
self.lay.addStretch(1)
|
||
|
||
def _policy(self, *_):
|
||
self.win.backend.run("set_profile_policy", auto_switch=self.auto.isChecked(),
|
||
ac=self.ac.currentData(), battery=self.bat.currentData())
|
||
|
||
def update_state(self, st):
|
||
for k, b in self.buttons.items():
|
||
b.setEnabled(k in st["profile_choices"])
|
||
b.setChecked(k == st["profile"])
|
||
self.desc.setText(PROFILE_INFO.get(st["profile"], ("", ""))[1])
|
||
p = st["config"]["profile"]
|
||
for w, v in ((self.auto, p["auto_switch"]),):
|
||
w.blockSignals(True)
|
||
w.setChecked(v)
|
||
w.blockSignals(False)
|
||
for combo, key in ((self.ac, "ac"), (self.bat, "battery")):
|
||
if not combo.view().isVisible():
|
||
combo.blockSignals(True)
|
||
combo.setCurrentIndex(max(0, combo.findData(p[key])))
|
||
combo.blockSignals(False)
|
||
|
||
|
||
class FansPage(Page):
|
||
MODES = [("auto", "Auto", "Firmware controls the fans."),
|
||
("max", "Max", "Both fans at full speed (cooler boost)."),
|
||
("custom", "Manual", "Fixed speed for each fan."),
|
||
("curve", "Curve", "Speed follows your temperature curve.")]
|
||
|
||
def __init__(self, win):
|
||
super().__init__(win)
|
||
self.lay.addWidget(_title("Fans", "Fan control via the Acer gaming WMI interface"))
|
||
top = Card("Fan mode")
|
||
row = QHBoxLayout()
|
||
self.group = QButtonGroup(self)
|
||
self.mode_btns = {}
|
||
for key, name, desc in self.MODES:
|
||
b = QPushButton(name)
|
||
b.setObjectName("profile")
|
||
b.setCheckable(True)
|
||
b.setToolTip(desc)
|
||
b.clicked.connect(lambda _=False, k=key: self._set_mode(k))
|
||
self.group.addButton(b)
|
||
self.mode_btns[key] = b
|
||
row.addWidget(b)
|
||
top.body.addLayout(row)
|
||
self.rpm = _muted("")
|
||
top.body.addWidget(self.rpm)
|
||
self.lay.addWidget(top)
|
||
|
||
self.manual = Card("Manual speed")
|
||
g = QGridLayout()
|
||
self.s_cpu = _slider(0, 100, 50)
|
||
self.s_gpu = _slider(0, 100, 50)
|
||
self.l_cpu = QLabel()
|
||
self.l_gpu = QLabel()
|
||
for i, (name, s, l) in enumerate((("CPU fan", self.s_cpu, self.l_cpu), ("GPU fan", self.s_gpu, self.l_gpu))):
|
||
s.setSingleStep(5)
|
||
s.setPageStep(10)
|
||
s.valueChanged.connect(lambda v, l=l: l.setText("%d %%" % v if v else "auto"))
|
||
s.sliderReleased.connect(self._apply_manual)
|
||
g.addWidget(QLabel(name), i, 0)
|
||
g.addWidget(s, i, 1)
|
||
g.addWidget(l, i, 2)
|
||
g.setColumnStretch(1, 1)
|
||
self.manual.body.addLayout(g)
|
||
self.manual.body.addWidget(_muted("0 % leaves that fan under firmware control."))
|
||
self.lay.addWidget(self.manual)
|
||
|
||
self.curve = Card("Fan curves")
|
||
self.c_cpu = CurveEditor("CPU (package temperature)")
|
||
self.c_gpu = CurveEditor("GPU (dGPU temperature)")
|
||
self.c_cpu.changed.connect(lambda pts: self._apply_curve("cpu", pts))
|
||
self.c_gpu.changed.connect(lambda pts: self._apply_curve("gpu", pts))
|
||
h = QHBoxLayout()
|
||
h.addWidget(self.c_cpu)
|
||
h.addWidget(self.c_gpu)
|
||
self.curve.body.addLayout(h)
|
||
self.curve_info = _muted("")
|
||
self.curve.body.addWidget(self.curve_info)
|
||
self.curve.body.addWidget(_muted("Drag the points. The daemon evaluates the curve every 1.5 s, "
|
||
"ramps up immediately and ramps down with 10 % hysteresis."))
|
||
self.lay.addWidget(self.curve)
|
||
self.lay.addStretch(1)
|
||
|
||
def _set_mode(self, mode):
|
||
args = {"mode": mode}
|
||
if mode == "custom":
|
||
args.update(cpu=self.s_cpu.value(), gpu=self.s_gpu.value())
|
||
self.win.backend.run("set_fans", **args)
|
||
|
||
def _apply_manual(self):
|
||
self.win.backend.run("set_fans", mode="custom", cpu=self.s_cpu.value(), gpu=self.s_gpu.value())
|
||
|
||
def _apply_curve(self, which, pts):
|
||
curve = dict(self.win.state["config"]["fans"]["curve"]) if self.win.state else {}
|
||
curve[which] = pts
|
||
self.win.backend.run("set_fans", curve=curve, mode="curve")
|
||
|
||
def update_state(self, st):
|
||
f = st["config"]["fans"]
|
||
for k, b in self.mode_btns.items():
|
||
b.setChecked(k == f["mode"])
|
||
s = st["sensors"]
|
||
self.rpm.setText("CPU fan %s RPM · GPU fan %s RPM · CPU %s °C · GPU %s" % (
|
||
s.get("cpu_fan"), s.get("gpu_fan"), s.get("cpu_temp"),
|
||
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "asleep"))
|
||
self.manual.setVisible(f["mode"] == "custom")
|
||
self.curve.setVisible(f["mode"] == "curve")
|
||
for sl, key in ((self.s_cpu, "cpu"), (self.s_gpu, "gpu")):
|
||
if not sl.isSliderDown():
|
||
sl.blockSignals(True)
|
||
sl.setValue(f[key])
|
||
sl.blockSignals(False)
|
||
self.l_cpu.setText("%d %%" % self.s_cpu.value() if self.s_cpu.value() else "auto")
|
||
self.l_gpu.setText("%d %%" % self.s_gpu.value() if self.s_gpu.value() else "auto")
|
||
self.c_cpu.set_points(f["curve"]["cpu"])
|
||
self.c_gpu.set_points(f["curve"]["gpu"])
|
||
self.c_cpu.set_current(s.get("cpu_temp"))
|
||
self.c_gpu.set_current(s.get("gpu_temp"))
|
||
ap = st.get("fan_curve_applied")
|
||
self.curve_info.setText("Current target: CPU %s %% · GPU %s %%" % tuple(ap) if ap else "")
|
||
|
||
|
||
class RgbPage(Page):
|
||
def __init__(self, win):
|
||
super().__init__(win)
|
||
self.lay.addWidget(_title("RGB Studio", "Keyboard effects, per-key painting and the rear lightbar"))
|
||
tabs = QTabWidget()
|
||
tabs.addTab(self._effects_tab(), "Keyboard effects")
|
||
tabs.addTab(self._perkey_tab(), "Per-key editor")
|
||
tabs.addTab(self._lightbar_tab(), "Lightbar")
|
||
self.tabs = tabs
|
||
self.lay.addWidget(tabs)
|
||
self.lay.addStretch(1)
|
||
self._preview_timer = QTimer(self)
|
||
self._preview_timer.setSingleShot(True)
|
||
self._preview_timer.setInterval(120)
|
||
self._preview_timer.timeout.connect(self._send_preview)
|
||
self._loaded_frame = None
|
||
|
||
# ---- effects
|
||
def _effects_tab(self):
|
||
w = QWidget()
|
||
lay = QHBoxLayout(w)
|
||
lay.setContentsMargins(0, 12, 0, 0)
|
||
lay.setSpacing(16)
|
||
left = Card("Effect")
|
||
self.effects = QListWidget()
|
||
self.effects.setMinimumWidth(220)
|
||
self.effects.itemClicked.connect(self._effect_clicked)
|
||
left.body.addWidget(self.effects)
|
||
lay.addWidget(left, 1)
|
||
|
||
right = Card("Settings")
|
||
right.body.addWidget(QLabel("Colour"))
|
||
self.color = ColorRow()
|
||
self.color.colorChosen.connect(lambda c: self.win.backend.run("set_keyboard", color=c.name()))
|
||
right.body.addWidget(self.color)
|
||
right.body.addSpacing(8)
|
||
right.body.addWidget(QLabel("Brightness"))
|
||
self.bright = _slider(0, 255, 200)
|
||
self.bright.sliderReleased.connect(lambda: self.win.backend.run("set_keyboard", brightness=self.bright.value()))
|
||
right.body.addWidget(self.bright)
|
||
right.body.addSpacing(8)
|
||
h = QHBoxLayout()
|
||
h.addWidget(QLabel("Effect variant (raw ID, 0 = default)"))
|
||
self.variant = QSpinBox()
|
||
self.variant.setRange(0, 255)
|
||
self.variant.setDisplayIntegerBase(16)
|
||
self.variant.setPrefix("0x")
|
||
self.variant.editingFinished.connect(lambda: self.win.backend.run("set_keyboard", effect_id=self.variant.value()))
|
||
h.addWidget(self.variant)
|
||
h.addStretch(1)
|
||
right.body.addLayout(h)
|
||
right.body.addWidget(_muted("Many effects have hidden variants with other speeds or directions "
|
||
"(e.g. 0x07 for ripple, 0x14 for explosion)."))
|
||
right.body.addSpacing(8)
|
||
right.body.addWidget(QLabel("Presets"))
|
||
pr = QHBoxLayout()
|
||
self.presets = QComboBox()
|
||
self.presets.setMinimumWidth(180)
|
||
pr.addWidget(self.presets, 1)
|
||
for text, fn in (("Load", self._load_preset), ("Save as…", self._save_preset), ("Delete", self._delete_preset)):
|
||
b = QPushButton(text)
|
||
b.clicked.connect(fn)
|
||
pr.addWidget(b)
|
||
right.body.addLayout(pr)
|
||
right.body.addStretch(1)
|
||
lay.addWidget(right, 2)
|
||
return w
|
||
|
||
def _effect_clicked(self, item):
|
||
self.win.backend.run("set_keyboard", effect=item.data(Qt.UserRole), effect_id=0)
|
||
|
||
def _save_preset(self):
|
||
name, ok = QInputDialog.getText(self, "Save preset", "Name:")
|
||
if ok and name.strip():
|
||
self.win.backend.run("save_preset", name=name.strip())
|
||
|
||
def _load_preset(self):
|
||
if self.presets.currentText():
|
||
self.win.backend.run("load_preset", name=self.presets.currentText())
|
||
|
||
def _delete_preset(self):
|
||
if self.presets.currentText():
|
||
self.win.backend.run("delete_preset", name=self.presets.currentText())
|
||
|
||
# ---- per key
|
||
def _perkey_tab(self):
|
||
w = QWidget()
|
||
lay = QVBoxLayout(w)
|
||
lay.setContentsMargins(0, 12, 0, 0)
|
||
lay.setSpacing(12)
|
||
card = Card()
|
||
tools = QHBoxLayout()
|
||
tools.addWidget(QLabel("Brush"))
|
||
self.brush = ColorRow()
|
||
self.brush.colorChosen.connect(lambda c: setattr(self.painter, "brush", QColor(c)))
|
||
tools.addWidget(self.brush, 1)
|
||
tools.addWidget(QLabel("Layout"))
|
||
self.layout_combo = QComboBox()
|
||
for k, v in layout.VARIANTS.items():
|
||
self.layout_combo.addItem(v, k)
|
||
tools.addWidget(self.layout_combo)
|
||
card.body.addLayout(tools)
|
||
self.painter = KeyboardPainter()
|
||
self.painter.brush = QColor(self.brush.current)
|
||
self.layout_combo.setCurrentIndex(max(0, self.layout_combo.findData(self.painter.variant)))
|
||
self.layout_combo.currentIndexChanged.connect(lambda: self.painter.set_variant(self.layout_combo.currentData()))
|
||
self.painter.painted.connect(self._painted)
|
||
self.painter.picked.connect(lambda c: (self.brush.set_color(c), setattr(self.painter, "brush", QColor(c))))
|
||
card.body.addWidget(self.painter)
|
||
card.body.addWidget(_muted("Left click / drag paints · right click picks a key's colour · "
|
||
"changes are previewed live on the keyboard"))
|
||
g = QHBoxLayout()
|
||
g.addWidget(QLabel("Fill:"))
|
||
b = QPushButton("All")
|
||
b.clicked.connect(lambda: self.painter.fill(range(128), self.painter.brush))
|
||
g.addWidget(b)
|
||
for name, cells in layout.GROUPS.items():
|
||
b = QPushButton(name)
|
||
b.clicked.connect(lambda _=False, c=cells: self.painter.fill(c, self.painter.brush))
|
||
g.addWidget(b)
|
||
b = QPushButton("Clear")
|
||
b.clicked.connect(lambda: self.painter.fill(range(128), QColor(0, 0, 0)))
|
||
g.addWidget(b)
|
||
g.addStretch(1)
|
||
card.body.addLayout(g)
|
||
act = QHBoxLayout()
|
||
act.addStretch(1)
|
||
self.live = QCheckBox("Live preview")
|
||
self.live.setChecked(True)
|
||
act.addWidget(self.live)
|
||
b = QPushButton("Discard")
|
||
b.clicked.connect(self._discard)
|
||
act.addWidget(b)
|
||
b = QPushButton("Apply && save")
|
||
b.setObjectName("primary")
|
||
b.clicked.connect(self._save_frame)
|
||
act.addWidget(b)
|
||
card.body.addLayout(act)
|
||
lay.addWidget(card)
|
||
return w
|
||
|
||
def _painted(self):
|
||
if self.live.isChecked():
|
||
self._preview_timer.start()
|
||
|
||
def _send_preview(self):
|
||
self.win.backend.run("preview_keyboard", effect="perkey", frame=self.painter.frame_hex())
|
||
|
||
def _save_frame(self):
|
||
self.win.backend.run("set_keyboard", effect="perkey", frame=self.painter.frame_hex())
|
||
|
||
def _discard(self):
|
||
self._loaded_frame = None
|
||
self.win.backend.run("restore")
|
||
|
||
# ---- lightbar
|
||
def _lightbar_tab(self):
|
||
w = QWidget()
|
||
lay = QVBoxLayout(w)
|
||
lay.setContentsMargins(0, 12, 0, 0)
|
||
card = Card("Rear lightbar")
|
||
self.lb_sync = QCheckBox("Follow the keyboard (colour, brightness and effect)")
|
||
self.lb_sync.toggled.connect(lambda v: self.win.backend.run("set_lightbar", sync_keyboard=v))
|
||
card.body.addWidget(self.lb_sync)
|
||
g = QGridLayout()
|
||
g.setVerticalSpacing(12)
|
||
self.lb_mode = QComboBox()
|
||
self.lb_mode.activated.connect(lambda: self.win.backend.run("set_lightbar", mode=self.lb_mode.currentData()))
|
||
g.addWidget(QLabel("Effect"), 0, 0)
|
||
g.addWidget(self.lb_mode, 0, 1)
|
||
self.lb_color = ColorRow()
|
||
self.lb_color.colorChosen.connect(lambda c: self.win.backend.run("set_lightbar", color=c.name()))
|
||
g.addWidget(QLabel("Colour"), 1, 0)
|
||
g.addWidget(self.lb_color, 1, 1)
|
||
self.lb_bright = _slider(0, 255, 200)
|
||
self.lb_bright.sliderReleased.connect(lambda: self.win.backend.run("set_lightbar", brightness=self.lb_bright.value()))
|
||
g.addWidget(QLabel("Brightness"), 2, 0)
|
||
g.addWidget(self.lb_bright, 2, 1)
|
||
self.lb_speed = _slider(1, 10, 5)
|
||
self.lb_speed.sliderReleased.connect(lambda: self.win.backend.run("set_lightbar", speed=self.lb_speed.value()))
|
||
g.addWidget(QLabel("Speed"), 3, 0)
|
||
g.addWidget(self.lb_speed, 3, 1)
|
||
g.setColumnStretch(1, 1)
|
||
self.lb_controls = QWidget()
|
||
self.lb_controls.setLayout(g)
|
||
card.body.addWidget(self.lb_controls)
|
||
lay.addWidget(card)
|
||
lay.addStretch(1)
|
||
return w
|
||
|
||
def update_state(self, st):
|
||
k = st["config"]["keyboard"]
|
||
effects = st.get("keyboard_effects", {})
|
||
if self.effects.count() != len(effects):
|
||
self.effects.clear()
|
||
for key, (label, _uses_color) in effects.items():
|
||
it = QListWidgetItem(label)
|
||
it.setData(Qt.UserRole, key)
|
||
self.effects.addItem(it)
|
||
for i in range(self.effects.count()):
|
||
it = self.effects.item(i)
|
||
if it.data(Qt.UserRole) == k["effect"] and not it.isSelected():
|
||
self.effects.setCurrentItem(it)
|
||
self.color.set_color(QColor(k["color"]))
|
||
if not self.bright.isSliderDown():
|
||
self.bright.setValue(k["brightness"])
|
||
if not self.variant.hasFocus():
|
||
self.variant.setValue(int(k.get("effect_id") or 0))
|
||
names = sorted(st["config"].get("keyboard_presets", {}))
|
||
if names != [self.presets.itemText(i) for i in range(self.presets.count())]:
|
||
self.presets.clear()
|
||
self.presets.addItems(names)
|
||
if self._loaded_frame != k.get("frame"):
|
||
self._loaded_frame = k.get("frame")
|
||
self.painter.load_frame(k.get("frame"))
|
||
|
||
lb = st["config"]["lightbar"]
|
||
modes = st.get("lightbar_modes", {})
|
||
if self.lb_mode.count() != len(modes):
|
||
self.lb_mode.clear()
|
||
for key, label in modes.items():
|
||
self.lb_mode.addItem(label, key)
|
||
if not self.lb_mode.view().isVisible():
|
||
self.lb_mode.setCurrentIndex(max(0, self.lb_mode.findData(lb["mode"])))
|
||
self.lb_sync.blockSignals(True)
|
||
self.lb_sync.setChecked(bool(lb.get("sync_keyboard")))
|
||
self.lb_sync.blockSignals(False)
|
||
self.lb_controls.setEnabled(not lb.get("sync_keyboard"))
|
||
self.lb_color.set_color(QColor(lb["color"]))
|
||
if not self.lb_bright.isSliderDown():
|
||
self.lb_bright.setValue(lb["brightness"])
|
||
if not self.lb_speed.isSliderDown():
|
||
self.lb_speed.setValue(lb["speed"])
|
||
|
||
|
||
class BatteryPage(Page):
|
||
def __init__(self, win):
|
||
super().__init__(win)
|
||
self.lay.addWidget(_title("Battery", "Battery status and health features"))
|
||
row = QHBoxLayout()
|
||
row.setSpacing(16)
|
||
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, 3)
|
||
health = Card("Battery health")
|
||
self.limit = QCheckBox("Limit charging to 80 %")
|
||
self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v))
|
||
health.body.addWidget(self.limit)
|
||
health.body.addWidget(_muted("Recommended when the laptop is mostly plugged in – greatly extends battery life."))
|
||
health.body.addSpacing(10)
|
||
self.calib = QCheckBox("Battery calibration running")
|
||
self.calib.clicked.connect(self._calibrate)
|
||
health.body.addWidget(self.calib)
|
||
health.body.addWidget(_muted("Calibration fully charges, discharges and recharges the battery to re-learn its "
|
||
"capacity. Keep the charger connected; it can take several hours."))
|
||
health.body.addStretch(1)
|
||
row.addWidget(health, 2)
|
||
self.lay.addLayout(row)
|
||
self.lay.addStretch(1)
|
||
self._fw_loaded = False
|
||
|
||
def _set(self, cmd, v):
|
||
self.win.backend.run(cmd, enabled=bool(v))
|
||
QTimer.singleShot(800, self.refresh)
|
||
|
||
def _calibrate(self, v):
|
||
if v and QMessageBox.question(self, "Battery calibration",
|
||
"Start battery calibration now? Keep the charger connected.") != QMessageBox.Yes:
|
||
self.calib.setChecked(False)
|
||
return
|
||
self._set("set_battery_calibration", v)
|
||
|
||
def refresh(self):
|
||
self.win.backend.run("get_firmware")
|
||
|
||
def firmware(self, fw):
|
||
for box, key in ((self.limit, "battery_limit"), (self.calib, "battery_calibration")):
|
||
v = fw.get(key, {})
|
||
box.setEnabled(v.get("value") is not None)
|
||
box.setChecked(bool(v.get("value")))
|
||
box.setToolTip(v.get("error", ""))
|
||
|
||
def showEvent(self, e):
|
||
super().showEvent(e)
|
||
self.refresh()
|
||
|
||
def update_state(self, st):
|
||
b = st["battery"]
|
||
if not b.get("present"):
|
||
self.details.setText("No battery found")
|
||
return
|
||
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))
|
||
|
||
|
||
class SystemPage(Page):
|
||
def __init__(self, win):
|
||
super().__init__(win)
|
||
self.lay.addWidget(_title("System", "Firmware options and service status"))
|
||
self.fw_card = Card("Firmware options")
|
||
self.fw_boxes = {}
|
||
self.fw_area = QVBoxLayout()
|
||
self.fw_card.body.addLayout(self.fw_area)
|
||
self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
|
||
self.lay.addWidget(self.fw_card)
|
||
|
||
svc = Card("Status")
|
||
self.status = _muted("")
|
||
svc.body.addWidget(self.status)
|
||
h = QHBoxLayout()
|
||
b = QPushButton("Re-apply all settings")
|
||
b.clicked.connect(lambda: win.backend.run("restore"))
|
||
h.addWidget(b)
|
||
h.addStretch(1)
|
||
svc.body.addLayout(h)
|
||
self.lay.addWidget(svc)
|
||
|
||
about = Card("About")
|
||
about.body.addWidget(QLabel("%s %s" % (APP_NAME, __version__)))
|
||
about.body.addWidget(_muted("Open-source PredatorSense alternative for the Acer Predator Helios 16 (PH16-71). "
|
||
"Protocols based on the work of the Venator and Linuwu-Sense projects. "
|
||
"Not affiliated with Acer."))
|
||
self.lay.addWidget(about)
|
||
self.lay.addStretch(1)
|
||
|
||
def showEvent(self, e):
|
||
super().showEvent(e)
|
||
self.win.backend.run("get_firmware")
|
||
|
||
def firmware(self, fw):
|
||
for name, info in fw.items():
|
||
if "label" not in info:
|
||
continue
|
||
if name == "usb_charging":
|
||
w = self.fw_boxes.get(name)
|
||
if w is None:
|
||
row = QHBoxLayout()
|
||
row.addWidget(QLabel(info["label"]))
|
||
w = QComboBox()
|
||
for val, text in ((0, "Off"), (10, "Until battery 10 %"), (20, "Until battery 20 %"), (30, "Until battery 30 %")):
|
||
w.addItem(text, val)
|
||
w.activated.connect(lambda _=0, w=w: self.win.backend.run("set_firmware", name="usb_charging", value=w.currentData()))
|
||
row.addWidget(w)
|
||
row.addStretch(1)
|
||
self.fw_area.addLayout(row)
|
||
self.fw_boxes[name] = w
|
||
w.setEnabled(info.get("value") is not None)
|
||
if info.get("value") is not None:
|
||
w.setCurrentIndex(max(0, w.findData(info["value"])))
|
||
w.setToolTip(info.get("error", ""))
|
||
continue
|
||
box = self.fw_boxes.get(name)
|
||
if box is None:
|
||
box = QCheckBox(info["label"])
|
||
box.clicked.connect(lambda v, n=name: self.win.backend.run("set_firmware", name=n, value=bool(v)))
|
||
self.fw_area.addWidget(box)
|
||
self.fw_boxes[name] = box
|
||
box.setEnabled(info.get("value") is not None)
|
||
box.setChecked(bool(info.get("value")))
|
||
box.setToolTip(info.get("error", "") or "")
|
||
|
||
def update_state(self, st):
|
||
a = st["available"]
|
||
yes = lambda v: "✔" if v else "✘"
|
||
lines = ["%s Service predatord %s" % (yes(True), st.get("version")),
|
||
"%s RGB keyboard (hidraw)" % yes(a.get("keyboard")),
|
||
"%s WMI bridge (kernel module predator_wmi)" % yes(a.get("wmi"))]
|
||
for k, v in st.get("errors", {}).items():
|
||
lines.append("⚠ %s: %s" % (k, v))
|
||
self.status.setText("\n".join(lines))
|
||
|
||
|
||
# ------------------------------------------------------------------ window
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self, page="home"):
|
||
super().__init__()
|
||
self.setWindowTitle(APP_NAME)
|
||
self.setWindowIcon(QIcon.fromTheme("predator-control"))
|
||
self.resize(1240, 820)
|
||
self.setMinimumSize(980, 640)
|
||
self.state = None
|
||
self.backend = Backend()
|
||
self.backend.finished.connect(self._done)
|
||
|
||
root = QWidget()
|
||
root.setObjectName("root")
|
||
h = QHBoxLayout(root)
|
||
h.setContentsMargins(0, 0, 0, 0)
|
||
h.setSpacing(0)
|
||
side = QFrame()
|
||
side.setObjectName("sidebar")
|
||
side.setFixedWidth(220)
|
||
sl = QVBoxLayout(side)
|
||
sl.setContentsMargins(14, 22, 14, 18)
|
||
sl.setSpacing(4)
|
||
brand = QLabel("PREDATOR")
|
||
brand.setObjectName("brand")
|
||
sl.addWidget(brand)
|
||
sub = QLabel("CONTROL CENTER")
|
||
sub.setObjectName("brandSub")
|
||
sl.addWidget(sub)
|
||
sl.addSpacing(22)
|
||
self.stack = QStackedWidget()
|
||
self.nav = QButtonGroup(self)
|
||
self.pages = {}
|
||
classes = {"home": HomePage, "performance": PerformancePage, "fans": FansPage,
|
||
"rgb": RgbPage, "battery": BatteryPage, "system": SystemPage}
|
||
for key, label in PAGES:
|
||
b = QPushButton(label)
|
||
b.setObjectName("nav")
|
||
b.setCheckable(True)
|
||
b.clicked.connect(lambda _=False, k=key: self.show_page(k))
|
||
self.nav.addButton(b)
|
||
sl.addWidget(b)
|
||
pg = classes[key](self)
|
||
self.pages[key] = (pg, b)
|
||
self.stack.addWidget(pg)
|
||
sl.addStretch(1)
|
||
self.footer = QLabel("")
|
||
self.footer.setObjectName("muted")
|
||
self.footer.setWordWrap(True)
|
||
sl.addWidget(self.footer)
|
||
h.addWidget(side)
|
||
right = QVBoxLayout()
|
||
right.setContentsMargins(0, 0, 0, 0)
|
||
self.banner = QLabel()
|
||
self.banner.setObjectName("error")
|
||
self.banner.setWordWrap(True)
|
||
self.banner.setStyleSheet("padding:10px 32px;background:#2a0f16;")
|
||
self.banner.hide()
|
||
right.addWidget(self.banner)
|
||
right.addWidget(self.stack)
|
||
h.addLayout(right, 1)
|
||
self.setCentralWidget(root)
|
||
self.statusBar().showMessage("Ready")
|
||
|
||
self.timer = QTimer(self)
|
||
self.timer.timeout.connect(self.refresh)
|
||
self.timer.start(1500)
|
||
self.show_page(page)
|
||
self.refresh()
|
||
|
||
def show_page(self, key):
|
||
if key not in self.pages:
|
||
key = "home"
|
||
pg, b = self.pages[key]
|
||
b.setChecked(True)
|
||
self.stack.setCurrentWidget(pg)
|
||
if self.state:
|
||
pg.update_state(self.state)
|
||
|
||
def refresh(self):
|
||
st = read_state()
|
||
if not st:
|
||
self.banner.setText("The predatord service is not running. Start it with: "
|
||
"sudo systemctl enable --now predatord")
|
||
self.banner.show()
|
||
return
|
||
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"),
|
||
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
|
||
|
||
def _done(self, cmd, result, err):
|
||
if err:
|
||
self.statusBar().showMessage("Error: %s" % err, 8000)
|
||
elif cmd == "get_firmware":
|
||
self.pages["system"][0].firmware(result)
|
||
self.pages["battery"][0].firmware(result)
|
||
return
|
||
else:
|
||
self.statusBar().showMessage("Applied", 2000)
|
||
QTimer.singleShot(250, self.refresh)
|
||
|
||
|
||
def main(argv=None):
|
||
ap = argparse.ArgumentParser(prog="predator-control")
|
||
ap.add_argument("--page", default="home", choices=[p for p, _ in PAGES])
|
||
args, qt_args = ap.parse_known_args(argv)
|
||
|
||
app = QApplication([sys.argv[0]] + qt_args)
|
||
app.setApplicationName("predator-control")
|
||
app.setApplicationDisplayName(APP_NAME)
|
||
app.setDesktopFileName("predator-control")
|
||
app.setStyle("Fusion")
|
||
app.setStyleSheet(theme.QSS)
|
||
|
||
# single instance: forward the requested page to a running window
|
||
name = "predator-control-%d" % os.getuid()
|
||
sock = QLocalSocket()
|
||
sock.connectToServer(name)
|
||
if sock.waitForConnected(300):
|
||
sock.write(args.page.encode())
|
||
sock.waitForBytesWritten(300)
|
||
sock.disconnectFromServer()
|
||
return 0
|
||
QLocalServer.removeServer(name)
|
||
server = QLocalServer()
|
||
server.listen(name)
|
||
|
||
win = MainWindow(args.page)
|
||
|
||
def incoming():
|
||
c = server.nextPendingConnection()
|
||
if c.waitForReadyRead(500):
|
||
win.show_page(bytes(c.readAll()).decode().strip())
|
||
win.showNormal()
|
||
win.raise_()
|
||
win.activateWindow()
|
||
server.newConnection.connect(incoming)
|
||
|
||
win.show()
|
||
return app.exec()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|