#!/usr/bin/env python3 """ Power Control Center v2 Covers: power-profiles-daemon · ACPI platform profile · intel_pstate (EPP, turbo, HWP boost, min/max %) · CPU governor · C-states · battery charge threshold · TLP (service + AC/BAT settings) · thermald · auto-cpufreq · turbostat · stress-ng · s-tui · powertop · lm_sensors · upower/acpi """ import tkinter as tk from tkinter import ttk, messagebox, scrolledtext, colorchooser import subprocess import threading import select import os import re import time import shutil import shlex import tempfile import json import logging import configparser from pathlib import Path logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(funcName)s: %(message)s") _log = logging.getLogger("pcc") # ─── Helpers ──────────────────────────────────────────────────────────────── # ─── Persistent root shell ────────────────────────────────────────────────── # One pkexec prompt at startup; all privileged ops go through this pipe. _root_shell = None # subprocess.Popen handle _root_lock = threading.Lock() # serialises all root_exec calls def acquire_root(): """Open a persistent bash root shell via pkexec. Returns True on success.""" global _root_shell if _root_shell and _root_shell.poll() is None: return True try: _root_shell = subprocess.Popen( ["pkexec", "bash"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # quick liveness check _root_shell.stdin.write("echo OK\n") _root_shell.stdin.flush() ready, _, _ = select.select([_root_shell.stdout], [], [], 3) if ready: line = _root_shell.stdout.readline().strip() return line == "OK" return False except Exception: _root_shell = None return False def root_exec(cmd_str, timeout=10): """Run a shell command in the persistent root shell. Returns (stdout, rc). Thread-safe: serialised via _root_lock.""" global _root_shell # Use a sentinel that cannot appear in normal command output sentinel = "__PCC_DONE_0x7E3A__" with _root_lock: if not _root_shell or _root_shell.poll() is not None: if not acquire_root(): return "No root shell", -1 try: # Write sentinel on its own line so it can't be confused with output _root_shell.stdin.write(f"{cmd_str}\necho {sentinel}$?\n") _root_shell.stdin.flush() out_lines = [] deadline = time.monotonic() + timeout while time.monotonic() < deadline: ready, _, _ = select.select([_root_shell.stdout], [], [], 0.1) if ready: line = _root_shell.stdout.readline() if line.startswith(sentinel): rc_str = line[len(sentinel):].strip() try: return "\n".join(out_lines).strip(), int(rc_str) except ValueError: return "\n".join(out_lines).strip(), 0 out_lines.append(line.rstrip()) _log.warning("root_exec timed out: %s", cmd_str[:80]) return "\n".join(out_lines), -1 except Exception as e: _log.error("root_exec error: %s", e) _root_shell = None return str(e), -1 def run(cmd, sudo=False, capture=True, timeout=15): """Run a command. If sudo=True, use the persistent root shell.""" if sudo: cmd_str = " ".join( f'"{c}"' if " " in c else c for c in (cmd if isinstance(cmd, list) else cmd.split())) return root_exec(cmd_str, timeout=timeout) try: r = subprocess.run( cmd if isinstance(cmd, list) else cmd.split(), capture_output=capture, text=True, timeout=timeout) return r.stdout.strip(), r.returncode except subprocess.TimeoutExpired: return "Timed out", -1 except Exception as e: return str(e), -1 def sysread(path): try: return Path(path).read_text().strip() except Exception: return None _SAFE_WRITE_PREFIXES = ("/sys/", "/proc/sys/", "/etc/tlp", "/etc/systemd") def _safe_path(path): """Return True if path is in an allowed prefix for privileged writes.""" return any(str(path).startswith(p) for p in _SAFE_WRITE_PREFIXES) def syswrite(path, value, sudo=False): """Write value to a sysfs/procfs path through root shell when needed.""" if sudo: if not _safe_path(path): _log.error("Refusing privileged write to unsafe path: %s", path) return False cmd = f"echo {shlex.quote(str(value))} > {shlex.quote(str(path))}" _, rc = root_exec(cmd) return rc == 0 try: Path(path).write_text(str(value)) return True except PermissionError: return syswrite(path, value, sudo=True) except Exception as e: _log.warning("syswrite %s: %s", path, e) return False def syswrite_many(writes, sudo=False): """Write multiple (path, value) pairs. Uses ';' so all writes attempt even if one fails — partial success is still better than stopping early.""" if not writes: return True if sudo: bad = [p for p, _ in writes if not _safe_path(p)] if bad: _log.error("Refusing privileged write to unsafe paths: %s", bad) return False # Use ; (not &&) so every write is attempted regardless cmds = " ; ".join( f"echo {shlex.quote(str(v))} > {shlex.quote(str(p))}" for p, v in writes) _, rc = root_exec(cmds) return rc == 0 ok = True for path, value in writes: ok = syswrite(path, value) and ok return ok def _write_config_as_root(content, dest_path): """Write content to a secure temp file then copy to dest as root.""" # Open with O_CREAT|O_WRONLY and mode 0o600 atomically — no world-readable window tmpdir = tempfile.gettempdir() tmp = os.path.join(tmpdir, f"pcc_{os.getpid()}_{int(time.monotonic()*1e6)}.conf") fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600) try: with os.fdopen(fd, "w") as f: f.write(content) _, rc = root_exec( f"cp {shlex.quote(tmp)} {shlex.quote(str(dest_path))}") return rc == 0 except Exception as e: _log.error("_write_config_as_root: %s", e) return False finally: try: os.unlink(tmp) except Exception: pass def systemctl(action, service, sudo=True): out, rc = run(["systemctl", action, service], sudo=sudo) return rc == 0 def service_active(name): _, rc = run(["systemctl", "is-active", "--quiet", name], sudo=False) return rc == 0 def service_enabled(name): _, rc = run(["systemctl", "is-enabled", "--quiet", name], sudo=False) return rc == 0 def which(cmd): return shutil.which(cmd) is not None def get_cpu_count(): try: return int(sysread("/sys/devices/system/cpu/present").split("-")[1]) + 1 except Exception: return os.cpu_count() or 1 # ─── Hardware detection ────────────────────────────────────────────────────── def _find_battery(): """Return (sys_path_str, name) for the first battery, e.g. ('/sys/...BAT1','BAT1').""" ps = Path("/sys/class/power_supply") if not ps.exists(): return None, None for p in sorted(ps.iterdir()): if sysread(p / "type") == "Battery": return str(p), p.name return None, None def _detect_cpu_vendor(): """Return 'intel', 'amd', or 'unknown'.""" try: info = Path("/proc/cpuinfo").read_text().lower() except Exception: return "unknown" if "genuineintel" in info: return "intel" if "authenticamd" in info: return "amd" return "unknown" def _get_turbo_path(): """Return (path_str, inverted) for CPU boost toggle. inverted=True means writing '1' disables boost (Intel no_turbo convention).""" if Path("/sys/devices/system/cpu/intel_pstate/no_turbo").exists(): return "/sys/devices/system/cpu/intel_pstate/no_turbo", True if Path("/sys/devices/system/cpu/amd_pstate/cpb_boost").exists(): return "/sys/devices/system/cpu/amd_pstate/cpb_boost", False if Path("/sys/devices/system/cpu/cpufreq/boost").exists(): return "/sys/devices/system/cpu/cpufreq/boost", False return None, False def _find_nvidia_pci_path(): """Return sysfs PCI path for first NVIDIA GPU, or None if not found.""" base = Path("/sys/bus/pci/devices") if base.exists(): for dev in sorted(base.iterdir()): if (sysread(dev / "vendor") or "") == "0x10de" and \ (sysread(dev / "class") or "").startswith("0x03"): pci_id = dev.name # Validate PCI address format: DDDD:BB:DD.F if re.match(r'^\d{4}:[0-9a-f]{2}:[0-9a-f]{2}\.\d$', pci_id, re.I): return pci_id return None def _find_drm_card(vendor_id): """Return card name (e.g. 'card1') for the given PCI vendor id, or None.""" drm = Path("/sys/class/drm") if not drm.exists(): return None for card in sorted(drm.iterdir()): if "-" in card.name or not card.name.startswith("card"): continue if (sysread(card / "device/vendor") or "") == vendor_id: return card.name return None def _find_intel_arc_card(): """Return card name for Intel Arc (must have gt_RP0_freq_mhz).""" drm = Path("/sys/class/drm") if not drm.exists(): return None for card in sorted(drm.iterdir()): if "-" in card.name or not card.name.startswith("card"): continue if (card / "gt_RP0_freq_mhz").exists() and \ (sysread(card / "device/vendor") or "") == "0x8086": return card.name return None def _nv_power_range(): """Return (min_w, max_w, default_w) from nvidia-smi or fallback.""" if not which("nvidia-smi"): return 5, 125, 80 out, rc = run(["nvidia-smi", "-q", "-d", "POWER"]) if rc != 0: return 5, 125, 80 mn = re.search(r"Min Power Limit\s*:\s*([\d.]+)", out) mx = re.search(r"Max Power Limit\s*:\s*([\d.]+)", out) df = re.search(r"Default Power Limit\s*:\s*([\d.]+)", out) return (int(float(mn.group(1))) if mn else 5, int(float(mx.group(1))) if mx else 125, int(float(df.group(1))) if df else 80) # Computed once at startup _BAT_SYS, _BAT_NAME = _find_battery() # e.g. "/sys/.../BAT1", "BAT1" _CPU_VEND = _detect_cpu_vendor() # 'intel'|'amd'|'unknown' _NV_PCI = _find_nvidia_pci_path() # e.g. '0000:01:00.0' _ARC_CARD = _find_intel_arc_card() # e.g. 'card1' or None _AMD_CARD = _find_drm_card("0x1002") # e.g. 'card0' or None _HAS_NV = which("nvidia-smi") and bool(which("nvidia-smi")) _TURBO_PATH, _TURBO_INV = _get_turbo_path() _UPOWER_BAT = (f"/org/freedesktop/UPower/devices/battery_{_BAT_NAME}" if _BAT_NAME else None) # ─── Display refresh rate ──────────────────────────────────────────────────── def _detect_desktop(): """Return 'hyprland', 'kde', or None.""" de = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() if "hyprland" in de or os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"): return "hyprland" if "kde" in de: return "kde" # fallback: probe binaries if which("hyprctl"): _, rc = run(["hyprctl", "monitors", "-j"]) if rc == 0: return "hyprland" if which("kscreen-doctor"): return "kde" return None def _set_display_hz_hyprland(target_hz): """Set all Hyprland monitors to target_hz ('max' or int) via wlr-randr. Returns True on success.""" # hyprctl keyword monitor doesn't work with monitorv2 config blocks — use wlr-randr instead out, rc = run(["wlr-randr"]) if rc != 0: return False ok = True # Parse wlr-randr output to find outputs and their available modes current_output = None outputs = {} # name -> [(width, height, hz), ...] for line in out.splitlines(): # Output header: non-indented line starting with a word char (e.g. "eDP-2 ..." or "DP-1") if line and not line[0].isspace(): current_output = line.split()[0] outputs.setdefault(current_output, []) continue if current_output: # Mode line: ' 2560x1600 px, 240.000000 Hz (preferred)' m = re.match(r'\s+(\d+)x(\d+)\s+px,\s+([\d.]+)\s+Hz', line) if m: outputs[current_output].append( (int(m.group(1)), int(m.group(2)), float(m.group(3)))) for name, modes in outputs.items(): if not modes: continue chosen = max(modes, key=lambda m: m[2]) if target_hz == "max" else \ min(modes, key=lambda m: abs(m[2] - float(target_hz))) _, rc = run(["wlr-randr", "--output", name, "--mode", f"{chosen[0]}x{chosen[1]}@{chosen[2]:g}"]) if rc != 0: ok = False return ok def _set_display_hz_kde(target_hz): """Set all KDE monitors via kscreen-doctor. Returns True on success.""" out, rc = run(["kscreen-doctor", "-o"]) if rc != 0: return False # Parse: "Output: 1 eDP-1 enabled connected" and mode lines "0:2560x1600@240" current_output = None outputs = {} for line in out.splitlines(): m = re.match(r"\s*Output:\s*\d+\s+(\S+)", line) if m: current_output = m.group(1) outputs[current_output] = [] if current_output: for mm in re.finditer(r"\d+:(\d+)x(\d+)@([\d.]+)", line): outputs[current_output].append( (int(mm.group(1)), int(mm.group(2)), float(mm.group(3)))) ok = True for name, modes in outputs.items(): if not modes: continue chosen = max(modes, key=lambda m: m[2]) if target_hz == "max" else \ min(modes, key=lambda m: abs(m[2] - float(target_hz))) _, rc = run(["kscreen-doctor", f"output.{name}.mode.{chosen[0]}x{chosen[1]}@{chosen[2]:.0f}"]) if rc != 0: ok = False return ok def set_display_hz(target_hz): """Auto-detect DE (Hyprland/KDE) and set display refresh rate. target_hz: 'max' for highest available, or an int like 60.""" de = _detect_desktop() if de == "hyprland": return _set_display_hz_hyprland(target_hz) if de == "kde": return _set_display_hz_kde(target_hz) return False # ─── Colour palette ───────────────────────────────────────────────────────── # Palette sourced from ~/.config/assets/palette/palette.conf # backprimary → backtertiary (pure black → dark greys) BG = "#000000" # backprimary BG2 = "#050505" # near-black panels BG3 = "#0a0a0a" # near-black cards BORDER = "#ce7688" # foreprimary rose — matches hyprland active border # foreprimary family — rose / mauve (active borders, accent) ACCENT = "#ce7688" # foreprimary ACCENT2 = "#ba6a7b" # foresecondary RED = "#ce7688" # foreprimary — performance / danger / hot ORANGE = "#ba6a7b" # foresecondary — caution / warning # highprimary family — warm gold / tan (text, balanced, positive) TEXT = "#c1b48e" # highprimary — main text (matches waybar `color`) YELLOW = "#c1b48e" # highprimary — balanced state GREEN = "#b5a985" # highsecondary — power-saver / good TEAL = "#a49978" # hightertiary — neutral tool highlights SUBTEXT = "#7a7158" # highsenary — dimmed / secondary text # ─── Theme config ──────────────────────────────────────────────────────────── _THEME_FILE = Path.home() / ".config" / "power-control-center" / "theme.conf" def _load_theme(): """Override palette globals from user theme config if it exists.""" global BG, BG2, BG3, BORDER, ACCENT, ACCENT2, RED, ORANGE, TEXT, YELLOW, GREEN, TEAL, SUBTEXT if not _THEME_FILE.exists(): return cfg = configparser.ConfigParser() cfg.read(_THEME_FILE) if "theme" not in cfg: return t = cfg["theme"] BG = t.get("bg", BG) BG2 = t.get("bg2", BG2) BG3 = t.get("bg3", BG3) BORDER = t.get("border", BORDER) ACCENT = t.get("accent", ACCENT) ACCENT2 = t.get("accent2", ACCENT2) RED = t.get("red", RED) ORANGE = t.get("orange", ORANGE) TEXT = t.get("text", TEXT) YELLOW = t.get("yellow", YELLOW) GREEN = t.get("green", GREEN) TEAL = t.get("teal", TEAL) SUBTEXT = t.get("subtext", SUBTEXT) _load_theme() # ─── Reusable widgets ──────────────────────────────────────────────────────── def card(parent, title=None, pady_inner=8): outer = tk.Frame(parent, bg=BG3, highlightbackground=BORDER, highlightcolor=BORDER, highlightthickness=1) if title: hdr = tk.Frame(outer, bg=BG2) hdr.pack(fill="x") tk.Label(hdr, text=title, bg=BG2, fg=ACCENT, font=("Segoe UI", 10, "bold"), padx=10, pady=5).pack(side="left") inner = tk.Frame(outer, bg=BG3, padx=10, pady=pady_inner) inner.pack(fill="both", expand=True) return outer, inner def mk_btn(parent, text, cmd, color=ACCENT, width=None, font_size=9): kw = dict(text=f" {text} ", command=cmd, bg=BG2, fg=color, relief="flat", bd=0, font=("Segoe UI", font_size, "bold"), activebackground=BG3, activeforeground=color, padx=8, pady=6, cursor="hand2") if width: kw["width"] = width return tk.Button(parent, **kw) def mk_sep(parent): tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", pady=6) def set_text(widget, text): widget.config(state="normal") widget.delete("1.0", "end") widget.insert("end", text) widget.config(state="disabled") # ─── Main App ──────────────────────────────────────────────────────────────── class App(tk.Tk): def __init__(self): super().__init__() self.title("Power Control Center") self.configure(bg=BG) self.geometry("920x740") self.minsize(820, 600) self._setup_style() self._build_header() self._build_preset_bar() self._build_notebook() self._refresh_all() self._start_loop() # Acquire a single root shell after the window is visible so the # pkexec dialog appears on top of the app rather than behind it. self.after(200, self._acquire_root_once) def _acquire_root_once(self): """Prompt for credentials once. All sysfs writes reuse the shell.""" self.h_profile.config(text="Waiting for root access…") self.update() ok = acquire_root() if ok: self.h_profile.config(text="Root shell ready") else: self.h_profile.config(text="No root — privileged writes will re-prompt") self._refresh_profiles() # ── ttk style ──────────────────────────────────────────────────────────── def _setup_style(self): s = ttk.Style(self) s.theme_use("clam") s.configure("TNotebook", background=BG, borderwidth=0, tabmargins=[0,0,0,0]) s.configure("TNotebook.Tab", background=BG2, foreground=SUBTEXT, padding=[14, 6], font=("Segoe UI", 9)) s.map("TNotebook.Tab", background=[("selected", BG3), ("active", BG3)], foreground=[("selected", ACCENT), ("active", TEXT)]) s.configure("TFrame", background=BG) s.configure("Horizontal.TScale", background=BG3, troughcolor=BG2, sliderlength=16) s.configure("TCombobox", fieldbackground=BG2, background=BG2, foreground=TEXT, selectbackground=ACCENT, borderwidth=0) s.map("TCombobox", fieldbackground=[("readonly", BG2)], foreground=[("readonly", TEXT)]) # ── Header ─────────────────────────────────────────────────────────────── def _build_header(self): hdr = tk.Frame(self, bg=BG2, height=54) hdr.pack(fill="x") hdr.pack_propagate(False) tk.Label(hdr, text="⚡ Power Control Center", bg=BG2, fg=ACCENT, font=("Segoe UI", 14, "bold"), padx=16).pack(side="left", pady=12) self.h_temp = tk.Label(hdr, text="", bg=BG2, fg=RED, font=("Segoe UI", 10)) self.h_bat = tk.Label(hdr, text="", bg=BG2, fg=YELLOW, font=("Segoe UI", 10)) self.h_profile = tk.Label(hdr, text="", bg=BG2, fg=GREEN, font=("Segoe UI", 10, "bold")) self.h_rate = tk.Label(hdr, text="", bg=BG2, fg=ORANGE, font=("Segoe UI", 10)) for w in (self.h_temp, self.h_bat, self.h_rate, self.h_profile): w.pack(side="right", padx=10) # ── Global Preset Bar ──────────────────────────────────────────────────── def _build_preset_bar(self): bar = tk.Frame(self, bg=BG2, highlightbackground=BORDER, highlightcolor=BORDER, highlightthickness=1) bar.pack(fill="x", padx=8, pady=(4, 0)) tk.Label(bar, text="GLOBAL PRESET", bg=BG2, fg=SUBTEXT, font=("Segoe UI", 8, "bold"), padx=12).pack(side="left") self._preset_btns = {} presets = [ ("PERFORMANCE", "performance", TEXT), ("BALANCED", "balanced", TEXT), ("POWER SAVER", "powersaver", TEXT), ] for label, key, color in presets: btn = tk.Button(bar, text=label, bg=BG, fg=color, relief="flat", bd=0, font=("Segoe UI", 10, "bold"), activebackground=BG3, activeforeground=color, padx=20, pady=8, cursor="hand2", command=lambda k=key: self._apply_global_preset(k)) btn.pack(side="left", padx=2, pady=4) self._preset_btns[key] = (btn, color) self.lbl_preset_status = tk.Label(bar, text="", bg=BG2, fg=SUBTEXT, font=("Segoe UI", 8, "italic")) self.lbl_preset_status.pack(side="right", padx=12) # ── Notebook ───────────────────────────────────────────────────────────── def _build_notebook(self): self.nb = ttk.Notebook(self) self.nb.pack(fill="both", expand=True, padx=8, pady=8) tabs = [ ("Profiles", self._tab_profiles), ("CPU / Pstate", self._tab_pstate), ("C-States", self._tab_cstates), ("Battery", self._tab_battery), ("TLP", self._tab_tlp), ("Daemons", self._tab_daemons), ("GPU", self._tab_gpu), ("Sensors", self._tab_sensors), ("Tools", self._tab_tools), ("Customize", self._tab_customize), ] for idx, (name, builder) in enumerate(tabs): if name == "Tools": self._tools_tab_idx = idx f = tk.Frame(self.nb, bg=BG) self.nb.add(f, text=f" {name} ") builder(f) # ════════════════════════════════════════════════════════════════════════ # TAB: Profiles # ════════════════════════════════════════════════════════════════════════ def _tab_profiles(self, p): p.columnconfigure(0, weight=1) p.columnconfigure(1, weight=1) # ── powerprofilesctl ───────────────────────────────────────────── co, ci = card(p, "power-profiles-daemon (powerprofilesctl)") co.grid(row=0, column=0, sticky="nsew", padx=(0,4), pady=4) self._pp_btns = {} for prof, desc, col in [ ("performance", "Max CPU & GPU performance", RED), ("balanced", "Balance power and speed", YELLOW), ("power-saver", "Minimize power consumption", GREEN), ]: b = tk.Button(ci, text=f" {prof.upper()}", bg=BG2, fg=TEXT, relief="flat", font=("Segoe UI", 11, "bold"), anchor="w", padx=12, pady=10, cursor="hand2", activebackground=BG3, activeforeground=col, command=lambda x=prof: self._set_pp(x)) b.pack(fill="x", pady=2) tk.Label(ci, text=desc, bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)).pack(fill="x", padx=14) self._pp_btns[prof] = (b, col) self.lbl_pp = tk.Label(ci, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8, "italic")) self.lbl_pp.pack(fill="x", pady=(8,0)) # ── ACPI platform profile ──────────────────────────────────────── co2, ci2 = card(p, "ACPI Platform Profile (/sys/firmware/acpi)") co2.grid(row=0, column=1, sticky="nsew", padx=(4,0), pady=4) choices = (sysread("/sys/firmware/acpi/platform_profile_choices") or "").split() self._acpi_btns = {} cmap = {"quiet": TEAL, "balanced": YELLOW, "performance": RED, "balanced-performance": ORANGE} for pr in choices: b = tk.Button(ci2, text=f" {pr.upper()}", bg=BG2, fg=TEXT, relief="flat", font=("Segoe UI", 11, "bold"), anchor="w", padx=12, pady=10, cursor="hand2", activebackground=BG3, activeforeground=cmap.get(pr, ACCENT), command=lambda x=pr: self._set_acpi(x)) b.pack(fill="x", pady=2) self._acpi_btns[pr] = (b, cmap.get(pr, ACCENT)) self.lbl_acpi = tk.Label(ci2, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8, "italic")) self.lbl_acpi.pack(fill="x", pady=(8,0)) # ── EPP ────────────────────────────────────────────────────────── co3, ci3 = card(p, "Energy Performance Preference (EPP) — all CPUs") co3.grid(row=1, column=0, columnspan=2, sticky="ew", pady=4) epp_list = (sysread( "/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_available_preferences") or "").split() epp_row = tk.Frame(ci3, bg=BG3) epp_row.pack(fill="x") epp_cols = {"default": SUBTEXT, "performance": RED, "balance_performance": ORANGE, "balance_power": YELLOW, "power": GREEN} self._epp_btns = {} for ep in epp_list: b = tk.Button(epp_row, text=ep.replace("_", "\n"), bg=BG2, fg=TEXT, relief="flat", font=("Segoe UI", 9), width=14, activebackground=BG3, activeforeground=epp_cols.get(ep, ACCENT), padx=6, pady=8, cursor="hand2", command=lambda e=ep: self._set_epp(e)) b.pack(side="left", padx=4, pady=4) self._epp_btns[ep] = (b, epp_cols.get(ep, ACCENT)) self.lbl_epp = tk.Label(ci3, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8, "italic")) self.lbl_epp.pack(anchor="w", pady=(4,0)) # ── CPU Governor ───────────────────────────────────────────────── co4, ci4 = card(p, "CPU Scaling Governor — all CPUs") co4.grid(row=2, column=0, columnspan=2, sticky="ew", pady=4) govs = (sysread( "/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors") or "").split() gov_row = tk.Frame(ci4, bg=BG3) gov_row.pack(fill="x") gov_cols = {"performance": RED, "powersave": GREEN, "schedutil": ACCENT, "ondemand": YELLOW, "conservative": SUBTEXT} self._gov_btns = {} for g in govs: b = tk.Button(gov_row, text=f" {g.upper()} ", bg=BG2, fg=TEXT, relief="flat", font=("Segoe UI", 10, "bold"), padx=10, pady=8, cursor="hand2", activebackground=BG3, activeforeground=gov_cols.get(g, ACCENT), command=lambda x=g: self._set_gov(x)) b.pack(side="left", padx=5, pady=4) self._gov_btns[g] = (b, gov_cols.get(g, ACCENT)) self.lbl_gov = tk.Label(ci4, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8, "italic")) self.lbl_gov.pack(anchor="w", pady=(4,0)) p.rowconfigure(0, weight=1) # ════════════════════════════════════════════════════════════════════════ # TAB: CPU / Pstate # ════════════════════════════════════════════════════════════════════════ def _tab_pstate(self, p): p.columnconfigure(0, weight=1) # ── Turbo / Boost toggle ───────────────────────────────────────── turbo_title = "intel_pstate Toggles" if _CPU_VEND == "intel" else \ "AMD CPU Controls" if _CPU_VEND == "amd" else "CPU Controls" co, ci = card(p, turbo_title) co.pack(fill="x", padx=4, pady=4) tgl_row = tk.Frame(ci, bg=BG3) tgl_row.pack(fill="x") self.turbo_var = tk.BooleanVar() self.hwpboost_var = tk.BooleanVar() # Turbo boost (all vendors) if _TURBO_PATH: box = tk.Frame(tgl_row, bg=BG2, padx=14, pady=8) box.pack(side="left", padx=6, pady=4) turbo_label = "Turbo Boost" if _CPU_VEND == "intel" else "CPU Boost" tk.Checkbutton(box, text=turbo_label, variable=self.turbo_var, bg=BG2, fg=TEXT, selectcolor=BG, activebackground=BG2, activeforeground=ACCENT, font=("Segoe UI", 10, "bold"), command=self._toggle_turbo).pack(anchor="w") tk.Label(box, text="Allow CPU to exceed base frequency", bg=BG2, fg=SUBTEXT, font=("Segoe UI", 8)).pack(anchor="w") # HWP dynamic boost — Intel only if _CPU_VEND == "intel" and \ Path("/sys/devices/system/cpu/intel_pstate/hwp_dynamic_boost").exists(): box2 = tk.Frame(tgl_row, bg=BG2, padx=14, pady=8) box2.pack(side="left", padx=6, pady=4) tk.Checkbutton(box2, text="HWP Dynamic Boost", variable=self.hwpboost_var, bg=BG2, fg=TEXT, selectcolor=BG, activebackground=BG2, activeforeground=ACCENT, font=("Segoe UI", 10, "bold"), command=self._toggle_hwpb).pack(anchor="w") tk.Label(box2, text="Hardware-directed dynamic performance", bg=BG2, fg=SUBTEXT, font=("Segoe UI", 8)).pack(anchor="w") # AMD pstate mode info if _CPU_VEND == "amd": amd_status = sysread("/sys/devices/system/cpu/amd_pstate/status") or \ sysread("/sys/devices/system/cpu/amd_pstate_epp/status") or "unknown" driver = sysread("/sys/devices/system/cpu/cpu0/cpufreq/scaling_driver") or "unknown" tk.Label(ci, text=f"Driver: {driver} amd_pstate: {amd_status}", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)).pack(anchor="w", pady=(4,0)) # ── Performance % sliders (Intel only) ────────────────────────── if _CPU_VEND == "intel" and \ Path("/sys/devices/system/cpu/intel_pstate/min_perf_pct").exists(): co2, ci2 = card(p, "Performance % Clamp (intel_pstate min/max_perf_pct)") co2.pack(fill="x", padx=4, pady=4) self.min_pct = tk.IntVar(value=8) self.max_pct = tk.IntVar(value=100) self._pct_labels = {} for lbl, var, lo, hi, key in [ ("Min %", self.min_pct, 8, 99, "min"), ("Max %", self.max_pct, 1, 100, "max"), ]: row = tk.Frame(ci2, bg=BG3) row.pack(fill="x", pady=4) tk.Label(row, text=lbl, bg=BG3, fg=TEXT, font=("Segoe UI", 9), width=8, anchor="w").pack(side="left") vlbl = tk.Label(row, text=f"{var.get()}%", bg=BG3, fg=ACCENT, font=("Segoe UI", 9, "bold"), width=5) vlbl.pack(side="right") sl = ttk.Scale(row, from_=lo, to=hi, orient="horizontal", variable=var, command=lambda v, vl=vlbl: vl.config(text=f"{int(float(v))}%")) sl.pack(side="left", fill="x", expand=True, padx=8) self._pct_labels[key] = vlbl btn_r = tk.Frame(ci2, bg=BG3) btn_r.pack(fill="x", pady=(6,0)) mk_btn(btn_r, "Apply %", self._apply_pct, ACCENT).pack(side="left") else: self.min_pct = tk.IntVar(value=8) self.max_pct = tk.IntVar(value=100) self._pct_labels = {} # ── Frequency grid ────────────────────────────────────────────── co3, ci3 = card(p, "Per-Core Frequencies (live)") co3.pack(fill="x", padx=4, pady=4) self.lbl_freqs = tk.Label(ci3, text="", bg=BG3, fg=TEXT, font=("Consolas", 9), justify="left", anchor="w") self.lbl_freqs.pack(fill="x") mk_btn(ci3, "Refresh", self._refresh_freqs, ACCENT).pack(anchor="w", pady=(6,0)) # ── Turbostat (Intel primarily, skip gracefully on AMD) ────────── co4, ci4 = card(p, "turbostat (requires sudo)") co4.pack(fill="x", padx=4, pady=4) btn_r2 = tk.Frame(ci4, bg=BG3) btn_r2.pack(fill="x") mk_btn(btn_r2, "Run turbostat (3s sample)", self._run_turbostat, TEAL).pack(side="left") self.lbl_ts_status = tk.Label(btn_r2, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)) self.lbl_ts_status.pack(side="left", padx=10) self.ts_text = scrolledtext.ScrolledText( ci4, bg=BG2, fg=TEXT, font=("Consolas", 8), relief="flat", bd=0, state="disabled", height=8) self.ts_text.pack(fill="x", pady=(6,0)) # ════════════════════════════════════════════════════════════════════════ # TAB: C-States # ════════════════════════════════════════════════════════════════════════ def _tab_cstates(self, p): p.columnconfigure(0, weight=1) p.rowconfigure(0, weight=1) co, ci = card(p, "CPU Idle C-States (applied to all CPUs)") co.grid(row=0, column=0, sticky="nsew", padx=4, pady=(4,0)) tk.Label(ci, bg=BG3, fg=SUBTEXT, justify="left", font=("Segoe UI", 8), text="Disabling deeper C-states reduces wake-up latency at the cost of higher idle power.").pack( anchor="w", pady=(0,8)) self._cs_vars = {} sp = Path("/sys/devices/system/cpu/cpu0/cpuidle") if sp.exists(): for sd in sorted(sp.iterdir()): if not sd.name.startswith("state"): continue name = sysread(sd / "name") or sd.name latency = sysread(sd / "latency") or "?" desc = sysread(sd / "desc") or "" dis = sysread(sd / "disable") or "0" var = tk.BooleanVar(value=(dis == "1")) self._cs_vars[sd.name] = var row = tk.Frame(ci, bg=BG2, pady=6, padx=10) row.pack(fill="x", pady=3) tk.Checkbutton(row, text=f"Disable {name}", variable=var, bg=BG2, fg=TEXT, selectcolor=BG, activebackground=BG2, activeforeground=RED, font=("Segoe UI", 10, "bold"), command=lambda d=sd.name, v=var: self._set_cs(d, v.get())).pack( side="left") tk.Label(row, text=f"latency {latency} µs {desc}", bg=BG2, fg=SUBTEXT, font=("Segoe UI", 8)).pack( side="left", padx=12) else: tk.Label(ci, text="cpuidle sysfs not available", bg=BG3, fg=RED).pack() co2, ci2 = card(p, "Quick Presets") co2.grid(row=1, column=0, sticky="ew", padx=4, pady=4) row2 = tk.Frame(ci2, bg=BG3) row2.pack(fill="x") mk_btn(row2, "Enable All (default)", lambda: self._cs_preset(False), GREEN).pack(side="left", padx=6, pady=4) mk_btn(row2, "Disable C2 + C3 (low latency)", lambda: self._cs_preset(True), YELLOW).pack(side="left", padx=6, pady=4) # ════════════════════════════════════════════════════════════════════════ # TAB: Battery # ════════════════════════════════════════════════════════════════════════ def _tab_battery(self, p): p.columnconfigure(0, weight=1) p.columnconfigure(1, weight=1) # upower status co, ci = card(p, "Battery Status (upower)") co.grid(row=0, column=0, sticky="nsew", padx=(0,4), pady=4) self._bat_lbls = {} for key in ["State", "Capacity", "Energy", "Energy Full", "Energy Full Design", "Health", "Energy Rate", "Voltage", "Cycle Count", "Technology"]: row = tk.Frame(ci, bg=BG3) row.pack(fill="x", pady=2) tk.Label(row, text=key+":", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 9), width=18, anchor="w").pack(side="left") lbl = tk.Label(row, text="—", bg=BG3, fg=TEXT, font=("Segoe UI", 9, "bold"), anchor="w") lbl.pack(side="left") self._bat_lbls[key] = lbl mk_btn(ci, "acpi -V detail", self._show_acpi_detail, SUBTEXT).pack( anchor="w", pady=(8,0)) # Charge threshold co2, ci2 = card(p, "Charge Control Thresholds") co2.grid(row=0, column=1, sticky="nsew", padx=(4,0), pady=4) bat_end_path = f"{_BAT_SYS}/charge_control_end_threshold" if _BAT_SYS else None end = int(sysread(bat_end_path) or 100) if bat_end_path else 100 self.c_end = tk.IntVar(value=end) tk.Label(ci2, text="Stop Charging At", bg=BG3, fg=TEXT, font=("Segoe UI", 10, "bold")).pack(anchor="w") tk.Label(ci2, text="Stops charging at this %. Prolongs battery life.", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)).pack(anchor="w", pady=(2,8)) self.lbl_cend = tk.Label(ci2, text=f"{end}%", bg=BG3, fg=ACCENT, font=("Segoe UI", 22, "bold")) self.lbl_cend.pack() ttk.Scale(ci2, from_=20, to=100, orient="horizontal", variable=self.c_end, command=lambda v: self.lbl_cend.config( text=f"{int(float(v))}%")).pack(fill="x", pady=8) prow = tk.Frame(ci2, bg=BG3) prow.pack(fill="x") for val, lbl in [(60,"60%\nLongevity"), (80,"80%\nBalanced"), (100,"100%\nFull")]: mk_btn(prow, lbl, lambda v=val: [self.c_end.set(v), self.lbl_cend.config(text=f"{v}%")], ACCENT, width=10).pack(side="left", padx=4) mk_btn(ci2, "Apply Stop Threshold", self._apply_cend, GREEN).pack(pady=(10,0)) start_p = f"{_BAT_SYS}/charge_control_start_threshold" if _BAT_SYS else None if start_p and Path(start_p).exists(): mk_sep(ci2) start = int(sysread(start_p) or 0) self.c_start = tk.IntVar(value=start) tk.Label(ci2, text="Start Charging At", bg=BG3, fg=TEXT, font=("Segoe UI", 10, "bold")).pack(anchor="w") self.lbl_cstart = tk.Label(ci2, text=f"{start}%", bg=BG3, fg=ACCENT2, font=("Segoe UI", 14, "bold")) self.lbl_cstart.pack() ttk.Scale(ci2, from_=0, to=95, orient="horizontal", variable=self.c_start, command=lambda v: self.lbl_cstart.config( text=f"{int(float(v))}%")).pack(fill="x", pady=6) mk_btn(ci2, "Apply Start Threshold", self._apply_cstart, ACCENT2).pack() p.rowconfigure(0, weight=1) # ════════════════════════════════════════════════════════════════════════ # TAB: TLP # ════════════════════════════════════════════════════════════════════════ def _tab_tlp(self, p): p.columnconfigure(0, weight=1) p.rowconfigure(1, weight=1) # Service control co, ci = card(p, "TLP Service Control") co.pack(fill="x", padx=4, pady=4) svc_row = tk.Frame(ci, bg=BG3) svc_row.pack(fill="x") self.lbl_tlp_svc = tk.Label(svc_row, text="", bg=BG3, font=("Segoe UI", 9, "bold")) self.lbl_tlp_svc.pack(side="left", padx=6) for lbl, act, col in [ ("Enable + Start", lambda: self._tlp_svc("enable"), GREEN), ("Disable + Stop", lambda: self._tlp_svc("disable"), RED), ("Apply Now (AC)", lambda: run(["tlp", "start"], sudo=True), ACCENT), ("Apply Now (BAT)", lambda: run(["tlp", "bat"], sudo=True), YELLOW), ]: mk_btn(svc_row, lbl, act, col).pack(side="left", padx=4) tk.Label(ci, bg=BG3, fg=TEXT, font=("Segoe UI", 8), text="TLP and power-profiles-daemon can run together — the only conflict is if both try to set CPU EPP/platform profile.\n" "Use 'Coexist Mode' below to blank TLP's CPU settings so ppd owns the CPU and TLP handles everything else (USB, disk, WiFi, PCIe).").pack( anchor="w", pady=(6,0)) mk_btn(ci, "Enable Coexist Mode (ppd → CPU, TLP → peripherals)", self._enable_coexist_mode, ACCENT).pack(anchor="w", pady=(6,0)) # Key settings co2, ci2 = card(p, "Key TLP Settings (written to /etc/tlp.d/99-power-control.conf)") co2.pack(fill="both", expand=True, padx=4, pady=4) # Scrollable canvas for settings canvas = tk.Canvas(ci2, bg=BG3, highlightthickness=0) sb = ttk.Scrollbar(ci2, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=sb.set) sb.pack(side="right", fill="y") canvas.pack(side="left", fill="both", expand=True) sf = tk.Frame(canvas, bg=BG3) canvas_win = canvas.create_window((0, 0), window=sf, anchor="nw") sf.bind("", lambda e: canvas.configure( scrollregion=canvas.bbox("all"))) canvas.bind("", lambda e: canvas.itemconfig(canvas_win, width=e.width)) self._tlp_vars = {} settings = [ # (label, key, type, options_or_range) ("CPU EPP on AC", "CPU_ENERGY_PERF_POLICY_ON_AC", "combo", ["performance","balance_performance","balance_power","power","default"]), ("CPU EPP on BAT", "CPU_ENERGY_PERF_POLICY_ON_BAT", "combo", ["balance_power","power","balance_performance","performance","default"]), ("Platform Profile AC", "PLATFORM_PROFILE_ON_AC", "combo", ["performance","balanced","quiet"]), ("Platform Profile BAT", "PLATFORM_PROFILE_ON_BAT", "combo", ["balanced","quiet","performance"]), ("WiFi Power AC", "WIFI_PWR_ON_AC", "combo", ["off","on"]), ("WiFi Power BAT", "WIFI_PWR_ON_BAT", "combo", ["on","off"]), ("USB Autosuspend", "USB_AUTOSUSPEND", "combo", ["1","0"]), ("Runtime PM AC", "RUNTIME_PM_ON_AC", "combo", ["on","auto"]), ("Runtime PM BAT", "RUNTIME_PM_ON_BAT","combo", ["auto","on"]), ("PCIe ASPM AC", "PCIE_ASPM_ON_AC", "combo", ["default","performance","powersave","powersupersave"]), ("PCIe ASPM BAT", "PCIE_ASPM_ON_BAT", "combo", ["default","powersupersave","powersave"]), ("AMDGPU Brightness AC", "AMDGPU_ABM_LEVEL_ON_AC", "combo", ["0","1","2","3"]), ("AMDGPU Brightness BAT", "AMDGPU_ABM_LEVEL_ON_BAT", "combo", ["1","2","3","0"]), ("Disk APM AC", "DISK_APM_LEVEL_ON_AC", "combo", ["254","128","64"]), ("Disk APM BAT", "DISK_APM_LEVEL_ON_BAT", "combo", ["128","64","254"]), ("NMI Watchdog", "NMI_WATCHDOG", "combo", ["0","1"]), ] for label, key, kind, opts in settings: row = tk.Frame(sf, bg=BG3) row.pack(fill="x", pady=3, padx=4) tk.Label(row, text=label+":", bg=BG3, fg=TEXT, font=("Segoe UI", 9), width=24, anchor="w").pack(side="left") var = tk.StringVar(value=opts[0]) self._tlp_vars[key] = var cb = ttk.Combobox(row, textvariable=var, values=opts, state="readonly", width=22) cb.pack(side="left", padx=6) btn_row = tk.Frame(sf, bg=BG3) btn_row.pack(fill="x", pady=10, padx=4) mk_btn(btn_row, "Write Config + Apply", self._apply_tlp_config, GREEN).pack(side="left") mk_btn(btn_row, "Load Current Config", self._load_tlp_config, ACCENT).pack(side="left", padx=8) mk_btn(btn_row, "View Full tlp-stat", self._show_tlp_stat, SUBTEXT).pack(side="left") # ════════════════════════════════════════════════════════════════════════ # TAB: Daemons # ════════════════════════════════════════════════════════════════════════ def _tab_daemons(self, p): p.columnconfigure(0, weight=1) p.rowconfigure(1, weight=1) co, ci = card(p, "Power Daemon Status & Control") co.pack(fill="x", padx=4, pady=4) daemons = [ ("power-profiles-daemon", "Manages powerprofilesctl profiles (EPP, platform profile)", ACCENT), ("tlp-peripheral", "TLP coexist mode — peripherals only (USB, disk, WiFi, PCIe). Created by Coexist Mode button.", TEAL), ("tlp", "Advanced laptop power saving (battery, disk, USB, WiFi, PCIe)", GREEN), ("thermald", "Intel thermal daemon — prevents CPU thermal throttling", ORANGE), ("auto-cpufreq", "Automatic CPU frequency/governor optimizer based on load & power source", YELLOW), ] self._daemon_rows = {} for name, desc, color in daemons: row = tk.Frame(ci, bg=BG2, padx=10, pady=8) row.pack(fill="x", pady=4) lbl_name = tk.Label(row, text=name, bg=BG2, fg=color, font=("Segoe UI", 10, "bold"), width=26, anchor="w") lbl_name.pack(side="left") lbl_desc = tk.Label(row, text=desc, bg=BG2, fg=SUBTEXT, font=("Segoe UI", 8), anchor="w") lbl_desc.pack(side="left", padx=8) btn_frame = tk.Frame(row, bg=BG2) btn_frame.pack(side="right") lbl_st = tk.Label(btn_frame, text="●", bg=BG2, font=("Segoe UI", 14)) lbl_st.pack(side="left", padx=6) for action, acol in [("start", GREEN), ("stop", RED), ("enable", ACCENT), ("disable", ORANGE)]: mk_btn(btn_frame, action, lambda s=name, a=action: self._daemon_ctl(s, a), acol).pack(side="left", padx=2) self._daemon_rows[name] = lbl_st tk.Label(ci, bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8), text="In Coexist Mode, TLP skips EPP and platform profile so power-profiles-daemon manages the CPU uncontested.").pack( anchor="w", pady=(8,0)) # auto-cpufreq stats co2, ci2 = card(p, "auto-cpufreq --stats (live, requires service running)") co2.pack(fill="both", expand=True, padx=4, pady=4) self.acf_text = scrolledtext.ScrolledText( ci2, bg=BG2, fg=TEXT, font=("Consolas", 8), relief="flat", bd=0, state="disabled") self.acf_text.pack(fill="both", expand=True) btn_r = tk.Frame(ci2, bg=BG3) btn_r.pack(fill="x", pady=(6,0)) mk_btn(btn_r, "Refresh Stats", self._refresh_acf_stats, ACCENT).pack(side="left") mk_btn(btn_r, "thermald --debug (5s)", self._run_thermald_debug, ORANGE).pack(side="left", padx=8) # ════════════════════════════════════════════════════════════════════════ # TAB: Sensors # ════════════════════════════════════════════════════════════════════════ # TAB: GPU # ════════════════════════════════════════════════════════════════════════ def _tab_gpu(self, p): p.columnconfigure(0, weight=1) p.columnconfigure(1, weight=1) has_nv = which("nvidia-smi") is not None has_arc = _ARC_CARD is not None has_amd = _AMD_CARD is not None if not has_nv and not has_arc and not has_amd: tk.Label(p, text="No supported GPU detected.\n(nvidia-smi not found, no Intel Arc or AMD GPU sysfs)", bg=BG, fg=SUBTEXT, font=("Segoe UI", 11)).pack(pady=40) return # ── NVIDIA section ─────────────────────────────────────────────── if has_nv: nv_name = "NVIDIA GPU" try: nm_out, _ = run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"]) if nm_out: nv_name = nm_out.strip() except Exception: pass nv_min, nv_max, nv_def = _nv_power_range() co, ci = card(p, f"{nv_name} (live stats)") co.grid(row=0, column=0, sticky="nsew", padx=(0,4), pady=4) self._nv_lbls = {} for key in ["Power Draw", "Power Limit", "Temp", "Perf State", "GPU Clock", "Mem Clock", "SM Clock", "GPU Util"]: row = tk.Frame(ci, bg=BG3) row.pack(fill="x", pady=2) tk.Label(row, text=key+":", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 9), width=14, anchor="w").pack(side="left") lbl = tk.Label(row, text="—", bg=BG3, fg=TEXT, font=("Segoe UI", 9, "bold"), anchor="w") lbl.pack(side="left") self._nv_lbls[key] = lbl mk_btn(ci, "Refresh", self._refresh_nvidia, ACCENT).pack(anchor="w", pady=(8,0)) mk_btn(ci, "Launch nvtop", self._launch_nvtop, TEAL).pack(anchor="w", pady=(4,0)) co2, ci2 = card(p, f"NVIDIA Power Limit ({nv_min}–{nv_max} W, requires sudo)") co2.grid(row=1, column=0, sticky="ew", padx=(0,4), pady=4) self.nv_pl_var = tk.IntVar(value=nv_def) self.lbl_nv_pl = tk.Label(ci2, text=f"{nv_def} W", bg=BG3, fg=ACCENT, font=("Segoe UI", 20, "bold")) self.lbl_nv_pl.pack() ttk.Scale(ci2, from_=nv_min, to=nv_max, orient="horizontal", variable=self.nv_pl_var, command=lambda v: self.lbl_nv_pl.config( text=f"{int(float(v))} W")).pack(fill="x", pady=6) prow = tk.Frame(ci2, bg=BG3) prow.pack(fill="x") presets_w = [ (nv_min, f"{nv_min}W\nMin"), (max(nv_min, nv_max//4), f"{max(nv_min, nv_max//4)}W\nLow"), (nv_def, f"{nv_def}W\nDflt"), (nv_max, f"{nv_max}W\nMax"), ] for w, lbl in presets_w: mk_btn(prow, lbl, lambda v=w: [self.nv_pl_var.set(v), self.lbl_nv_pl.config(text=f"{v} W")], ACCENT, width=8).pack(side="left", padx=3) mk_btn(ci2, "Apply Power Limit", self._apply_nv_power_limit, GREEN).pack( anchor="w", pady=(8,0)) co3, ci3 = card(p, "NVIDIA Runtime Power Management") co3.grid(row=2, column=0, sticky="ew", padx=(0,4), pady=4) self.nv_rtpm_var = tk.StringVar(value="on") rtpm_row = tk.Frame(ci3, bg=BG3) rtpm_row.pack(fill="x") for val, lbl, col in [("on","On (no suspend)", RED), ("auto","Auto (suspend idle)", GREEN)]: b = tk.Button(rtpm_row, text=f" {lbl} ", bg=BG2, fg=col, relief="flat", font=("Segoe UI", 9, "bold"), activebackground=BG3, activeforeground=col, padx=8, pady=6, cursor="hand2", command=lambda v=val: self._set_nv_rtpm(v)) b.pack(side="left", padx=4, pady=4) tk.Label(ci3, text="Auto: NVIDIA suspends when idle (~5W saved). On: always powered.", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)).pack(anchor="w") right_col = 1 if has_nv else 0 # ── Intel Arc section ──────────────────────────────────────────── if has_arc: arc_card = _ARC_CARD arc_max_hw = int(sysread(f"/sys/class/drm/{arc_card}/gt_RP0_freq_mhz") or 2350) arc_min_hw = int(sysread(f"/sys/class/drm/{arc_card}/gt_RPn_freq_mhz") or 100) co4, ci4 = card(p, f"Intel Arc ({arc_card}) (integrated GPU)") co4.grid(row=0, column=right_col, sticky="nsew", padx=(4 if has_nv else 0, 0), pady=4) self._arc_lbls = {} for key in ["Actual Freq", "Current Freq", "Min Freq", "Max Freq", "Boost Freq"]: row = tk.Frame(ci4, bg=BG3) row.pack(fill="x", pady=2) tk.Label(row, text=key+":", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 9), width=14, anchor="w").pack(side="left") lbl = tk.Label(row, text="—", bg=BG3, fg=TEXT, font=("Segoe UI", 9, "bold"), anchor="w") lbl.pack(side="left") self._arc_lbls[key] = lbl mk_sep(ci4) self.arc_min_var = tk.IntVar(value=arc_min_hw) self.arc_max_var = tk.IntVar(value=arc_max_hw) self._arc_freq_labels = {} for lbl, var, lo, hi, key in [ ("Min Freq", self.arc_min_var, arc_min_hw, arc_max_hw, "min"), ("Max Freq", self.arc_max_var, arc_min_hw, arc_max_hw, "max"), ]: row = tk.Frame(ci4, bg=BG3) row.pack(fill="x", pady=4) tk.Label(row, text=lbl, bg=BG3, fg=TEXT, font=("Segoe UI", 9), width=10, anchor="w").pack(side="left") vlbl = tk.Label(row, text=f"{var.get()} MHz", bg=BG3, fg=ACCENT, font=("Segoe UI", 9, "bold"), width=9) vlbl.pack(side="right") ttk.Scale(row, from_=lo, to=hi, orient="horizontal", variable=var, command=lambda v, vl=vlbl: vl.config( text=f"{int(float(v))} MHz")).pack( side="left", fill="x", expand=True, padx=6) self._arc_freq_labels[key] = vlbl mk_btn(ci4, "Apply Arc Freq Limits", self._apply_arc_freqs, GREEN).pack( anchor="w", pady=(8,0)) co5, ci5 = card(p, "Intel Arc Runtime PM") co5.grid(row=1, column=right_col, sticky="ew", padx=(4 if has_nv else 0, 0), pady=4) arc_rtpm_row = tk.Frame(ci5, bg=BG3) arc_rtpm_row.pack(fill="x") for val, lbl, col in [("on","On (always active)", RED), ("auto","Auto (suspend idle)", GREEN)]: tk.Button(arc_rtpm_row, text=f" {lbl} ", bg=BG2, fg=col, relief="flat", font=("Segoe UI", 9, "bold"), activebackground=BG3, activeforeground=col, padx=8, pady=6, cursor="hand2", command=lambda v=val: self._set_arc_rtpm(v)).pack( side="left", padx=4, pady=4) # ── AMD GPU section ────────────────────────────────────────────── if has_amd and not has_arc: amd_card = _AMD_CARD amd_col = right_col co6, ci6 = card(p, f"AMD GPU ({amd_card})") co6.grid(row=0, column=amd_col, sticky="nsew", padx=(4 if has_nv else 0, 0), pady=4) self._amd_gpu_lbls = {} for key in ["Driver", "Power State", "Busy %"]: row = tk.Frame(ci6, bg=BG3) row.pack(fill="x", pady=2) tk.Label(row, text=key+":", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 9), width=14, anchor="w").pack(side="left") lbl = tk.Label(row, text="—", bg=BG3, fg=TEXT, font=("Segoe UI", 9, "bold"), anchor="w") lbl.pack(side="left") self._amd_gpu_lbls[key] = lbl mk_btn(ci6, "Refresh", self._refresh_amd_gpu, ACCENT).pack( anchor="w", pady=(8,0)) co7, ci7 = card(p, f"AMD GPU Runtime PM ({amd_card})") co7.grid(row=1, column=amd_col, sticky="ew", padx=(4 if has_nv else 0, 0), pady=4) amd_rtpm_row = tk.Frame(ci7, bg=BG3) amd_rtpm_row.pack(fill="x") for val, lbl, col in [("on","On (always active)", RED), ("auto","Auto (suspend idle)", GREEN)]: tk.Button(amd_rtpm_row, text=f" {lbl} ", bg=BG2, fg=col, relief="flat", font=("Segoe UI", 9, "bold"), activebackground=BG3, activeforeground=col, padx=8, pady=6, cursor="hand2", command=lambda v=val: self._set_amd_rtpm(v)).pack( side="left", padx=4, pady=4) p.rowconfigure(0, weight=1) if has_nv: self._refresh_nvidia() if has_arc: self._refresh_arc() if has_amd and not has_arc: self._refresh_amd_gpu() # ── GPU actions ────────────────────────────────────────────────────────── def _refresh_nvidia(self): fields = ("power.draw,power.limit,temperature.gpu,pstate," "clocks.current.graphics,clocks.current.memory," "clocks.current.sm,utilization.gpu") out, rc = run(["nvidia-smi", f"--query-gpu={fields}", "--format=csv,noheader,nounits"]) if rc != 0 or not out: return vals = [v.strip() for v in out.split(",")] keys = ["Power Draw", "Power Limit", "Temp", "Perf State", "GPU Clock", "Mem Clock", "SM Clock", "GPU Util"] units = [" W", " W", " °C", "", " MHz", " MHz", " MHz", " %"] for i, (k, u) in enumerate(zip(keys, units)): if i < len(vals) and k in self._nv_lbls: v = vals[i] if v not in ("[N/A]", "N/A", ""): self._nv_lbls[k].config(text=f"{v}{u}") # sync power limit slider try: pl_out, _ = run(["nvidia-smi", "--query-gpu=power.limit", "--format=csv,noheader,nounits"]) if pl_out and pl_out not in ("[N/A]", "N/A"): pl = int(float(pl_out.strip())) self.nv_pl_var.set(pl) self.lbl_nv_pl.config(text=f"{pl} W") except Exception: pass def _apply_nv_power_limit(self): w = self.nv_pl_var.get() out, rc = run(["nvidia-smi", f"--power-limit={w}"], sudo=True) if rc != 0: messagebox.showerror("Error", f"Failed to set power limit:\n{out}") else: self._refresh_nvidia() def _set_nv_rtpm(self, val): if _NV_PCI: syswrite(f"/sys/bus/pci/devices/{_NV_PCI}/power/control", val, sudo=True) def _refresh_arc(self): if not _ARC_CARD: return c = _ARC_CARD paths = { "Actual Freq": f"/sys/class/drm/{c}/gt_act_freq_mhz", "Current Freq": f"/sys/class/drm/{c}/gt_cur_freq_mhz", "Min Freq": f"/sys/class/drm/{c}/gt_min_freq_mhz", "Max Freq": f"/sys/class/drm/{c}/gt_max_freq_mhz", "Boost Freq": f"/sys/class/drm/{c}/gt_boost_freq_mhz", } for key, path in paths.items(): v = sysread(path) if v and hasattr(self, "_arc_lbls") and key in self._arc_lbls: self._arc_lbls[key].config(text=f"{v} MHz") mn = sysread(f"/sys/class/drm/{c}/gt_min_freq_mhz") mx = sysread(f"/sys/class/drm/{c}/gt_max_freq_mhz") if mn and hasattr(self, "arc_min_var"): try: self.arc_min_var.set(int(mn)) if hasattr(self, "_arc_freq_labels"): self._arc_freq_labels["min"].config(text=f"{mn} MHz") except (ValueError, TypeError): pass if mx and hasattr(self, "arc_max_var"): try: self.arc_max_var.set(int(mx)) if hasattr(self, "_arc_freq_labels"): self._arc_freq_labels["max"].config(text=f"{mx} MHz") except (ValueError, TypeError): pass def _apply_arc_freqs(self): if not _ARC_CARD: return c = _ARC_CARD mn = self.arc_min_var.get() mx = self.arc_max_var.get() if mn > mx: messagebox.showerror("Error", "Min freq must be ≤ Max freq") return ok = (syswrite(f"/sys/class/drm/{c}/gt_min_freq_mhz", str(mn), sudo=True) and syswrite(f"/sys/class/drm/{c}/gt_max_freq_mhz", str(mx), sudo=True) and syswrite(f"/sys/class/drm/{c}/gt_boost_freq_mhz", str(mx), sudo=True)) if not ok: messagebox.showerror("Error", "Failed to write Arc freq limits") self._refresh_arc() def _set_arc_rtpm(self, val): if _ARC_CARD: syswrite(f"/sys/class/drm/{_ARC_CARD}/device/power/control", val, sudo=True) def _refresh_amd_gpu(self): if not _AMD_CARD or not hasattr(self, "_amd_gpu_lbls"): return c = _AMD_CARD driver = sysread(f"/sys/class/drm/{c}/device/uevent") or "" drv = re.search(r"DRIVER=(\S+)", driver) busy = sysread(f"/sys/class/drm/{c}/device/gpu_busy_percent") or "—" dpm = sysread(f"/sys/class/drm/{c}/device/power_dpm_state") or \ sysread(f"/sys/class/drm/{c}/device/pp_power_profile_mode") or "—" updates = { "Driver": drv.group(1) if drv else "amdgpu", "Power State": dpm.split("\n")[0][:40] if "\n" in dpm else dpm, "Busy %": f"{busy}%" if busy != "—" else "—", } def _apply(): for k, v in updates.items(): if k in self._amd_gpu_lbls: self._amd_gpu_lbls[k].config(text=v) self.after(0, _apply) def _set_amd_rtpm(self, val): if _AMD_CARD: syswrite(f"/sys/class/drm/{_AMD_CARD}/device/power/control", val, sudo=True) def _launch_nvtop(self): terminals = ["konsole", "kitty", "alacritty", "gnome-terminal", "xterm"] term = next((t for t in terminals if which(t)), None) if not term: messagebox.showerror("Error", "No terminal emulator found") return args = [term, "--", "nvtop"] if term != "gnome-terminal" else [term, "-e", "nvtop"] subprocess.Popen(args) # ════════════════════════════════════════════════════════════════════════ def _tab_sensors(self, p): p.columnconfigure(0, weight=1) p.rowconfigure(0, weight=1) co, ci = card(p, "Live Sensors (lm_sensors + thermal zones, auto-refresh 3s)") co.grid(row=0, column=0, sticky="nsew", padx=4, pady=4) ci.configure(padx=4, pady=4) self.sensor_text = scrolledtext.ScrolledText( ci, bg=BG2, fg=TEXT, font=("Consolas", 9), relief="flat", bd=0, state="disabled") self.sensor_text.pack(fill="both", expand=True) row = tk.Frame(ci, bg=BG3) row.pack(fill="x", pady=(6,0)) mk_btn(row, "Refresh Now", self._refresh_sensors, ACCENT).pack(side="left") self.lbl_sens_time = tk.Label(row, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)) self.lbl_sens_time.pack(side="left", padx=10) # ════════════════════════════════════════════════════════════════════════ # TAB: Tools (PowerTOP + stress-ng + s-tui) # ════════════════════════════════════════════════════════════════════════ def _tab_tools(self, p): p.columnconfigure(0, weight=1) p.rowconfigure(2, weight=1) # PowerTOP co, ci = card(p, "PowerTOP") co.pack(fill="x", padx=4, pady=4) row = tk.Frame(ci, bg=BG3) row.pack(fill="x") mk_btn(row, "Run (2s sample)", self._run_powertop, ACCENT).pack(side="left") mk_btn(row, "Auto-Tune (apply all suggestions)", self._powertop_autotune, YELLOW).pack(side="left", padx=8) self.lbl_pt = tk.Label(row, text="", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)) self.lbl_pt.pack(side="left", padx=8) # stress-ng co2, ci2 = card(p, "stress-ng CPU Stress Test") co2.pack(fill="x", padx=4, pady=4) srow = tk.Frame(ci2, bg=BG3) srow.pack(fill="x") tk.Label(srow, text="Workers:", bg=BG3, fg=TEXT, font=("Segoe UI", 9)).pack(side="left") self.stress_workers = tk.IntVar(value=get_cpu_count()) ttk.Spinbox(srow, from_=1, to=get_cpu_count(), textvariable=self.stress_workers, width=5, font=("Segoe UI", 9)).pack(side="left", padx=6) tk.Label(srow, text="Duration (s):", bg=BG3, fg=TEXT, font=("Segoe UI", 9)).pack(side="left", padx=(12,0)) self.stress_dur = tk.IntVar(value=30) ttk.Spinbox(srow, from_=5, to=600, textvariable=self.stress_dur, width=6, font=("Segoe UI", 9)).pack(side="left", padx=6) mk_btn(srow, "Start Stress Test", self._run_stress, RED).pack(side="left", padx=12) mk_btn(srow, "Stop", self._stop_stress, SUBTEXT).pack(side="left") self.lbl_stress = tk.Label(ci2, text="Idle", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)) self.lbl_stress.pack(anchor="w", pady=(4,0)) self._stress_proc = None self._stress_lock = threading.Lock() # s-tui launcher co3, ci3 = card(p, "s-tui (Stress Terminal UI — launches in new terminal)") co3.pack(fill="x", padx=4, pady=4) srow3 = tk.Frame(ci3, bg=BG3) srow3.pack(fill="x") mk_btn(srow3, "Launch s-tui", self._launch_stui, TEAL).pack(side="left") tk.Label(ci3, text="Interactive TUI: shows CPU freq, power, temp + built-in stress test.", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 8)).pack(anchor="w", pady=(4,0)) # Output co4, ci4 = card(p, "Output") co4.pack(fill="both", expand=True, padx=4, pady=4) ci4.configure(padx=4, pady=4) self.tool_text = scrolledtext.ScrolledText( ci4, bg=BG2, fg=TEXT, font=("Consolas", 9), relief="flat", bd=0, state="disabled") self.tool_text.pack(fill="both", expand=True) # ════════════════════════════════════════════════════════════════════════ # Profile / EPP / Governor actions # ════════════════════════════════════════════════════════════════════════ def _set_pp(self, profile): out, rc = run(["powerprofilesctl", "set", profile]) if rc != 0: messagebox.showerror("Error", f"powerprofilesctl: {out}") self._refresh_profiles() def _set_acpi(self, profile): if not syswrite("/sys/firmware/acpi/platform_profile", profile, sudo=True): messagebox.showerror("Error", f"Failed to set ACPI profile to {profile}") self._refresh_profiles() def _set_epp(self, epp): n = get_cpu_count() writes = [(f"/sys/devices/system/cpu/cpu{i}/cpufreq/energy_performance_preference", epp) for i in range(n)] if not syswrite_many(writes, sudo=True): messagebox.showerror("Error", "Failed to set EPP on one or more CPUs") self._refresh_profiles() def _set_gov(self, gov): n = get_cpu_count() writes = [(f"/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_governor", gov) for i in range(n)] if not syswrite_many(writes, sudo=True): messagebox.showerror("Error", "Failed to set governor on one or more CPUs") self._refresh_profiles() # ── pstate ─────────────────────────────────────────────────────────────── def _toggle_turbo(self): if not _TURBO_PATH: messagebox.showinfo("Not supported", "No CPU boost control found on this system") return enabled = self.turbo_var.get() if _TURBO_INV: val = "0" if enabled else "1" else: val = "1" if enabled else "0" if not syswrite(_TURBO_PATH, val, sudo=True): messagebox.showerror("Error", "Failed to toggle CPU boost") self._refresh_pstate() def _toggle_hwpb(self): val = 1 if self.hwpboost_var.get() else 0 if not syswrite("/sys/devices/system/cpu/intel_pstate/hwp_dynamic_boost", str(val), sudo=True): messagebox.showerror("Error", "Failed to toggle HWP boost") self._refresh_pstate() def _apply_pct(self): mn, mx = self.min_pct.get(), self.max_pct.get() if mn > mx: messagebox.showerror("Error", "Min % must be ≤ Max %") return ok = (syswrite("/sys/devices/system/cpu/intel_pstate/min_perf_pct", str(mn), sudo=True) and syswrite("/sys/devices/system/cpu/intel_pstate/max_perf_pct", str(mx), sudo=True)) if not ok: messagebox.showerror("Error", "Failed to write perf %") self._refresh_pstate() def _run_turbostat(self): self.lbl_ts_status.config(text="Running...") self.update() def _task(): out, rc = run(["turbostat", "--quiet", "--interval", "3", "--num_iterations", "1"], sudo=True) self.after(0, lambda: set_text(self.ts_text, out or f"Exit {rc}")) self.after(0, lambda: self.lbl_ts_status.config(text=f"Done (exit {rc})")) threading.Thread(target=_task, daemon=True).start() # ── C-states ───────────────────────────────────────────────────────────── def _set_cs(self, state_name, disabled): n = get_cpu_count() val = "1" if disabled else "0" writes = [(f"/sys/devices/system/cpu/cpu{i}/cpuidle/{state_name}/disable", val) for i in range(n)] if not syswrite_many(writes, sudo=True): messagebox.showerror("Error", f"Failed to set C-state {state_name}") def _cs_preset(self, disable_deep): sp = Path("/sys/devices/system/cpu/cpu0/cpuidle") if not sp.exists(): return for sd in sorted(sp.iterdir()): if not sd.name.startswith("state"): continue lat = int(sysread(sd / "latency") or 0) do_dis = disable_deep and lat >= 100 self._set_cs(sd.name, do_dis) if sd.name in self._cs_vars: self._cs_vars[sd.name].set(do_dis) # ── Battery ────────────────────────────────────────────────────────────── def _apply_cend(self): if not _BAT_SYS: messagebox.showerror("Error", "No battery found") return val = self.c_end.get() ok = syswrite(f"{_BAT_SYS}/charge_control_end_threshold", str(val), sudo=True) if not ok: messagebox.showerror("Error", "Failed. Your battery driver may not support charge thresholds.") else: messagebox.showinfo("Applied", f"Charge stop threshold → {val}%") self._refresh_battery() def _apply_cstart(self): if not _BAT_SYS: return val = self.c_start.get() if not syswrite(f"{_BAT_SYS}/charge_control_start_threshold", str(val), sudo=True): messagebox.showerror("Error", "Failed to set start threshold") self._refresh_battery() def _show_acpi_detail(self): out, _ = run(["acpi", "-V"]) set_text(self.tool_text, out) self.nb.select(self._tools_tab_idx) # switch to Tools tab # ── TLP ────────────────────────────────────────────────────────────────── def _tlp_svc(self, action): if action == "enable": systemctl("enable", "tlp") systemctl("start", "tlp") else: systemctl("stop", "tlp") systemctl("disable", "tlp") self._refresh_daemon_status() def _apply_tlp_config(self): lines = [] for key, var in self._tlp_vars.items(): val = var.get().strip() if val: lines.append(f'{key}="{val}"') content = "# Written by Power Control Center\n" + "\n".join(lines) + "\n" conf_path = "/etc/tlp.d/99-power-control.conf" if not _write_config_as_root(content, conf_path): messagebox.showerror("Error", f"Failed to write {conf_path}") return run(["tlp", "start"], sudo=True) messagebox.showinfo("Applied", f"Config written to {conf_path}\ntlp start executed.") def _load_tlp_config(self): # Read defaults + user overrides out, _ = run(["sudo", "tlp-stat", "--config"], sudo=False) if not out: out, _ = run(["tlp-stat", "--config"], sudo=True) for key, var in self._tlp_vars.items(): m = re.search(rf'{re.escape(key)}="?([^"\n]+)"?', out) if m: var.set(m.group(1).strip()) messagebox.showinfo("Loaded", "Current TLP config loaded into fields.") def _enable_coexist_mode(self): """ Make power-profiles-daemon and TLP coexist. The Conflicts=tlp.service in ppd's unit file cannot be removed via a drop-in (systemd does not support resetting Conflicts= that way). Solution: create a separate 'tlp-peripheral.service' that runs 'tlp start' under a different name — ppd has no conflict with it. """ # ── Guard: both packages must be present ────────────────────────── tlp_bin = shutil.which("tlp") # Check the service unit exists, not just the CLI tool — # powerprofilesctl is the D-Bus client; power-profiles-daemon is the service. # They ship together but `systemctl cat` is the definitive existence check. _, ppd_rc = run(["systemctl", "cat", "power-profiles-daemon.service"], sudo=False) ppd_ok = ppd_rc == 0 missing = [] if not tlp_bin: missing.append("tlp (not found in PATH)") if not ppd_ok: missing.append("power-profiles-daemon.service (unit not found)") if missing: messagebox.showerror("Missing packages", "Coexist Mode requires both TLP and power-profiles-daemon.\n\n" "Not found:\n" + "\n".join(f" • {m}" for m in missing)) return # ── Step 1: create tlp-peripheral.service ───────────────────────── svc = ( "# Written by Power Control Center\n" "# Runs TLP for peripheral power management alongside power-profiles-daemon.\n" "# ppd owns CPU (EPP/platform profile); TLP owns USB/disk/WiFi/PCIe.\n" "[Unit]\n" "Description=TLP peripheral power management (coexist with ppd)\n" "After=power-profiles-daemon.service\n" "Requires=power-profiles-daemon.service\n" "\n" "[Service]\n" "Type=oneshot\n" "RemainAfterExit=yes\n" f"ExecStart={tlp_bin} start\n" f"ExecStop={tlp_bin} init stop\n" "\n" "[Install]\n" "WantedBy=multi-user.target\n" ) svc_file = "/etc/systemd/system/tlp-peripheral.service" if not _write_config_as_root(svc, svc_file): messagebox.showerror("Error", f"Failed to write {svc_file}") return # ── Step 2: TLP config — blank CPU knobs so ppd owns them ───────── tlp_conf = ( "# Coexist Mode — written by Power Control Center\n" "# TLP manages peripherals; power-profiles-daemon manages CPU.\n" 'CPU_ENERGY_PERF_POLICY_ON_AC=""\n' 'CPU_ENERGY_PERF_POLICY_ON_BAT=""\n' 'CPU_ENERGY_PERF_POLICY_ON_SAV=""\n' 'PLATFORM_PROFILE_ON_AC=""\n' 'PLATFORM_PROFILE_ON_BAT=""\n' 'PLATFORM_PROFILE_ON_SAV=""\n' ) _write_config_as_root(tlp_conf, "/etc/tlp.d/99-power-control.conf") # ── Step 3: disable original tlp.service, enable new one ────────── root_exec("systemctl daemon-reload") systemctl("disable", "tlp") systemctl("stop", "tlp") systemctl("enable", "power-profiles-daemon") systemctl("start", "power-profiles-daemon") systemctl("enable", "tlp-peripheral") systemctl("start", "tlp-peripheral") messagebox.showinfo("Coexist Mode Active", "Created: /etc/systemd/system/tlp-peripheral.service\n\n" "power-profiles-daemon → CPU (EPP, platform profile, turbo)\n" "tlp-peripheral → USB, disk, WiFi, PCIe, runtime PM, battery thresholds\n\n" "tlp.service is disabled (ppd conflicts with it by name).\n" "tlp-peripheral.service runs 'tlp start' with no name conflict.") self._refresh_daemon_status() def _show_tlp_stat(self): def _task(): out, _ = run(["tlp-stat"], sudo=True, timeout=20) self.after(0, lambda: set_text(self.tool_text, out)) self.after(0, lambda: self.nb.select(self._tools_tab_idx)) threading.Thread(target=_task, daemon=True).start() # ── Daemons ─────────────────────────────────────────────────────────────── def _daemon_ctl(self, service, action): ok = systemctl(action, service, sudo=True) if not ok: messagebox.showerror("Error", f"systemctl {action} {service} failed") self._refresh_daemon_status() def _refresh_acf_stats(self): if not which("auto-cpufreq"): set_text(self.acf_text, "auto-cpufreq not found") return def _task(): out, _ = run(["auto-cpufreq", "--stats"], sudo=False, timeout=5) self.after(0, lambda: set_text(self.acf_text, out or "No output (service may not be running)")) threading.Thread(target=_task, daemon=True).start() def _run_thermald_debug(self): def _task(): out, _ = run(["thermald", "--no-daemon", "--loglevel=debug"], sudo=True, timeout=5) self.after(0, lambda: set_text(self.tool_text, out)) self.after(0, lambda: self.nb.select(self._tools_tab_idx)) threading.Thread(target=_task, daemon=True).start() # ── PowerTOP ───────────────────────────────────────────────────────────── def _run_powertop(self): self.lbl_pt.config(text="Running…") self.update() def _task(): tmpdir = tempfile.mkdtemp(prefix="pcc_pt_") html_path = os.path.join(tmpdir, "powertop.html") csv_path = os.path.join(tmpdir, "powertop.csv") try: run(["powertop", "--time=2", f"--html={html_path}"], sudo=True) run(["powertop", "--time=1", f"--csv={csv_path}"], sudo=True) try: txt = Path(csv_path).read_text(errors="replace") except Exception: txt = "(no CSV output)" self.after(0, lambda: set_text(self.tool_text, "=== PowerTOP Summary ===\n" + txt[:8000])) self.after(0, lambda p=html_path: self.lbl_pt.config( text=f"Done — HTML at {p}")) finally: # Clean up CSV; keep HTML so user can view it try: os.unlink(csv_path) except OSError: pass self.after(0, lambda: self.nb.select(self._tools_tab_idx)) threading.Thread(target=_task, daemon=True).start() def _powertop_autotune(self): if not messagebox.askyesno("Confirm", "Apply ALL PowerTOP power-saving suggestions?\n" "This affects USB autosuspend, ASPM, writeback timers, etc.\n\nContinue?"): return self.lbl_pt.config(text="Auto-tuning…") def _task(): out, rc = run(["powertop", "--auto-tune"], sudo=True) self.after(0, lambda: set_text(self.tool_text, f"auto-tune exit {rc}\n\n{out}")) self.after(0, lambda: self.lbl_pt.config( text=f"Auto-tune done (exit {rc})")) self.after(0, lambda: self.nb.select(self._tools_tab_idx)) threading.Thread(target=_task, daemon=True).start() # ── stress-ng / s-tui ──────────────────────────────────────────────────── def _run_stress(self): try: workers = int(self.stress_workers.get()) dur = int(self.stress_dur.get()) if not (1 <= workers <= get_cpu_count()): raise ValueError if not (5 <= dur <= 600): raise ValueError except (ValueError, TypeError): messagebox.showerror("Error", f"Workers must be 1–{get_cpu_count()}, duration 5–600s") return with self._stress_lock: if self._stress_proc and self._stress_proc.poll() is None: messagebox.showinfo("Running", "Stress test already in progress") return proc = subprocess.Popen( ["stress-ng", "--cpu", str(workers), "--timeout", str(dur), "--metrics-brief"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) self._stress_proc = proc self.lbl_stress.config(text=f"Running {workers} worker(s) for {dur}s…", fg=RED) def _task(): out, _ = proc.communicate() # waits for completion self.after(0, lambda: set_text(self.tool_text, out)) self.after(0, lambda: self.lbl_stress.config( text="Stress test complete", fg=GREEN)) threading.Thread(target=_task, daemon=True).start() def _stop_stress(self): with self._stress_lock: proc = self._stress_proc if proc and proc.poll() is None: proc.terminate() try: proc.wait(timeout=3) # reap zombie except subprocess.TimeoutExpired: proc.kill() proc.wait() self.lbl_stress.config(text="Stopped", fg=SUBTEXT) def _launch_stui(self): terminals = ["konsole", "gnome-terminal", "xterm", "alacritty", "kitty"] term = next((t for t in terminals if which(t)), None) if not term: messagebox.showerror("Error", "No terminal emulator found in PATH") return if term in ("gnome-terminal",): subprocess.Popen([term, "--", "s-tui"]) else: subprocess.Popen([term, "-e", "s-tui"]) # ════════════════════════════════════════════════════════════════════════ # Refresh methods # ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════ # Global presets # ════════════════════════════════════════════════════════════════════════ # Each preset defines every tunable knob in one place. _PRESETS = { "performance": { "pp_profile": "performance", "acpi_profile": "performance", "epp": "performance", "governor": "performance", "turbo": True, "hwp_boost": True, "min_pct": 50, "max_pct": 100, "tlp_epp_ac": "performance", "tlp_epp_bat": "balance_performance", "tlp_plat_ac": "performance", "tlp_plat_bat": "balanced", # GPU "nv_power_limit": 125, "arc_min_mhz": 800, "arc_max_mhz": 2350, "nv_rtpm": "on", "display_hz": "max", }, "balanced": { "pp_profile": "balanced", "acpi_profile": "balanced", "epp": "balance_performance", "governor": "powersave", "turbo": True, "hwp_boost": True, "min_pct": 8, "max_pct": 100, "tlp_epp_ac": "balance_performance", "tlp_epp_bat": "balance_power", "tlp_plat_ac": "balanced", "tlp_plat_bat": "balanced", # GPU "nv_power_limit": 80, "arc_min_mhz": 100, "arc_max_mhz": 2350, "nv_rtpm": "auto", "display_hz": "max", }, "powersaver": { "pp_profile": "power-saver", "acpi_profile": "quiet", "epp": "power", "governor": "powersave", "turbo": False, "hwp_boost": False, "min_pct": 8, "max_pct": 50, "tlp_epp_ac": "balance_power", "tlp_epp_bat": "power", "tlp_plat_ac": "quiet", "tlp_plat_bat": "quiet", # GPU "nv_power_limit": 30, "arc_min_mhz": 100, "arc_max_mhz": 800, "nv_rtpm": "auto", "display_hz": 60, }, } def _apply_global_preset(self, key): p = self._PRESETS[key] self._highlight_active_preset(key) self.lbl_preset_status.config(text="Applying…") self.update() def _task(): errors = [] n = get_cpu_count() # 1. powerprofilesctl _, rc = run(["powerprofilesctl", "set", p["pp_profile"]]) if rc != 0: errors.append("powerprofilesctl") # 2. ACPI platform profile if not syswrite("/sys/firmware/acpi/platform_profile", p["acpi_profile"], sudo=True): errors.append("acpi-profile") # 3. EPP — all CPUs in one shot epp_writes = [ (f"/sys/devices/system/cpu/cpu{i}/cpufreq/energy_performance_preference", p["epp"]) for i in range(n)] if not syswrite_many(epp_writes, sudo=True): errors.append("epp") # 4. Governor — all CPUs in one shot gov_writes = [ (f"/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_governor", p["governor"]) for i in range(n)] if not syswrite_many(gov_writes, sudo=True): errors.append("governor") # 5. Turbo if _TURBO_PATH: if _TURBO_INV: turbo_val = "0" if p["turbo"] else "1" else: turbo_val = "1" if p["turbo"] else "0" if not syswrite(_TURBO_PATH, turbo_val, sudo=True): errors.append("turbo") # 6. HWP dynamic boost (Intel only) if _CPU_VEND == "intel": hwp = "1" if p["hwp_boost"] else "0" syswrite("/sys/devices/system/cpu/intel_pstate/hwp_dynamic_boost", hwp, sudo=True) # 7. Performance % clamp (Intel only) / AMD freq clamp if _CPU_VEND == "intel" and \ Path("/sys/devices/system/cpu/intel_pstate/min_perf_pct").exists(): if not (syswrite("/sys/devices/system/cpu/intel_pstate/min_perf_pct", str(p["min_pct"]), sudo=True) and syswrite("/sys/devices/system/cpu/intel_pstate/max_perf_pct", str(p["max_pct"]), sudo=True)): errors.append("perf-pct") elif _CPU_VEND == "amd": # AMD: clamp per-CPU scaling_max_freq as a fraction of cpuinfo_max_freq hw_max = sysread("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") hw_min = sysread("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq") if hw_max and hw_min: hw_max_khz = int(hw_max) hw_min_khz = int(hw_min) freq_max = int(hw_max_khz * p["max_pct"] / 100) freq_min = int(hw_min_khz + (hw_max_khz - hw_min_khz) * p["min_pct"] / 100) freq_max_writes = [ (f"/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_max_freq", str(freq_max)) for i in range(n)] freq_min_writes = [ (f"/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_min_freq", str(freq_min)) for i in range(n)] syswrite_many(freq_max_writes, sudo=True) syswrite_many(freq_min_writes, sudo=True) # 8. GPU # NVIDIA power limit nv_pl = p.get("nv_power_limit") if nv_pl and which("nvidia-smi"): _, rc = run(["nvidia-smi", f"--power-limit={nv_pl}"], sudo=True) if rc != 0: errors.append("nv-power-limit") # NVIDIA runtime PM if _NV_PCI: nv_rtpm = p.get("nv_rtpm", "auto") syswrite(f"/sys/bus/pci/devices/{_NV_PCI}/power/control", nv_rtpm, sudo=True) # Intel Arc freq clamp arc_min = p.get("arc_min_mhz") arc_max = p.get("arc_max_mhz") if arc_min and arc_max and _ARC_CARD: syswrite(f"/sys/class/drm/{_ARC_CARD}/gt_min_freq_mhz", str(arc_min), sudo=True) syswrite(f"/sys/class/drm/{_ARC_CARD}/gt_max_freq_mhz", str(arc_max), sudo=True) syswrite(f"/sys/class/drm/{_ARC_CARD}/gt_boost_freq_mhz", str(arc_max), sudo=True) # Update GPU tab widgets if nv_pl and hasattr(self, "nv_pl_var"): self.after(0, lambda w=nv_pl: [ self.nv_pl_var.set(w), self.lbl_nv_pl.config(text=f"{w} W")]) if arc_min and arc_max and hasattr(self, "_arc_freq_labels"): self.after(0, lambda mn=arc_min, mx=arc_max: [ self.arc_min_var.set(mn), self.arc_max_var.set(mx), self._arc_freq_labels["min"].config(text=f"{mn} MHz"), self._arc_freq_labels["max"].config(text=f"{mx} MHz")]) # 9. Display refresh rate display_hz = p.get("display_hz") if display_hz is not None: if not set_display_hz(display_hz): errors.append("display-hz") # 10. TLP config (if service is active) if service_active("tlp"): tlp_conf = ( f'CPU_ENERGY_PERF_POLICY_ON_AC="{p["tlp_epp_ac"]}"\n' f'CPU_ENERGY_PERF_POLICY_ON_BAT="{p["tlp_epp_bat"]}"\n' f'PLATFORM_PROFILE_ON_AC="{p["tlp_plat_ac"]}"\n' f'PLATFORM_PROFILE_ON_BAT="{p["tlp_plat_bat"]}"\n' ) _write_config_as_root( f"# Written by Power Control Center preset: {key}\n" + tlp_conf, "/etc/tlp.d/99-power-control.conf") run(["tlp", "start"], sudo=True) status = f"{key} applied" + (f" (errors: {', '.join(errors)})" if errors else " ✓") self.after(0, lambda: self.lbl_preset_status.config(text=status)) self.after(0, self._refresh_all) threading.Thread(target=_task, daemon=True).start() def _highlight_active_preset(self, active_key): for key, (btn, color) in self._preset_btns.items(): if key == active_key: btn.config(fg=ACCENT, bg=BG3, relief="ridge") else: btn.config(fg=color, bg=BG, relief="flat") def _refresh_all(self): self._refresh_header() self._refresh_profiles() self._refresh_pstate() self._refresh_battery() self._refresh_sensors() self._refresh_freqs() self._refresh_daemon_status() def _refresh_header(self): profile = sysread("/sys/firmware/acpi/platform_profile") or "" pp, _ = run(["powerprofilesctl", "get"]) self.h_profile.config(text=f"ppd:{pp} acpi:{profile}") cap = sysread(f"{_BAT_SYS}/capacity") if _BAT_SYS else "?" status = sysread(f"{_BAT_SYS}/status") if _BAT_SYS else "?" self.h_bat.config(text=f"BAT {cap}% {status}") # power rate from upower if _UPOWER_BAT: try: up, _ = run(["upower", "-i", _UPOWER_BAT]) m = re.search(r"energy-rate:\s+([\d.]+)\s+(\w+)", up) if m: self.h_rate.config(text=f"{m.group(1)} {m.group(2)}") except Exception: pass # Scan thermal zones for a CPU-type sensor t = None for zone_id in range(20): tp = sysread(f"/sys/class/thermal/thermal_zone{zone_id}/type") or "" if "cpu" in tp.lower() or "pkg" in tp.lower() or "x86" in tp.lower(): t = sysread(f"/sys/class/thermal/thermal_zone{zone_id}/temp") if t: break if not t: t = sysread("/sys/class/thermal/thermal_zone0/temp") if t: try: tc = int(t) / 1000 col = RED if tc > 85 else YELLOW if tc > 70 else GREEN self.h_temp.config(text=f"CPU {tc:.0f}°C", fg=col) except (ValueError, TypeError): pass def _refresh_profiles(self): cur_pp, _ = run(["powerprofilesctl", "get"]) cur_acpi = sysread("/sys/firmware/acpi/platform_profile") or "" cur_epp = sysread( "/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference") or "" cur_gov = sysread( "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor") or "" for d, cur, lbl_w, btns in [ ("pp", cur_pp, self.lbl_pp, self._pp_btns), ("acpi", cur_acpi, self.lbl_acpi, self._acpi_btns), ("epp", cur_epp, self.lbl_epp, self._epp_btns), ("gov", cur_gov, self.lbl_gov, self._gov_btns), ]: for name, (btn, color) in btns.items(): if name == cur: btn.config(bg=BG3, fg=ACCENT, relief="ridge") else: btn.config(bg=BG2, fg=TEXT, relief="flat") lbl_w.config(text=f"Active: {cur}") self._refresh_header() def _refresh_pstate(self): # Turbo if _TURBO_PATH: val = sysread(_TURBO_PATH) if val is not None: if _TURBO_INV: self.turbo_var.set(val.strip() == "0") else: self.turbo_var.set(val.strip() == "1") # HWP (Intel only) hwp = sysread("/sys/devices/system/cpu/intel_pstate/hwp_dynamic_boost") or "0" self.hwpboost_var.set(hwp == "1") # Perf % (Intel only) mn = sysread("/sys/devices/system/cpu/intel_pstate/min_perf_pct") or "8" mx = sysread("/sys/devices/system/cpu/intel_pstate/max_perf_pct") or "100" self.min_pct.set(int(mn)) self.max_pct.set(int(mx)) if hasattr(self, "_pct_labels"): if "min" in self._pct_labels: self._pct_labels["min"].config(text=f"{int(mn)}%") if "max" in self._pct_labels: self._pct_labels["max"].config(text=f"{int(mx)}%") def _refresh_freqs(self): n = get_cpu_count() mn_f = sysread("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq") or "0" mx_f = sysread("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") or "0" base = sysread("/sys/devices/system/cpu/cpu0/cpufreq/base_frequency") or "0" lines = [f"HW range: {int(mn_f)//1000}–{int(mx_f)//1000} MHz Base: {int(base)//1000} MHz", ""] per = [] for i in range(min(n, 16)): f = sysread(f"/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq") or "0" per.append(f"cpu{i}: {int(f)//1000:>5} MHz") for i in range(0, len(per), 4): lines.append(" ".join(per[i:i+4])) self.lbl_freqs.config(text="\n".join(lines)) def _refresh_battery(self): if not _UPOWER_BAT: return try: out, _ = run(["upower", "-i", _UPOWER_BAT]) def ug(key): m = re.search(rf'{re.escape(key)}:\s+(.+)', out) return m.group(1).strip() if m else "—" ef = ug("energy-full") efsd = ug("energy-full-design") try: ef_parts, efsd_parts = ef.split(), efsd.split() if ef_parts and efsd_parts: ef_v = float(ef_parts[0]) efd_v = float(efsd_parts[0]) health = f"{100*ef_v/efd_v:.1f}%" if efd_v else "—" else: health = "—" except (ValueError, IndexError, ZeroDivisionError): health = "—" updates = { "State": ug("state"), "Capacity": ug("capacity-level"), "Energy": ug("energy"), "Energy Full": ef, "Energy Full Design":efsd, "Health": health, "Energy Rate": ug("energy-rate"), "Voltage": ug("voltage"), "Cycle Count": sysread(f"{_BAT_SYS}/cycle_count") if _BAT_SYS else "—", "Technology": ug("technology") if "technology" in out else sysread(f"{_BAT_SYS}/technology") if _BAT_SYS else "—", } def _apply(): for k, v in updates.items(): if k in self._bat_lbls: self._bat_lbls[k].config(text=v) self.after(0, _apply) except Exception as e: _log.warning("_refresh_battery: %s", e) def _refresh_sensors(self): try: out, _ = run(["sensors"]) lines = [out] if out else ["sensors not found"] zones = [] for zp in sorted(Path("/sys/class/thermal").iterdir()): if not zp.name.startswith("thermal_zone"): continue t = sysread(zp / "temp") tp = sysread(zp / "type") if t and tp: zones.append(f"{tp:<24} {int(t)/1000:>6.1f} °C") if zones: lines += ["\n── Thermal Zones ─────────────────────"] + zones text = "\n".join(lines) ts = time.strftime("%H:%M:%S") self.after(0, lambda: set_text(self.sensor_text, text)) self.after(0, lambda: self.lbl_sens_time.config(text=f"Last: {ts}")) except Exception as e: _log.warning("_refresh_sensors: %s", e) def _refresh_daemon_status(self): for name, lbl in self._daemon_rows.items(): active = service_active(name) enabled = service_enabled(name) if active: color = GREEN txt = f"● active{' (enabled)' if enabled else ''}" elif enabled: color = YELLOW txt = "● enabled (inactive)" else: color = SUBTEXT txt = "○ inactive" lbl.config(text=txt, fg=color) # TLP service label active = service_active("tlp") self.lbl_tlp_svc.config( text=f"tlp.service: {'active' if active else 'inactive'}", fg=GREEN if active else RED) # ════════════════════════════════════════════════════════════════════════ # TAB: Customize # ════════════════════════════════════════════════════════════════════════ def _tab_customize(self, p): p.columnconfigure(0, weight=1) p.rowconfigure(1, weight=1) co, ci = card(p, "Theme Colors (saved to ~/.config/power-control-center/theme.conf)") co.pack(fill="x", padx=4, pady=4) tk.Label(ci, text="Click a swatch to change its color. Save + restart to apply.", bg=BG3, fg=SUBTEXT, font=("Segoe UI", 9)).pack(anchor="w", pady=(0,8)) self._theme_vars = {} color_defs = [ ("bg", "Background (primary)", BG), ("bg2", "Background (panels)", BG2), ("bg3", "Background (cards)", BG3), ("border", "Border / Accent", BORDER), ("accent", "Accent (primary)", ACCENT), ("accent2", "Accent (secondary)", ACCENT2), ("text", "Text (main)", TEXT), ("yellow", "Balanced / Warning", YELLOW), ("green", "Power-saver / Good", GREEN), ("red", "Performance / Hot", RED), ("orange", "Caution", ORANGE), ("teal", "Neutral / Tools", TEAL), ("subtext", "Dimmed / Subtext", SUBTEXT), ] grid = tk.Frame(ci, bg=BG3) grid.pack(fill="x") for i, (key, label, default) in enumerate(color_defs): var = tk.StringVar(value=default) self._theme_vars[key] = var row_f = tk.Frame(grid, bg=BG3) row_f.grid(row=i//2, column=i%2, sticky="ew", padx=6, pady=3) grid.columnconfigure(i%2, weight=1) swatch = tk.Label(row_f, text=" ", bg=default, width=3, relief="ridge", cursor="hand2") swatch.pack(side="left", padx=(0,8)) tk.Label(row_f, text=label, bg=BG3, fg=TEXT, font=("Segoe UI", 9), width=22, anchor="w").pack(side="left") hex_lbl = tk.Label(row_f, textvariable=var, bg=BG3, fg=ACCENT, font=("Consolas", 9), width=9) hex_lbl.pack(side="left") def pick(k=key, v=var, sw=swatch, hl=hex_lbl, cur=default): result = colorchooser.askcolor(color=v.get(), title=f"Choose color for {k}") if result and result[1]: v.set(result[1]) sw.config(bg=result[1]) swatch.bind("", lambda e, fn=pick: fn()) hex_lbl.bind("", lambda e, fn=pick: fn()) btn_row = tk.Frame(ci, bg=BG3) btn_row.pack(fill="x", pady=(12,0)) mk_btn(btn_row, "Apply & Save", self._apply_theme_live, GREEN).pack(side="left") mk_btn(btn_row, "Reset to Defaults", self._reset_theme, SUBTEXT).pack(side="left", padx=8) co2, ci2 = card(p, "Presets") co2.pack(fill="x", padx=4, pady=4) preset_row = tk.Frame(ci2, bg=BG3) preset_row.pack(fill="x") theme_presets = [ ("Rose/Gold (default)", { "bg":"#000000","bg2":"#050505","bg3":"#0a0a0a", "border":"#ce7688","accent":"#ce7688","accent2":"#ba6a7b", "text":"#c1b48e","yellow":"#c1b48e","green":"#b5a985", "red":"#ce7688","orange":"#ba6a7b","teal":"#a49978","subtext":"#7a7158"}), ("Blue/Teal", { "bg":"#0a0e14","bg2":"#0f1520","bg3":"#141c2a", "border":"#5eb8ff","accent":"#5eb8ff","accent2":"#3d9ad1", "text":"#bfc8d5","yellow":"#ffcc66","green":"#7bd88f", "red":"#fc618d","orange":"#fd9353","teal":"#5eb8ff","subtext":"#546a7b"}), ("Green Terminal", { "bg":"#020c02","bg2":"#041204","bg3":"#061806", "border":"#39ff14","accent":"#39ff14","accent2":"#2dd10f", "text":"#aaffaa","yellow":"#ccff66","green":"#39ff14", "red":"#ff3366","orange":"#ff9933","teal":"#00ffcc","subtext":"#446644"}), ("Monochrome", { "bg":"#111111","bg2":"#1a1a1a","bg3":"#222222", "border":"#cccccc","accent":"#ffffff","accent2":"#bbbbbb", "text":"#dddddd","yellow":"#bbbbbb","green":"#aaaaaa", "red":"#eeeeee","orange":"#cccccc","teal":"#999999","subtext":"#666666"}), ] for name, colors in theme_presets: mk_btn(preset_row, name, lambda c=colors: self._apply_preset_theme(c), ACCENT).pack(side="left", padx=4, pady=4) def _apply_preset_theme(self, colors): for k, v in colors.items(): if k in self._theme_vars: self._theme_vars[k].set(v) self._apply_theme_live() def _apply_theme_live(self): """Update all global color vars from _theme_vars, re-style every widget, save.""" global BG, BG2, BG3, BORDER, ACCENT, ACCENT2, RED, ORANGE, TEXT, YELLOW, GREEN, TEAL, SUBTEXT # Capture old→new remap BEFORE updating globals new_vals = {k: self._theme_vars[k].get() for k in self._theme_vars} color_remap = { BG: new_vals.get("bg", BG), BG2: new_vals.get("bg2", BG2), BG3: new_vals.get("bg3", BG3), BORDER: new_vals.get("border", BORDER), ACCENT: new_vals.get("accent", ACCENT), ACCENT2: new_vals.get("accent2", ACCENT2), RED: new_vals.get("red", RED), ORANGE: new_vals.get("orange", ORANGE), TEXT: new_vals.get("text", TEXT), YELLOW: new_vals.get("yellow", YELLOW), GREEN: new_vals.get("green", GREEN), TEAL: new_vals.get("teal", TEAL), SUBTEXT: new_vals.get("subtext", SUBTEXT), } BG = new_vals.get("bg", BG) BG2 = new_vals.get("bg2", BG2) BG3 = new_vals.get("bg3", BG3) BORDER = new_vals.get("border", BORDER) ACCENT = new_vals.get("accent", ACCENT) ACCENT2 = new_vals.get("accent2", ACCENT2) RED = new_vals.get("red", RED) ORANGE = new_vals.get("orange", ORANGE) TEXT = new_vals.get("text", TEXT) YELLOW = new_vals.get("yellow", YELLOW) GREEN = new_vals.get("green", GREEN) TEAL = new_vals.get("teal", TEAL) SUBTEXT = new_vals.get("subtext", SUBTEXT) # Re-style ttk styles (notebook tabs, scales, comboboxes, etc.) self._setup_style() # Walk every widget and remap bg/fg from old palette values to new ones def _walk(w): try: cls = w.winfo_class() cfg = {} if cls in ("Frame", "Label", "Button", "Checkbutton", "Canvas", "Entry", "Text", "Listbox", "Message", "Radiobutton", "Scale", "Scrollbar", "Spinbox", "OptionMenu", "Menu"): try: cur_bg = w.cget("bg") if cur_bg in color_remap: cfg["bg"] = color_remap[cur_bg] except tk.TclError: pass try: cur_fg = w.cget("fg") if cur_fg in color_remap: cfg["fg"] = color_remap[cur_fg] except tk.TclError: pass # Also handle highlightbackground, activebackground, selectcolor etc. for opt in ("highlightbackground", "activebackground", "activeforeground", "selectcolor", "insertbackground", "troughcolor"): try: cur = w.cget(opt) if cur in color_remap: cfg[opt] = color_remap[cur] except tk.TclError: pass if cfg: w.config(**cfg) except Exception: pass for child in w.winfo_children(): _walk(child) _walk(self) self.configure(bg=BG) self._save_theme(silent=True) def _save_theme(self, silent=False): cfg = configparser.ConfigParser() cfg["theme"] = {k: v.get() for k, v in self._theme_vars.items()} _THEME_FILE.parent.mkdir(parents=True, exist_ok=True) with open(_THEME_FILE, "w") as f: cfg.write(f) if not silent: self.lbl_preset_status.config(text="Theme saved ✓") def _reset_theme(self): defaults = { "bg":"#000000","bg2":"#050505","bg3":"#0a0a0a", "border":"#ce7688","accent":"#ce7688","accent2":"#ba6a7b", "text":"#c1b48e","yellow":"#c1b48e","green":"#b5a985", "red":"#ce7688","orange":"#ba6a7b","teal":"#a49978","subtext":"#7a7158", } for k, v in defaults.items(): if k in self._theme_vars: self._theme_vars[k].set(v) self._apply_theme_live() # ════════════════════════════════════════════════════════════════════════ # Auto-refresh loop # ════════════════════════════════════════════════════════════════════════ def _start_loop(self): def _bg_refresh(): """Run slow I/O off the main thread, push results back via after().""" try: # Fast sysfs reads — safe on main thread self.after(0, self._refresh_header) self.after(0, self._refresh_pstate) self.after(0, self._refresh_freqs) # Slow subprocess calls — run here in background self._refresh_battery() self._refresh_sensors() self._refresh_daemon_status() if hasattr(self, "_nv_lbls"): self._refresh_nvidia() if hasattr(self, "_arc_lbls"): self._refresh_arc() except Exception as e: _log.warning("refresh loop error: %s", e) def loop(): threading.Thread(target=_bg_refresh, daemon=True).start() self.after(3000, loop) self.after(3000, loop) if __name__ == "__main__": app = App() app.mainloop()