aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArpit Chakladar <arpitchakladar+git@gmail.com>2026-08-28 03:31:06 +0530
committerArpit Chakladar <arpitchakladar+git@gmail.com>2026-08-29 03:50:31 +0530
commit112d93af69e922b779b5dd3f1be9fb407211bb0a (patch)
tree7bbc1d19a85375e8a7e004c3a72461ae4d17752a
parentd380e7e2df8a560f64d32ea4150f4423d8029d50 (diff)
downloadhome-manager-config-112d93af69e922b779b5dd3f1be9fb407211bb0a.tar.gz
home-manager-config-112d93af69e922b779b5dd3f1be9fb407211bb0a.zip
desktop: more informative eww bar tooltips
-rw-r--r--modules/desktop/eww/system-stats.py262
-rw-r--r--modules/desktop/eww/yuck.nix12
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)