| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- """LuxStats system monitor v3.
- Sources (all toggleable in the configurator):
- cpu, gpu, ram 0-1 utilisation
- cpu_temp, gpu_temp RAW Celsius (not normalised)
- eth wired link speed in Mbps; 0 unless the link is
- actually ACTIVE (carrier up + operstate up)
- wifi band GHz (0/2/5/6)
- vpn, cam bool
- disk_<dev> 0-1 usage per detected physical drive
- disk_<dev>_temp RAW Celsius where a nvme/drivetemp sensor exists
- disk / disk_temp aggregates (max usage / max temp)
- Camera detection ignores media daemons (PipeWire/WirePlumber/PulseAudio)
- that hold /dev/video* open permanently on modern Arch/GNOME setups - only a
- "real" consumer (browser, OBS, Zoom, ...) counts as camera-in-use.
- Slow probes are cached with per-probe minimum ages so the configurator can
- poll at 100 ms without spawning subprocesses at 10 Hz.
- """
- import glob
- import os
- import re
- import shutil
- import subprocess
- import sys
- import threading
- import time
- import psutil
- VPN_IF_PATTERNS = re.compile(r"^(tun|tap|wg|ppp|tailscale|zt|nordlynx|proton)", re.I)
- VIRTUAL_IF_PATTERNS = re.compile(
- r"^(lo|docker|veth|br-|virbr|vmnet|vbox|kube|flannel|cni)", re.I
- )
- # Media stacks that keep camera devices open without actually using them.
- # /proc/<pid>/comm is truncated to 15 chars, so compare prefixes.
- CAMERA_IGNORE_PREFIXES = (
- "pipewire", "wireplumber", "pulseaudio", "v4l2-relayd",
- "pipewire-media", "pipewire-pulse",
- )
- # Minimum seconds between real reads of each slow probe (values are cached
- # in between so 100 ms polling stays cheap).
- PROBE_MIN_AGE = {
- "cpu_temp": 1.0, "gpu": 1.0, "wifi": 1.0, "cam": 0.5,
- "eth": 0.3, "drives_usage": 2.0, "drives_temp": 2.0,
- }
- _cache = {}
- def _cached(key, fn):
- now = time.monotonic()
- ent = _cache.get(key)
- if ent and now - ent[0] < PROBE_MIN_AGE.get(key, 0.0):
- return ent[1]
- val = fn()
- _cache[key] = (now, val)
- return val
- def _run(cmd, timeout=2):
- try:
- out = subprocess.run(cmd, capture_output=True, text=True,
- timeout=timeout)
- return out.stdout
- except (OSError, subprocess.SubprocessError):
- return ""
- def _wireless_ifaces():
- if sys.platform.startswith("linux"):
- return [os.path.basename(os.path.dirname(p))
- for p in glob.glob("/sys/class/net/*/wireless")]
- return []
- def _sensors():
- try:
- return psutil.sensors_temperatures()
- except (AttributeError, OSError):
- return {}
- # --------------------------------------------------------------------------
- # CPU / RAM / temps (raw Celsius)
- # --------------------------------------------------------------------------
- def cpu_percent():
- return psutil.cpu_percent(interval=None) / 100.0
- def ram_percent():
- return psutil.virtual_memory().percent / 100.0
- def cpu_temp():
- """Raw CPU temperature in Celsius, or None."""
- temps = _sensors()
- for key in ("coretemp", "k10temp", "zenpower", "cpu_thermal", "acpitz"):
- if key in temps and temps[key]:
- return float(temps[key][0].current)
- return None
- # --------------------------------------------------------------------------
- # GPU (NVIDIA via nvidia-smi, AMD via sysfs/hwmon) - temp is raw Celsius
- # --------------------------------------------------------------------------
- _NVSMI = shutil.which("nvidia-smi")
- def _nvidia_query():
- if not _NVSMI:
- return None
- out = _run([_NVSMI, "--query-gpu=utilization.gpu,temperature.gpu",
- "--format=csv,noheader,nounits"], timeout=3)
- m = re.match(r"\s*(\d+)\s*,\s*(\d+)", out or "")
- if m:
- return int(m.group(1)) / 100.0, float(m.group(2))
- return None
- def _amd_util():
- for path in glob.glob("/sys/class/drm/card*/device/gpu_busy_percent"):
- try:
- with open(path) as f:
- return int(f.read().strip()) / 100.0
- except (OSError, ValueError):
- continue
- return None
- def gpu_stats():
- """Return (util 0-1 or None, temp raw Celsius or None)."""
- nv = _nvidia_query()
- if nv:
- return nv
- util = _amd_util()
- temp = None
- temps = _sensors()
- for key in ("amdgpu", "radeon", "nouveau"):
- if key in temps and temps[key]:
- temp = float(temps[key][0].current)
- break
- return util, temp
- # --------------------------------------------------------------------------
- # Drives - usage per physical drive + raw Celsius temps
- # --------------------------------------------------------------------------
- def _device_base(device):
- name = os.path.basename(device.rstrip("\\/"))
- if sys.platform == "win32":
- return name.rstrip(":") or name
- if name.startswith(("nvme", "mmcblk")):
- return re.sub(r"p\d+$", "", name)
- if re.match(r"^(sd[a-z]+|hd[a-z]+|vd[a-z]+|xvd[a-z]+)\d*$", name):
- return re.sub(r"\d+$", "", name)
- return name
- def physical_drives():
- drives = {}
- try:
- parts = psutil.disk_partitions(all=False)
- except OSError:
- return drives
- for p in parts:
- if not p.device:
- continue
- if p.fstype in ("squashfs", "iso9660", "overlay", "tmpfs"):
- continue
- drives.setdefault(_device_base(p.device), []).append(p.mountpoint)
- return drives
- def _drives_usage():
- out = {}
- for d, mounts in physical_drives().items():
- worst = None
- for m in mounts:
- try:
- worst = max(worst or 0.0,
- psutil.disk_usage(m).percent / 100.0)
- except (OSError, PermissionError):
- continue
- if worst is not None:
- out[d] = worst
- return out
- def drives_usage():
- return _cached("drives_usage", _drives_usage)
- def _drives_temp():
- """Best-effort {drive: raw Celsius} from nvme/drivetemp sensors."""
- temps = _sensors()
- result = {}
- drives = sorted(physical_drives())
- nvme = [d for d in drives if d.startswith("nvme")]
- sata = [d for d in drives if re.match(r"^(sd|hd)[a-z]+$", d)]
- for i, entry in enumerate(temps.get("nvme", [])):
- if i < len(nvme):
- result[nvme[i]] = float(entry.current)
- for i, entry in enumerate(temps.get("drivetemp", [])):
- if i < len(sata):
- result[sata[i]] = float(entry.current)
- return result
- def drives_temp():
- return _cached("drives_temp", _drives_temp)
- # --------------------------------------------------------------------------
- # Network - eth requires an ACTIVE link (carrier), not just a reported speed
- # --------------------------------------------------------------------------
- def _iface_active(name):
- """True only when the interface has a live link."""
- if sys.platform.startswith("linux"):
- try:
- with open(f"/sys/class/net/{name}/carrier") as f:
- if f.read().strip() != "1":
- return False
- except OSError:
- return False
- try:
- with open(f"/sys/class/net/{name}/operstate") as f:
- return f.read().strip() in ("up", "unknown")
- except OSError:
- return False
- st = psutil.net_if_stats().get(name)
- return bool(st and st.isup)
- def _eth_speed():
- """Best speed (Mbps) among ACTIVE wired links; 0 when nothing is live."""
- wireless = set(_wireless_ifaces())
- best = 0
- for name, st in psutil.net_if_stats().items():
- if name in wireless:
- continue
- if VPN_IF_PATTERNS.match(name) or VIRTUAL_IF_PATTERNS.match(name):
- continue
- if not st.isup or not st.speed or st.speed <= 0:
- continue
- if not _iface_active(name):
- continue
- best = max(best, st.speed)
- return best
- def eth_speed():
- return _cached("eth", _eth_speed)
- def _wifi_band():
- freq_mhz = 0
- if sys.platform.startswith("linux"):
- iw = shutil.which("iw")
- for iface in _wireless_ifaces():
- if not _iface_active(iface):
- continue
- if iw:
- m = re.search(r"freq:\s*(\d+)",
- _run([iw, "dev", iface, "link"]))
- if m:
- freq_mhz = max(freq_mhz, int(m.group(1)))
- if not freq_mhz and shutil.which("nmcli"):
- out = _run(["nmcli", "-t", "-f", "ACTIVE,FREQ", "dev", "wifi"])
- for line in out.splitlines():
- if line.startswith(("yes:", "sí:", "oui:")):
- m = re.search(r"(\d+)\s*MHz", line)
- if m:
- freq_mhz = max(freq_mhz, int(m.group(1)))
- elif sys.platform == "win32":
- out = _run(["netsh", "wlan", "show", "interfaces"])
- m = re.search(r"Band\s*:\s*(\d+(?:\.\d+)?)\s*GHz", out)
- if m:
- return {2.4: 2}.get(float(m.group(1)), int(float(m.group(1))))
- m = re.search(r"Channel\s*:\s*(\d+)", out)
- if m:
- return 2 if int(m.group(1)) <= 14 else 5
- if freq_mhz >= 5925:
- return 6
- if freq_mhz >= 4900:
- return 5
- if freq_mhz >= 2400:
- return 2
- return 0
- def wifi_band():
- return _cached("wifi", _wifi_band)
- def vpn_active():
- for name, st in psutil.net_if_stats().items():
- if st.isup and VPN_IF_PATTERNS.match(name):
- return True
- return False
- # --------------------------------------------------------------------------
- # Camera - ignore media daemons that hold /dev/video* open permanently
- # --------------------------------------------------------------------------
- def _pid_comm(pid):
- try:
- with open(f"/proc/{pid}/comm") as f:
- return f.read().strip()
- except OSError:
- return ""
- def _camera_holder_pids(devices):
- pids = set()
- fuser = shutil.which("fuser")
- if fuser:
- for tok in _run([fuser] + devices).split():
- tok = tok.strip().rstrip("mrce") # fuser access-mode suffixes
- if tok.isdigit():
- pids.add(tok)
- return pids
- try:
- for pid in filter(str.isdigit, os.listdir("/proc")):
- fd_dir = f"/proc/{pid}/fd"
- try:
- for fd in os.listdir(fd_dir):
- if os.readlink(os.path.join(fd_dir, fd)) \
- .startswith("/dev/video"):
- pids.add(pid)
- break
- except OSError:
- continue
- except OSError:
- pass
- return pids
- def _camera_active():
- if not sys.platform.startswith("linux"):
- return None
- devices = glob.glob("/dev/video*")
- if not devices:
- return False
- for pid in _camera_holder_pids(devices):
- comm = _pid_comm(pid).lower()
- if comm and not comm.startswith(CAMERA_IGNORE_PREFIXES):
- return True
- return False
- def camera_active():
- return _cached("cam", _camera_active)
- # --------------------------------------------------------------------------
- # Source registry + collection
- # --------------------------------------------------------------------------
- BASE_SOURCES = ["cpu", "cpu_temp", "gpu", "gpu_temp", "ram",
- "eth", "wifi", "vpn", "cam"]
- def discover_sources():
- sources = list(BASE_SOURCES)
- drives = physical_drives()
- temps = drives_temp()
- sources.append("disk")
- if temps:
- sources.append("disk_temp")
- for d in sorted(drives):
- sources.append(f"disk_{d}")
- if d in temps:
- sources.append(f"disk_{d}_temp")
- return sources
- def collect(enabled=None):
- def want(key):
- return enabled is None or key in enabled
- values = {}
- if want("cpu"):
- values["cpu"] = round(cpu_percent(), 3)
- if want("cpu_temp"):
- t = _cached("cpu_temp", cpu_temp)
- if t is not None:
- values["cpu_temp"] = round(t, 1)
- if want("gpu") or want("gpu_temp"):
- util, temp = _cached("gpu", gpu_stats)
- if want("gpu") and util is not None:
- values["gpu"] = round(util, 3)
- if want("gpu_temp") and temp is not None:
- values["gpu_temp"] = round(temp, 1)
- if want("ram"):
- values["ram"] = round(ram_percent(), 3)
- if want("eth"):
- values["eth"] = eth_speed()
- if want("wifi"):
- values["wifi"] = wifi_band()
- if want("vpn"):
- values["vpn"] = vpn_active()
- if want("cam"):
- cam = camera_active()
- if cam is not None:
- values["cam"] = cam
- usage = drives_usage() if (want("disk") or any(
- want(f"disk_{d}") for d in physical_drives())) else {}
- temps = drives_temp() if (want("disk_temp") or any(
- want(f"disk_{d}_temp") for d in physical_drives())) else {}
- agg_u, agg_t = None, None
- for d, u in usage.items():
- if want(f"disk_{d}"):
- values[f"disk_{d}"] = round(u, 3)
- agg_u = max(agg_u or 0.0, u)
- for d, t in temps.items():
- if want(f"disk_{d}_temp"):
- values[f"disk_{d}_temp"] = round(t, 1)
- agg_t = max(agg_t or 0.0, t)
- if want("disk") and agg_u is not None:
- values["disk"] = round(agg_u, 3)
- if want("disk_temp") and agg_t is not None:
- values["disk_temp"] = round(agg_t, 1)
- return values
- class NotificationWatcher(threading.Thread):
- """Fires `callback()` on any desktop notification (Linux, dbus-monitor)."""
- MATCH = "interface='org.freedesktop.Notifications',member='Notify'"
- def __init__(self, callback):
- super().__init__(daemon=True)
- self.callback = callback
- self.proc = None
- self._stop = threading.Event()
- def run(self):
- if not shutil.which("dbus-monitor"):
- return
- try:
- self.proc = subprocess.Popen(
- ["dbus-monitor", self.MATCH],
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
- for line in self.proc.stdout:
- if self._stop.is_set():
- break
- if "member=Notify" in line:
- try:
- self.callback()
- except Exception:
- pass
- except OSError:
- pass
- def stop(self):
- self._stop.set()
- if self.proc:
- try:
- self.proc.terminate()
- except OSError:
- pass
- if __name__ == "__main__":
- import json
- psutil.cpu_percent(interval=None)
- time.sleep(0.3)
- print("sources:", discover_sources())
- print(json.dumps(collect(), indent=2))
|