diff --git a/daemon/clearpowerd/charge_control.py b/daemon/clearpowerd/charge_control.py index 06a9dbb..acda9c0 100644 --- a/daemon/clearpowerd/charge_control.py +++ b/daemon/clearpowerd/charge_control.py @@ -68,11 +68,13 @@ def _write_thresholds(self, start, end): order = order[::-1] try: for path, value in order: - write_str(path, value) + if os.path.exists(path): + write_str(path, value) except OSError: # firmware quirk: try the other order once before giving up for path, value in order[::-1]: - write_str(path, value) + if os.path.exists(path): + write_str(path, value) def _write_behaviour(self, b): if not self.behaviour_supported: diff --git a/daemon/clearpowerd/display_cal.py b/daemon/clearpowerd/display_cal.py index c1b6d53..55ee6d0 100644 --- a/daemon/clearpowerd/display_cal.py +++ b/daemon/clearpowerd/display_cal.py @@ -67,7 +67,7 @@ def _save(self): @property def calibrated(self): - return len(self.table) >= 2 + return len(self.table) >= 2 and any(p[1] > 0.001 for p in self.table) # ---- content level from the UI ------------------------------------------- def set_content(self, apl, now): diff --git a/daemon/clearpowerd/sampler.py b/daemon/clearpowerd/sampler.py index 5cf9d7b..0ea509b 100644 --- a/daemon/clearpowerd/sampler.py +++ b/daemon/clearpowerd/sampler.py @@ -13,11 +13,12 @@ from .sources.rapl import Rapl from .sources.backlight import Backlight from .sources.hwmon import Hwmon +from .sources.nvidia import NvidiaGpu from .sources.procs import Procs from .smoothing import Ema from .sysfs import read_str -SMOOTHED = ("bat_w", "psys", "package", "core", "uncore", "dram") +SMOOTHED = ("bat_w", "psys", "package", "core", "uncore", "dram", "dgpu") class Sampler: @@ -26,6 +27,7 @@ def __init__(self, cfg, display_cal=None): self.battery = Battery(cfg["battery"]) self.adapter = Adapter() self.rapl = Rapl() + self.nvidia = NvidiaGpu() self.backlight = Backlight() self.hwmon = Hwmon() self.procs = Procs(cfg["procs_interval_s"]) @@ -65,11 +67,16 @@ def _smooth(self, key, value, now): return self.ema[key].update(value, now) @staticmethod - def _rest(psys, package, dram): - """Platform power not attributable to the SoC or memory (display + peripherals).""" - if psys is None or psys < 0: + def _rest(psys, package, dram, bat_w=None, on_ac=True, dgpu=0.0): + """Platform power not attributable to the SoC, memory, or dGPU (display + peripherals).""" + plat = None + if not on_ac and bat_w is not None and bat_w < -0.05: + plat = -bat_w + elif psys is not None and psys > 0 and (package is None or package <= 0 or psys >= package * 0.7): + plat = psys + if plat is None or plat < 0: return -1.0 - return max(psys - max(package or 0.0, 0.0) - max(dram or 0.0, 0.0), 0.0) + return max(plat - max(package or 0.0, 0.0) - max(dram or 0.0, 0.0) - max(dgpu or 0.0, 0.0), 0.0) def sample(self, hot=True): now = time.monotonic() @@ -77,11 +84,15 @@ def sample(self, hot=True): bat = self.battery.read() ad = self.adapter.read() rapl = self.rapl.read() + nv = self.nvidia.read() if getattr(self, "nvidia", None) is not None else {"gpu_power_w": 0.0, "temp_gpu": -1.0} bl = self.backlight.read() snap.update(bat) snap.update(ad) snap.update(bl) - snap.update(self._thermal_read(hot)) + thermal = self._thermal_read(hot) + if nv.get("temp_gpu", -1.0) > 0 and thermal.get("temp_gpu", -1.0) < 0: + thermal["temp_gpu"] = nv["temp_gpu"] + snap.update(thermal) on_ac = ad["on_ac"] # ---- raw inputs (for history / runtime / calibration) ---- @@ -92,8 +103,9 @@ def sample(self, hot=True): "core": rapl.get("core", -1.0), "uncore": rapl.get("uncore", -1.0), "dram": rapl.get("dram", -1.0), + "dgpu": nv.get("gpu_power_w", 0.0), } - raw["rest"] = self._rest(raw["psys"], raw["package"], raw["dram"]) + raw["rest"] = self._rest(raw["psys"], raw["package"], raw["dram"], raw["bat_w"], on_ac, raw["dgpu"]) self.raw = raw # ---- smoothed inputs ---- @@ -104,13 +116,26 @@ def sample(self, hot=True): core = self._smooth("core", raw["core"], now) uncore = self._smooth("uncore", raw["uncore"], now) dram = self._smooth("dram", raw["dram"], now) + dgpu = self._smooth("dgpu", raw["dgpu"], now) if "dgpu" in self.ema else 0.0 + dgpu = max(dgpu, 0.0) + # ---- whole-machine draw ---- + psys_valid = psys > 0 and (package <= 0 or psys >= package * 0.7) + disp_est = 0.0 if not on_ac and bat_w < -0.05: sys_w, sys_source = -bat_w, "battery" # physical truth incl. all losses - elif psys > 0: + elif psys_valid: sys_w, sys_source = psys, "psys" elif package > 0: - sys_w, sys_source = package + 3.0, "estimate" + if bl.get("display_on", True): + if self.display_cal is not None and bl.get("brightness_raw") is not None: + e = self.display_cal.emission_w(bl.get("brightness_raw"), now) + disp_est = max(e, 0.0) if e >= 0 else (bl.get("brightness_pct", 50.0) / 100.0 * 5.0) + else: + disp_est = bl.get("brightness_pct", 50.0) / 100.0 * 5.0 + base_other = 3.0 + sys_w = package + max(dram or 0.0, 0.0) + dgpu + disp_est + base_other + sys_source = "estimate" else: sys_w, sys_source = -1.0, "none" @@ -119,10 +144,11 @@ def sample(self, hot=True): rest_w = -1.0 if package > 0 and sys_w > 0: cpu_w = max(core, 0.0) - gpu_w = max(uncore, 0.0) - soc_w = max(package - cpu_w - gpu_w, 0.0) + igpu_w = max(uncore, 0.0) + soc_w = max(package - cpu_w - igpu_w, 0.0) + gpu_w = igpu_w + dgpu mem_w = max(dram, 0.0) - measured = package + mem_w + measured = package + mem_w + dgpu if measured > sys_w: # psys occasionally undershoots; keep the total authoritative k = sys_w / measured cpu_w, gpu_w, soc_w, mem_w = cpu_w * k, gpu_w * k, soc_w * k, mem_w * k @@ -133,10 +159,14 @@ def sample(self, hot=True): display_w = -1.0 other_w = rest_w - if self.display_cal is not None and rest_w >= 0: - e = self.display_cal.emission_w(bl.get("brightness_raw"), now) - if e >= 0: - display_w = 0.0 if not bl["display_on"] else min(e, rest_w) + if rest_w >= 0: + if self.display_cal is not None: + e = self.display_cal.emission_w(bl.get("brightness_raw"), now) + if e >= 0: + display_w = 0.0 if not bl.get("display_on", True) else min(e, rest_w) + other_w = rest_w - display_w + if display_w < 0 and sys_source == "estimate": + display_w = min(disp_est, rest_w) other_w = rest_w - display_w # Adapter supplies the machine (minus whatever the battery is contributing when the diff --git a/daemon/clearpowerd/sources/hwmon.py b/daemon/clearpowerd/sources/hwmon.py index 058b557..c897854 100644 --- a/daemon/clearpowerd/sources/hwmon.py +++ b/daemon/clearpowerd/sources/hwmon.py @@ -27,10 +27,12 @@ def _labelled(h, prefix): class Hwmon: def __init__(self): self.thinkpad = _find("thinkpad") + self.asus = _find("asus") self.coretemp = _find("coretemp") self.nvme = _find("nvme") self.tp_temps = _labelled(self.thinkpad, "temp") self.tp_fans = _labelled(self.thinkpad, "fan") + self.asus_fans = _labelled(self.asus, "fan") self.core_temps = _labelled(self.coretemp, "temp") self.nvme_temps = _labelled(self.nvme, "temp") @@ -52,7 +54,8 @@ def read(self): if lbl in self.nvme_temps: nvme = self._c(self.nvme_temps[lbl]) break - fans = sorted(self.tp_fans.items()) + fans_map = self.tp_fans if self.tp_fans else self.asus_fans + fans = sorted(fans_map.items()) fan1 = read_int(fans[0][1]) if len(fans) > 0 else -1 fan2 = read_int(fans[1][1]) if len(fans) > 1 else -1 return { diff --git a/daemon/clearpowerd/sources/nvidia.py b/daemon/clearpowerd/sources/nvidia.py new file mode 100644 index 0000000..046b252 --- /dev/null +++ b/daemon/clearpowerd/sources/nvidia.py @@ -0,0 +1,98 @@ +"""NVIDIA GPU power and temperature readings via libnvidia-ml.so.1.""" +import ctypes +import glob +import os + + +class NvidiaGpu: + def __init__(self): + self._dev_path = None + for d in glob.glob("/sys/bus/pci/devices/*"): + vpath = os.path.join(d, "vendor") + cpath = os.path.join(d, "class") + if os.path.exists(vpath) and os.path.exists(cpath): + try: + with open(vpath) as vf, open(cpath) as cf: + if vf.read().strip() == "0x10de" and cf.read().strip().startswith("0x03"): + self._dev_path = d + break + except OSError: + pass + self._rpath = os.path.join(self._dev_path, "power", "runtime_status") if self._dev_path else None + self._nvml = None + self._dev = None + self._last_power_w = 0.0 + self._max_power_w = 150.0 + self.available = self._dev_path is not None + + def is_suspended(self): + if not self._rpath or not os.path.exists(self._rpath): + return False + try: + with open(self._rpath) as f: + return f.read().strip() == "suspended" + except OSError: + return False + + def _ensure_nvml(self): + if self._dev is not None: + return True + try: + self._nvml = ctypes.CDLL("libnvidia-ml.so.1") + self._nvml.nvmlInit.restype = ctypes.c_int + self._nvml.nvmlDeviceGetHandleByIndex.argtypes = [ctypes.c_uint, ctypes.POINTER(ctypes.c_void_p)] + self._nvml.nvmlDeviceGetHandleByIndex.restype = ctypes.c_int + self._nvml.nvmlDeviceGetPowerUsage.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint)] + self._nvml.nvmlDeviceGetPowerUsage.restype = ctypes.c_int + self._nvml.nvmlDeviceGetTemperature.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint)] + self._nvml.nvmlDeviceGetTemperature.restype = ctypes.c_int + if self._nvml.nvmlInit() == 0: + self._dev = ctypes.c_void_p() + if self._nvml.nvmlDeviceGetHandleByIndex(0, ctypes.byref(self._dev)) == 0: + try: + pmin = ctypes.c_uint() + pmax = ctypes.c_uint() + self._nvml.nvmlDeviceGetPowerManagementLimitConstraints.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint) + ] + self._nvml.nvmlDeviceGetPowerManagementLimitConstraints.restype = ctypes.c_int + if self._nvml.nvmlDeviceGetPowerManagementLimitConstraints(self._dev, ctypes.byref(pmin), ctypes.byref(pmax)) == 0: + self._max_power_w = max(pmax.value / 1000.0 * 1.2, 100.0) + except Exception: + pass + return True + except Exception: + pass + self._dev = None + return False + + def read(self): + if not self.available: + return {"gpu_power_w": 0.0, "temp_gpu": -1.0} + # If suspended in D3cold/D3hot, do NOT touch NVML to avoid waking the card up! + if self.is_suspended(): + self._last_power_w = 0.0 + return {"gpu_power_w": 0.0, "temp_gpu": -1.0} + if not self._ensure_nvml(): + return {"gpu_power_w": 0.0, "temp_gpu": -1.0} + power_w = 0.0 + temp_gpu = -1.0 + try: + mw = ctypes.c_uint() + if self._nvml.nvmlDeviceGetPowerUsage(self._dev, ctypes.byref(mw)) == 0: + val_w = mw.value / 1000.0 + # Filter out EC/SBIOS phantom reading glitch (e.g. 752W / 753W on mobile GPUs) + if 0.0 <= val_w <= self._max_power_w: + power_w = val_w + self._last_power_w = val_w + else: + power_w = self._last_power_w + except Exception: + pass + try: + temp = ctypes.c_uint() + if self._nvml.nvmlDeviceGetTemperature(self._dev, 0, ctypes.byref(temp)) == 0: + temp_gpu = float(temp.value) + except Exception: + pass + return {"gpu_power_w": power_w, "temp_gpu": temp_gpu} diff --git a/daemon/clearpowerd/sources/rapl.py b/daemon/clearpowerd/sources/rapl.py index e89a842..3c85b32 100644 --- a/daemon/clearpowerd/sources/rapl.py +++ b/daemon/clearpowerd/sources/rapl.py @@ -17,6 +17,8 @@ def __init__(self): if not name: continue key = name.split("-")[0] # package-0 -> package + if key == "psys" and read_int(os.path.join(d, "enabled")) == 0: + continue self.domains[key] = d self._wrap = {k: read_int(os.path.join(p, "max_energy_range_uj")) or (1 << 32) for k, p in self.domains.items()}