- Show charge/discharge power in watts, a battery graphic with charge bolt, a power history graph and the estimated time until full or remaining (respecting the 80 % charge limit) in the app, the panel widget, its tooltip and `predatorctl status` - Add packaging/arch/PKGBUILD that builds a pacman package; the kernel module ships as DKMS source and is rebuilt for every kernel - Bump version to 1.1.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""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(limit_80=False):
|
|
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,
|
|
})
|
|
# remaining time in minutes (to full while charging, to empty while discharging)
|
|
now = _read_int(bat + "/charge_now") or _read_int(bat + "/energy_now")
|
|
rate = cur if _read_int(bat + "/charge_now") is not None else pwr
|
|
minutes = None
|
|
if now is not None and full and rate:
|
|
rate = abs(rate)
|
|
if out["status"] == "Charging":
|
|
target = full * 0.8 if limit_80 else full
|
|
minutes = int(max(0, target - now) / rate * 60)
|
|
elif out["status"] == "Discharging":
|
|
minutes = int(now / rate * 60)
|
|
out["minutes_left"] = minutes
|
|
out["charging"] = out["status"] == "Charging"
|
|
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"),
|
|
}
|