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