Files
MrBlakeandClaude Opus 5.5 571cfcca2a Add power settings for the lighting, bump to 1.4.0
New Power page:
- toggle for the firmware's 30 s keyboard backlight timeout
- idle timeout for keyboard and lightbar, separately for AC and battery;
  predatord watches all input devices and restores the lighting on the
  next input
- battery rules: dimmed keyboard brightness, keyboard off, lightbar off

The rules only change what is sent to the hardware; the saved lighting
settings stay untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:59:42 +02:00

1059 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"),
("power", "Power"),
("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"])
IDLE_CHOICES = [(0, "Never"), (30, "30 seconds"), (60, "1 minute"), (120, "2 minutes"), (300, "5 minutes"),
(600, "10 minutes"), (900, "15 minutes"), (1800, "30 minutes")]
class PowerPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Power", "Energy settings for the keyboard lighting and lightbar"))
fw = Card("Firmware backlight timeout")
self.fw_timeout = QCheckBox("Turn the keyboard backlight off after 30 s without input (firmware)")
self.fw_timeout.clicked.connect(lambda v: (win.backend.run("set_firmware", name="kbd_timeout", value=bool(v)),
QTimer.singleShot(800, lambda: win.backend.run("get_firmware"))))
fw.body.addWidget(self.fw_timeout)
fw.body.addWidget(_muted("This is the built-in automatic switch-off of the laptop. Turn it off to keep the "
"lighting on permanently, or use the adjustable timeout below instead."))
self.lay.addWidget(fw)
idle = Card("Turn lighting off when idle")
g = QGridLayout()
self.idle_ac = QComboBox()
self.idle_bat = QComboBox()
for combo, key, label in ((self.idle_ac, "idle_off_ac", "On AC power"),
(self.idle_bat, "idle_off_battery", "On battery")):
for val, text in IDLE_CHOICES:
combo.addItem(text, val)
combo.activated.connect(lambda _=0, c=combo, k=key: win.backend.run("set_power", **{k: c.currentData()}))
g.addWidget(QLabel("On AC power"), 0, 0)
g.addWidget(self.idle_ac, 0, 1)
g.addWidget(QLabel("On battery"), 1, 0)
g.addWidget(self.idle_bat, 1, 1)
g.setColumnStretch(2, 1)
idle.body.addLayout(g)
idle.body.addWidget(_muted("Keyboard and lightbar turn off after this time without keyboard, touchpad or "
"mouse input, and come back on with the next input."))
self.idle_state = _muted("")
idle.body.addWidget(self.idle_state)
self.lay.addWidget(idle)
bat = Card("On battery")
h = QHBoxLayout()
h.addWidget(QLabel("Keyboard brightness"))
self.bat_bright = _slider(10, 100, 100)
self.bat_bright.setSingleStep(10)
self.bat_bright_l = QLabel("100 %")
self.bat_bright.valueChanged.connect(lambda v: self.bat_bright_l.setText("%d %%" % v))
self.bat_bright.sliderReleased.connect(lambda: win.backend.run("set_power", battery_brightness=self.bat_bright.value()))
h.addWidget(self.bat_bright, 1)
h.addWidget(self.bat_bright_l)
bat.body.addLayout(h)
bat.body.addWidget(_muted("Percentage of your normal keyboard brightness while running on battery."))
self.bat_off = QCheckBox("Turn the keyboard lighting off completely on battery")
self.bat_off.clicked.connect(lambda v: win.backend.run("set_power", battery_lights_off=bool(v)))
bat.body.addWidget(self.bat_off)
self.bat_lb = QCheckBox("Turn the lightbar off on battery")
self.bat_lb.clicked.connect(lambda v: win.backend.run("set_power", battery_lightbar_off=bool(v)))
bat.body.addWidget(self.bat_lb)
self.lay.addWidget(bat)
self.lay.addStretch(1)
def showEvent(self, e):
super().showEvent(e)
self.win.backend.run("get_firmware")
def firmware(self, fw):
v = fw.get("kbd_timeout", {})
self.fw_timeout.setEnabled(v.get("value") is not None)
self.fw_timeout.setChecked(bool(v.get("value")))
self.fw_timeout.setToolTip(v.get("error", "") or "")
def update_state(self, st):
p = st["config"].get("power", {})
for combo, key in ((self.idle_ac, "idle_off_ac"), (self.idle_bat, "idle_off_battery")):
if not combo.view().isVisible():
i = combo.findData(p.get(key, 0))
if i < 0:
combo.addItem("%d seconds" % p[key], p[key])
i = combo.count() - 1
combo.setCurrentIndex(i)
if not self.bat_bright.isSliderDown():
self.bat_bright.setValue(p.get("battery_brightness", 100))
self.bat_off.setChecked(bool(p.get("battery_lights_off")))
self.bat_lb.setChecked(bool(p.get("battery_lightbar_off")))
self.idle_state.setText("Lighting is currently off because the laptop is idle." if st.get("lights_idle") else "")
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 or name == "kbd_timeout": # shown on the Power page
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, "power": PowerPage,
"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)
self.pages["power"][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())