Initial release of Predator Control 1.0.0
PredatorSense replacement for the Acer Predator Helios 16 (PH16-71) on Linux: - predatord: root service for profiles, fans (incl. temperature curves), keyboard RGB, lightbar and battery health; restores settings at boot and after resume, switches profiles on AC/battery changes - Qt desktop app with dashboard, fan curve editor and per-key RGB studio - Plasma 6 panel widget with quick controls - predatorctl command line tool - predator_wmi kernel module (DKMS) as a minimal WMI method bridge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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}; }}
|
||||
"""
|
||||
@@ -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))
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user