The Predator key sends keyboard usage 0xC9, which lies outside the logical range the keyboard declares, so the kernel never emits an input event for it. It is also reported on vendor report 4 of USB interface 2 (04 81 ff on press). predatord now listens there and opens the app in the active graphical session via the user's systemd manager. The action is configurable on the System page: open the app, switch to the next performance mode, or do nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
970 lines
39 KiB
Python
970 lines
39 KiB
Python
"""Predator Control main window."""
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
||
from PySide6.QtGui import QColor, QIcon, QPalette
|
||
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)
|
||
|
||
key = Card("Predator key")
|
||
kr = QHBoxLayout()
|
||
kr.addWidget(QLabel("When the Predator key is pressed"))
|
||
self.key_action = QComboBox()
|
||
for val, text in (("open", "Open Predator Control"), ("cycle_profile", "Switch to the next performance mode"),
|
||
("none", "Do nothing")):
|
||
self.key_action.addItem(text, val)
|
||
self.key_action.activated.connect(lambda: win.backend.run("set_predator_key", action=self.key_action.currentData()))
|
||
kr.addWidget(self.key_action)
|
||
kr.addStretch(1)
|
||
key.body.addLayout(kr)
|
||
self.lay.addWidget(key)
|
||
|
||
upd = Card("Updates")
|
||
self.upd_head = QLabel("")
|
||
self.upd_head.setObjectName("big")
|
||
upd.body.addWidget(self.upd_head)
|
||
self.upd_text = _muted("")
|
||
self.upd_text.setTextFormat(Qt.MarkdownText)
|
||
self.upd_text.setOpenExternalLinks(True)
|
||
upd.body.addWidget(self.upd_text)
|
||
h = QHBoxLayout()
|
||
self.upd_check = QPushButton("Check now")
|
||
self.upd_check.clicked.connect(lambda: (self.upd_check.setEnabled(False), win.backend.run("check_update")))
|
||
h.addWidget(self.upd_check)
|
||
self.upd_install = QPushButton("Install update")
|
||
self.upd_install.setObjectName("primary")
|
||
self.upd_install.clicked.connect(self._install)
|
||
h.addWidget(self.upd_install)
|
||
self.upd_auto = QCheckBox("Check for updates automatically")
|
||
self.upd_auto.clicked.connect(lambda v: win.backend.run("set_updates", auto_check=bool(v)))
|
||
h.addWidget(self.upd_auto)
|
||
h.addStretch(1)
|
||
upd.body.addLayout(h)
|
||
self.lay.addWidget(upd)
|
||
|
||
svc = Card("Status")
|
||
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 _install(self):
|
||
u = self.win.state.get("update") or {}
|
||
if QMessageBox.question(self, "Install update",
|
||
"Install Predator Control %s now?\n\nThe background service restarts briefly; "
|
||
"reopen the app afterwards." % u.get("latest")) == QMessageBox.Yes:
|
||
self.upd_install.setEnabled(False)
|
||
self.win.backend.run("install_update")
|
||
|
||
def update_state(self, st):
|
||
u = st.get("update") or {}
|
||
status = st.get("update_status")
|
||
self.upd_check.setEnabled(True)
|
||
if status == "running":
|
||
self.upd_head.setText("Installing update…")
|
||
self.upd_text.setText("pacman is installing the new version.")
|
||
self.upd_install.hide()
|
||
elif u.get("available"):
|
||
self.upd_head.setText("Version %s available" % u["latest"])
|
||
text = "Installed: %s · [Release page](%s)\n\n%s" % (u["current"], u.get("url"), u.get("notes", "").split("### Install")[0].strip())
|
||
if not u.get("can_install"):
|
||
text += "\n\n*Automatic install needs the pacman package – otherwise run `git pull && sudo ./install.sh`.*"
|
||
self.upd_text.setText(text)
|
||
self.upd_install.setVisible(bool(u.get("can_install")))
|
||
else:
|
||
self.upd_head.setText("Up to date" if u else "Not checked yet")
|
||
when = time.strftime("%d.%m.%Y %H:%M", time.localtime(u["checked"])) if u.get("checked") else "–"
|
||
extra = "\n\nLast update failed – see: journalctl -u predator-control-update" if status == "failed" else ""
|
||
err = ("\n\n⚠ %s" % st["update_error"]) if st.get("update_error") else ""
|
||
self.upd_text.setText("Installed version %s · last check %s%s%s" % (st.get("version"), when, err, extra))
|
||
self.upd_install.hide()
|
||
if not self.key_action.view().isVisible():
|
||
self.key_action.setCurrentIndex(max(0, self.key_action.findData(st["config"].get("predator_key", "open"))))
|
||
self.upd_auto.setChecked(st["config"].get("updates", {}).get("auto_check", True))
|
||
a = st["available"]
|
||
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"]
|
||
u = st.get("update") or {}
|
||
badge = ("⬆ Update %s available\n" % u["latest"]) if u.get("available") else ""
|
||
self.footer.setText(badge + "%s\nCPU %s °C · GPU %s" % (
|
||
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
|
||
("%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 == "install_update":
|
||
self.statusBar().showMessage("Update is being installed…", 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)
|
||
pal = app.palette()
|
||
pal.setColor(QPalette.Link, QColor(theme.ACCENT))
|
||
app.setPalette(pal)
|
||
|
||
# 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())
|