diff options
Diffstat (limited to 'modules/desktop/eww')
| -rw-r--r-- | modules/desktop/eww/system-stats.py | 262 | ||||
| -rw-r--r-- | modules/desktop/eww/yuck.nix | 12 |
2 files changed, 240 insertions, 34 deletions
diff --git a/modules/desktop/eww/system-stats.py b/modules/desktop/eww/system-stats.py index e1a8c71..b85ffd4 100644 --- a/modules/desktop/eww/system-stats.py +++ b/modules/desktop/eww/system-stats.py @@ -1,7 +1,25 @@ #!/usr/bin/env python3 """system-stats: emit {cpu, ram, net} as JSON. -net is now an object: { +cpu is now an object: { + "percent": 23, # overall utilization, 0-100 + "per_core": [12, 34, ...], # per-core utilization, 0-100 each + "freq_mhz": 2400, # average current frequency across cores + "load_avg": [0.5, 0.6, 0.7] # 1/5/15 min load averages +} + +ram is now an object: { + "percent": 42, # used %, based on MemAvailable + "used": "6.2G", # formatted absolute used + "total": "16.0G", # formatted absolute total + "available": "9.8G", # formatted absolute available + "swap_percent": 5, + "swap_used": "512M", + "swap_total": "8.0G", + "speed_mhz": 3200 # configured RAM speed, or null if unavailable +} + +net is an object: { "status": "up"|"limited"|"down", "tooltip": "↓12M ↑5M\\nwlan0: 192.168.1.5", "routes": [ @@ -18,6 +36,9 @@ net is now an object: { } Requires `iw` (for wifi link speed) and `iproute2` (for route/ip lookup) on PATH. +RAM speed detection requires `dmidecode`, which typically needs root privileges +(run as root, grant CAP_SYS_RAWIO, or add a passwordless sudo rule) — if it's +unavailable or unreadable, "speed_mhz" is simply reported as null. """ import json import os @@ -27,8 +48,24 @@ import time STATE_LOCK = threading.Lock() STATE = { - "cpu": 0, - "ram": 0, + "cpu": { + "percent": 0, + "per_core": [], + "freq_mhz": None, + "load_avg": None, + "tooltip": "CPU: 0%", + }, + "ram": { + "percent": 0, + "used": "0B", + "total": "0B", + "available": "0B", + "swap_percent": 0, + "swap_used": "0B", + "swap_total": "0B", + "speed_mhz": None, + "tooltip": "RAM: 0%", + }, "net": { "status": "down", "tooltip": "Network Offline", @@ -45,23 +82,46 @@ def format_rate(bytes_per_sec): """Format bytes/sec into an ultra-compact 3-5 character string (e.g., '500B', '1.2M').""" if bytes_per_sec is None or bytes_per_sec < 0: return "0B" - + units = ["B", "K", "M", "G", "T"] val = float(bytes_per_sec) unit_idx = 0 - + while val >= 1024 and unit_idx < len(units) - 1: val /= 1024.0 unit_idx += 1 - + unit = units[unit_idx] - + if val >= 100 or unit == "B": return f"{int(val)}{unit}" else: return f"{val:.1f}{unit}" +def format_bytes(kib): + """Format an absolute value given in KiB into a compact human-readable + string (e.g., '6.2G', '512M'). Same style as format_rate, but for + absolute quantities rather than a per-second rate.""" + if kib is None or kib < 0: + return "0B" + + units = ["K", "M", "G", "T"] + val = float(kib) + unit_idx = 0 + + while val >= 1024 and unit_idx < len(units) - 1: + val /= 1024.0 + unit_idx += 1 + + unit = units[unit_idx] + + if val >= 100 or unit == "K": + return f"{int(val)}{unit}" + else: + return f"{val:.1f}{unit}" + + def get_iface_io_bytes(iface): """Read cumulative rx/tx bytes for a specific interface from /proc/net/dev.""" if not iface: @@ -81,42 +141,186 @@ def get_iface_io_bytes(iface): def read_cpu_times(): + """Return {'cpu': (idle, total), 'cpu0': (idle, total), ...} for the + aggregate and every individual core, parsed from /proc/stat.""" + times = {} with open("/proc/stat") as f: - parts = f.readline().split()[1:] - vals = list(map(int, parts)) - idle = vals[3] + vals[4] - total = sum(vals) - return idle, total + for line in f: + if not line.startswith("cpu"): + break + parts = line.split() + label = parts[0] + vals = list(map(int, parts[1:])) + idle = vals[3] + vals[4] + total = sum(vals) + times[label] = (idle, total) + return times + + +def get_cpu_freq_mhz(): + """Average current CPU frequency (MHz) across cores, from /proc/cpuinfo.""" + freqs = [] + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.lower().startswith("cpu mhz"): + try: + freqs.append(float(line.split(":", 1)[1].strip())) + except ValueError: + pass + except OSError: + pass + if not freqs: + return None + return round(sum(freqs) / len(freqs)) + + +def get_load_avg(): + try: + with open("/proc/loadavg") as f: + parts = f.read().split() + return [float(parts[0]), float(parts[1]), float(parts[2])] + except (OSError, ValueError, IndexError): + return None + + +def get_ram_speed_mhz(): + """Query DMI tables (SMBIOS type 17) for the configured/current memory + speed via dmidecode. Requires root — returns None if dmidecode is + missing, unreadable (permission denied), or reports no populated slots. + This only needs to be queried once at startup since it doesn't change.""" + try: + out = subprocess.check_output( + ["dmidecode", "-t", "17"], text=True, stderr=subprocess.DEVNULL + ) + except (subprocess.CalledProcessError, FileNotFoundError, PermissionError): + return None + + speeds = [] + for line in out.splitlines(): + line = line.strip() + if line.startswith("Configured Memory Speed:"): + val = line.split(":", 1)[1].strip() + if val.lower() in ("unknown", "no module installed"): + continue + try: + speeds.append(int(val.split()[0])) + except (ValueError, IndexError): + continue + + if not speeds: + # Fall back to rated "Speed" if no configured speed was reported + for line in out.splitlines(): + line = line.strip() + if line.startswith("Speed:"): + val = line.split(":", 1)[1].strip() + if val.lower() in ("unknown", "no module installed"): + continue + try: + speeds.append(int(val.split()[0])) + except (ValueError, IndexError): + continue + + return max(speeds) if speeds else None + + +def build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg): + lines = [f"CPU: {overall_pct}%"] + if freq_mhz is not None: + lines.append(f"Freq: {freq_mhz} MHz") + if load_avg is not None: + l1, l5, l15 = load_avg + lines.append(f"Load: {l1:.2f} {l5:.2f} {l15:.2f}") + for i, pct in enumerate(per_core_list): + lines.append(f"Core {i}: {pct}%") + return "\n".join(lines) + + +def build_ram_tooltip(ram_pct, used, total, available, swap_pct, swap_used, swap_total, speed_mhz): + lines = [f"RAM: {ram_pct}% ({used} / {total})"] + lines.append(f"Available: {available}") + lines.append(f"Swap: {swap_pct}% ({swap_used} / {swap_total})") + lines.append(f"Speed: {speed_mhz} MHz" if speed_mhz is not None else "Speed: unknown") + return "\n".join(lines) def cpu_ram_loop(interval=2.0): - prev_idle, prev_total = read_cpu_times() + prev_times = read_cpu_times() + # Static for the life of the process — no need to shell out every tick. + ram_speed_mhz = get_ram_speed_mhz() + while True: time.sleep(interval) - - # CPU - idle, total = read_cpu_times() - d_idle = idle - prev_idle - d_total = total - prev_total - prev_idle, prev_total = idle, total - cpu_pct = round((1 - d_idle / d_total) * 100) if d_total else 0 - - # RAM + + # --- CPU --- + cur_times = read_cpu_times() + overall_pct = 0 + per_core = {} + for label, (idle, total) in cur_times.items(): + prev_idle, prev_total = prev_times.get(label, (idle, total)) + d_idle = idle - prev_idle + d_total = total - prev_total + pct = round((1 - d_idle / d_total) * 100) if d_total else 0 + if label == "cpu": + overall_pct = pct + else: + per_core[int(label[len("cpu"):])] = pct + prev_times = cur_times + per_core_list = [per_core[i] for i in sorted(per_core)] + + freq_mhz = get_cpu_freq_mhz() + load_avg = get_load_avg() + cpu_state = { + "percent": overall_pct, + "per_core": per_core_list, + "freq_mhz": freq_mhz, + "load_avg": load_avg, + "tooltip": build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg), + } + + # --- RAM --- with open("/proc/meminfo") as f: meminfo = {} for line in f: k, v, *_ = line.split() meminfo[k.rstrip(":")] = int(v) - total_kb = meminfo["MemTotal"] - avail_kb = meminfo["MemAvailable"] + + total_kb = meminfo.get("MemTotal", 0) + avail_kb = meminfo.get("MemAvailable", 0) + used_kb = max(0, total_kb - avail_kb) ram_pct = round((1 - avail_kb / total_kb) * 100) if total_kb else 0 - # Network update + swap_total_kb = meminfo.get("SwapTotal", 0) + swap_free_kb = meminfo.get("SwapFree", 0) + swap_used_kb = max(0, swap_total_kb - swap_free_kb) + swap_pct = round((swap_used_kb / swap_total_kb) * 100) if swap_total_kb else 0 + + used_fmt = format_bytes(used_kb) + total_fmt = format_bytes(total_kb) + avail_fmt = format_bytes(avail_kb) + swap_used_fmt = format_bytes(swap_used_kb) + swap_total_fmt = format_bytes(swap_total_kb) + ram_state = { + "percent": ram_pct, + "used": used_fmt, + "total": total_fmt, + "available": avail_fmt, + "swap_percent": swap_pct, + "swap_used": swap_used_fmt, + "swap_total": swap_total_fmt, + "speed_mhz": ram_speed_mhz, + "tooltip": build_ram_tooltip( + ram_pct, used_fmt, total_fmt, avail_fmt, + swap_pct, swap_used_fmt, swap_total_fmt, ram_speed_mhz, + ), + } + + # --- Network --- net_status = check_net_status() with STATE_LOCK: - STATE["cpu"] = cpu_pct - STATE["ram"] = ram_pct + STATE["cpu"] = cpu_state + STATE["ram"] = ram_state STATE["net"] = net_status emit() @@ -130,7 +334,7 @@ def get_all_default_ifaces(): ) except (subprocess.CalledProcessError, FileNotFoundError): return [] - + ifaces = [] for line in out.splitlines(): parts = line.split() @@ -256,7 +460,7 @@ def check_net_status(): }) NET_LAST_TIME = now - + # Global status aggregate if any_up: overall_status = "up" diff --git a/modules/desktop/eww/yuck.nix b/modules/desktop/eww/yuck.nix index 253c274..1f2f993 100644 --- a/modules/desktop/eww/yuck.nix +++ b/modules/desktop/eww/yuck.nix @@ -94,7 +94,9 @@ (defpoll full_date :interval "60s" "date '+%A, %d %B %Y'") (deflisten workspaces :initial "[]" "${lib.getExe workspaces}") (deflisten active-windows :initial "[]" "${lib.getExe activate-windows}") - (deflisten system_stats :initial "{\"cpu\":0,\"ram\":0,\"net\":{\"status\":\"down\",\"tooltip\":\"Network Offline\",\"routes\":[]}}" "${lib.getExe system-stats}") + (deflisten system_stats + :initial "{\"cpu\":{\"percent\":0,\"per_core\":[],\"freq_mhz\":null,\"load_avg\":null,\"tooltip\":\"CPU: 0%\"},\"ram\":{\"percent\":0,\"used\":\"0B\",\"total\":\"0B\",\"available\":\"0B\",\"swap_percent\":0,\"swap_used\":\"0B\",\"swap_total\":\"0B\",\"speed_mhz\":null,\"tooltip\":\"RAM: 0%\"},\"net\":{\"status\":\"down\",\"tooltip\":\"Network Offline\",\"routes\":[]}}" + "${lib.getExe system-stats}") (defwindow bar :monitor 0 @@ -153,13 +155,13 @@ (label :class "system-label small" :text "''${system_stats.net.status == 'down' ? '-' : system_stats.net.routes[0].rx}")) (box :orientation "v" :class "system-item" :space-evenly false :spacing 4 - :tooltip "CPU: ''${system_stats.cpu}%" + :tooltip "''${system_stats.cpu.tooltip}" (image :class "system-icon" :path "${../../../assets/icons/bar/cpu.svg}" :image-width 24 :image-height 24) - (label :class "system-label" :text "''${system_stats.cpu}%")) + (label :class "system-label" :text "''${system_stats.cpu.percent}%")) (box :orientation "v" :class "system-item" :space-evenly false :spacing 4 - :tooltip "RAM: ''${system_stats.ram}%" + :tooltip "''${system_stats.ram.tooltip}" (image :class "system-icon" :path "${../../../assets/icons/bar/ram.svg}" :image-width 24 :image-height 24) - (label :class "system-label" :text "''${system_stats.ram}%")) + (label :class "system-label" :text "''${system_stats.ram.percent}%")) (box :orientation "v" :class "system-item" :space-evenly false :spacing 4 :tooltip "''${full_date} ''${time} (''${date})" (image :class "system-icon" :path "${../../../assets/icons/bar/calendar.svg}" :image-width 24 :image-height 24) |
