system_monitor.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """LuxStats system monitor v3.
  2. Sources (all toggleable in the configurator):
  3. cpu, gpu, ram 0-1 utilisation
  4. cpu_temp, gpu_temp RAW Celsius (not normalised)
  5. eth wired link speed in Mbps; 0 unless the link is
  6. actually ACTIVE (carrier up + operstate up)
  7. wifi band GHz (0/2/5/6)
  8. vpn, cam bool
  9. disk_<dev> 0-1 usage per detected physical drive
  10. disk_<dev>_temp RAW Celsius where a nvme/drivetemp sensor exists
  11. disk / disk_temp aggregates (max usage / max temp)
  12. Camera detection ignores media daemons (PipeWire/WirePlumber/PulseAudio)
  13. that hold /dev/video* open permanently on modern Arch/GNOME setups - only a
  14. "real" consumer (browser, OBS, Zoom, ...) counts as camera-in-use.
  15. Slow probes are cached with per-probe minimum ages so the configurator can
  16. poll at 100 ms without spawning subprocesses at 10 Hz.
  17. """
  18. import glob
  19. import os
  20. import re
  21. import shutil
  22. import subprocess
  23. import sys
  24. import threading
  25. import time
  26. import psutil
  27. VPN_IF_PATTERNS = re.compile(r"^(tun|tap|wg|ppp|tailscale|zt|nordlynx|proton)", re.I)
  28. VIRTUAL_IF_PATTERNS = re.compile(
  29. r"^(lo|docker|veth|br-|virbr|vmnet|vbox|kube|flannel|cni)", re.I
  30. )
  31. # Media stacks that keep camera devices open without actually using them.
  32. # /proc/<pid>/comm is truncated to 15 chars, so compare prefixes.
  33. CAMERA_IGNORE_PREFIXES = (
  34. "pipewire", "wireplumber", "pulseaudio", "v4l2-relayd",
  35. "pipewire-media", "pipewire-pulse",
  36. )
  37. # Minimum seconds between real reads of each slow probe (values are cached
  38. # in between so 100 ms polling stays cheap).
  39. PROBE_MIN_AGE = {
  40. "cpu_temp": 1.0, "gpu": 1.0, "wifi": 1.0, "cam": 0.5,
  41. "eth": 0.3, "drives_usage": 2.0, "drives_temp": 2.0,
  42. }
  43. _cache = {}
  44. def _cached(key, fn):
  45. now = time.monotonic()
  46. ent = _cache.get(key)
  47. if ent and now - ent[0] < PROBE_MIN_AGE.get(key, 0.0):
  48. return ent[1]
  49. val = fn()
  50. _cache[key] = (now, val)
  51. return val
  52. def _run(cmd, timeout=2):
  53. try:
  54. out = subprocess.run(cmd, capture_output=True, text=True,
  55. timeout=timeout)
  56. return out.stdout
  57. except (OSError, subprocess.SubprocessError):
  58. return ""
  59. def _wireless_ifaces():
  60. if sys.platform.startswith("linux"):
  61. return [os.path.basename(os.path.dirname(p))
  62. for p in glob.glob("/sys/class/net/*/wireless")]
  63. return []
  64. def _sensors():
  65. try:
  66. return psutil.sensors_temperatures()
  67. except (AttributeError, OSError):
  68. return {}
  69. # --------------------------------------------------------------------------
  70. # CPU / RAM / temps (raw Celsius)
  71. # --------------------------------------------------------------------------
  72. def cpu_percent():
  73. return psutil.cpu_percent(interval=None) / 100.0
  74. def ram_percent():
  75. return psutil.virtual_memory().percent / 100.0
  76. def cpu_temp():
  77. """Raw CPU temperature in Celsius, or None."""
  78. temps = _sensors()
  79. for key in ("coretemp", "k10temp", "zenpower", "cpu_thermal", "acpitz"):
  80. if key in temps and temps[key]:
  81. return float(temps[key][0].current)
  82. return None
  83. # --------------------------------------------------------------------------
  84. # GPU (NVIDIA via nvidia-smi, AMD via sysfs/hwmon) - temp is raw Celsius
  85. # --------------------------------------------------------------------------
  86. _NVSMI = shutil.which("nvidia-smi")
  87. def _nvidia_query():
  88. if not _NVSMI:
  89. return None
  90. out = _run([_NVSMI, "--query-gpu=utilization.gpu,temperature.gpu",
  91. "--format=csv,noheader,nounits"], timeout=3)
  92. m = re.match(r"\s*(\d+)\s*,\s*(\d+)", out or "")
  93. if m:
  94. return int(m.group(1)) / 100.0, float(m.group(2))
  95. return None
  96. def _amd_util():
  97. for path in glob.glob("/sys/class/drm/card*/device/gpu_busy_percent"):
  98. try:
  99. with open(path) as f:
  100. return int(f.read().strip()) / 100.0
  101. except (OSError, ValueError):
  102. continue
  103. return None
  104. def gpu_stats():
  105. """Return (util 0-1 or None, temp raw Celsius or None)."""
  106. nv = _nvidia_query()
  107. if nv:
  108. return nv
  109. util = _amd_util()
  110. temp = None
  111. temps = _sensors()
  112. for key in ("amdgpu", "radeon", "nouveau"):
  113. if key in temps and temps[key]:
  114. temp = float(temps[key][0].current)
  115. break
  116. return util, temp
  117. # --------------------------------------------------------------------------
  118. # Drives - usage per physical drive + raw Celsius temps
  119. # --------------------------------------------------------------------------
  120. def _device_base(device):
  121. name = os.path.basename(device.rstrip("\\/"))
  122. if sys.platform == "win32":
  123. return name.rstrip(":") or name
  124. if name.startswith(("nvme", "mmcblk")):
  125. return re.sub(r"p\d+$", "", name)
  126. if re.match(r"^(sd[a-z]+|hd[a-z]+|vd[a-z]+|xvd[a-z]+)\d*$", name):
  127. return re.sub(r"\d+$", "", name)
  128. return name
  129. def physical_drives():
  130. drives = {}
  131. try:
  132. parts = psutil.disk_partitions(all=False)
  133. except OSError:
  134. return drives
  135. for p in parts:
  136. if not p.device:
  137. continue
  138. if p.fstype in ("squashfs", "iso9660", "overlay", "tmpfs"):
  139. continue
  140. drives.setdefault(_device_base(p.device), []).append(p.mountpoint)
  141. return drives
  142. def _drives_usage():
  143. out = {}
  144. for d, mounts in physical_drives().items():
  145. worst = None
  146. for m in mounts:
  147. try:
  148. worst = max(worst or 0.0,
  149. psutil.disk_usage(m).percent / 100.0)
  150. except (OSError, PermissionError):
  151. continue
  152. if worst is not None:
  153. out[d] = worst
  154. return out
  155. def drives_usage():
  156. return _cached("drives_usage", _drives_usage)
  157. def _drives_temp():
  158. """Best-effort {drive: raw Celsius} from nvme/drivetemp sensors."""
  159. temps = _sensors()
  160. result = {}
  161. drives = sorted(physical_drives())
  162. nvme = [d for d in drives if d.startswith("nvme")]
  163. sata = [d for d in drives if re.match(r"^(sd|hd)[a-z]+$", d)]
  164. for i, entry in enumerate(temps.get("nvme", [])):
  165. if i < len(nvme):
  166. result[nvme[i]] = float(entry.current)
  167. for i, entry in enumerate(temps.get("drivetemp", [])):
  168. if i < len(sata):
  169. result[sata[i]] = float(entry.current)
  170. return result
  171. def drives_temp():
  172. return _cached("drives_temp", _drives_temp)
  173. # --------------------------------------------------------------------------
  174. # Network - eth requires an ACTIVE link (carrier), not just a reported speed
  175. # --------------------------------------------------------------------------
  176. def _iface_active(name):
  177. """True only when the interface has a live link."""
  178. if sys.platform.startswith("linux"):
  179. try:
  180. with open(f"/sys/class/net/{name}/carrier") as f:
  181. if f.read().strip() != "1":
  182. return False
  183. except OSError:
  184. return False
  185. try:
  186. with open(f"/sys/class/net/{name}/operstate") as f:
  187. return f.read().strip() in ("up", "unknown")
  188. except OSError:
  189. return False
  190. st = psutil.net_if_stats().get(name)
  191. return bool(st and st.isup)
  192. def _eth_speed():
  193. """Best speed (Mbps) among ACTIVE wired links; 0 when nothing is live."""
  194. wireless = set(_wireless_ifaces())
  195. best = 0
  196. for name, st in psutil.net_if_stats().items():
  197. if name in wireless:
  198. continue
  199. if VPN_IF_PATTERNS.match(name) or VIRTUAL_IF_PATTERNS.match(name):
  200. continue
  201. if not st.isup or not st.speed or st.speed <= 0:
  202. continue
  203. if not _iface_active(name):
  204. continue
  205. best = max(best, st.speed)
  206. return best
  207. def eth_speed():
  208. return _cached("eth", _eth_speed)
  209. def _wifi_band():
  210. freq_mhz = 0
  211. if sys.platform.startswith("linux"):
  212. iw = shutil.which("iw")
  213. for iface in _wireless_ifaces():
  214. if not _iface_active(iface):
  215. continue
  216. if iw:
  217. m = re.search(r"freq:\s*(\d+)",
  218. _run([iw, "dev", iface, "link"]))
  219. if m:
  220. freq_mhz = max(freq_mhz, int(m.group(1)))
  221. if not freq_mhz and shutil.which("nmcli"):
  222. out = _run(["nmcli", "-t", "-f", "ACTIVE,FREQ", "dev", "wifi"])
  223. for line in out.splitlines():
  224. if line.startswith(("yes:", "sí:", "oui:")):
  225. m = re.search(r"(\d+)\s*MHz", line)
  226. if m:
  227. freq_mhz = max(freq_mhz, int(m.group(1)))
  228. elif sys.platform == "win32":
  229. out = _run(["netsh", "wlan", "show", "interfaces"])
  230. m = re.search(r"Band\s*:\s*(\d+(?:\.\d+)?)\s*GHz", out)
  231. if m:
  232. return {2.4: 2}.get(float(m.group(1)), int(float(m.group(1))))
  233. m = re.search(r"Channel\s*:\s*(\d+)", out)
  234. if m:
  235. return 2 if int(m.group(1)) <= 14 else 5
  236. if freq_mhz >= 5925:
  237. return 6
  238. if freq_mhz >= 4900:
  239. return 5
  240. if freq_mhz >= 2400:
  241. return 2
  242. return 0
  243. def wifi_band():
  244. return _cached("wifi", _wifi_band)
  245. def vpn_active():
  246. for name, st in psutil.net_if_stats().items():
  247. if st.isup and VPN_IF_PATTERNS.match(name):
  248. return True
  249. return False
  250. # --------------------------------------------------------------------------
  251. # Camera - ignore media daemons that hold /dev/video* open permanently
  252. # --------------------------------------------------------------------------
  253. def _pid_comm(pid):
  254. try:
  255. with open(f"/proc/{pid}/comm") as f:
  256. return f.read().strip()
  257. except OSError:
  258. return ""
  259. def _camera_holder_pids(devices):
  260. pids = set()
  261. fuser = shutil.which("fuser")
  262. if fuser:
  263. for tok in _run([fuser] + devices).split():
  264. tok = tok.strip().rstrip("mrce") # fuser access-mode suffixes
  265. if tok.isdigit():
  266. pids.add(tok)
  267. return pids
  268. try:
  269. for pid in filter(str.isdigit, os.listdir("/proc")):
  270. fd_dir = f"/proc/{pid}/fd"
  271. try:
  272. for fd in os.listdir(fd_dir):
  273. if os.readlink(os.path.join(fd_dir, fd)) \
  274. .startswith("/dev/video"):
  275. pids.add(pid)
  276. break
  277. except OSError:
  278. continue
  279. except OSError:
  280. pass
  281. return pids
  282. def _camera_active():
  283. if not sys.platform.startswith("linux"):
  284. return None
  285. devices = glob.glob("/dev/video*")
  286. if not devices:
  287. return False
  288. for pid in _camera_holder_pids(devices):
  289. comm = _pid_comm(pid).lower()
  290. if comm and not comm.startswith(CAMERA_IGNORE_PREFIXES):
  291. return True
  292. return False
  293. def camera_active():
  294. return _cached("cam", _camera_active)
  295. # --------------------------------------------------------------------------
  296. # Source registry + collection
  297. # --------------------------------------------------------------------------
  298. BASE_SOURCES = ["cpu", "cpu_temp", "gpu", "gpu_temp", "ram",
  299. "eth", "wifi", "vpn", "cam"]
  300. def discover_sources():
  301. sources = list(BASE_SOURCES)
  302. drives = physical_drives()
  303. temps = drives_temp()
  304. sources.append("disk")
  305. if temps:
  306. sources.append("disk_temp")
  307. for d in sorted(drives):
  308. sources.append(f"disk_{d}")
  309. if d in temps:
  310. sources.append(f"disk_{d}_temp")
  311. return sources
  312. def collect(enabled=None):
  313. def want(key):
  314. return enabled is None or key in enabled
  315. values = {}
  316. if want("cpu"):
  317. values["cpu"] = round(cpu_percent(), 3)
  318. if want("cpu_temp"):
  319. t = _cached("cpu_temp", cpu_temp)
  320. if t is not None:
  321. values["cpu_temp"] = round(t, 1)
  322. if want("gpu") or want("gpu_temp"):
  323. util, temp = _cached("gpu", gpu_stats)
  324. if want("gpu") and util is not None:
  325. values["gpu"] = round(util, 3)
  326. if want("gpu_temp") and temp is not None:
  327. values["gpu_temp"] = round(temp, 1)
  328. if want("ram"):
  329. values["ram"] = round(ram_percent(), 3)
  330. if want("eth"):
  331. values["eth"] = eth_speed()
  332. if want("wifi"):
  333. values["wifi"] = wifi_band()
  334. if want("vpn"):
  335. values["vpn"] = vpn_active()
  336. if want("cam"):
  337. cam = camera_active()
  338. if cam is not None:
  339. values["cam"] = cam
  340. usage = drives_usage() if (want("disk") or any(
  341. want(f"disk_{d}") for d in physical_drives())) else {}
  342. temps = drives_temp() if (want("disk_temp") or any(
  343. want(f"disk_{d}_temp") for d in physical_drives())) else {}
  344. agg_u, agg_t = None, None
  345. for d, u in usage.items():
  346. if want(f"disk_{d}"):
  347. values[f"disk_{d}"] = round(u, 3)
  348. agg_u = max(agg_u or 0.0, u)
  349. for d, t in temps.items():
  350. if want(f"disk_{d}_temp"):
  351. values[f"disk_{d}_temp"] = round(t, 1)
  352. agg_t = max(agg_t or 0.0, t)
  353. if want("disk") and agg_u is not None:
  354. values["disk"] = round(agg_u, 3)
  355. if want("disk_temp") and agg_t is not None:
  356. values["disk_temp"] = round(agg_t, 1)
  357. return values
  358. class NotificationWatcher(threading.Thread):
  359. """Fires `callback()` on any desktop notification (Linux, dbus-monitor)."""
  360. MATCH = "interface='org.freedesktop.Notifications',member='Notify'"
  361. def __init__(self, callback):
  362. super().__init__(daemon=True)
  363. self.callback = callback
  364. self.proc = None
  365. self._stop = threading.Event()
  366. def run(self):
  367. if not shutil.which("dbus-monitor"):
  368. return
  369. try:
  370. self.proc = subprocess.Popen(
  371. ["dbus-monitor", self.MATCH],
  372. stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
  373. for line in self.proc.stdout:
  374. if self._stop.is_set():
  375. break
  376. if "member=Notify" in line:
  377. try:
  378. self.callback()
  379. except Exception:
  380. pass
  381. except OSError:
  382. pass
  383. def stop(self):
  384. self._stop.set()
  385. if self.proc:
  386. try:
  387. self.proc.terminate()
  388. except OSError:
  389. pass
  390. if __name__ == "__main__":
  391. import json
  392. psutil.cpu_percent(interval=None)
  393. time.sleep(0.3)
  394. print("sources:", discover_sources())
  395. print(json.dumps(collect(), indent=2))