diff --git a/Build/README.md b/Build/README.md new file mode 100644 index 0000000..52e4533 --- /dev/null +++ b/Build/README.md @@ -0,0 +1,100 @@ +# Ryzen Master Commander – Installation + +Linux GUI application (PyQt6) to monitor and control Ryzen laptops: temperature, fan (NBFC), TDP (ryzenadj), real-time graphs, and custom profiles. + +**Target:** Ubuntu 24.04.4 (Noble). May work on other Ubuntu/Debian-based systems. + +**Requirements:** Internet connection (the installer will fetch all dependencies). + +--- + +## Contents of this folder + +| File | Description | +|------|-------------| +| **install-standalone.sh** | One-shot installer: system deps, extract bundle, Python venv, polkit, desktop shortcut, ryzenadj, nbfc. | +| **RyzenMasterCommander-bundle.tar.gz** | Application sources (no installer inside). | +| **README.md** | This file. | + +The installer script must sit in the **same folder** as the `.tar.gz` file. + +--- + +## Dependencies + +The script installs everything; you do not need to install anything manually. + +### System (apt) + +- **python3** – Python 3 interpreter +- **python3-venv** – Virtual environments +- **python3.X-venv** – Venv for default Python (e.g. python3.12-venv on Ubuntu 24.04) +- **python3-pip** – Pip for Python packages +- **libxcb-cursor0** – Qt6 xcb plugin (GUI) +- **lm-sensors** – Power/temperature readout (`sensors` command) +- **curl** – Used only if needed to download nbfc .deb + +### Optional (installed by the script when available) + +- **ryzenadj** – TDP/power limits (installed via Snap: `ryzenadj --beta --devmode`) +- **nbfc** – Fan control (apt if in repos, otherwise .deb from [nbfc-linux](https://github.com/nbfc-linux/nbfc-linux/releases)) + +### Python (pip, inside venv) + +- **PyQt6** – GUI +- **pyqtgraph** – Graphs +- **numpy** – Numeric data +- **Pillow** – Images +- **pystray** – System tray icon + +--- + +## Installation (single command) + +1. Download or clone this repo and go into the **Build** folder (or download only the Build folder with the three files). +2. In a terminal, inside that folder: + +```bash +chmod +x install-standalone.sh +./install-standalone.sh +``` + +You will be prompted for your sudo password to install system packages, polkit, and optionally Snap/nbfc. + +The script will: + +1. Install system dependencies (Python, venv, pip, libxcb-cursor0). +2. Extract `RyzenMasterCommander-bundle.tar.gz` to `~/.local/ryzen-master-commander`. +3. Create the run script and Python virtual environment and install Python dependencies. +4. Install the polkit policy (fewer password prompts for TDP/fan). +5. Create a desktop shortcut. +6. Install **ryzenadj** via Snap (if Snap is available). +7. Install **nbfc** via apt or by downloading the .deb from nbfc-linux releases. + +Then run **Ryzen Master Commander** from the desktop icon or: + +```bash +~/.local/ryzen-master-commander/run-ryzen-master-commander.sh +``` + +### System-wide install (/opt) + +```bash +./install-standalone.sh --system +``` + +Uses sudo to install under `/opt/ryzen-master-commander`. + +--- + +## After installation + +- **TDP (ryzenadj):** Installed by the script via Snap when available. If not, run: + `sudo snap install ryzenadj --beta --devmode` +- **Fan (nbfc):** Installed by the script (apt or .deb). Configure a profile for your laptop (e.g. “Lenovo ThinkPad T14 Gen2” for Lenovo V15 G3). + +--- + +## License + +This project is licensed under the Apache-2.0 license. Ryzen Master Commander uses [nbfc-linux](https://github.com/nbfc-linux/nbfc-linux) and [ryzenadj](https://github.com/FlyGoat/RyzenAdj). diff --git a/Build/RyzenMasterCommander-bundle.tar.gz b/Build/RyzenMasterCommander-bundle.tar.gz new file mode 100644 index 0000000..c37ce41 Binary files /dev/null and b/Build/RyzenMasterCommander-bundle.tar.gz differ diff --git a/Build/create-bundle.sh b/Build/create-bundle.sh new file mode 100755 index 0000000..1d60dc2 --- /dev/null +++ b/Build/create-bundle.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Genera RyzenMasterCommander-bundle.tar.gz con solo los fuentes (sin Build/, sin .git, sin .venv). +# El instalador install-standalone.sh debe estar FUERA de este comprimido; se deja en Build/ para copiarlo aparte. +# Uso: desde la raíz del repositorio: ./Build/create-bundle.sh +# Resultado: RyzenMasterCommander-bundle.tar.gz en la raíz del repositorio. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT="$SCRIPT_DIR/RyzenMasterCommander-bundle.tar.gz" + +cd "$REPO_ROOT" +echo "Creando comprimido (solo fuentes, sin Build/)..." +tar czf "$OUTPUT" \ + src bin polkit share tdp_profiles img \ + requirements.txt version.txt + +echo "Creado: $OUTPUT" +echo "" +echo "Para distribuir: copia la carpeta Build/ completa al Ubuntu; dentro estarán" +echo " install-standalone.sh y RyzenMasterCommander-bundle.tar.gz." +echo " Luego: cd Build && chmod +x install-standalone.sh && ./install-standalone.sh" +echo "" diff --git a/Build/install-standalone.sh b/Build/install-standalone.sh new file mode 100755 index 0000000..cc7c370 --- /dev/null +++ b/Build/install-standalone.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Instalador independiente: debe estar FUERA del comprimido, en la misma carpeta que RyzenMasterCommander-bundle.tar.gz +# Uso: copia este script y RyzenMasterCommander-bundle.tar.gz a la misma carpeta; ejecuta: ./install-standalone.sh +# Opción: ./install-standalone.sh --system (instala en /opt) + +set -e + +# El script debe estar en la misma carpeta que RyzenMasterCommander-bundle.tar.gz (p. ej. dentro de Build/) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUNDLE="$SCRIPT_DIR/RyzenMasterCommander-bundle.tar.gz" +INSTALL_SYSTEM=false +if [ "${1:-}" = "--system" ]; then + INSTALL_SYSTEM=true +fi + +if [ "$INSTALL_SYSTEM" = true ]; then + INSTALL_DIR="/opt/ryzen-master-commander" + echo "Modo sistema: se instalará en $INSTALL_DIR" +else + INSTALL_DIR="${HOME}/.local/ryzen-master-commander" + echo "Modo usuario: se instalará en $INSTALL_DIR" +fi + +if [ ! -f "$BUNDLE" ]; then + echo "Error: no se encuentra el comprimido." + echo " Coloca RyzenMasterCommander-bundle.tar.gz en la misma carpeta que este script:" + echo " $SCRIPT_DIR" + exit 1 +fi + +echo "Comprimido: $BUNDLE" +echo "Destino: $INSTALL_DIR" +echo "" + +# [1/8] Instalar todas las dependencias de sistema (el usuario no tiene que instalar nada a mano) +echo "[1/8] Instalando dependencias de sistema (se pedirá contraseña sudo)..." +sudo apt-get update -qq +sudo apt-get install -y python3 python3-venv python3-pip libxcb-cursor0 lm-sensors +# En Ubuntu 24.04+ el venv necesita python3.X-venv (p. ej. python3.12-venv) para ensurepip +PYVER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)" +if [ -n "$PYVER" ]; then + sudo apt-get install -y "python${PYVER}-venv" +fi + +# [2/8] Descomprimir el .tar.gz en el directorio de instalación +echo "[2/8] Descomprimiendo RyzenMasterCommander-bundle.tar.gz en $INSTALL_DIR..." +if [ "$INSTALL_SYSTEM" = true ]; then + sudo mkdir -p "$INSTALL_DIR" + sudo tar xzf "$BUNDLE" -C "$INSTALL_DIR" + sudo chown -R "$USER:$USER" "$INSTALL_DIR" +else + mkdir -p "$INSTALL_DIR" + tar xzf "$BUNDLE" -C "$INSTALL_DIR" +fi + +# [3/8] Script de ejecución +echo "[3/8] Configurando script de ejecución..." +cat > "$INSTALL_DIR/run-ryzen-master-commander.sh" << 'RUNSCRIPT' +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")" +if [ ! -d ".venv" ]; then + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt +fi +export PYTHONPATH="$(pwd):${PYTHONPATH}" +exec "$(pwd)/.venv/bin/python" -m src.main +RUNSCRIPT +chmod +x "$INSTALL_DIR/run-ryzen-master-commander.sh" + +# [4/8] Entorno virtual y dependencias Python +echo "[4/8] Creando entorno virtual e instalando dependencias Python..." +cd "$INSTALL_DIR" +python3 -m venv .venv +.venv/bin/pip install -q -r requirements.txt + +# [5/8] Polkit +echo "[5/8] Instalando política polkit..." +POLKIT_SRC="$INSTALL_DIR/polkit/com.merrythieves.ryzenadj.policy" +POLKIT_DST="/usr/share/polkit-1/actions/com.merrythieves.ryzenadj.policy" +if [ -f "$POLKIT_SRC" ]; then + sudo cp "$POLKIT_SRC" "$POLKIT_DST" + sudo chown root:root "$POLKIT_DST" + sudo chmod 0644 "$POLKIT_DST" + echo " Política instalada. Opcional: sudo systemctl daemon-reload && sudo systemctl restart polkit" +else + echo " No se encontró política; omitiendo polkit." +fi + +# [6/8] Acceso directo en el escritorio +echo "[6/8] Creando acceso directo en el escritorio..." +DESKTOP_DIR="" +[ -d "$HOME/Escritorio" ] && DESKTOP_DIR="$HOME/Escritorio" +[ -d "$HOME/Desktop" ] && DESKTOP_DIR="${DESKTOP_DIR:-$HOME/Desktop}" +ICON="$INSTALL_DIR/img/icon.png" +[ ! -f "$ICON" ] && ICON="" +if [ -n "$DESKTOP_DIR" ]; then + SHORTCUT="$DESKTOP_DIR/RyzenMasterCommander.desktop" + cat > "$SHORTCUT" << DESKTOP +[Desktop Entry] +Type=Application +Name=Ryzen Master Commander +Comment=Monitorizar y controlar TDP y ventilador Ryzen +Exec=$INSTALL_DIR/run-ryzen-master-commander.sh +Icon=$ICON +Terminal=false +Categories=Utility;System; +DESKTOP + chmod +x "$SHORTCUT" + echo " Creado: $SHORTCUT" +else + echo " No se encontró Escritorio ni Desktop; no se creó acceso directo." +fi + +# [7/8] ryzenadj (control TDP) +echo "[7/8] Instalando ryzenadj (control TDP)..." +if command -v snap &>/dev/null; then + if ! snap list ryzenadj &>/dev/null 2>&1; then + sudo snap install ryzenadj --beta --devmode 2>/dev/null && echo " ryzenadj instalado (snap)." || echo " ryzenadj: no se pudo instalar por snap (instálalo manualmente: sudo snap install ryzenadj --beta --devmode)." + else + echo " ryzenadj ya está instalado." + fi +else + echo " Snap no está instalado; para control TDP instala ryzenadj manualmente (p. ej. desde fuentes)." +fi + +# [8/8] nbfc (control ventilador) y arranque del servicio +echo "[8/8] Instalando nbfc (control ventilador)..." +if command -v nbfc &>/dev/null; then + echo " nbfc ya está instalado." +else + if sudo apt-get install -y nbfc &>/dev/null 2>&1; then + echo " nbfc instalado desde los repos." + else + NBFC_DEB_URL="https://github.com/nbfc-linux/nbfc-linux/releases/download/0.3.19/nbfc-linux_0.3.19_amd64.deb" + if command -v curl &>/dev/null; then + if curl -sLf "$NBFC_DEB_URL" -o /tmp/nbfc-linux.deb 2>/dev/null; then + sudo dpkg -i /tmp/nbfc-linux.deb 2>/dev/null && sudo apt-get install -f -y -qq 2>/dev/null && echo " nbfc instalado desde nbfc-linux (GitHub)." || echo " nbfc: no se pudo instalar el .deb." + rm -f /tmp/nbfc-linux.deb + else + echo " nbfc: descarga fallida; instálalo desde https://github.com/nbfc-linux/nbfc-linux/releases" + fi + else + sudo apt-get install -y curl -qq 2>/dev/null || true + if curl -sLf "$NBFC_DEB_URL" -o /tmp/nbfc-linux.deb 2>/dev/null; then + sudo dpkg -i /tmp/nbfc-linux.deb 2>/dev/null && sudo apt-get install -f -y -qq 2>/dev/null && echo " nbfc instalado desde nbfc-linux (GitHub)." || echo " nbfc: no se pudo instalar el .deb." + rm -f /tmp/nbfc-linux.deb + else + echo " nbfc: instálalo desde https://github.com/nbfc-linux/nbfc-linux/releases" + fi + fi + fi +fi +# Actualizar configs e intentar arrancar el servicio nbfc +if command -v nbfc &>/dev/null; then + sudo nbfc update 2>/dev/null || true + sudo systemctl enable nbfc_service 2>/dev/null || true + (sudo nbfc config -s auto 2>/dev/null; sudo nbfc start 2>/dev/null) || true + echo " Servicio nbfc: enable al arranque; si no arranca, elige un perfil en la app (p. ej. Lenovo ThinkPad T14 Gen2)." +fi + +echo "" +echo "=== Instalación completada ===" +echo " Ejecutar la aplicación:" +echo " $INSTALL_DIR/run-ryzen-master-commander.sh" +echo " o usar el icono del escritorio." +echo "" diff --git a/Build/install.sh b/Build/install.sh new file mode 100755 index 0000000..c1e4ea1 --- /dev/null +++ b/Build/install.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Instalador todo-en-uno para Ryzen Master Commander. +# Uso: desde el repositorio clonado, ejecutar: ./Build/install.sh +# Opción: ./Build/install.sh --system (instala en /opt, requiere sudo para los archivos) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_SYSTEM=false +if [ "${1:-}" = "--system" ]; then + INSTALL_SYSTEM=true +fi + +# Comprobar que estamos en el repositorio completo (no solo la carpeta Build) +if [ ! -f "$REPO_ROOT/requirements.txt" ] || [ ! -d "$REPO_ROOT/src" ]; then + echo "Error: hace falta el repositorio completo." + echo " Debe existir $REPO_ROOT/requirements.txt y $REPO_ROOT/src/" + echo " Copia o clona la carpeta completa del proyecto (no solo Build/) y ejecuta: ./Build/install.sh" + exit 1 +fi + +if [ "$INSTALL_SYSTEM" = true ]; then + INSTALL_DIR="/opt/ryzen-master-commander" + echo "Modo sistema: se instalará en $INSTALL_DIR (se usará sudo para copiar archivos)." +else + INSTALL_DIR="${HOME}/.local/ryzen-master-commander" + echo "Modo usuario: se instalará en $INSTALL_DIR" +fi + +echo "Origen (repositorio): $REPO_ROOT" +echo "Destino: $INSTALL_DIR" +echo "" + +# Dependencias de sistema (Python, venv, pip, libs Qt para la GUI) +echo "[1/6] Comprobando dependencias de sistema..." +if ! command -v python3 &>/dev/null; then + echo "Se necesita python3. Instálalo con: sudo apt update && sudo apt install -y python3 python3-venv python3-pip" + exit 1 +fi +# Instalar paquetes necesarios (en Ubuntu recién instalado suelen faltar) +NEEDED="" +for pkg in python3-venv python3-pip libxcb-cursor0; do + if ! dpkg -l "$pkg" &>/dev/null 2>&1; then + NEEDED="$NEEDED $pkg" + fi +done +if [ -n "$NEEDED" ]; then + echo "Instalando:$NEEDED" + sudo apt-get update -qq + sudo apt-get install -y $NEEDED +fi + +# Crear directorio de instalación y copiar fuentes necesarios +echo "[2/6] Copiando fuentes a $INSTALL_DIR..." +if [ "$INSTALL_SYSTEM" = true ]; then + sudo mkdir -p "$INSTALL_DIR" + sudo cp -r "$REPO_ROOT/src" "$REPO_ROOT/bin" "$REPO_ROOT/polkit" "$REPO_ROOT/share" "$REPO_ROOT/tdp_profiles" "$REPO_ROOT/img" "$INSTALL_DIR/" + sudo cp "$REPO_ROOT/requirements.txt" "$REPO_ROOT/version.txt" "$INSTALL_DIR/" + sudo chown -R "$USER:$USER" "$INSTALL_DIR" +else + mkdir -p "$INSTALL_DIR" + cp -r "$REPO_ROOT/src" "$REPO_ROOT/bin" "$REPO_ROOT/polkit" "$REPO_ROOT/share" "$REPO_ROOT/tdp_profiles" "$REPO_ROOT/img" "$INSTALL_DIR/" + cp "$REPO_ROOT/requirements.txt" "$REPO_ROOT/version.txt" "$INSTALL_DIR/" +fi + +# Script de ejecución en el directorio instalado +echo "[3/6] Configurando script de ejecución..." +cat > "$INSTALL_DIR/run-ryzen-master-commander.sh" << 'RUNSCRIPT' +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")" +if [ ! -d ".venv" ]; then + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt +fi +export PYTHONPATH="$(pwd):${PYTHONPATH}" +exec "$(pwd)/.venv/bin/python" -m src.main +RUNSCRIPT +chmod +x "$INSTALL_DIR/run-ryzen-master-commander.sh" + +# Entorno virtual e dependencias Python +echo "[4/6] Creando entorno virtual e instalando dependencias Python..." +cd "$INSTALL_DIR" +python3 -m venv .venv +.venv/bin/pip install -q -r requirements.txt + +# Polkit (ryzenadj/nbfc sin pedir contraseña cada vez) +echo "[5/6] Instalando política polkit..." +POLKIT_SRC="$INSTALL_DIR/polkit/com.merrythieves.ryzenadj.policy" +POLKIT_DST="/usr/share/polkit-1/actions/com.merrythieves.ryzenadj.policy" +if [ -f "$POLKIT_SRC" ]; then + sudo cp "$POLKIT_SRC" "$POLKIT_DST" + sudo chown root:root "$POLKIT_DST" + sudo chmod 0644 "$POLKIT_DST" + echo " Política instalada. Opcional: sudo systemctl daemon-reload && sudo systemctl restart polkit" +else + echo " No se encontró $POLKIT_SRC; omitiendo polkit." +fi + +# Acceso directo en el escritorio +echo "[6/6] Creando acceso directo en el escritorio..." +DESKTOP_DIR="" +[ -d "$HOME/Escritorio" ] && DESKTOP_DIR="$HOME/Escritorio" +[ -d "$HOME/Desktop" ] && DESKTOP_DIR="${DESKTOP_DIR:-$HOME/Desktop}" +ICON="$INSTALL_DIR/img/icon.png" +[ ! -f "$ICON" ] && ICON="" +if [ -n "$DESKTOP_DIR" ]; then + SHORTCUT="$DESKTOP_DIR/RyzenMasterCommander.desktop" + cat > "$SHORTCUT" << DESKTOP +[Desktop Entry] +Type=Application +Name=Ryzen Master Commander +Comment=Monitorizar y controlar TDP y ventilador Ryzen +Exec=$INSTALL_DIR/run-ryzen-master-commander.sh +Icon=$ICON +Terminal=false +Categories=Utility;System; +DESKTOP + chmod +x "$SHORTCUT" + echo " Creado: $SHORTCUT" +else + echo " No se encontró Escritorio ni Desktop; no se creó acceso directo." +fi + +echo "" +echo "=== Instalación completada ===" +echo " Ejecutar la aplicación:" +echo " $INSTALL_DIR/run-ryzen-master-commander.sh" +echo "" +echo " Dependencias opcionales (control TDP y ventilador):" +echo " - ryzenadj: sudo snap install ryzenadj --beta --devmode" +echo " - nbfc: según tu distro (p. ej. AUR, o desde nbfc-linux)" +echo "" diff --git a/Makefile b/Makefile index a4a756a..6143d67 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ PACKAGE_NAME := ryzen-master-commander RPM_BUILD_DIR := $(HOME)/rpmbuild # Default target +.NOTPARALLEL: all all: arch deb rpm flatpak # Create build directory diff --git a/bin/ryzen-master-commander-helper b/bin/ryzen-master-commander-helper index e30099c..9493d77 100644 --- a/bin/ryzen-master-commander-helper +++ b/bin/ryzen-master-commander-helper @@ -6,7 +6,7 @@ shift case "$command" in "ryzenadj") - pkexec /usr/bin/ryzenadj "$@" + pkexec /snap/bin/ryzenadj "$@" ;; "nbfc") pkexec /usr/bin/nbfc "$@" diff --git a/install-polkit-rmc.sh b/install-polkit-rmc.sh new file mode 100755 index 0000000..3dcdaec --- /dev/null +++ b/install-polkit-rmc.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +echo "Instalando políticas de polkit para Ryzen Master Commander..." + +if [ "$EUID" -ne 0 ]; then + echo "Se requiere sudo para copiar la política a /usr/share/polkit-1/actions/" + exec sudo "$0" "$@" +fi + +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +POLKIT_SRC="$PROJECT_DIR/polkit/com.merrythieves.ryzenadj.policy" +POLKIT_DST="/usr/share/polkit-1/actions/com.merrythieves.ryzenadj.policy" + +if [ ! -f "$POLKIT_SRC" ]; then + echo "No se encuentra el archivo de política en: $POLKIT_SRC" + exit 1 +fi + +cp "$POLKIT_SRC" "$POLKIT_DST" +chown root:root "$POLKIT_DST" +chmod 0644 "$POLKIT_DST" + +echo "Política instalada en $POLKIT_DST" +echo "Es posible que tengas que reiniciar la sesión o ejecutar:" +echo " sudo systemctl restart polkit" +echo "para que los cambios se apliquen." + diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 633c80f..5235682 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -3,7 +3,7 @@ pkgname=ryzen-master-commander _realname=Ryzen-Master-Commander -pkgver=1.0.13 +pkgver=1.2.0 pkgrel=1 pkgdesc="TDP and fan control for AMD Ryzen processors" arch=('any') @@ -20,11 +20,11 @@ build() { mkdir -p "$srcdir/$pkgname-$pkgver" tar -xzf "$srcdir/$pkgname-$pkgver.tar.gz" -C "$srcdir/$pkgname-$pkgver" --strip-components=1 cd "$srcdir/$pkgname-$pkgver" - - python -m build --wheel --no-isolation + + /usr/bin/python -m build --wheel --no-isolation } package() { cd "$srcdir/$pkgname-$pkgver" - python -m installer --destdir="$pkgdir" dist/*.whl + /usr/bin/python -m installer --destdir="$pkgdir" dist/*.whl } \ No newline at end of file diff --git a/polkit/com.merrythieves.ryzenadj.policy b/polkit/com.merrythieves.ryzenadj.policy index bf288b5..cf09e08 100644 --- a/polkit/com.merrythieves.ryzenadj.policy +++ b/polkit/com.merrythieves.ryzenadj.policy @@ -11,11 +11,11 @@ Authentication is required to change processor settings cpu - auth_admin_keep - auth_admin_keep + yes + yes yes - /usr/bin/ryzenadj + /snap/bin/ryzenadj true @@ -24,8 +24,8 @@ Authentication is required to control fan settings fan - auth_admin_keep - auth_admin_keep + yes + yes yes /usr/bin/nbfc diff --git a/run-ryzen-master-commander.sh b/run-ryzen-master-commander.sh new file mode 100755 index 0000000..a8fc7ac --- /dev/null +++ b/run-ryzen-master-commander.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -e + +# Ir a la carpeta del proyecto +cd "$(dirname "$0")" + +# Asegurar que el entorno virtual existe e instala dependencias básicas si hace falta +if [ ! -d ".venv" ]; then + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt +fi + +# Añadir la carpeta del proyecto al PYTHONPATH para que se encuentre el paquete src +export PYTHONPATH="$(pwd):${PYTHONPATH}" + +# Ejecutar como usuario (la sesión gráfica carga bien). La contraseña se pide +# una sola vez cuando la app use nbfc/ryzenadj (polkit/pkexec). +# Si ves error de "xcb" o "libxcb-cursor0", instala: sudo apt install libxcb-cursor0 +exec "$(pwd)/.venv/bin/python" -m src.main + diff --git a/src/__init__.py b/src/__init__.py index e9537c3..df3eb77 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,2 @@ from .version import __version__ -from . import main from . import app diff --git a/src/app/main_window.py b/src/app/main_window.py index 70ffbd7..4f6a8b9 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -53,8 +53,11 @@ def __init__(self): # Set up system tray self.setup_system_tray() - # Set auto control by default - self.radio_auto_control.setChecked(True) + # Set auto control by default only if nbfc is running; otherwise manual to avoid errors + if self.check_nbfc_running(): + self.radio_auto_control.setChecked(True) + else: + self.radio_manual_control.setChecked(True) self.update_fan_control_visibility() # Start reading system values @@ -71,9 +74,8 @@ def check_nbfc_running(self): result = subprocess.run( ["nbfc", "status"], capture_output=True, text=True ) - return "ERROR: connect()" not in result.stderr + return "ERROR: connect()" not in (result.stderr or "") except Exception: - print(f"nbfc not running: {result.stderr}") return False def start_nbfc_service(self): @@ -125,6 +127,14 @@ def setup_system_tray(self): tray_menu.addAction(toggle_auto_action) self.toggle_auto_action = toggle_auto_action + tray_menu.addSeparator() + tdp_15_action = QAction("15W", self) + tdp_15_action.triggered.connect(lambda: self.profile_manager.apply_preset_tdp(15)) + tray_menu.addAction(tdp_15_action) + tdp_25_action = QAction("25W", self) + tdp_25_action.triggered.connect(lambda: self.profile_manager.apply_preset_tdp(25)) + tray_menu.addAction(tdp_25_action) + tray_menu.addSeparator() quit_action = QAction("Quit", self) @@ -431,7 +441,6 @@ def on_finished(exit_code, exit_status): def set_auto_control(self): if self.radio_auto_control.isChecked(): - # Create QProcess for non-blocking execution process = QProcess(self) def on_finished(exit_code, exit_status): @@ -439,11 +448,13 @@ def on_finished(exit_code, exit_status): print("Auto fan control enabled") else: stderr = process.readAllStandardError().data().decode('utf-8', errors='ignore') - error_msg = f"Error setting automatic fan control (exit code: {exit_code})" - if stderr: - error_msg += f": {stderr}" - print(error_msg) - # Remove from active processes list + if not getattr(self, "_auto_fan_error_shown", False): + self._auto_fan_error_shown = True + msg = "Automatic fan control unavailable (NBFC service not running or not configured)." + if stderr and "Service not running" in stderr: + print(msg) + else: + print(f"{msg} {stderr.strip() or exit_code}") if process in self.active_processes: self.active_processes.remove(process) diff --git a/src/app/nbfc_manager.py b/src/app/nbfc_manager.py index f288750..bc04013 100644 --- a/src/app/nbfc_manager.py +++ b/src/app/nbfc_manager.py @@ -42,7 +42,9 @@ def is_nbfc_configured(): try: # First check if the service can start result = subprocess.run( - ["sudo", "nbfc", "start"], capture_output=True, text=True + ["bin/ryzen-master-commander-helper", "nbfc", "start"], + capture_output=True, + text=True, ) # If there's an error about missing config file, it's not configured return ( @@ -63,11 +65,16 @@ def update_nbfc_configs(parent=None, callback=None): """ process = QProcess(parent) - def on_finished(exit_code, exit_status): + def on_finished( + exit_code, + exit_status, + callback_fn=callback, + process_obj=process, + ): success = exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit - if callback: - callback(success) - process.deleteLater() + if callback_fn: + callback_fn(success) + process_obj.deleteLater() process.finished.connect(on_finished) process.start("pkexec", ["nbfc", "update"]) @@ -137,12 +144,23 @@ def set_nbfc_config(config_name, parent=None, callback=None): """ process = QProcess(parent) - def on_finished(exit_code, exit_status): - stderr = process.readAllStandardError().data().decode('utf-8', errors='ignore') - success = exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit and "ERROR" not in stderr - if callback: - callback(success) - process.deleteLater() + def on_finished( + exit_code, + exit_status, + callback_fn=callback, + process_obj=process, + ): + stderr = process_obj.readAllStandardError().data().decode( + "utf-8", errors="ignore" + ) + success = ( + exit_code == 0 + and exit_status == QProcess.ExitStatus.NormalExit + and "ERROR" not in stderr + ) + if callback_fn: + callback_fn(success) + process_obj.deleteLater() process.finished.connect(on_finished) process.start("pkexec", ["nbfc", "config", "-s", config_name]) @@ -159,12 +177,17 @@ def start_nbfc_service(parent=None, callback=None): """ process = QProcess(parent) - def on_finished(exit_code, exit_status): + def on_finished( + exit_code, + exit_status, + callback_fn=callback, + process_obj=process, + ): # Check if service is actually running after the command success = NBFCManager.is_nbfc_running() - if callback: - callback(success) - process.deleteLater() + if callback_fn: + callback_fn(success) + process_obj.deleteLater() process.finished.connect(on_finished) process.start("pkexec", ["nbfc", "start"]) @@ -193,17 +216,22 @@ def setup_nbfc(parent=None): return True print("NBFC service failed to start, checking configuration...") - # If still not running, update configs silently - NBFCManager.update_nbfc_configs() + # If still not running, update configs silently (async; list may already be from package) + NBFCManager.update_nbfc_configs(parent=parent) # Try to get and apply recommended config automatically recommended = NBFCManager.get_recommended_config() if recommended: - # Apply recommended config silently - if NBFCManager.set_nbfc_config(recommended): - print(f"Applied recommended config: {recommended}") - return NBFCManager.start_nbfc_service() + def on_recommended_set(success): + if success: + print(f"Applied recommended config: {recommended}. Starting service...") + NBFCManager.start_nbfc_service(parent=parent) + else: + print("Failed to apply recommended config.") + + NBFCManager.set_nbfc_config(recommended, parent=parent, callback=on_recommended_set) + return True # Only show dialog if we couldn't find or apply a recommended config print( @@ -214,8 +242,23 @@ def setup_nbfc(parent=None): config_dialog.exec() == QDialog.DialogCode.Accepted and config_dialog.selected_config ): - if NBFCManager.set_nbfc_config(config_dialog.selected_config): - return NBFCManager.start_nbfc_service() + config_name = config_dialog.selected_config + # Chain async: set config first, then start service when config is done + def on_config_set(success): + if success: + print(f"Config '{config_name}' applied. Starting NBFC service...") + NBFCManager.start_nbfc_service(parent=parent, callback=on_service_started) + else: + print(f"Failed to apply config '{config_name}'.") + + def on_service_started(success): + if success: + print("NBFC service is running.") + else: + print("NBFC service failed to start. Try running: sudo nbfc start") + + NBFCManager.set_nbfc_config(config_name, parent=parent, callback=on_config_set) + return True # We started the async chain; service may come up shortly return False diff --git a/src/app/profile_manager.py b/src/app/profile_manager.py index 21a3777..ae77a25 100644 --- a/src/app/profile_manager.py +++ b/src/app/profile_manager.py @@ -13,7 +13,7 @@ QWidget, QVBoxLayout, ) -from PyQt6.QtCore import pyqtSlot, Qt, QProcess +from PyQt6.QtCore import pyqtSlot, Qt, QProcess, QSettings from src.app.system_utils import apply_tdp_settings @@ -47,6 +47,9 @@ def __init__(self): self.current_profile = None self.cached_profiles = self.load_profiles() + # Initialize settings for persisting TDP values + self.settings = QSettings("MerryThieves", "RyzenMasterCommander") + def create_widgets(self, parent): self.parent = parent layout = parent.layout() @@ -78,6 +81,18 @@ def create_widgets(self, parent): # Add the power controls to the main layout layout.addLayout(power_controls_layout) + + # Preset TDP buttons (15W and 25W) + preset_buttons_layout = QHBoxLayout() + btn_15w = QPushButton("15W") + btn_15w.setToolTip("Aplica 15W (boost y sostenido). Tctl 70°C.") + btn_15w.clicked.connect(lambda: self.apply_preset_tdp(15)) + btn_25w = QPushButton("25W") + btn_25w.setToolTip("Aplica 25W (boost y sostenido). Tctl 70°C.") + btn_25w.clicked.connect(lambda: self.apply_preset_tdp(25)) + preset_buttons_layout.addWidget(btn_15w) + preset_buttons_layout.addWidget(btn_25w) + layout.addLayout(preset_buttons_layout) # Collapsible Advanced Settings Section self.advanced_container = QWidget() @@ -209,6 +224,8 @@ def create_widgets(self, parent): # Populate profile dropdown self.update_profile_dropdown() + # No auto-apply on startup: TDP is applied only when the user selects a profile or clicks 15W/25W + def toggle_advanced_section(self): """Toggle visibility of advanced settings section""" if self.advanced_content.isVisible(): @@ -227,6 +244,29 @@ def toggle_profile_section(self): self.profile_content.show() self.profile_toggle_btn.setText("▼ Profile Management") + def apply_preset_tdp(self, watts): + """Apply a preset TDP (15 or 25W) with tctl-temp 70°C. 25W uses --max-performance.""" + use_max_performance = watts == 25 + profile = { + "name": f"Preset {watts}W", + "fast-limit": watts, + "slow-limit": watts, + "slow-time": 60, + "tctl-temp": 70, + "apu-skin-temp": 50, + "max-performance": use_max_performance, + "power-saving": False, + } + self.fast_limit_entry.setText(str(watts)) + self.slow_limit_entry.setText(str(watts)) + self.slow_time_entry.setText("60") + self.tctl_temp_entry.setText("70") + self.apu_skin_temp_entry.setText("50") + self.max_performance_var.setChecked(use_max_performance) + self.power_saving_var.setChecked(False) + apply_tdp_settings(profile, parent=self.parent) + self.save_tdp_settings(profile) + def auto_apply_basic_settings(self): """Auto-apply basic settings when values change""" # Only auto-apply if advanced settings are hidden @@ -239,9 +279,48 @@ def auto_apply_basic_settings(self): } apply_tdp_settings(basic_profile, parent=self.parent) print("Auto-applied basic TDP settings") + + # Save settings for auto-restore on next startup + self.save_tdp_settings(basic_profile) except (ValueError, TypeError) as e: print(f"Error auto-applying settings: {e}") + def save_tdp_settings(self, profile): + """Save TDP settings to persistent storage""" + try: + self.settings.setValue("tdp/fast_limit", profile.get("fast-limit")) + self.settings.setValue("tdp/slow_limit", profile.get("slow-limit")) + self.settings.sync() + print(f"Saved TDP settings: Fast={profile.get('fast-limit')}W, Slow={profile.get('slow-limit')}W") + except Exception as e: + print(f"Error saving TDP settings: {e}") + + def restore_tdp_settings(self): + """Restore and apply saved TDP settings on startup""" + try: + fast_limit = self.settings.value("tdp/fast_limit", type=int) + slow_limit = self.settings.value("tdp/slow_limit", type=int) + + if fast_limit and slow_limit: + # Update UI fields + self.fast_limit_entry.setText(str(fast_limit)) + self.slow_limit_entry.setText(str(slow_limit)) + + # Apply the settings + basic_profile = { + "fast-limit": fast_limit, + "slow-limit": slow_limit, + } + apply_tdp_settings(basic_profile, parent=self.parent) + print(f"Restored and applied TDP settings: Fast={fast_limit}W, Slow={slow_limit}W") + return True + else: + print("No saved TDP settings found") + return False + except Exception as e: + print(f"Error restoring TDP settings: {e}") + return False + def apply_current_settings(self, include_advanced=False): """Apply current TDP settings""" try: @@ -263,6 +342,9 @@ def apply_current_settings(self, include_advanced=False): # Apply the settings apply_tdp_settings(profile, parent=self.parent) + + # Save basic settings (fast/slow limits) for auto-restore + self.save_tdp_settings(profile) except (ValueError, TypeError) as e: print(f"Error applying settings: {e}") @@ -307,32 +389,19 @@ def load_profiles(self): return profiles # Return the loaded profiles def update_profile_dropdown(self): - if self.cached_profiles: + if self.cached_profiles is not None: self.profile_dropdown.clear() + self.profile_dropdown.addItem("— No aplicar —") for profile in self.cached_profiles: self.profile_dropdown.addItem(profile["name"]) + self.profile_dropdown.setCurrentIndex(0) - # Select first profile by default if none is selected - if ( - self.profile_dropdown.currentIndex() == -1 - and self.profile_dropdown.count() > 0 - ): - self.profile_dropdown.setCurrentIndex(0) - - # Fixed method to properly handle the signal def on_profile_select(self, index): - """Handle profile selection from dropdown""" - if ( - index < 0 - or not self.cached_profiles - or index >= len(self.cached_profiles) - ): + """Handle profile selection from dropdown. Index 0 = no aplicar.""" + if index <= 0 or not self.cached_profiles or index > len(self.cached_profiles): return - - selected_profile = self.cached_profiles[index] + selected_profile = self.cached_profiles[index - 1] self.current_profile = selected_profile - - # Update the entries with profile values self.fast_limit_entry.setText(str(self.current_profile["fast-limit"])) self.slow_limit_entry.setText(str(self.current_profile["slow-limit"])) self.slow_time_entry.setText(str(self.current_profile["slow-time"])) @@ -344,9 +413,8 @@ def on_profile_select(self, index): self.current_profile["max-performance"] ) self.power_saving_var.setChecked(self.current_profile["power-saving"]) - - # Apply the profile apply_tdp_settings(self.current_profile, parent=self.parent) + self.save_tdp_settings(self.current_profile) def save_profile(self): profile_name, ok = QInputDialog.getText( diff --git a/src/app/system_utils.py b/src/app/system_utils.py index 67fabbd..270f261 100644 --- a/src/app/system_utils.py +++ b/src/app/system_utils.py @@ -3,19 +3,26 @@ import os from PyQt6.QtCore import QProcess +# Only warn once per session to avoid spamming when nbfc/sensors are missing +_nbfc_warned = False +_sensors_warned = False + def get_system_readings(): + global _nbfc_warned, _sensors_warned # Get temperature and fan speed from NBFC try: output = subprocess.check_output(["nbfc", "status", "-a"], text=True) - except subprocess.CalledProcessError as e: - print(f"Failed to execute 'nbfc status -a': {e}") + except subprocess.CalledProcessError: temp, fan_speed, profile = "n/a", "n/a", "n/a" + if not _nbfc_warned: + _nbfc_warned = True + print("NBFC service not running. Start it or select a config in the app (e.g. Lenovo ThinkPad T14 Gen2).") except FileNotFoundError: - print( - "nbfc command not found. Make sure NoteBook FanControl is installed." - ) temp, fan_speed, profile = "n/a", "n/a", "n/a" + if not _nbfc_warned: + _nbfc_warned = True + print("nbfc not found. Install it for fan control (the installer can do this).") else: temperature_match = re.search(r"Temperature\s+:\s+(\d+\.?\d*)", output) fan_speed_match = re.search(r"Current Fan Speed\s+:\s+(\d+\.?\d*)", output) @@ -28,16 +35,17 @@ def get_system_readings(): profile = ( current_profile_match.group(1) if current_profile_match else "n/a" ) - - # Get power consumption data using sensors + + # Get power consumption data using sensors (lm-sensors) try: sensors_output = subprocess.check_output(["sensors"], text=True) power_match = re.search(r"power1:\s+(\d+\.\d+)\s*W", sensors_output) power = power_match.group(1) if power_match else "n/a" - except (subprocess.CalledProcessError, FileNotFoundError) as e: - print(f"Failed to get power data: {e}") + except (subprocess.CalledProcessError, FileNotFoundError): power = "n/a" - + if not _sensors_warned: + _sensors_warned = True + print("Power data unavailable (install lm-sensors and run 'sensors-detect' for optional power readout).") return temp, fan_speed, profile, power diff --git a/src/main.py b/src/main.py index f61df2f..8d3d6f3 100644 --- a/src/main.py +++ b/src/main.py @@ -54,15 +54,19 @@ def main(): except Exception as e: print(f"Error detecting KDE theme: {e}") - # Another approach for dark mode detection + # Another approach for dark mode detection (PyQt6) if not is_dark_mode: try: - from PyQt5.QtGui import QPalette + from PyQt6.QtGui import QPalette app_palette = app.palette() # If text is lighter than background, we're likely in dark mode - bg_color = app_palette.color(QPalette.Window).lightness() - text_color = app_palette.color(QPalette.WindowText).lightness() + bg_color = app_palette.color( + QPalette.ColorGroup.Active, QPalette.ColorRole.Window + ).lightness() + text_color = app_palette.color( + QPalette.ColorGroup.Active, QPalette.ColorRole.WindowText + ).lightness() is_dark_mode = text_color > bg_color except Exception as e: print(f"Error using palette for theme detection: {e}") diff --git a/src/version.py b/src/version.py index 7666d13..dca6b7c 100644 --- a/src/version.py +++ b/src/version.py @@ -1,7 +1,7 @@ # src/version.py VERSION_MAJOR = 1 -VERSION_MINOR = 0 -VERSION_PATCH = 15 +VERSION_MINOR = 2 +VERSION_PATCH = 0 VERSION_TUPLE = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) __version__ = ".".join(map(str, VERSION_TUPLE)) diff --git a/tdp_profiles/LenovoV15-22W.json b/tdp_profiles/LenovoV15-22W.json new file mode 100644 index 0000000..b2a1990 --- /dev/null +++ b/tdp_profiles/LenovoV15-22W.json @@ -0,0 +1,10 @@ +{ + "name": "LenovoV15-22W", + "fast-limit": 22, + "slow-limit": 22, + "slow-time": 60, + "tctl-temp": 70, + "apu-skin-temp": 50, + "max-performance": true, + "power-saving": false +} \ No newline at end of file diff --git a/tdp_profiles/Por-defecto-15W.json b/tdp_profiles/Por-defecto-15W.json new file mode 100644 index 0000000..90c1bf6 --- /dev/null +++ b/tdp_profiles/Por-defecto-15W.json @@ -0,0 +1,10 @@ +{ + "name": "Por defecto (15W)", + "fast-limit": 15, + "slow-limit": 15, + "slow-time": 60, + "tctl-temp": 70, + "apu-skin-temp": 50, + "max-performance": false, + "power-saving": false +} diff --git a/version.txt b/version.txt index 524cb55..26aaba0 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1.1 +1.2.0