Initial release of Predator Control 1.0.0

PredatorSense replacement for the Acer Predator Helios 16 (PH16-71) on Linux:

- predatord: root service for profiles, fans (incl. temperature curves),
  keyboard RGB, lightbar and battery health; restores settings at boot
  and after resume, switches profiles on AC/battery changes
- Qt desktop app with dashboard, fan curve editor and per-key RGB studio
- Plasma 6 panel widget with quick controls
- predatorctl command line tool
- predator_wmi kernel module (DKMS) as a minimal WMI method bridge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 18:38:14 +02:00
co-authored by Claude Opus 5.5
commit ea7ce49452
34 changed files with 3774 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
"""Predator Control – a PredatorSense replacement for the Acer Predator Helios 16 (PH16-71) on Linux."""
import os
__version__ = "1.0.0"
APP_NAME = "Predator Control"
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
SOCKET_PATH = RUN_DIR + "/control.sock"
STATE_PATH = RUN_DIR + "/state.json"
CONFIG_DIR = os.environ.get("PREDATOR_CONTROL_CONFIG_DIR", "/etc/predator-control")
CONFIG_PATH = CONFIG_DIR + "/config.json"
+145
View File
@@ -0,0 +1,145 @@
"""predatorctl – command line interface for Predator Control.
Examples:
predatorctl status
predatorctl profile performance
predatorctl fans max | auto | custom 60 70 | curve
predatorctl kbd static '#ff0000' --brightness 255
predatorctl kbd off
predatorctl lightbar wave --color '#00ffcc' --speed 7
predatorctl battery-limit on
predatorctl open rgb (opens the main window on a given page)
"""
import argparse
import json
import os
import subprocess
import sys
from .client import Client, DaemonError, read_state
def _bool(v):
return v.lower() in ("1", "on", "true", "yes")
def main(argv=None):
p = argparse.ArgumentParser(prog="predatorctl", description="Predator Control CLI")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="show status")
sp = sub.add_parser("state", help="raw state as JSON")
sp.add_argument("--cached", action="store_true", help="read state.json instead of asking the daemon")
sub.add_parser("restore", help="re-apply saved settings")
sp = sub.add_parser("profile", help="set power profile")
sp.add_argument("profile")
sp = sub.add_parser("profile-cycle", help="switch to the next profile")
sp = sub.add_parser("fans", help="fan mode")
sp.add_argument("mode", choices=["auto", "max", "custom", "curve"])
sp.add_argument("cpu", nargs="?", type=int)
sp.add_argument("gpu", nargs="?", type=int)
sp = sub.add_parser("kbd", help="keyboard lighting (effect name, 'on' or 'off')")
sp.add_argument("effect")
sp.add_argument("color", nargs="?")
sp.add_argument("--brightness", "-b", type=int)
sp = sub.add_parser("kbd-brightness", help="keyboard brightness 0-255 or +/-step")
sp.add_argument("value")
sp = sub.add_parser("preset", help="load a keyboard preset")
sp.add_argument("name")
sp = sub.add_parser("lightbar", help="Lightbar")
sp.add_argument("mode", nargs="?")
sp.add_argument("--color", "-c")
sp.add_argument("--brightness", "-b", type=int)
sp.add_argument("--speed", "-s", type=int)
sp.add_argument("--sync", choices=["on", "off"])
sp = sub.add_parser("battery-limit", help="80 %% charge limit")
sp.add_argument("value", choices=["on", "off"])
sp = sub.add_parser("open", help="open the main window")
sp.add_argument("page", nargs="?", default="home")
a = p.parse_args(argv)
if a.cmd == "open":
subprocess.Popen(["predator-control", "--page", a.page], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return 0
if a.cmd == "state" and a.cached:
print(json.dumps(read_state()))
return 0
c = Client()
try:
if a.cmd == "status":
st = c.call("state")
s, b = st["sensors"], st["battery"]
lbl = st["profile_labels"].get(st["profile"], st["profile"])
print("Profile: %s" % lbl)
print("CPU: %s °C %s %% fan %s RPM" % (s["cpu_temp"], st["cpu_load"], s["cpu_fan"]))
print("GPU: %s fan %s RPM" % ("%s °C" % s["gpu_temp"] if s["gpu_temp"] else "asleep", s["gpu_fan"]))
print("Fan mode: %s" % st["config"]["fans"]["mode"])
print("Battery: %s %% (%s), AC: %s" % (b.get("capacity"), b.get("status"), "yes" if b["ac_online"] else "no"))
k = st["config"]["keyboard"]
print("Keyboard: %s %s brightness %s" % (k["effect"], k["color"], k["brightness"]))
for k, v in st["errors"].items():
print("Error [%s]: %s" % (k, v))
elif a.cmd == "state":
print(json.dumps(c.call("state")))
elif a.cmd == "restore":
c.call("restore")
elif a.cmd == "profile":
c.call("set_profile", profile=a.profile)
elif a.cmd == "profile-cycle":
st = c.call("state")
ch = st["profile_choices"]
nxt = ch[(ch.index(st["profile"]) + 1) % len(ch)] if st["profile"] in ch else ch[0]
c.call("set_profile", profile=nxt)
print(st["profile_labels"].get(nxt, nxt))
elif a.cmd == "fans":
args = {"mode": a.mode}
if a.cpu is not None:
args["cpu"] = a.cpu
if a.gpu is not None:
args["gpu"] = a.gpu
c.call("set_fans", **args)
elif a.cmd == "kbd":
args = {"effect": a.effect}
if a.color:
args["color"] = a.color if a.color.startswith("#") else "#" + a.color
if a.brightness is not None:
args["brightness"] = a.brightness
c.call("set_keyboard", **args)
elif a.cmd == "kbd-brightness":
cur = c.call("state")["config"]["keyboard"]["brightness"]
v = a.value
val = cur + int(v) if v[0] in "+-" else int(v)
c.call("set_keyboard", brightness=max(0, min(255, val)))
elif a.cmd == "preset":
c.call("load_preset", name=a.name)
elif a.cmd == "lightbar":
args = {"mode": a.mode} if a.mode else {}
if a.color:
args["color"] = a.color if a.color.startswith("#") else "#" + a.color
if a.brightness is not None:
args["brightness"] = a.brightness
if a.speed is not None:
args["speed"] = a.speed
if a.sync:
args["sync_keyboard"] = a.sync == "on"
c.call("set_lightbar", **args)
elif a.cmd == "battery-limit":
c.call("set_battery_limit", enabled=_bool(a.value))
except DaemonError as e:
print("Error: %s" % e, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+65
View File
@@ -0,0 +1,65 @@
"""Client for the predatord socket."""
import json
import socket
from . import SOCKET_PATH, STATE_PATH
class DaemonError(RuntimeError):
pass
class Client:
def __init__(self, path=SOCKET_PATH, timeout=10):
self.path = path
self.timeout = timeout
self._sock = None
self._file = None
def _connect(self):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(self.timeout)
try:
s.connect(self.path)
except OSError as e:
s.close()
raise DaemonError("cannot reach the predatord service (%s). "
"Check 'systemctl status predatord'." % e) from e
self._sock = s
self._file = s.makefile("rwb")
def close(self):
if self._sock:
self._file.close()
self._sock.close()
self._sock = self._file = None
def call(self, cmd, **args):
for attempt in (0, 1):
if self._sock is None:
self._connect()
try:
self._file.write((json.dumps({"cmd": cmd, "args": args}) + "\n").encode())
self._file.flush()
line = self._file.readline()
if not line:
raise OSError("connection closed")
break
except OSError:
self.close()
if attempt:
raise DaemonError("lost connection to the service")
res = json.loads(line)
if not res.get("ok"):
raise DaemonError(res.get("error", "unknown error"))
return res.get("result")
def read_state():
"""Read the latest state snapshot (no socket needed)."""
try:
with open(STATE_PATH) as f:
return json.load(f)
except (OSError, ValueError):
return None
+469
View File
@@ -0,0 +1,469 @@
"""predatord – privileged background service.
* line-based JSON protocol over a Unix socket (root and members of the allowed group only)
* writes a state snapshot to /run/predator-control/state.json every 1.5 s
(read by the Plasma panel widget)
* restores settings at boot and after resume
* switches power profiles when the charger is plugged in / unplugged
* drives the fans along a user-defined temperature curve
"""
import copy
import grp
import json
import os
import pwd
import signal
import socket
import socketserver
import struct
import sys
import threading
import time
import traceback
from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__
from . import hardware as hw
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
DEFAULT_CONFIG = {
"allowed_group": "wheel",
"profile": {"ac": "balanced", "battery": "quiet", "auto_switch": True},
"fans": {
"mode": "auto", # auto | max | custom | curve
"cpu": 50, "gpu": 50,
"curve": {
"cpu": [[45, 0], [60, 35], [72, 55], [82, 75], [90, 100]],
"gpu": [[45, 0], [60, 35], [70, 55], [78, 75], [85, 100]],
},
},
"keyboard": {
"effect": "static", "color": "#00c8ff", "brightness": 200, "effect_id": 0,
"frame": "", # 384 bytes as hex (perkey)
},
"lightbar": {
"mode": "static", "color": "#00c8ff", "brightness": 200, "speed": 5,
"direction": 1, "sync_keyboard": True,
},
"battery": {"limit": None}, # None = leave the firmware state alone
"keyboard_presets": {},
}
def hex_to_rgb(s):
s = s.lstrip("#")
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
def merge(base, upd):
for k, v in upd.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
merge(base[k], v)
else:
base[k] = v
return base
def interp(points, x):
pts = sorted(points)
if not pts:
return 0
if x <= pts[0][0]:
return pts[0][1]
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
if x <= x1:
return y0 + (y1 - y0) * (x - x0) / max(1e-6, x1 - x0)
return pts[-1][1]
def log(*a):
print(*a, flush=True)
class Daemon:
def __init__(self):
self.lock = threading.RLock()
self.kbd = Keyboard()
self.cpuload = CpuLoadProxy()
self.config = self._load_config()
self.errors = {}
self.last_ac = None
self.curve_applied = None
self.kbd_present = False
self.state = {}
self.battery_limit = None
self._ticks = 0
# ---------------------------------------------------------- Configuration
def _load_config(self):
cfg = copy.deepcopy(DEFAULT_CONFIG)
try:
with open(CONFIG_PATH) as f:
merge(cfg, json.load(f))
except FileNotFoundError:
pass
except Exception as e:
log("Invalid configuration, using defaults:", e)
return cfg
def save_config(self):
os.makedirs(CONFIG_DIR, exist_ok=True)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(self.config, f, indent=2)
os.replace(tmp, CONFIG_PATH)
# ---------------------------------------------------------- Applying
def _try(self, key, fn, *a, **kw):
try:
r = fn(*a, **kw)
self.errors.pop(key, None)
return r
except Exception as e:
if self.errors.get(key) != str(e):
log("[%s] %s" % (key, e))
self.errors[key] = str(e)
return None
def apply_keyboard(self, cfg=None):
k = cfg or self.config["keyboard"]
frame = bytes.fromhex(k.get("frame") or "") if k["effect"] == "perkey" else None
self._try("keyboard", self.kbd.apply, k["effect"], hex_to_rgb(k["color"]),
k["brightness"], frame, int(k.get("effect_id") or 0))
if self.config["lightbar"].get("sync_keyboard") and cfg is None:
self.apply_lightbar()
def lightbar_effective(self):
lb = dict(self.config["lightbar"])
if lb.get("sync_keyboard"):
k = self.config["keyboard"]
lb["brightness"] = k["brightness"]
if k["effect"] == "off":
lb["mode"] = "off"
elif k["effect"] in ("rainbow", "neon"):
lb["mode"] = "rainbow" if k["effect"] == "rainbow" else "neon"
elif k["effect"] == "breathing":
lb["mode"], lb["color"] = "breathing", k["color"]
elif k["effect"] == "perkey":
pass # keep the lightbar's own setting
else:
lb["mode"], lb["color"] = "static", k["color"]
return lb
def apply_lightbar(self):
lb = self.lightbar_effective()
self._try("lightbar", hw.set_lightbar, lb["mode"], hex_to_rgb(lb["color"]),
lb["brightness"], lb["speed"], lb["direction"])
def apply_fans(self):
f = self.config["fans"]
self.curve_applied = None
if f["mode"] == "curve":
self.fan_curve_tick(force=True)
else:
self._try("fans", hw.set_fans, f["mode"], f["cpu"], f["gpu"])
def apply_profile_for_power(self, ac):
p = self.config["profile"]
target = p["ac"] if ac else p["battery"]
if target in hw.profile_choices():
self._try("profile", hw.set_profile, target)
def restore(self):
log("Restoring settings")
bat = hw.battery()
if self.config["profile"].get("auto_switch"):
self.apply_profile_for_power(bat.get("ac_online", True))
self.apply_fans()
if self.kbd.available():
self.apply_keyboard()
else:
self.apply_lightbar()
lim = self.config["battery"].get("limit")
if lim is not None and hw.wmi_available(hw.GUID_BATTERY):
self._try("battery", hw.set_battery_limit, lim)
self.refresh_battery_limit()
def refresh_battery_limit(self):
if hw.wmi_available(hw.GUID_BATTERY):
try:
self.battery_limit = hw.get_battery_limit()
except Exception:
self.battery_limit = None
# ---------------------------------------------------------- Background
def fan_curve_tick(self, force=False):
f = self.config["fans"]
if f["mode"] != "curve":
return
s = hw.sensors()
cpu_t = s.get("cpu_temp") or 0
gpu_t = s.get("gpu_temp") or cpu_t
cpu = int(round(interp(f["curve"]["cpu"], cpu_t) / 5) * 5)
gpu = int(round(interp(f["curve"]["gpu"], gpu_t) / 5) * 5)
if s.get("dgpu_active") is False:
gpu = min(gpu, cpu)
target = (cpu, gpu)
prev = self.curve_applied
# hysteresis: ramp up immediately, ramp down only after a >=10 % drop
if not force and prev is not None:
if all(t <= p and p - t < 10 for t, p in zip(target, prev)) or target == prev:
return
self._try("fans", hw.set_fans, "custom", cpu, gpu)
self.curve_applied = target
def build_state(self):
s = hw.sensors()
bat = hw.battery()
st = {
"version": __version__,
"time": time.time(),
"sensors": s,
"cpu_load": self.cpuload.value,
"cpu_freq": hw.cpu_freq_mhz(),
"memory": hw.mem_usage(),
"battery": bat,
"profile": hw.get_profile(),
"profile_choices": hw.profile_choices(),
"profile_labels": hw.PROFILE_LABELS,
"config": self.config,
"lightbar_effective": self.lightbar_effective(),
"fan_curve_applied": self.curve_applied,
"battery_limit": self.battery_limit,
"available": {
"keyboard": self.kbd_present,
"wmi": hw.wmi_available(hw.GUID_GAMING),
"battery_wmi": hw.wmi_available(hw.GUID_BATTERY),
},
"errors": dict(self.errors),
"keyboard_effects": {k: [v[2], v[1]] for k, v in EFFECTS.items()}, # [label, uses colour]
"lightbar_modes": {k: v[1] for k, v in hw.LIGHTBAR_MODES.items()},
}
return st
def loop(self):
while True:
try:
self.cpuload.sample()
with self.lock:
present = self.kbd.available()
if present and not self.kbd_present:
# keyboard (re)appeared, e.g. after resume
if self.kbd_present is not None:
time.sleep(0.5)
self.apply_keyboard()
self.kbd_present = present
ac = hw.battery().get("ac_online", True)
if self.last_ac is not None and ac != self.last_ac:
log("Power source changed:", "AC" if ac else "battery")
if self.config["profile"].get("auto_switch"):
self.apply_profile_for_power(ac)
self.last_ac = ac
self.fan_curve_tick()
self._ticks += 1
if self._ticks % 40 == 0:
self.refresh_battery_limit()
self.state = self.build_state()
write_state(self.state)
except Exception:
traceback.print_exc()
time.sleep(1.5)
# ---------------------------------------------------------- Commands
def handle(self, req):
cmd = req.get("cmd")
a = req.get("args", {}) or {}
with self.lock:
if cmd == "ping":
return {"version": __version__}
if cmd == "state":
self.state = self.build_state()
return self.state
if cmd == "restore":
self.restore()
return {}
if cmd == "set_profile":
hw.set_profile(a["profile"])
# a manual choice becomes the profile for the current power source
ac = hw.battery().get("ac_online", True)
self.config["profile"]["ac" if ac else "battery"] = a["profile"]
self.save_config()
return {}
if cmd == "set_profile_policy":
merge(self.config["profile"], {k: a[k] for k in ("ac", "battery", "auto_switch") if k in a})
self.save_config()
if self.config["profile"]["auto_switch"]:
self.apply_profile_for_power(hw.battery().get("ac_online", True))
return {}
if cmd == "set_fans":
merge(self.config["fans"], {k: a[k] for k in ("mode", "cpu", "gpu", "curve") if k in a})
self.save_config()
self.apply_fans()
return self._result("fans")
if cmd == "set_keyboard":
k = self.config["keyboard"]
if a.get("effect") == "on":
a["effect"] = k.get("last_effect") or "static"
if a.get("effect") == "off" and k["effect"] != "off":
k["last_effect"] = k["effect"]
for key in ("effect", "color", "brightness", "effect_id", "frame"):
if key in a:
k[key] = a[key]
if k["effect"] not in EFFECTS:
raise ValueError("unknown effect")
if a.get("frame") is not None and len(bytes.fromhex(a["frame"])) != NUM_CELLS * 3:
raise ValueError("frame must be %d bytes" % (NUM_CELLS * 3))
if a.get("save", True):
self.save_config()
self.apply_keyboard()
return self._result("keyboard")
if cmd == "preview_keyboard":
# preview without saving (e.g. while painting in the RGB studio)
k = dict(self.config["keyboard"])
k.update({x: a[x] for x in ("effect", "color", "brightness", "frame", "effect_id") if x in a})
self.apply_keyboard(k)
return self._result("keyboard")
if cmd == "set_lightbar":
for key in ("mode", "color", "brightness", "speed", "direction", "sync_keyboard"):
if key in a:
self.config["lightbar"][key] = a[key]
if self.config["lightbar"]["mode"] not in hw.LIGHTBAR_MODES:
raise ValueError("unknown lightbar mode")
self.save_config()
self.apply_lightbar()
return self._result("lightbar")
if cmd == "save_preset":
self.config["keyboard_presets"][a["name"]] = dict(self.config["keyboard"])
self.save_config()
return {}
if cmd == "delete_preset":
self.config["keyboard_presets"].pop(a["name"], None)
self.save_config()
return {}
if cmd == "load_preset":
self.config["keyboard"] = dict(self.config["keyboard_presets"][a["name"]])
self.save_config()
self.apply_keyboard()
return self._result("keyboard")
if cmd == "set_battery_limit":
hw.set_battery_limit(bool(a["enabled"]))
self.config["battery"]["limit"] = bool(a["enabled"])
self.save_config()
self.refresh_battery_limit()
return {}
if cmd == "set_battery_calibration":
hw.set_battery_calibration(bool(a["enabled"]))
return {}
if cmd == "get_firmware":
out = {}
for name, (getter, _s, label) in hw.FIRMWARE_OPTIONS.items():
try:
out[name] = {"label": label, "value": getter()}
except Exception as e:
out[name] = {"label": label, "value": None, "error": str(e)}
for name, getter in (("battery_limit", hw.get_battery_limit),
("battery_calibration", hw.get_battery_calibration)):
try:
out[name] = {"value": getter()}
except Exception as e:
out[name] = {"value": None, "error": str(e)}
return out
if cmd == "set_firmware":
name = a["name"]
if name not in hw.FIRMWARE_OPTIONS:
raise ValueError("unknown option")
hw.FIRMWARE_OPTIONS[name][1](a["value"])
return {}
raise ValueError("unknown command: %s" % cmd)
def _result(self, key):
if key in self.errors:
raise RuntimeError(self.errors[key])
return {}
class CpuLoadProxy:
def __init__(self):
self._c = hw.CpuLoad()
self.value = None
def sample(self):
v = self._c.sample()
if v is not None:
self.value = v
def write_state(state):
tmp = STATE_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f)
os.chmod(tmp, 0o644)
os.replace(tmp, STATE_PATH)
def peer_allowed(sock, group):
creds = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
_pid, uid, gid = struct.unpack("3i", creds)
if uid == 0:
return True
try:
g = grp.getgrnam(group)
except KeyError:
return False
if gid == g.gr_gid:
return True
try:
user = pwd.getpwuid(uid).pw_name
except KeyError:
return False
return user in g.gr_mem or pwd.getpwuid(uid).pw_gid == g.gr_gid
def make_handler(daemon):
class Handler(socketserver.StreamRequestHandler):
def handle(self):
if not peer_allowed(self.request, daemon.config.get("allowed_group", "wheel")):
self.wfile.write(b'{"ok": false, "error": "permission denied (user not in allowed group)"}\n')
return
for line in self.rfile:
try:
req = json.loads(line)
res = {"ok": True, "result": daemon.handle(req)}
except Exception as e:
res = {"ok": False, "error": str(e)}
self.wfile.write((json.dumps(res) + "\n").encode())
self.wfile.flush()
return Handler
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def main():
if os.geteuid() != 0:
sys.exit("predatord must run as root")
os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True)
os.chmod(os.path.dirname(SOCKET_PATH), 0o755)
try:
os.unlink(SOCKET_PATH)
except FileNotFoundError:
pass
d = Daemon()
d.kbd_present = None
log("predatord %s started" % __version__)
d.restore()
d.kbd_present = d.kbd.available()
server = Server(SOCKET_PATH, make_handler(d))
os.chmod(SOCKET_PATH, 0o666) # access is checked via SO_PEERCRED
signal.signal(signal.SIGTERM, lambda *_: (server.server_close(), sys.exit(0)))
signal.signal(signal.SIGUSR1, lambda *_: threading.Thread(target=lambda: (d.lock.acquire(), d.restore(), d.lock.release())).start())
threading.Thread(target=d.loop, daemon=True).start()
server.serve_forever()
if __name__ == "__main__":
main()
View File
+887
View File
@@ -0,0 +1,887 @@
"""Predator Control main window."""
import argparse
import os
import sys
import threading
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QIcon
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget,
QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
QScrollArea, QSlider, QSpinBox, QStackedWidget, QTabWidget,
QVBoxLayout, QWidget)
from .. import APP_NAME, __version__, layout
from ..client import Client, DaemonError, read_state
from . import theme
from .widgets import Bar, Card, ColorRow, CurveEditor, Gauge, KeyboardPainter
PAGES = [
("home", "Dashboard"),
("performance", "Performance"),
("fans", "Fans"),
("rgb", "RGB Studio"),
("battery", "Battery"),
("system", "System"),
]
PROFILE_INFO = {
"low-power": ("Eco", "Maximum battery life, lowest power limits."),
"quiet": ("Quiet", "Low noise for office work and video."),
"balanced": ("Balanced", "Everyday mix of performance and acoustics."),
"balanced-performance": ("Performance", "Higher power limits for gaming."),
"performance": ("Turbo", "Maximum CPU/GPU power, fans run loud."),
}
# ------------------------------------------------------------------ backend
class Backend(QObject):
"""Runs daemon commands in worker threads so the UI never blocks."""
finished = Signal(str, object, str) # command, result, error
def run(self, cmd, **args):
def work():
c = Client()
try:
res, err = c.call(cmd, **args), ""
except (DaemonError, OSError, ValueError) as e:
res, err = None, str(e)
finally:
c.close()
self.finished.emit(cmd, res, err)
threading.Thread(target=work, daemon=True).start()
def call(self, cmd, **args):
c = Client()
try:
return c.call(cmd, **args)
finally:
c.close()
def _title(text, sub=None):
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 8)
lay.setSpacing(2)
t = QLabel(text)
t.setObjectName("pageTitle")
lay.addWidget(t)
if sub:
s = QLabel(sub)
s.setObjectName("pageSub")
s.setWordWrap(True)
lay.addWidget(s)
return w
def _slider(lo, hi, val=0):
s = QSlider(Qt.Horizontal)
s.setRange(lo, hi)
s.setValue(val)
return s
def _muted(text):
l = QLabel(text)
l.setObjectName("muted")
l.setWordWrap(True)
return l
class Page(QScrollArea):
def __init__(self, win):
super().__init__()
self.win = win
self.setWidgetResizable(True)
inner = QWidget()
inner.setObjectName("page")
self.lay = QVBoxLayout(inner)
self.lay.setContentsMargins(32, 28, 32, 28)
self.lay.setSpacing(16)
self.setWidget(inner)
def update_state(self, st):
pass
# ------------------------------------------------------------------ pages
class HomePage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Dashboard", "Live system monitoring"))
grid = QGridLayout()
grid.setSpacing(16)
self.g_cpu = Gauge("CPU", "°C", 105, warn=90)
self.g_gpu = Gauge("GPU", "°C", 100, warn=85)
self.g_cfan = Gauge("CPU fan", "RPM", 7000)
self.g_gfan = Gauge("GPU fan", "RPM", 7000)
for i, g in enumerate((self.g_cpu, self.g_gpu, self.g_cfan, self.g_gfan)):
c = Card()
c.body.addWidget(g)
c.setMinimumHeight(210)
grid.addWidget(c, 0, i)
self.lay.addLayout(grid)
row = QHBoxLayout()
row.setSpacing(16)
load = Card("System")
self.b_load = Bar("CPU load")
self.b_freq = Bar("CPU clock (avg)")
self.b_mem = Bar("Memory")
self.b_sys = Bar("Chassis temperature")
for b in (self.b_load, self.b_freq, self.b_mem, self.b_sys):
load.body.addWidget(b)
row.addWidget(load, 3)
quick = Card("Mode")
self.l_profile = QLabel("–")
self.l_profile.setObjectName("big")
quick.body.addWidget(self.l_profile)
self.l_fans = _muted("")
quick.body.addWidget(self.l_fans)
self.l_bat = _muted("")
quick.body.addWidget(self.l_bat)
b = QPushButton("Next profile")
b.setObjectName("primary")
b.clicked.connect(self._cycle)
quick.body.addWidget(b)
quick.body.addStretch(1)
row.addWidget(quick, 2)
self.lay.addLayout(row)
self.lay.addStretch(1)
def _cycle(self):
st = self.win.state
if not st:
return
ch = st["profile_choices"]
nxt = ch[(ch.index(st["profile"]) + 1) % len(ch)] if st["profile"] in ch else ch[0]
self.win.backend.run("set_profile", profile=nxt)
def update_state(self, st):
s = st["sensors"]
self.g_cpu.set_value(s.get("cpu_temp"))
self.g_gpu.set_value(s.get("gpu_temp"), "" if s.get("gpu_temp") else "dGPU asleep")
self.g_cfan.set_value(s.get("cpu_fan"))
self.g_gfan.set_value(s.get("gpu_fan"))
load = st.get("cpu_load") or 0
self.b_load.set_value(load / 100, "%.0f %%" % load)
f = st.get("cpu_freq") or 0
self.b_freq.set_value(f / 5500, "%.2f GHz" % (f / 1000))
m = st.get("memory") or {}
self.b_mem.set_value((m.get("percent") or 0) / 100, "%s / %s GB" % (m.get("used_gb"), m.get("total_gb")))
t = s.get("sys_temp") or 0
self.b_sys.set_value(t / 100, "%s °C" % (t or "–"))
self.l_profile.setText(PROFILE_INFO.get(st["profile"], (st["profile"],))[0])
self.l_fans.setText("Fans: %s" % st["config"]["fans"]["mode"].capitalize())
b = st["battery"]
self.l_bat.setText("Battery %s %% · %s" % (b.get("capacity"), "AC" if b.get("ac_online") else "on battery"))
class PerformancePage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Performance", "Choose an operating mode. Uses the firmware's ACPI platform profiles."))
card = Card("Operating mode")
row = QHBoxLayout()
row.setSpacing(12)
self.group = QButtonGroup(self)
self.group.setExclusive(True)
self.buttons = {}
for key, (name, desc) in PROFILE_INFO.items():
b = QPushButton(name)
b.setObjectName("profileTurbo" if key == "performance" else "profile")
b.setCheckable(True)
b.setToolTip(desc)
b.clicked.connect(lambda _=False, k=key: win.backend.run("set_profile", profile=k))
self.group.addButton(b)
self.buttons[key] = b
row.addWidget(b)
card.body.addLayout(row)
self.desc = _muted("")
card.body.addWidget(self.desc)
self.lay.addWidget(card)
pol = Card("Automatic switching")
self.auto = QCheckBox("Switch the profile automatically when plugging in / unplugging the charger")
self.auto.toggled.connect(self._policy)
pol.body.addWidget(self.auto)
g = QGridLayout()
self.ac = QComboBox()
self.bat = QComboBox()
for k, (name, _d) in PROFILE_INFO.items():
self.ac.addItem(name, k)
self.bat.addItem(name, k)
self.ac.activated.connect(self._policy)
self.bat.activated.connect(self._policy)
g.addWidget(QLabel("On AC power"), 0, 0)
g.addWidget(self.ac, 0, 1)
g.addWidget(QLabel("On battery"), 1, 0)
g.addWidget(self.bat, 1, 1)
g.setColumnStretch(2, 1)
pol.body.addLayout(g)
pol.body.addWidget(_muted("Choosing a mode above also stores it for the current power source. "
"Tip: the Predator/Turbo key cycles the firmware profiles as well."))
self.lay.addWidget(pol)
self.lay.addStretch(1)
def _policy(self, *_):
self.win.backend.run("set_profile_policy", auto_switch=self.auto.isChecked(),
ac=self.ac.currentData(), battery=self.bat.currentData())
def update_state(self, st):
for k, b in self.buttons.items():
b.setEnabled(k in st["profile_choices"])
b.setChecked(k == st["profile"])
self.desc.setText(PROFILE_INFO.get(st["profile"], ("", ""))[1])
p = st["config"]["profile"]
for w, v in ((self.auto, p["auto_switch"]),):
w.blockSignals(True)
w.setChecked(v)
w.blockSignals(False)
for combo, key in ((self.ac, "ac"), (self.bat, "battery")):
if not combo.view().isVisible():
combo.blockSignals(True)
combo.setCurrentIndex(max(0, combo.findData(p[key])))
combo.blockSignals(False)
class FansPage(Page):
MODES = [("auto", "Auto", "Firmware controls the fans."),
("max", "Max", "Both fans at full speed (cooler boost)."),
("custom", "Manual", "Fixed speed for each fan."),
("curve", "Curve", "Speed follows your temperature curve.")]
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Fans", "Fan control via the Acer gaming WMI interface"))
top = Card("Fan mode")
row = QHBoxLayout()
self.group = QButtonGroup(self)
self.mode_btns = {}
for key, name, desc in self.MODES:
b = QPushButton(name)
b.setObjectName("profile")
b.setCheckable(True)
b.setToolTip(desc)
b.clicked.connect(lambda _=False, k=key: self._set_mode(k))
self.group.addButton(b)
self.mode_btns[key] = b
row.addWidget(b)
top.body.addLayout(row)
self.rpm = _muted("")
top.body.addWidget(self.rpm)
self.lay.addWidget(top)
self.manual = Card("Manual speed")
g = QGridLayout()
self.s_cpu = _slider(0, 100, 50)
self.s_gpu = _slider(0, 100, 50)
self.l_cpu = QLabel()
self.l_gpu = QLabel()
for i, (name, s, l) in enumerate((("CPU fan", self.s_cpu, self.l_cpu), ("GPU fan", self.s_gpu, self.l_gpu))):
s.setSingleStep(5)
s.setPageStep(10)
s.valueChanged.connect(lambda v, l=l: l.setText("%d %%" % v if v else "auto"))
s.sliderReleased.connect(self._apply_manual)
g.addWidget(QLabel(name), i, 0)
g.addWidget(s, i, 1)
g.addWidget(l, i, 2)
g.setColumnStretch(1, 1)
self.manual.body.addLayout(g)
self.manual.body.addWidget(_muted("0 % leaves that fan under firmware control."))
self.lay.addWidget(self.manual)
self.curve = Card("Fan curves")
self.c_cpu = CurveEditor("CPU (package temperature)")
self.c_gpu = CurveEditor("GPU (dGPU temperature)")
self.c_cpu.changed.connect(lambda pts: self._apply_curve("cpu", pts))
self.c_gpu.changed.connect(lambda pts: self._apply_curve("gpu", pts))
h = QHBoxLayout()
h.addWidget(self.c_cpu)
h.addWidget(self.c_gpu)
self.curve.body.addLayout(h)
self.curve_info = _muted("")
self.curve.body.addWidget(self.curve_info)
self.curve.body.addWidget(_muted("Drag the points. The daemon evaluates the curve every 1.5 s, "
"ramps up immediately and ramps down with 10 % hysteresis."))
self.lay.addWidget(self.curve)
self.lay.addStretch(1)
def _set_mode(self, mode):
args = {"mode": mode}
if mode == "custom":
args.update(cpu=self.s_cpu.value(), gpu=self.s_gpu.value())
self.win.backend.run("set_fans", **args)
def _apply_manual(self):
self.win.backend.run("set_fans", mode="custom", cpu=self.s_cpu.value(), gpu=self.s_gpu.value())
def _apply_curve(self, which, pts):
curve = dict(self.win.state["config"]["fans"]["curve"]) if self.win.state else {}
curve[which] = pts
self.win.backend.run("set_fans", curve=curve, mode="curve")
def update_state(self, st):
f = st["config"]["fans"]
for k, b in self.mode_btns.items():
b.setChecked(k == f["mode"])
s = st["sensors"]
self.rpm.setText("CPU fan %s RPM · GPU fan %s RPM · CPU %s °C · GPU %s" % (
s.get("cpu_fan"), s.get("gpu_fan"), s.get("cpu_temp"),
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "asleep"))
self.manual.setVisible(f["mode"] == "custom")
self.curve.setVisible(f["mode"] == "curve")
for sl, key in ((self.s_cpu, "cpu"), (self.s_gpu, "gpu")):
if not sl.isSliderDown():
sl.blockSignals(True)
sl.setValue(f[key])
sl.blockSignals(False)
self.l_cpu.setText("%d %%" % self.s_cpu.value() if self.s_cpu.value() else "auto")
self.l_gpu.setText("%d %%" % self.s_gpu.value() if self.s_gpu.value() else "auto")
self.c_cpu.set_points(f["curve"]["cpu"])
self.c_gpu.set_points(f["curve"]["gpu"])
self.c_cpu.set_current(s.get("cpu_temp"))
self.c_gpu.set_current(s.get("gpu_temp"))
ap = st.get("fan_curve_applied")
self.curve_info.setText("Current target: CPU %s %% · GPU %s %%" % tuple(ap) if ap else "")
class RgbPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("RGB Studio", "Keyboard effects, per-key painting and the rear lightbar"))
tabs = QTabWidget()
tabs.addTab(self._effects_tab(), "Keyboard effects")
tabs.addTab(self._perkey_tab(), "Per-key editor")
tabs.addTab(self._lightbar_tab(), "Lightbar")
self.tabs = tabs
self.lay.addWidget(tabs)
self.lay.addStretch(1)
self._preview_timer = QTimer(self)
self._preview_timer.setSingleShot(True)
self._preview_timer.setInterval(120)
self._preview_timer.timeout.connect(self._send_preview)
self._loaded_frame = None
# ---- effects
def _effects_tab(self):
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 12, 0, 0)
lay.setSpacing(16)
left = Card("Effect")
self.effects = QListWidget()
self.effects.setMinimumWidth(220)
self.effects.itemClicked.connect(self._effect_clicked)
left.body.addWidget(self.effects)
lay.addWidget(left, 1)
right = Card("Settings")
right.body.addWidget(QLabel("Colour"))
self.color = ColorRow()
self.color.colorChosen.connect(lambda c: self.win.backend.run("set_keyboard", color=c.name()))
right.body.addWidget(self.color)
right.body.addSpacing(8)
right.body.addWidget(QLabel("Brightness"))
self.bright = _slider(0, 255, 200)
self.bright.sliderReleased.connect(lambda: self.win.backend.run("set_keyboard", brightness=self.bright.value()))
right.body.addWidget(self.bright)
right.body.addSpacing(8)
h = QHBoxLayout()
h.addWidget(QLabel("Effect variant (raw ID, 0 = default)"))
self.variant = QSpinBox()
self.variant.setRange(0, 255)
self.variant.setDisplayIntegerBase(16)
self.variant.setPrefix("0x")
self.variant.editingFinished.connect(lambda: self.win.backend.run("set_keyboard", effect_id=self.variant.value()))
h.addWidget(self.variant)
h.addStretch(1)
right.body.addLayout(h)
right.body.addWidget(_muted("Many effects have hidden variants with other speeds or directions "
"(e.g. 0x07 for ripple, 0x14 for explosion)."))
right.body.addSpacing(8)
right.body.addWidget(QLabel("Presets"))
pr = QHBoxLayout()
self.presets = QComboBox()
self.presets.setMinimumWidth(180)
pr.addWidget(self.presets, 1)
for text, fn in (("Load", self._load_preset), ("Save as…", self._save_preset), ("Delete", self._delete_preset)):
b = QPushButton(text)
b.clicked.connect(fn)
pr.addWidget(b)
right.body.addLayout(pr)
right.body.addStretch(1)
lay.addWidget(right, 2)
return w
def _effect_clicked(self, item):
self.win.backend.run("set_keyboard", effect=item.data(Qt.UserRole), effect_id=0)
def _save_preset(self):
name, ok = QInputDialog.getText(self, "Save preset", "Name:")
if ok and name.strip():
self.win.backend.run("save_preset", name=name.strip())
def _load_preset(self):
if self.presets.currentText():
self.win.backend.run("load_preset", name=self.presets.currentText())
def _delete_preset(self):
if self.presets.currentText():
self.win.backend.run("delete_preset", name=self.presets.currentText())
# ---- per key
def _perkey_tab(self):
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 12, 0, 0)
lay.setSpacing(12)
card = Card()
tools = QHBoxLayout()
tools.addWidget(QLabel("Brush"))
self.brush = ColorRow()
self.brush.colorChosen.connect(lambda c: setattr(self.painter, "brush", QColor(c)))
tools.addWidget(self.brush, 1)
tools.addWidget(QLabel("Layout"))
self.layout_combo = QComboBox()
for k, v in layout.VARIANTS.items():
self.layout_combo.addItem(v, k)
tools.addWidget(self.layout_combo)
card.body.addLayout(tools)
self.painter = KeyboardPainter()
self.painter.brush = QColor(self.brush.current)
self.layout_combo.setCurrentIndex(max(0, self.layout_combo.findData(self.painter.variant)))
self.layout_combo.currentIndexChanged.connect(lambda: self.painter.set_variant(self.layout_combo.currentData()))
self.painter.painted.connect(self._painted)
self.painter.picked.connect(lambda c: (self.brush.set_color(c), setattr(self.painter, "brush", QColor(c))))
card.body.addWidget(self.painter)
card.body.addWidget(_muted("Left click / drag paints · right click picks a key's colour · "
"changes are previewed live on the keyboard"))
g = QHBoxLayout()
g.addWidget(QLabel("Fill:"))
b = QPushButton("All")
b.clicked.connect(lambda: self.painter.fill(range(128), self.painter.brush))
g.addWidget(b)
for name, cells in layout.GROUPS.items():
b = QPushButton(name)
b.clicked.connect(lambda _=False, c=cells: self.painter.fill(c, self.painter.brush))
g.addWidget(b)
b = QPushButton("Clear")
b.clicked.connect(lambda: self.painter.fill(range(128), QColor(0, 0, 0)))
g.addWidget(b)
g.addStretch(1)
card.body.addLayout(g)
act = QHBoxLayout()
act.addStretch(1)
self.live = QCheckBox("Live preview")
self.live.setChecked(True)
act.addWidget(self.live)
b = QPushButton("Discard")
b.clicked.connect(self._discard)
act.addWidget(b)
b = QPushButton("Apply && save")
b.setObjectName("primary")
b.clicked.connect(self._save_frame)
act.addWidget(b)
card.body.addLayout(act)
lay.addWidget(card)
return w
def _painted(self):
if self.live.isChecked():
self._preview_timer.start()
def _send_preview(self):
self.win.backend.run("preview_keyboard", effect="perkey", frame=self.painter.frame_hex())
def _save_frame(self):
self.win.backend.run("set_keyboard", effect="perkey", frame=self.painter.frame_hex())
def _discard(self):
self._loaded_frame = None
self.win.backend.run("restore")
# ---- lightbar
def _lightbar_tab(self):
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 12, 0, 0)
card = Card("Rear lightbar")
self.lb_sync = QCheckBox("Follow the keyboard (colour, brightness and effect)")
self.lb_sync.toggled.connect(lambda v: self.win.backend.run("set_lightbar", sync_keyboard=v))
card.body.addWidget(self.lb_sync)
g = QGridLayout()
g.setVerticalSpacing(12)
self.lb_mode = QComboBox()
self.lb_mode.activated.connect(lambda: self.win.backend.run("set_lightbar", mode=self.lb_mode.currentData()))
g.addWidget(QLabel("Effect"), 0, 0)
g.addWidget(self.lb_mode, 0, 1)
self.lb_color = ColorRow()
self.lb_color.colorChosen.connect(lambda c: self.win.backend.run("set_lightbar", color=c.name()))
g.addWidget(QLabel("Colour"), 1, 0)
g.addWidget(self.lb_color, 1, 1)
self.lb_bright = _slider(0, 255, 200)
self.lb_bright.sliderReleased.connect(lambda: self.win.backend.run("set_lightbar", brightness=self.lb_bright.value()))
g.addWidget(QLabel("Brightness"), 2, 0)
g.addWidget(self.lb_bright, 2, 1)
self.lb_speed = _slider(1, 10, 5)
self.lb_speed.sliderReleased.connect(lambda: self.win.backend.run("set_lightbar", speed=self.lb_speed.value()))
g.addWidget(QLabel("Speed"), 3, 0)
g.addWidget(self.lb_speed, 3, 1)
g.setColumnStretch(1, 1)
self.lb_controls = QWidget()
self.lb_controls.setLayout(g)
card.body.addWidget(self.lb_controls)
lay.addWidget(card)
lay.addStretch(1)
return w
def update_state(self, st):
k = st["config"]["keyboard"]
effects = st.get("keyboard_effects", {})
if self.effects.count() != len(effects):
self.effects.clear()
for key, (label, _uses_color) in effects.items():
it = QListWidgetItem(label)
it.setData(Qt.UserRole, key)
self.effects.addItem(it)
for i in range(self.effects.count()):
it = self.effects.item(i)
if it.data(Qt.UserRole) == k["effect"] and not it.isSelected():
self.effects.setCurrentItem(it)
self.color.set_color(QColor(k["color"]))
if not self.bright.isSliderDown():
self.bright.setValue(k["brightness"])
if not self.variant.hasFocus():
self.variant.setValue(int(k.get("effect_id") or 0))
names = sorted(st["config"].get("keyboard_presets", {}))
if names != [self.presets.itemText(i) for i in range(self.presets.count())]:
self.presets.clear()
self.presets.addItems(names)
if self._loaded_frame != k.get("frame"):
self._loaded_frame = k.get("frame")
self.painter.load_frame(k.get("frame"))
lb = st["config"]["lightbar"]
modes = st.get("lightbar_modes", {})
if self.lb_mode.count() != len(modes):
self.lb_mode.clear()
for key, label in modes.items():
self.lb_mode.addItem(label, key)
if not self.lb_mode.view().isVisible():
self.lb_mode.setCurrentIndex(max(0, self.lb_mode.findData(lb["mode"])))
self.lb_sync.blockSignals(True)
self.lb_sync.setChecked(bool(lb.get("sync_keyboard")))
self.lb_sync.blockSignals(False)
self.lb_controls.setEnabled(not lb.get("sync_keyboard"))
self.lb_color.set_color(QColor(lb["color"]))
if not self.lb_bright.isSliderDown():
self.lb_bright.setValue(lb["brightness"])
if not self.lb_speed.isSliderDown():
self.lb_speed.setValue(lb["speed"])
class BatteryPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("Battery", "Battery status and health features"))
row = QHBoxLayout()
row.setSpacing(16)
info = Card("Status")
self.cap = QLabel("–")
self.cap.setObjectName("big")
info.body.addWidget(self.cap)
self.details = _muted("")
info.body.addWidget(self.details)
info.body.addStretch(1)
row.addWidget(info, 1)
health = Card("Battery health")
self.limit = QCheckBox("Limit charging to 80 %")
self.limit.clicked.connect(lambda v: self._set("set_battery_limit", v))
health.body.addWidget(self.limit)
health.body.addWidget(_muted("Recommended when the laptop is mostly plugged in – greatly extends battery life."))
health.body.addSpacing(10)
self.calib = QCheckBox("Battery calibration running")
self.calib.clicked.connect(self._calibrate)
health.body.addWidget(self.calib)
health.body.addWidget(_muted("Calibration fully charges, discharges and recharges the battery to re-learn its "
"capacity. Keep the charger connected; it can take several hours."))
health.body.addStretch(1)
row.addWidget(health, 2)
self.lay.addLayout(row)
self.lay.addStretch(1)
self._fw_loaded = False
def _set(self, cmd, v):
self.win.backend.run(cmd, enabled=bool(v))
QTimer.singleShot(800, self.refresh)
def _calibrate(self, v):
if v and QMessageBox.question(self, "Battery calibration",
"Start battery calibration now? Keep the charger connected.") != QMessageBox.Yes:
self.calib.setChecked(False)
return
self._set("set_battery_calibration", v)
def refresh(self):
self.win.backend.run("get_firmware")
def firmware(self, fw):
for box, key in ((self.limit, "battery_limit"), (self.calib, "battery_calibration")):
v = fw.get(key, {})
box.setEnabled(v.get("value") is not None)
box.setChecked(bool(v.get("value")))
box.setToolTip(v.get("error", ""))
def showEvent(self, e):
super().showEvent(e)
self.refresh()
def update_state(self, st):
b = st["battery"]
if not b.get("present"):
self.cap.setText("No battery")
return
self.cap.setText("%s %%" % b.get("capacity"))
lines = ["Status: %s" % b.get("status"),
"Power source: %s" % ("AC adapter" if b.get("ac_online") else "battery"),
"Power draw: %s W" % b.get("power_w"),
"Health: %s %% of design capacity" % b.get("health"),
"Charge cycles: %s" % b.get("cycles")]
self.details.setText("\n".join(lines))
class SystemPage(Page):
def __init__(self, win):
super().__init__(win)
self.lay.addWidget(_title("System", "Firmware options and service status"))
self.fw_card = Card("Firmware options")
self.fw_boxes = {}
self.fw_area = QVBoxLayout()
self.fw_card.body.addLayout(self.fw_area)
self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
self.lay.addWidget(self.fw_card)
svc = Card("Status")
self.status = _muted("")
svc.body.addWidget(self.status)
h = QHBoxLayout()
b = QPushButton("Re-apply all settings")
b.clicked.connect(lambda: win.backend.run("restore"))
h.addWidget(b)
h.addStretch(1)
svc.body.addLayout(h)
self.lay.addWidget(svc)
about = Card("About")
about.body.addWidget(QLabel("%s %s" % (APP_NAME, __version__)))
about.body.addWidget(_muted("Open-source PredatorSense alternative for the Acer Predator Helios 16 (PH16-71). "
"Protocols based on the work of the Venator and Linuwu-Sense projects. "
"Not affiliated with Acer."))
self.lay.addWidget(about)
self.lay.addStretch(1)
def showEvent(self, e):
super().showEvent(e)
self.win.backend.run("get_firmware")
def firmware(self, fw):
for name, info in fw.items():
if "label" not in info:
continue
if name == "usb_charging":
w = self.fw_boxes.get(name)
if w is None:
row = QHBoxLayout()
row.addWidget(QLabel(info["label"]))
w = QComboBox()
for val, text in ((0, "Off"), (10, "Until battery 10 %"), (20, "Until battery 20 %"), (30, "Until battery 30 %")):
w.addItem(text, val)
w.activated.connect(lambda _=0, w=w: self.win.backend.run("set_firmware", name="usb_charging", value=w.currentData()))
row.addWidget(w)
row.addStretch(1)
self.fw_area.addLayout(row)
self.fw_boxes[name] = w
w.setEnabled(info.get("value") is not None)
if info.get("value") is not None:
w.setCurrentIndex(max(0, w.findData(info["value"])))
w.setToolTip(info.get("error", ""))
continue
box = self.fw_boxes.get(name)
if box is None:
box = QCheckBox(info["label"])
box.clicked.connect(lambda v, n=name: self.win.backend.run("set_firmware", name=n, value=bool(v)))
self.fw_area.addWidget(box)
self.fw_boxes[name] = box
box.setEnabled(info.get("value") is not None)
box.setChecked(bool(info.get("value")))
box.setToolTip(info.get("error", "") or "")
def update_state(self, st):
a = st["available"]
yes = lambda v: "✔" if v else "✘"
lines = ["%s Service predatord %s" % (yes(True), st.get("version")),
"%s RGB keyboard (hidraw)" % yes(a.get("keyboard")),
"%s WMI bridge (kernel module predator_wmi)" % yes(a.get("wmi"))]
for k, v in st.get("errors", {}).items():
lines.append("⚠ %s: %s" % (k, v))
self.status.setText("\n".join(lines))
# ------------------------------------------------------------------ window
class MainWindow(QMainWindow):
def __init__(self, page="home"):
super().__init__()
self.setWindowTitle(APP_NAME)
self.setWindowIcon(QIcon.fromTheme("predator-control"))
self.resize(1240, 820)
self.setMinimumSize(980, 640)
self.state = None
self.backend = Backend()
self.backend.finished.connect(self._done)
root = QWidget()
root.setObjectName("root")
h = QHBoxLayout(root)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(0)
side = QFrame()
side.setObjectName("sidebar")
side.setFixedWidth(220)
sl = QVBoxLayout(side)
sl.setContentsMargins(14, 22, 14, 18)
sl.setSpacing(4)
brand = QLabel("PREDATOR")
brand.setObjectName("brand")
sl.addWidget(brand)
sub = QLabel("CONTROL CENTER")
sub.setObjectName("brandSub")
sl.addWidget(sub)
sl.addSpacing(22)
self.stack = QStackedWidget()
self.nav = QButtonGroup(self)
self.pages = {}
classes = {"home": HomePage, "performance": PerformancePage, "fans": FansPage,
"rgb": RgbPage, "battery": BatteryPage, "system": SystemPage}
for key, label in PAGES:
b = QPushButton(label)
b.setObjectName("nav")
b.setCheckable(True)
b.clicked.connect(lambda _=False, k=key: self.show_page(k))
self.nav.addButton(b)
sl.addWidget(b)
pg = classes[key](self)
self.pages[key] = (pg, b)
self.stack.addWidget(pg)
sl.addStretch(1)
self.footer = QLabel("")
self.footer.setObjectName("muted")
self.footer.setWordWrap(True)
sl.addWidget(self.footer)
h.addWidget(side)
right = QVBoxLayout()
right.setContentsMargins(0, 0, 0, 0)
self.banner = QLabel()
self.banner.setObjectName("error")
self.banner.setWordWrap(True)
self.banner.setStyleSheet("padding:10px 32px;background:#2a0f16;")
self.banner.hide()
right.addWidget(self.banner)
right.addWidget(self.stack)
h.addLayout(right, 1)
self.setCentralWidget(root)
self.statusBar().showMessage("Ready")
self.timer = QTimer(self)
self.timer.timeout.connect(self.refresh)
self.timer.start(1500)
self.show_page(page)
self.refresh()
def show_page(self, key):
if key not in self.pages:
key = "home"
pg, b = self.pages[key]
b.setChecked(True)
self.stack.setCurrentWidget(pg)
if self.state:
pg.update_state(self.state)
def refresh(self):
st = read_state()
if not st:
self.banner.setText("The predatord service is not running. Start it with: "
"sudo systemctl enable --now predatord")
self.banner.show()
return
self.banner.hide()
self.state = st
self.stack.currentWidget().update_state(st)
s = st["sensors"]
self.footer.setText("%s\nCPU %s °C · GPU %s" % (
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
def _done(self, cmd, result, err):
if err:
self.statusBar().showMessage("Error: %s" % err, 8000)
elif cmd == "get_firmware":
self.pages["system"][0].firmware(result)
self.pages["battery"][0].firmware(result)
return
else:
self.statusBar().showMessage("Applied", 2000)
QTimer.singleShot(250, self.refresh)
def main(argv=None):
ap = argparse.ArgumentParser(prog="predator-control")
ap.add_argument("--page", default="home", choices=[p for p, _ in PAGES])
args, qt_args = ap.parse_known_args(argv)
app = QApplication([sys.argv[0]] + qt_args)
app.setApplicationName("predator-control")
app.setApplicationDisplayName(APP_NAME)
app.setDesktopFileName("predator-control")
app.setStyle("Fusion")
app.setStyleSheet(theme.QSS)
# single instance: forward the requested page to a running window
name = "predator-control-%d" % os.getuid()
sock = QLocalSocket()
sock.connectToServer(name)
if sock.waitForConnected(300):
sock.write(args.page.encode())
sock.waitForBytesWritten(300)
sock.disconnectFromServer()
return 0
QLocalServer.removeServer(name)
server = QLocalServer()
server.listen(name)
win = MainWindow(args.page)
def incoming():
c = server.nextPendingConnection()
if c.waitForReadyRead(500):
win.show_page(bytes(c.readAll()).decode().strip())
win.showNormal()
win.raise_()
win.activateWindow()
server.newConnection.connect(incoming)
win.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+79
View File
@@ -0,0 +1,79 @@
"""Colours and the Qt style sheet (dark, PredatorSense-inspired)."""
BG = "#0a0e13"
SIDEBAR = "#0d1219"
SURFACE = "#121a24"
SURFACE2 = "#182230"
BORDER = "#223043"
TEXT = "#e6edf3"
MUTED = "#8593a6"
ACCENT = "#00d4ff"
ACCENT_DIM = "#0a6f8a"
WARN = "#ffb020"
DANGER = "#ff3b5c"
TRACK = "#1c2735"
GRID = "#1b2532"
KEY = "#151d28"
KEY_BORDER = "#2a3a50"
QSS = f"""
* {{ color: {TEXT}; font-size: 10pt; }}
QMainWindow, QWidget#root {{ background: {BG}; }}
QWidget#page {{ background: {BG}; }}
QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: none; }}
QFrame#sidebar {{ background: {SIDEBAR}; border-right: 1px solid {BORDER}; }}
QLabel#brand {{ font-size: 15pt; font-weight: 700; color: {ACCENT}; letter-spacing: 2px; }}
QLabel#brandSub {{ color: {MUTED}; font-size: 8pt; letter-spacing: 3px; }}
QPushButton#nav {{
text-align: left; padding: 11px 16px; border: none; border-radius: 8px;
background: transparent; color: {MUTED}; font-size: 10.5pt;
}}
QPushButton#nav:hover {{ background: {SURFACE}; color: {TEXT}; }}
QPushButton#nav:checked {{ background: {SURFACE2}; color: {ACCENT}; border-left: 3px solid {ACCENT}; }}
QLabel#pageTitle {{ font-size: 18pt; font-weight: 700; }}
QLabel#pageSub {{ color: {MUTED}; }}
QFrame#card {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 14px; }}
QLabel#cardTitle {{ color: {MUTED}; font-size: 9pt; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; }}
QLabel#big {{ font-size: 20pt; font-weight: 700; }}
QLabel#muted {{ color: {MUTED}; }}
QLabel#error {{ color: {DANGER}; }}
QPushButton {{
background: {SURFACE2}; border: 1px solid {BORDER}; border-radius: 8px; padding: 8px 14px;
}}
QPushButton:hover {{ border-color: {ACCENT_DIM}; }}
QPushButton:pressed {{ background: {BORDER}; }}
QPushButton:checked {{ background: {ACCENT_DIM}; border-color: {ACCENT}; color: white; }}
QPushButton:disabled {{ color: {MUTED}; }}
QPushButton#primary {{ background: {ACCENT}; color: #00131a; font-weight: 700; border: none; }}
QPushButton#primary:hover {{ background: #4de1ff; }}
QPushButton#profile, QPushButton#profileTurbo {{
padding: 16px 10px; border-radius: 12px; font-size: 11pt; font-weight: 600; min-width: 110px;
}}
QPushButton#profile:checked {{ background: {ACCENT_DIM}; border: 2px solid {ACCENT}; }}
QPushButton#profileTurbo:checked {{ background: #6b1022; border: 2px solid {DANGER}; }}
QPushButton#swatch {{ border-radius: 14px; border: 2px solid {BORDER}; padding: 0; }}
QPushButton#swatch:hover {{ border-color: {TEXT}; }}
QComboBox, QSpinBox {{
background: {SURFACE2}; border: 1px solid {BORDER}; border-radius: 8px; padding: 6px 10px; min-height: 20px;
}}
QComboBox QAbstractItemView {{ background: {SURFACE2}; selection-background-color: {ACCENT_DIM}; border: 1px solid {BORDER}; }}
QSlider::groove:horizontal {{ height: 6px; background: {TRACK}; border-radius: 3px; }}
QSlider::sub-page:horizontal {{ background: {ACCENT}; border-radius: 3px; }}
QSlider::handle:horizontal {{
background: {TEXT}; width: 16px; height: 16px; margin: -6px 0; border-radius: 8px;
}}
QCheckBox {{ spacing: 10px; }}
QCheckBox::indicator {{ width: 36px; height: 20px; border-radius: 10px; background: {TRACK}; border: 1px solid {BORDER}; }}
QCheckBox::indicator:checked {{ background: {ACCENT}; border-color: {ACCENT}; }}
QTabWidget::pane {{ border: none; }}
QTabBar::tab {{
background: transparent; color: {MUTED}; padding: 10px 18px; border: none;
border-bottom: 2px solid transparent; font-weight: 600;
}}
QTabBar::tab:selected {{ color: {ACCENT}; border-bottom: 2px solid {ACCENT}; }}
QListWidget {{ background: {SURFACE2}; border: 1px solid {BORDER}; border-radius: 8px; padding: 4px; }}
QListWidget::item {{ padding: 8px; border-radius: 6px; }}
QListWidget::item:selected {{ background: {ACCENT_DIM}; color: white; }}
QStatusBar {{ background: {SIDEBAR}; color: {MUTED}; border-top: 1px solid {BORDER}; }}
QToolTip {{ background: {SURFACE2}; color: {TEXT}; border: 1px solid {BORDER}; }}
"""
+390
View File
@@ -0,0 +1,390 @@
"""Custom widgets: gauges, fan curve editor, per-key keyboard painter, colour swatches."""
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import (QColorDialog, QFrame, QHBoxLayout, QLabel, QPushButton,
QSizePolicy, QVBoxLayout, QWidget)
from .. import layout
from . import theme
class Card(QFrame):
"""Rounded panel with an optional title."""
def __init__(self, title=None, parent=None):
super().__init__(parent)
self.setObjectName("card")
self.body = QVBoxLayout(self)
self.body.setContentsMargins(18, 16, 18, 16)
self.body.setSpacing(10)
if title:
t = QLabel(title)
t.setObjectName("cardTitle")
self.body.addWidget(t)
class Gauge(QWidget):
"""Circular gauge (270° arc)."""
def __init__(self, title, unit, maximum=100, warn=None, parent=None):
super().__init__(parent)
self.title, self.unit, self.maximum, self.warn = title, unit, maximum, warn
self.value = None
self.sub = ""
self.setMinimumSize(150, 150)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def set_value(self, value, sub=""):
self.value, self.sub = value, sub
self.update()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
side = min(self.width(), self.height()) - 16
r = QRectF((self.width() - side) / 2, (self.height() - side) / 2, side, side)
pen = QPen(QColor(theme.TRACK), max(8, side / 14), Qt.SolidLine, Qt.RoundCap)
p.setPen(pen)
p.drawArc(r, -45 * 16, 270 * 16)
if self.value is not None:
frac = max(0.0, min(1.0, self.value / self.maximum))
col = QColor(theme.ACCENT)
if self.warn and self.value >= self.warn:
col = QColor(theme.DANGER)
elif self.warn and self.value >= self.warn * 0.85:
col = QColor(theme.WARN)
pen.setColor(col)
p.setPen(pen)
p.drawArc(r, 225 * 16, int(-270 * 16 * frac))
p.setPen(QColor(theme.TEXT))
f = QFont(self.font())
f.setPointSizeF(max(10, side / 7))
f.setBold(True)
p.setFont(f)
txt = "–" if self.value is None else ("%d" % round(self.value))
p.drawText(r.adjusted(0, -side * 0.08, 0, -side * 0.08), Qt.AlignCenter, txt)
f.setPointSizeF(max(7, side / 16))
f.setBold(False)
p.setFont(f)
p.setPen(QColor(theme.MUTED))
p.drawText(r.adjusted(0, side * 0.22, 0, 0), Qt.AlignHCenter | Qt.AlignVCenter, self.unit)
p.setPen(QColor(theme.TEXT))
p.drawText(QRectF(r.left(), r.bottom() - side * 0.2, r.width(), side * 0.2),
Qt.AlignHCenter | Qt.AlignVCenter, self.title)
if self.sub:
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(r.left(), r.bottom() - side * 0.05, r.width(), side * 0.2),
Qt.AlignHCenter | Qt.AlignTop, self.sub)
class Bar(QWidget):
"""Thin labelled progress bar."""
def __init__(self, title, parent=None):
super().__init__(parent)
self.title = title
self.value, self.text = 0, ""
self.setMinimumHeight(38)
def set_value(self, frac, text):
self.value, self.text = max(0.0, min(1.0, frac or 0)), text
self.update()
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(0, 0, self.width(), 18), Qt.AlignLeft | Qt.AlignVCenter, self.title)
p.setPen(QColor(theme.TEXT))
p.drawText(QRectF(0, 0, self.width(), 18), Qt.AlignRight | Qt.AlignVCenter, self.text)
track = QRectF(0, 24, self.width(), 8)
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.TRACK))
p.drawRoundedRect(track, 4, 4)
p.setBrush(QColor(theme.ACCENT))
p.drawRoundedRect(QRectF(0, 24, self.width() * self.value, 8), 4, 4)
class CurveEditor(QWidget):
"""Drag-and-drop fan curve: temperature (°C) -> fan speed (%)."""
changed = Signal(list)
T_MIN, T_MAX = 30, 100
def __init__(self, title, parent=None):
super().__init__(parent)
self.title = title
self.points = [[45, 0], [60, 35], [72, 55], [82, 75], [90, 100]]
self.current_temp = None
self._drag = None
self.setMinimumHeight(220)
self.setMouseTracking(True)
def set_points(self, pts):
if self._drag is None:
self.points = sorted([list(p) for p in pts])
self.update()
def set_current(self, temp):
self.current_temp = temp
self.update()
def _plot(self):
return QRectF(44, 26, self.width() - 60, self.height() - 56)
def _to_px(self, t, s):
r = self._plot()
return QPointF(r.left() + (t - self.T_MIN) / (self.T_MAX - self.T_MIN) * r.width(),
r.bottom() - s / 100 * r.height())
def _from_px(self, pos):
r = self._plot()
t = self.T_MIN + (pos.x() - r.left()) / r.width() * (self.T_MAX - self.T_MIN)
s = (r.bottom() - pos.y()) / r.height() * 100
return t, s
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
r = self._plot()
p.setPen(QColor(theme.TEXT))
p.drawText(QRectF(0, 0, self.width(), 20), Qt.AlignLeft | Qt.AlignVCenter, self.title)
grid = QPen(QColor(theme.GRID), 1)
p.setPen(grid)
for s in range(0, 101, 25):
a = self._to_px(self.T_MIN, s)
p.drawLine(QPointF(r.left(), a.y()), QPointF(r.right(), a.y()))
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(0, a.y() - 8, 38, 16), Qt.AlignRight | Qt.AlignVCenter, "%d%%" % s)
p.setPen(grid)
for t in range(self.T_MIN, self.T_MAX + 1, 10):
a = self._to_px(t, 0)
p.drawLine(QPointF(a.x(), r.top()), QPointF(a.x(), r.bottom()))
p.setPen(QColor(theme.MUTED))
p.drawText(QRectF(a.x() - 20, r.bottom() + 4, 40, 16), Qt.AlignCenter, "%d°" % t)
p.setPen(grid)
if self.current_temp:
a = self._to_px(max(self.T_MIN, min(self.T_MAX, self.current_temp)), 0)
p.setPen(QPen(QColor(theme.WARN), 1, Qt.DashLine))
p.drawLine(QPointF(a.x(), r.top()), QPointF(a.x(), r.bottom()))
pts = sorted(self.points)
path = QPainterPath(self._to_px(self.T_MIN, pts[0][1]))
for t, s in pts:
path.lineTo(self._to_px(t, s))
path.lineTo(self._to_px(self.T_MAX, pts[-1][1]))
fill = QPainterPath(path)
fill.lineTo(self._to_px(self.T_MAX, 0))
fill.lineTo(self._to_px(self.T_MIN, 0))
c = QColor(theme.ACCENT)
c.setAlpha(40)
p.fillPath(fill, c)
p.setPen(QPen(QColor(theme.ACCENT), 2.5))
p.drawPath(path)
p.setBrush(QColor(theme.BG))
for i, (t, s) in enumerate(pts):
p.setPen(QPen(QColor(theme.TEXT if i == self._drag else theme.ACCENT), 2.5))
p.drawEllipse(self._to_px(t, s), 6, 6)
if i == self._drag:
p.drawText(self._to_px(t, s) + QPointF(10, -10), "%d° → %d%%" % (t, s))
def mousePressEvent(self, e):
for i, (t, s) in enumerate(sorted(self.points)):
d = self._to_px(t, s) - e.position()
if abs(d.x()) < 12 and abs(d.y()) < 12:
self.points = sorted(self.points)
self._drag = i
self.update()
return
def mouseMoveEvent(self, e):
if self._drag is None:
return
t, s = self._from_px(e.position())
i = self._drag
lo = self.points[i - 1][0] + 1 if i > 0 else self.T_MIN
hi = self.points[i + 1][0] - 1 if i < len(self.points) - 1 else self.T_MAX
self.points[i] = [int(max(lo, min(hi, round(t)))), int(max(0, min(100, round(s / 5) * 5)))]
self.update()
def mouseReleaseEvent(self, _):
if self._drag is not None:
self._drag = None
self.update()
self.changed.emit([list(p) for p in self.points])
class KeyboardPainter(QWidget):
"""Draws the keyboard and lets the user paint keys with the current colour."""
painted = Signal()
picked = Signal(QColor)
def __init__(self, parent=None):
super().__init__(parent)
self.variant = layout.default_variant()
self.keys = layout.keys(self.variant)
self.colors = {} # cell -> QColor
self.brush = QColor(theme.ACCENT)
self._painting = False
self._hover = None
self.setMouseTracking(True)
self.setMinimumHeight(260)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def set_variant(self, variant):
self.variant = variant
self.keys = layout.keys(variant)
self.update()
def heightForWidth(self, w):
kw, kh = layout.size(self.keys)
return int(w * kh / kw)
def hasHeightForWidth(self):
return True
def _scale(self):
kw, kh = layout.size(self.keys)
s = min((self.width() - 8) / kw, (self.height() - 8) / kh)
ox = (self.width() - kw * s) / 2
oy = (self.height() - kh * s) / 2
return s, ox, oy
def _rect(self, k, s, ox, oy):
_l, _c, x, y, w, h = k
g = s * 0.08
return QRectF(ox + x * s + g / 2, oy + y * s + g / 2, w * s - g, h * s - g)
def _key_at(self, pos):
s, ox, oy = self._scale()
for k in self.keys:
if self._rect(k, s, ox, oy).contains(pos):
return k
return None
def paintEvent(self, _):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
s, ox, oy = self._scale()
f = QFont(self.font())
for k in self.keys:
label, cell = k[0], k[1]
r = self._rect(k, s, ox, oy)
col = self.colors.get(cell, QColor(0, 0, 0))
base = QColor(theme.KEY)
p.setPen(QPen(QColor(theme.TEXT) if k is self._hover else QColor(theme.KEY_BORDER), 1.5))
p.setBrush(base)
p.drawRoundedRect(r, s * 0.12, s * 0.12)
# glow of the key colour
glow = QColor(col)
glow.setAlpha(210 if (col.red() + col.green() + col.blue()) > 0 else 0)
p.setPen(Qt.NoPen)
p.setBrush(glow)
p.drawRoundedRect(r.adjusted(3, 3, -3, -3), s * 0.1, s * 0.1)
lum = 0.299 * col.red() + 0.587 * col.green() + 0.114 * col.blue()
p.setPen(QColor("#0b0f14") if lum > 150 else QColor(theme.TEXT))
f.setPointSizeF(max(6, s * (0.22 if len(label) <= 2 else 0.16)))
p.setFont(f)
p.drawText(r, Qt.AlignCenter, label)
def _paint_at(self, pos, button):
k = self._key_at(pos)
if not k:
return
if button == Qt.RightButton:
self.picked.emit(QColor(self.colors.get(k[1], QColor(0, 0, 0))))
return
if self.colors.get(k[1]) != self.brush:
self.colors[k[1]] = QColor(self.brush)
self.update()
self.painted.emit()
def mousePressEvent(self, e):
self._painting = e.button() == Qt.LeftButton
self._paint_at(e.position(), e.button())
def mouseMoveEvent(self, e):
k = self._key_at(e.position())
if k is not self._hover:
self._hover = k
self.update()
if self._painting:
self._paint_at(e.position(), Qt.LeftButton)
def mouseReleaseEvent(self, _):
self._painting = False
def leaveEvent(self, _):
self._hover = None
self.update()
def fill(self, cells, color):
for c in cells:
self.colors[c] = QColor(color)
self.update()
self.painted.emit()
def frame_hex(self):
out = bytearray(128 * 3)
for c, col in self.colors.items():
if 0 <= c < 128:
out[c * 3:c * 3 + 3] = bytes((col.red(), col.green(), col.blue()))
return out.hex()
def load_frame(self, hexstr):
self.colors = {}
try:
data = bytes.fromhex(hexstr or "")
except ValueError:
data = b""
for c in range(min(128, len(data) // 3)):
self.colors[c] = QColor(data[c * 3], data[c * 3 + 1], data[c * 3 + 2])
self.update()
PRESET_COLORS = ["#00d4ff", "#0057ff", "#7a00ff", "#ff00c8", "#ff0033", "#ff6a00",
"#ffd000", "#3cff00", "#00ff9c", "#ffffff"]
class ColorRow(QWidget):
"""Row of colour swatches plus a 'custom colour' button."""
colorChosen = Signal(QColor)
def __init__(self, parent=None):
super().__init__(parent)
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
self.current = QColor(PRESET_COLORS[0])
for c in PRESET_COLORS:
b = QPushButton()
b.setObjectName("swatch")
b.setFixedSize(28, 28)
b.setStyleSheet("QPushButton#swatch{background:%s;}" % c)
b.setToolTip(c)
b.clicked.connect(lambda _=False, c=c: self._choose(QColor(c)))
lay.addWidget(b)
self.custom = QPushButton("Custom…")
self.custom.clicked.connect(self._dialog)
lay.addWidget(self.custom)
self.preview = QLabel()
self.preview.setFixedSize(46, 28)
lay.addWidget(self.preview)
lay.addStretch(1)
self.set_color(self.current)
def _dialog(self):
c = QColorDialog.getColor(self.current, self, "Choose colour")
if c.isValid():
self._choose(c)
def _choose(self, c):
self.set_color(c)
self.colorChosen.emit(c)
def set_color(self, c):
self.current = QColor(c)
self.preview.setStyleSheet("background:%s;border-radius:6px;border:1px solid %s;"
% (self.current.name(), theme.BORDER))
+359
View File
@@ -0,0 +1,359 @@
"""Access to power profiles, sensors, fans, battery, lightbar and firmware options.
WMI calls go through the predator_wmi kernel module (sysfs attribute predator_call).
Wire formats come from Linuwu-Sense and Venator (both reverse-engineered from PredatorSense).
"""
import glob
import os
import struct
import threading
import time
GUID_GAMING = "7A4DDFE7-5B5D-40B4-8595-4408E0CC7F56" # WMBH
GUID_BATTERY = "79772EC5-04B1-4BFD-843C-61E7F77B6CC9" # WMBE
GUID_APGE = "61EF69EA-865C-4BC3-A502-A0DEBA0CB531" # WMAA
PLATFORM_PROFILE = "/sys/firmware/acpi/platform_profile"
PLATFORM_CHOICES = "/sys/firmware/acpi/platform_profile_choices"
PROFILE_LABELS = {
"low-power": "Eco",
"quiet": "Quiet",
"balanced": "Balanced",
"balanced-performance": "Performance",
"performance": "Turbo",
}
LIGHTBAR_MODES = {
"off": (0x00, "Off"),
"static": (0xFF, "Static"),
"breathing": (0x01, "Breathing"),
"neon": (0x02, "Neon"),
"rainbow": (0x03, "Rainbow"),
"wave": (0x04, "Wave"),
"ripple": (0x05, "Ripple"),
"scanner": (0x06, "Scanner"),
"strobe": (0x07, "Strobe"),
}
def _read(path, default=None):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return default
def _read_int(path, default=None):
v = _read(path)
try:
return int(v)
except (TypeError, ValueError):
return default
def _write(path, value):
with open(path, "w") as f:
f.write(str(value))
# --------------------------------------------------------------- WMI bridge
class WMIError(RuntimeError):
pass
_wmi_lock = threading.Lock()
def wmi_available(guid=GUID_GAMING):
return bool(glob.glob("/sys/bus/wmi/devices/%s-*/predator_call" % guid))
def wmi_call(guid, method, data=b""):
"""Call a WMI method. Returns bytes, int or None."""
paths = glob.glob("/sys/bus/wmi/devices/%s-*/predator_call" % guid)
if not paths:
raise WMIError("kernel module predator_wmi is not loaded")
path = paths[0]
with _wmi_lock:
try:
_write(path, "%d %s" % (method, bytes(data).hex()))
except OSError as e:
raise WMIError("WMI method %d failed: %s" % (method, e)) from e
res = _read(path, "err -5").split()
if res[0] != "ok":
raise WMIError("WMI method %d: %s" % (method, " ".join(res)))
if res[1] == "buf":
return bytes.fromhex(res[2]) if len(res) > 2 else b""
if res[1] == "int":
return int(res[2], 16)
return None
def _as_u64(res):
if isinstance(res, int):
return res
if isinstance(res, (bytes, bytearray)):
return int.from_bytes(bytes(res[:8]).ljust(8, b"\0"), "little")
return 0
def gaming_u64(method, value, in_size=8):
return _as_u64(wmi_call(GUID_GAMING, method, value.to_bytes(in_size, "little")))
# --------------------------------------------------------------- Profiles
def profile_choices():
c = _read(PLATFORM_CHOICES, "")
return c.split() if c else []
def get_profile():
return _read(PLATFORM_PROFILE)
def set_profile(name):
if name not in profile_choices():
raise ValueError("profile not available: %s" % name)
_write(PLATFORM_PROFILE, name)
# --------------------------------------------------------------- Sensors
def _hwmon(name):
for h in glob.glob("/sys/class/hwmon/hwmon*"):
if _read(h + "/name") == name:
return h
return None
class CpuLoad:
def __init__(self):
self._last = None
def sample(self):
line = _read("/proc/stat", "").splitlines()[0].split()[1:]
vals = [int(x) for x in line]
idle = vals[3] + vals[4]
total = sum(vals)
pct = None
if self._last:
dt = total - self._last[0]
di = idle - self._last[1]
pct = round(100.0 * (dt - di) / dt, 1) if dt > 0 else 0.0
self._last = (total, idle)
return pct
def cpu_freq_mhz():
freqs = [_read_int(p) for p in glob.glob(
"/sys/devices/system/cpu/cpu[0-9]*/cpufreq/scaling_cur_freq")]
freqs = [f for f in freqs if f]
return round(sum(freqs) / len(freqs) / 1000) if freqs else None
def mem_usage():
info = {}
for line in _read("/proc/meminfo", "").splitlines():
k, v = line.split(":", 1)
info[k] = int(v.split()[0])
total = info.get("MemTotal", 0)
avail = info.get("MemAvailable", 0)
return {"total_gb": round(total / 1048576, 1),
"used_gb": round((total - avail) / 1048576, 1),
"percent": round(100.0 * (total - avail) / total, 1) if total else None}
def dgpu_active():
"""True if the NVIDIA dGPU is awake (checked without waking it up)."""
for dev in glob.glob("/sys/bus/pci/devices/*"):
if _read(dev + "/vendor") == "0x10de" and (_read(dev + "/class") or "").startswith("0x03"):
return _read(dev + "/power_state") == "D0"
return None
def sensors():
out = {"cpu_temp": None, "gpu_temp": None, "sys_temp": None,
"cpu_fan": None, "gpu_fan": None}
core = _hwmon("coretemp")
if core:
pkg = None
for lbl in glob.glob(core + "/temp*_label"):
if _read(lbl, "").startswith("Package"):
pkg = _read_int(lbl.replace("_label", "_input"))
out["cpu_temp"] = round(pkg / 1000, 1) if pkg else None
acer = _hwmon("acer")
if acer:
g = _read_int(acer + "/temp2_input")
s = _read_int(acer + "/temp3_input")
out["gpu_temp"] = round(g / 1000, 1) if g else None
out["sys_temp"] = round(s / 1000, 1) if s else None
out["cpu_fan"] = _read_int(acer + "/fan1_input")
out["gpu_fan"] = _read_int(acer + "/fan2_input")
out["dgpu_active"] = dgpu_active()
return out
# --------------------------------------------------------------- Fans
FAN_BEHAVIOR = 14
FAN_SPEED = 16
def _fan_val(percent, index):
return ((int(percent) * 25600 // 100) & 0xFF00) + index
def set_fans(mode, cpu=0, gpu=0):
"""mode: 'auto' | 'max' | 'custom' (cpu/gpu in percent, 0 = that fan stays automatic)."""
if mode == "auto" or (mode == "custom" and cpu == 0 and gpu == 0):
gaming_u64(FAN_BEHAVIOR, 0x410009)
elif mode == "max":
gaming_u64(FAN_BEHAVIOR, 0x820009)
elif mode == "custom":
cpu = max(0, min(100, int(cpu)))
gpu = max(0, min(100, int(gpu)))
if cpu == 0:
gaming_u64(FAN_BEHAVIOR, 0x10001)
gaming_u64(FAN_BEHAVIOR, 0xC00008)
gaming_u64(FAN_SPEED, _fan_val(gpu, 4))
elif gpu == 0:
gaming_u64(FAN_BEHAVIOR, 0x400008)
gaming_u64(FAN_BEHAVIOR, 0x30001)
gaming_u64(FAN_SPEED, _fan_val(cpu, 1))
else:
gaming_u64(FAN_BEHAVIOR, 0xC30009)
gaming_u64(FAN_SPEED, _fan_val(cpu, 1))
gaming_u64(FAN_SPEED, _fan_val(gpu, 4))
else:
raise ValueError("unknown fan mode: %s" % mode)
# --------------------------------------------------------------- Lightbar
def set_lightbar(mode="static", color=(0, 200, 255), brightness=200, speed=5, direction=1):
code = LIGHTBAR_MODES[mode][0]
r, g, b = (max(0, min(255, int(c))) for c in color)
buf = bytes([code, max(1, min(10, int(speed))), max(0, min(255, int(brightness))),
0, int(direction) & 0xFF, r, g, b, 0x03, 0x02]).ljust(16, b"\0")
res = wmi_call(GUID_GAMING, 20, buf)
if isinstance(res, (bytes, bytearray)) and res and res[0] != 0:
raise WMIError("lightbar mode rejected by firmware (0x%02x)" % res[0])
# --------------------------------------------------------------- Battery
def battery():
bat = next(iter(glob.glob("/sys/class/power_supply/BAT*")), None)
ac = next((p for p in glob.glob("/sys/class/power_supply/*")
if _read(p + "/type") == "Mains"), None)
out = {"present": bool(bat), "ac_online": _read_int(ac + "/online", 0) == 1 if ac else True}
if not bat:
return out
cur = _read_int(bat + "/current_now")
volt = _read_int(bat + "/voltage_now")
pwr = _read_int(bat + "/power_now")
if pwr is None and cur is not None and volt is not None:
pwr = cur * volt // 1_000_000
full = _read_int(bat + "/charge_full") or _read_int(bat + "/energy_full")
design = _read_int(bat + "/charge_full_design") or _read_int(bat + "/energy_full_design")
out.update({
"capacity": _read_int(bat + "/capacity"),
"status": _read(bat + "/status"),
"power_w": round(pwr / 1_000_000, 1) if pwr is not None else None,
"cycles": _read_int(bat + "/cycle_count"),
"health": round(100.0 * full / design, 1) if full and design else None,
})
return out
def _battery_status():
res = wmi_call(GUID_BATTERY, 20, bytes([1, 1, 0, 0]))
if not isinstance(res, (bytes, bytearray)) or len(res) < 5:
raise WMIError("unexpected reply from the battery interface")
return res
def get_battery_limit():
return _battery_status()[3] == 1
def get_battery_calibration():
return _battery_status()[4] == 1
def _battery_set(function, value):
res = wmi_call(GUID_BATTERY, 21, bytes([1, function, 1 if value else 0, 0, 0, 0, 0, 0]))
if isinstance(res, (bytes, bytearray)) and res and res[0] != 0:
raise WMIError("battery setting rejected by firmware (0x%02x)" % res[0])
def set_battery_limit(enabled):
_battery_set(1, enabled)
def set_battery_calibration(enabled):
_battery_set(2, enabled)
# --------------------------------------------------------------- Firmware options
def get_boot_sound():
v = gaming_u64(23, 0x02, in_size=4)
if v & 0xFF:
raise WMIError("Status 0x%02x" % (v & 0xFF))
return ((v >> 8) & 0xFF) == 2
def set_boot_sound(enabled):
v = gaming_u64(22, ((2 if enabled else 1) << 8) | 0x02)
if v & 0xFF:
raise WMIError("boot sound setting rejected (0x%02x)" % (v & 0xFF))
def get_lcd_overdrive():
v = gaming_u64(9, 0x10, in_size=4)
if v & 0xFF:
raise WMIError("Status 0x%02x" % (v & 0xFF))
return bool((v >> 8) & 0xFF)
def set_lcd_overdrive(enabled):
v = gaming_u64(8, 0x10 | ((1 << 48) if enabled else 0))
if v & 0xFF:
raise WMIError("LCD overdrive setting rejected (0x%02x)" % (v & 0xFF))
_USB_GET = {663296: 0, 659200: 10, 1314560: 20, 1969920: 30}
_USB_SET = {0: 663300, 10: 659204, 20: 1314564, 30: 1969924}
def get_usb_charging():
v = _as_u64(wmi_call(GUID_APGE, 2, (4).to_bytes(8, "little")))
return _USB_GET.get(v)
def set_usb_charging(level):
wmi_call(GUID_APGE, 1, _USB_SET[int(level)].to_bytes(8, "little"))
def get_kbd_timeout():
v = _as_u64(wmi_call(GUID_APGE, 2, (0x88401).to_bytes(8, "little")))
return {0x1E0000080000: True, 0x80000: False}.get(v)
def set_kbd_timeout(enabled):
wmi_call(GUID_APGE, 1, (0x1E0000088402 if enabled else 0x88402).to_bytes(8, "little"))
FIRMWARE_OPTIONS = {
# name: (getter, setter, label)
"boot_sound": (get_boot_sound, set_boot_sound, "Boot animation & sound"),
"lcd_overdrive": (get_lcd_overdrive, set_lcd_overdrive, "LCD overdrive (faster pixel response)"),
"kbd_timeout": (get_kbd_timeout, set_kbd_timeout, "Turn keyboard backlight off after 30 s idle"),
"usb_charging": (get_usb_charging, set_usb_charging, "USB charging while powered off"),
}
+139
View File
@@ -0,0 +1,139 @@
"""Per-key RGB keyboard (Chicony MCU 04F2:0117/011A, interface 3, vendor page FF02) via hidraw.
Protocol (from PredatorSense USB captures, see the Venator project):
* 8-byte feature reports <op> p1..p6 <checksum>, checksum = 0xFF - (sum & 0xFF)
* 512-byte output report carrying the per-key framebuffer (128 cells x {0,R,G,B})
"""
import fcntl
import glob
import os
import threading
ACER_VID = 0x04F2
KNOWN_PIDS = (0x0117, 0x011A)
LED_INTERFACE = 3
NUM_CELLS = 128
OP_BEGIN = 0x88
OP_MODE_ZONE = 0xB1
OP_MODE_PERKEY = 0x12
OP_SET_COLOR = 0x14
OP_APPLY = 0x08
SUB_APPLY = 0x02
MODE_TAG = 0x05
FLAG_DEFAULT = 0x01
SCOPE_ZONE = 0x01
SCOPE_PERKEY = 0x08
# name -> (EFF byte, uses colour?, display name)
EFFECTS = {
"static": (0x01, True, "Static"),
"breathing": (0x02, True, "Breathing"),
"rainbow": (0x03, False, "Rainbow wave"),
"snake": (0x05, True, "Snake"),
"ripple": (0x06, True, "Ripple (reactive)"),
"neon": (0x08, False, "Neon"),
"rain": (0x0A, True, "Rain"),
"explosion": (0x12, True, "Explosion"),
"pulse": (0x25, True, "Pulse (reactive)"),
"stars": (0x26, True, "Stars"),
"meteor": (0x27, True, "Meteor (reactive)"),
"aura": (0x28, True, "Aura (reactive)"),
"perkey": (0x33, False, "Custom (per key)"),
"off": (0x01, False, "Off"),
}
def _ioc(direction, type_, nr, size):
return (direction << 30) | (size << 16) | (ord(type_) << 8) | nr
def HIDIOCSFEATURE(length):
return _ioc(3, "H", 0x06, length) # _IOC_WRITE|_IOC_READ
def checksum(body7):
return (0xFF - (sum(body7) & 0xFF)) & 0xFF
def find_hidraw():
"""Path of the hidraw node of the LED interface, or None."""
for node in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
try:
with open(os.path.join(node, "device", "uevent")) as f:
uevent = f.read()
except OSError:
continue
hid_id = next((l.split("=", 1)[1] for l in uevent.splitlines()
if l.startswith("HID_ID=")), "")
parts = hid_id.split(":")
if len(parts) != 3:
continue
vid, pid = int(parts[1], 16), int(parts[2], 16)
if vid != ACER_VID or pid not in KNOWN_PIDS:
continue
devpath = os.path.realpath(os.path.join(node, "device"))
# .../1-7:1.3/0003:04F2:011A.0004 -> interface number from the parent
iface = os.path.basename(os.path.dirname(devpath)).rsplit(".", 1)[-1]
if iface == str(LED_INTERFACE):
return "/dev/" + os.path.basename(node)
return None
class Keyboard:
def __init__(self):
self._lock = threading.Lock()
@property
def path(self):
return find_hidraw()
def available(self):
return self.path is not None
def _cmd(self, fd, op, p1=0, p2=0, p3=0, p4=0, p5=0, p6=0):
body = [op, p1, p2, p3, p4, p5, p6]
buf = bytearray([0] + body + [checksum(body)])
fcntl.ioctl(fd, HIDIOCSFEATURE(len(buf)), buf)
def apply(self, effect="static", color=(255, 255, 255), brightness=200,
frame=None, effect_id=0):
"""Apply a hardware effect or (effect='perkey') a per-key image.
frame: bytes/list with 128*3 RGB values (perkey only).
effect_id: optional raw EFF byte (effect variants with other speed/direction).
"""
path = self.path
if not path:
raise RuntimeError("RGB keyboard (hidraw) not found")
brightness = max(0, min(255, int(brightness)))
r, g, b = (max(0, min(255, int(c))) for c in color)
with self._lock:
fd = os.open(path, os.O_RDWR)
try:
self._cmd(fd, OP_BEGIN)
if effect == "perkey":
data = bytes(frame or bytes(NUM_CELLS * 3))
data = data[:NUM_CELLS * 3].ljust(NUM_CELLS * 3, b"\0")
self._cmd(fd, OP_MODE_PERKEY, 0, 0, 8, 0, 0, 0)
out = bytearray(1 + NUM_CELLS * 4)
for i in range(NUM_CELLS):
out[1 + i * 4 + 1:1 + i * 4 + 4] = data[i * 3:i * 3 + 3]
os.write(fd, bytes(out))
self._cmd(fd, OP_APPLY, SUB_APPLY, 0x33, MODE_TAG,
brightness, SCOPE_PERKEY, FLAG_DEFAULT)
return
self._cmd(fd, OP_MODE_ZONE)
if effect == "off" or brightness == 0:
self._cmd(fd, OP_APPLY, SUB_APPLY, 0x01, MODE_TAG, 0,
SCOPE_ZONE, FLAG_DEFAULT)
return
if effect not in EFFECTS:
raise ValueError("Unknown effect: %s" % effect)
self._cmd(fd, OP_SET_COLOR, 0, 0, r, g, b, 0)
eff = effect_id or EFFECTS[effect][0]
self._cmd(fd, OP_APPLY, SUB_APPLY, eff, MODE_TAG, brightness,
SCOPE_ZONE, FLAG_DEFAULT)
finally:
os.close(fd)
+115
View File
@@ -0,0 +1,115 @@
"""Physical keyboard layouts of the PH16-71 mapped to the 128 LED cells of the keyboard MCU.
cell = column * 6 + row (row 0 = bottom row). The mapping is based on the keymap
recorded by the Venator project (GPL-2.0). Corrections can be placed in
~/.config/predator-control/layout.json as {"<label>": <cell>}.
Each key: (label, cell, x, y, width, height) in key units.
"""
import json
import locale
import os
def _row(y, keys, x0=0.0, h=1.0):
out, x = [], x0
for label, cell, w in keys:
if label is not None:
out.append((label, cell, x, y, w, h))
x += w
return out
def _build(variant):
iso = variant == "iso-de"
keys = []
fw = 15.0 / 16
keys += _row(0, [("Esc", 5, fw)] + [("F%d" % i, 5 + 6 * i, fw) for i in range(1, 13)] +
[("PrtSc", 83, fw), ("Ins", 89, fw), ("Del", 95, fw)], h=0.8)
y = 0.9
nums = [str((i + 1) % 10) for i in range(10)]
keys += _row(y, [("^" if iso else "`", 4, 1)] + [(n, 10 + 6 * i, 1) for i, n in enumerate(nums)] +
[("ß" if iso else "-", 70, 1), ("´" if iso else "=", 76, 1), ("⌫", 82, 2)])
y += 1
top = "QWERTZUIOP" if iso else "QWERTYUIOP"
if iso:
keys += _row(y, [("Tab", 3, 1.5)] + [(c, 15 + 6 * i, 1) for i, c in enumerate(top)] +
[("Ü", 75, 1), ("+", 81, 1), ("↵", 86, 1.5)])
else:
keys += _row(y, [("Tab", 3, 1.5)] + [(c, 15 + 6 * i, 1) for i, c in enumerate(top)] +
[("[", 75, 1), ("]", 81, 1), ("\\", 87, 1.5)])
y += 1
if iso:
keys += _row(y, [("Caps", 2, 1.75)] + [(c, 20 + 6 * i, 1) for i, c in enumerate("ASDFGHJKL")] +
[("Ö", 74, 1), ("Ä", 80, 1), ("#", 87, 1), ("↵", 86, 1.25)])
else:
keys += _row(y, [("Caps", 2, 1.75)] + [(c, 20 + 6 * i, 1) for i, c in enumerate("ASDFGHJKL")] +
[(";", 74, 1), ("'", 80, 1), ("Enter", 86, 2.25)])
y += 1
if iso:
keys += _row(y, [("⇧", 1, 1.25), ("<", 13, 1)] + [(c, 19 + 6 * i, 1) for i, c in enumerate("YXCVBNM")] +
[(",", 61, 1), (".", 67, 1), ("-", 73, 1), ("⇧", 85, 2.75)])
else:
keys += _row(y, [("⇧", 1, 2.25)] + [(c, 19 + 6 * i, 1) for i, c in enumerate("ZXCVBNM")] +
[(",", 61, 1), (".", 67, 1), ("/", 73, 1), ("⇧", 85, 2.75)])
y += 1
keys += _row(y, [("Ctrl", 0, 1.25), ("Fn", 6, 1), ("⊞", 12, 1), ("Alt", 18, 1),
("", 24, 5), ("AltGr" if iso else "Alt", 60, 1), ("☰", 72, 1), ("Ctrl", 78, 1),
("←", 84, 1)])
keys.append(("↑", 90, 13.25, y, 1, 0.5))
keys.append(("↓", 96, 13.25, y + 0.5, 1, 0.5))
keys.append(("→", 102, 14.25, y, 1, 1))
# column of special keys between the main block and the numpad
sx = 15.6
for i, (lbl, cell) in enumerate([("⏻", 107), ("P", 106), ("⏯", 105), ("⏮", 104), ("⏭", 103)]):
keys.append((lbl, cell, sx, 0 if i == 0 else 0.9 + (i - 1), 1, 0.8 if i == 0 else 1))
# numpad
nx = 16.85
y = 0.9
keys += _row(y, [("Num", 112, 1), ("/", 118, 1), ("*", 124, 1), ("−", 123, 1)], x0=nx)
keys += _row(y + 1, [("7", 99, 1), ("8", 111, 1), ("9", 117, 1)], x0=nx)
keys.append(("+", 122, nx + 3, y + 1, 1, 2))
keys += _row(y + 2, [("4", 101, 1), ("5", 110, 1), ("6", 116, 1)], x0=nx)
keys += _row(y + 3, [("1", 100, 1), ("2", 109, 1), ("3", 115, 1)], x0=nx)
keys.append(("↵", 120, nx + 3, y + 3, 1, 2))
keys += _row(y + 4, [("0", 108, 2), ("," if iso else ".", 114, 1)], x0=nx)
return keys
VARIANTS = {"iso-de": "German (ISO)", "ansi-us": "US (ANSI)"}
def default_variant():
lang = (os.environ.get("LANG") or locale.getlocale()[0] or "").lower()
return "iso-de" if lang.startswith(("de", "at", "ch")) else "ansi-us"
def keys(variant=None):
ks = _build(variant or default_variant())
path = os.path.expanduser("~/.config/predator-control/layout.json")
try:
with open(path) as f:
over = json.load(f)
ks = [(l, over.get(l, c), x, y, w, h) for (l, c, x, y, w, h) in ks]
except (OSError, ValueError):
pass
return ks
def size(ks):
return max(k[2] + k[4] for k in ks), max(k[3] + k[5] for k in ks)
# cell groups for quick selection in the RGB studio
GROUPS = {
"WASD": [21, 20, 26, 32],
"Arrows": [84, 90, 96, 102],
"F-row": [5 + 6 * i for i in range(13)] + [83, 89, 95],
"Number row": [4] + [10 + 6 * i for i in range(10)] + [70, 76, 82],
"Numpad": [112, 118, 124, 123, 99, 111, 117, 122, 101, 110, 116, 100, 109, 115, 120, 108, 114],
"Special keys": [107, 106, 105, 104, 103],
"Modifiers": [0, 6, 12, 18, 60, 72, 78, 1, 85, 2, 3],
}