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