Initial release of Predator Control 1.0.0
PredatorSense replacement for the Acer Predator Helios 16 (PH16-71) on Linux: - predatord: root service for profiles, fans (incl. temperature curves), keyboard RGB, lightbar and battery health; restores settings at boot and after resume, switches profiles on AC/battery changes - Qt desktop app with dashboard, fan curve editor and per-key RGB studio - Plasma 6 panel widget with quick controls - predatorctl command line tool - predator_wmi kernel module (DKMS) as a minimal WMI method bridge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,887 @@
|
||||
"""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, ColorRow, CurveEditor, Gauge, KeyboardPainter
|
||||
|
||||
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.l_bat = _muted("")
|
||||
quick.body.addWidget(self.l_bat)
|
||||
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.l_bat.setText("Battery %s %% · %s" % (b.get("capacity"), "AC" if b.get("ac_online") else "on battery"))
|
||||
|
||||
|
||||
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("Status")
|
||||
self.cap = QLabel("–")
|
||||
self.cap.setObjectName("big")
|
||||
info.body.addWidget(self.cap)
|
||||
self.details = _muted("")
|
||||
info.body.addWidget(self.details)
|
||||
info.body.addStretch(1)
|
||||
row.addWidget(info, 1)
|
||||
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.cap.setText("No battery")
|
||||
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"),
|
||||
"Health: %s %% of design capacity" % b.get("health"),
|
||||
"Charge cycles: %s" % b.get("cycles")]
|
||||
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)
|
||||
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())
|
||||
Reference in New Issue
Block a user