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,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()
|
||||
Reference in New Issue
Block a user