Files
MrBlakeandClaude Opus 5.5 2ef62e85cd Add charging indicator and pacman package, bump to 1.1.0
- 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>
2026-10-07 17:37:03 +02:00

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"),
}