commit c95362ac35ce4b43c978ccc208831aa3a9067f83 Author: root Date: Thu Dec 11 12:53:22 2025 +0100 Initial commit: T14 System Settings GUI and Tray application Features: - CPU governor control (performance/powersave) - Fan level control (L1-L7) with thinkfan integration - Battery charge threshold management (75-80%, 80-90%, 0-100%) - Quick charge to 100% option - Automated battery management (time-based and network-based) - System tray application with auto-start - Privilege elevation via pkexec helper script diff --git a/system-settings-gui.py b/system-settings-gui.py new file mode 100755 index 0000000..2944770 --- /dev/null +++ b/system-settings-gui.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +import tkinter as tk +from tkinter import ttk +import subprocess +import threading + +class SystemSettingsGUI: + def __init__(self, root): + self.root = root + self.root.title("T14 System Settings") + self.root.geometry("500x600") + self.root.configure(bg='#f0f0f0') + + # Title + title = tk.Label(root, text="T14 System Settings", font=("Arial", 16, "bold"), bg='#f0f0f0') + title.pack(pady=10) + + # CPU Governor Section + self.create_section(root, "CPU Governor") + self.gov_var = tk.StringVar() + + gov_frame = tk.Frame(root, bg='#f0f0f0') + gov_frame.pack(pady=5) + tk.Button(gov_frame, text="Performance", width=15, command=lambda: self.set_governor("performance")).pack(side=tk.LEFT, padx=5) + tk.Button(gov_frame, text="Powersave", width=15, command=lambda: self.set_governor("powersave")).pack(side=tk.LEFT, padx=5) + + self.gov_label = tk.Label(root, text="", font=("Arial", 10), bg='#f0f0f0', fg='#0066cc') + self.gov_label.pack() + + # Fan Level Section + self.create_section(root, "Fan Level") + fan_frame = tk.Frame(root, bg='#f0f0f0') + fan_frame.pack(pady=5) + + for level in range(1, 8): + tk.Button(fan_frame, text=f"L{level}", width=5, command=lambda l=level: self.set_fan_level(l)).pack(side=tk.LEFT, padx=2) + + self.fan_label = tk.Label(root, text="", font=("Arial", 10), bg='#f0f0f0', fg='#0066cc') + self.fan_label.pack() + + # Battery Thresholds Section + self.create_section(root, "Battery Charging - Manual") + + battery_frame = tk.Frame(root, bg='#f0f0f0') + battery_frame.pack(pady=5) + tk.Button(battery_frame, text="Conservative (75-80%)", width=20, command=lambda: self.set_battery(75, 80)).pack(pady=5) + tk.Button(battery_frame, text="Moderate (80-90%)", width=20, command=lambda: self.set_battery(80, 90)).pack(pady=5) + tk.Button(battery_frame, text="Full Charge (0-100%)", width=20, command=lambda: self.set_battery(0, 100)).pack(pady=5) + tk.Button(battery_frame, text="⚡ Charge to 100% NOW", width=20, bg='#ffaa00', command=self.charge_full_now).pack(pady=5) + + self.battery_label = tk.Label(root, text="", font=("Arial", 10), bg='#f0f0f0', fg='#0066cc') + self.battery_label.pack() + + # Automated Battery Management Section + self.create_section(root, "Automated Battery Management") + + auto_frame = tk.Frame(root, bg='#f0f0f0') + auto_frame.pack(pady=5) + tk.Button(auto_frame, text="Setup Time-Based (3:45 PM)", width=25, command=self.setup_time_based).pack(pady=3) + tk.Button(auto_frame, text="Setup Network-Based", width=25, command=self.setup_network_based).pack(pady=3) + + self.auto_label = tk.Label(root, text="Not configured", font=("Arial", 9), bg='#f0f0f0', fg='#666666') + self.auto_label.pack() + + # Status Section + self.create_section(root, "Current Status") + self.status_label = tk.Label(root, text="Loading...", font=("Arial", 9), bg='#f0f0f0', justify=tk.LEFT) + self.status_label.pack(pady=10) + + # Refresh button + tk.Button(root, text="Refresh Status", command=self.refresh_all).pack(pady=10) + + # Start refresh thread + self.refresh_all() + + def create_section(self, parent, title): + sep = ttk.Separator(parent, orient='horizontal') + sep.pack(fill='x', pady=10) + label = tk.Label(parent, text=title, font=("Arial", 12, "bold"), bg='#f0f0f0') + label.pack() + + def run_command(self, cmd): + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5) + return result.stdout.strip() + except Exception as e: + return f"Error: {e}" + + def set_governor(self, gov): + self.run_command(f"pkexec /usr/local/bin/system-settings-helper.sh cpu-governor {gov}") + self.update_governor_label() + + def update_governor_label(self): + gov = self.run_command("cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor") + self.gov_label.config(text=f"Current: {gov.upper()}") + + def set_fan_level(self, level): + if level == 2: + # Level 2 = re-enable automatic control via thinkfan + self.run_command("pkexec /usr/local/bin/system-settings-helper.sh fan-start-thinkfan") + self.fan_label.config(text="Fan: Automatic (thinkfan managing)") + else: + # Other levels = manual control, stop thinkfan + self.run_command("pkexec /usr/local/bin/system-settings-helper.sh fan-stop-thinkfan") + self.run_command(f"pkexec /usr/local/bin/system-settings-helper.sh fan-level {level}") + self.update_fan_label() + + def update_fan_label(self): + fan_info = self.run_command("grep -E 'speed|level' /proc/acpi/ibm/fan | head -2") + self.fan_label.config(text=fan_info) + + def set_battery(self, start, end): + # Get current capacity and status + capacity = int(self.run_command("cat /sys/class/power_supply/BAT0/capacity")) + status = self.run_command("cat /sys/class/power_supply/BAT0/status") + + # If currently charging and new end threshold is below current capacity, stop charging first + if status == "Charging" and end < capacity: + # Set end threshold below current to stop charging + self.run_command(f"pkexec /usr/local/bin/system-settings-helper.sh battery-end {capacity - 1}") + import time + time.sleep(0.5) + + # Now set the desired thresholds + self.run_command(f"pkexec /usr/local/bin/system-settings-helper.sh battery-start {start}") + self.run_command(f"pkexec /usr/local/bin/system-settings-helper.sh battery-end {end}") + self.update_battery_label() + + def update_battery_label(self): + start = self.run_command("cat /sys/class/power_supply/BAT0/charge_control_start_threshold") + end = self.run_command("cat /sys/class/power_supply/BAT0/charge_control_end_threshold") + capacity = self.run_command("cat /sys/class/power_supply/BAT0/capacity") + self.battery_label.config(text=f"Thresholds: {start}-{end}% | Current: {capacity}%") + + def charge_full_now(self): + # Set to full range to force charging + self.run_command("pkexec /usr/local/bin/system-settings-helper.sh battery-start 0") + self.run_command("pkexec /usr/local/bin/system-settings-helper.sh battery-end 100") + + # Wait a moment and check + import time + time.sleep(0.5) + + capacity = self.run_command("cat /sys/class/power_supply/BAT0/capacity") + status = self.run_command("cat /sys/class/power_supply/BAT0/status") + + # Visual feedback + self.battery_label.config(text=f"⚡ Charging to 100% | Status: {status} | Current: {capacity}%", fg='#ff6600') + + # Refresh after a second to show updated status + self.root.after(2000, self.update_battery_label) + + def refresh_all(self): + thread = threading.Thread(target=self._refresh_status) + thread.daemon = True + thread.start() + + def _refresh_status(self): + temp = self.run_command("cat /sys/class/hwmon/hwmon5/temp1_input | awk '{print int($1/1000)}'") + gov = self.run_command("cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor") + fan = self.run_command("grep '^speed' /proc/acpi/ibm/fan | awk '{print $NF}'") + capacity = self.run_command("cat /sys/class/power_supply/BAT0/capacity") + status = self.run_command("cat /sys/class/power_supply/BAT0/status") + + status_text = f""" +CPU Temperature: {temp}°C +Governor: {gov.upper()} +Fan Speed: {fan} RPM + +Battery: {capacity}% +Status: {status} + """ + self.status_label.config(text=status_text) + self.update_governor_label() + self.update_fan_label() + self.update_battery_label() + + def setup_time_based(self): + # Create a dialog for time-based setup + dialog = tk.Toplevel(self.root) + dialog.title("Time-Based Battery Management") + dialog.geometry("400x250") + dialog.configure(bg='#f0f0f0') + + tk.Label(dialog, text="Time-Based Battery Management", font=("Arial", 12, "bold"), bg='#f0f0f0').pack(pady=10) + tk.Label(dialog, text="At 3:45 PM: Switch to 0-100% charging\nBefore 3:45 PM: Keep 75-80% charging", bg='#f0f0f0').pack(pady=10) + + tk.Label(dialog, text="Time threshold (HH:MM):", bg='#f0f0f0').pack() + time_entry = tk.Entry(dialog, width=10) + time_entry.insert(0, "15:45") + time_entry.pack(pady=5) + + def confirm_time_based(): + threshold_time = time_entry.get() + self.install_time_based_management(threshold_time) + dialog.destroy() + + tk.Button(dialog, text="Install", command=confirm_time_based).pack(pady=10) + + def setup_network_based(self): + # Create a dialog for network-based setup + dialog = tk.Toplevel(self.root) + dialog.title("Network-Based Battery Management") + dialog.geometry("450x250") + dialog.configure(bg='#f0f0f0') + + tk.Label(dialog, text="Network-Based Battery Management", font=("Arial", 12, "bold"), bg='#f0f0f0').pack(pady=10) + tk.Label(dialog, text="Logic:\n• At office before 3:45 PM → 75-80% (conservative)\n• At office after 3:45 PM → 0-100% (full charge)\n• At home (not on office WiFi) → 75-80% (conservative)", + justify=tk.LEFT, bg='#f0f0f0').pack(pady=10) + + tk.Label(dialog, text="Office WiFi SSID:", bg='#f0f0f0').pack() + office_entry = tk.Entry(dialog, width=30) + office_entry.pack(pady=5) + + tk.Label(dialog, text="Time threshold (HH:MM):", bg='#f0f0f0').pack() + time_entry = tk.Entry(dialog, width=10) + time_entry.insert(0, "15:45") + time_entry.pack(pady=5) + + def confirm_network_based(): + office_ssid = office_entry.get() + threshold_time = time_entry.get() + if office_ssid: + self.install_network_based_management(office_ssid, threshold_time) + dialog.destroy() + + tk.Button(dialog, text="Install", command=confirm_network_based).pack(pady=10) + + def install_time_based_management(self, threshold_time): + # Create systemd timer for time-based switching + script_content = f"""#!/bin/bash +# Switch to full charging at {threshold_time} +echo 0 > /sys/class/power_supply/BAT0/charge_control_start_threshold +echo 100 > /sys/class/power_supply/BAT0/charge_control_end_threshold +echo "[$(date)] Switched to 0-100% charging (time-based)" >> /var/log/battery-thresholds.log +""" + self.run_command(f"sudo tee /usr/local/bin/battery-charge-full.sh > /dev/null << 'EOF'\n{script_content}EOF") + self.run_command("sudo chmod +x /usr/local/bin/battery-charge-full.sh") + + # Create systemd timer + timer_unit = f"""[Unit] +Description=Switch battery to full charge at {threshold_time} +After=network.target + +[Timer] +OnCalendar=*-*-* {threshold_time}:00 +Persistent=true + +[Install] +WantedBy=timers.target +""" + self.run_command(f"sudo tee /etc/systemd/system/battery-charge-full.timer > /dev/null << 'EOF'\n{timer_unit}EOF") + + service_unit = """[Unit] +Description=Battery full charge service +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/battery-charge-full.sh +""" + self.run_command(f"sudo tee /etc/systemd/system/battery-charge-full.service > /dev/null << 'EOF'\n{service_unit}EOF") + + self.run_command("sudo systemctl daemon-reload") + self.run_command("sudo systemctl enable battery-charge-full.timer") + self.run_command("sudo systemctl start battery-charge-full.timer") + + self.auto_label.config(text=f"✓ Time-based enabled (switch at {threshold_time})", fg='#009900') + + def install_network_based_management(self, office_ssid, threshold_time): + # Create network monitoring script + script_content = f"""#!/bin/bash +# Network-based battery management + +OFFICE_SSID="{office_ssid}" +THRESHOLD_TIME="{threshold_time}" + +CURRENT_SSID=$(nmcli -t -f active,ssid dev wifi | grep '^yes' | cut -d: -f2) +CURRENT_TIME=$(date +%H:%M) + +if [ "$CURRENT_SSID" = "$OFFICE_SSID" ]; then + # At office - check time + if [ "$CURRENT_TIME" \>= "$THRESHOLD_TIME" ]; then + # After threshold time - charge to full before leaving + echo 0 > /sys/class/power_supply/BAT0/charge_control_start_threshold + echo 100 > /sys/class/power_supply/BAT0/charge_control_end_threshold + echo "[$(date)] Office WiFi + after $THRESHOLD_TIME - 0-100% charging (full before leaving)" >> /var/log/battery-thresholds.log + else + # Before threshold time - conservative charging + echo 75 > /sys/class/power_supply/BAT0/charge_control_start_threshold + echo 80 > /sys/class/power_supply/BAT0/charge_control_end_threshold + echo "[$(date)] Office WiFi + before $THRESHOLD_TIME - 75-80% charging (conservative)" >> /var/log/battery-thresholds.log + fi +else + # Not on office WiFi - assume at home, conservative charging + echo 75 > /sys/class/power_supply/BAT0/charge_control_start_threshold + echo 80 > /sys/class/power_supply/BAT0/charge_control_end_threshold + echo "[$(date)] Not on office WiFi (at home) - 75-80% charging (conservative)" >> /var/log/battery-thresholds.log +fi +""" + self.run_command(f"sudo tee /usr/local/bin/battery-network-mgmt.sh > /dev/null << 'EOFSCRIPT'\n{script_content}EOFSCRIPT") + self.run_command("sudo chmod +x /usr/local/bin/battery-network-mgmt.sh") + + # Create dispatcher script for NetworkManager + self.run_command(f"sudo tee /etc/NetworkManager/dispatcher.d/99-battery-management > /dev/null << 'EOF'\n#!/bin/bash\n/usr/local/bin/battery-network-mgmt.sh\nEOF") + self.run_command("sudo chmod +x /etc/NetworkManager/dispatcher.d/99-battery-management") + + # Also create systemd timer to check every 5 minutes as backup + timer_content = """[Unit] +Description=Battery Network Management Timer +After=network.target + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min +Persistent=true + +[Install] +WantedBy=timers.target +""" + self.run_command(f"sudo tee /etc/systemd/system/battery-network-check.timer > /dev/null << 'EOF'\n{timer_content}EOF") + + service_content = """[Unit] +Description=Battery Network Management Check +After=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/battery-network-mgmt.sh +""" + self.run_command(f"sudo tee /etc/systemd/system/battery-network-check.service > /dev/null << 'EOF'\n{service_content}EOF") + + self.run_command("sudo systemctl daemon-reload") + self.run_command("sudo systemctl enable battery-network-check.timer") + self.run_command("sudo systemctl start battery-network-check.timer") + + # Run once immediately + self.run_command("sudo /usr/local/bin/battery-network-mgmt.sh") + + self.auto_label.config(text=f"✓ Network-based enabled (Office: {office_ssid}, at {threshold_time})", fg='#009900') + +if __name__ == "__main__": + root = tk.Tk() + app = SystemSettingsGUI(root) + root.mainloop() diff --git a/system-settings-tray.py b/system-settings-tray.py new file mode 100755 index 0000000..275aba4 --- /dev/null +++ b/system-settings-tray.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +import tkinter as tk +from tkinter import Menu +import subprocess +import threading +import sys + +# Try to import pystray for system tray +try: + from PIL import Image, ImageDraw + import pystray + HAS_TRAY = True +except ImportError: + print("Installing required packages for system tray...") + subprocess.run([sys.executable, "-m", "pip", "install", "pystray", "pillow"], check=True) + from PIL import Image, ImageDraw + import pystray + HAS_TRAY = True + +class SystemTrayApp: + def __init__(self): + self.icon = None + self.current_status = {} + self.update_status() + + def run_command(self, cmd): + try: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5) + return result.stdout.strip() + except Exception as e: + return "" + + def update_status(self): + """Get current system status""" + self.current_status = { + 'temp': self.run_command("cat /sys/class/hwmon/hwmon5/temp1_input | awk '{print int($1/1000)}'"), + 'governor': self.run_command("cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + 'battery': self.run_command("cat /sys/class/power_supply/BAT0/capacity"), + 'charging': self.run_command("cat /sys/class/power_supply/BAT0/status"), + 'fan': self.run_command("grep '^level' /proc/acpi/ibm/fan | awk '{print $NF}'") + } + + def create_image(self): + """Create system tray icon""" + # Create a simple icon + image = Image.new('RGB', (64, 64), color='black') + draw = ImageDraw.Draw(image) + draw.ellipse([16, 16, 48, 48], fill='#0066cc', outline='white') + return image + + def open_full_gui(self, icon=None, item=None): + """Open the main GUI""" + subprocess.Popen(['python3', '/home/rwiegand/Nextcloud/entwicklung/Werkzeuge/battery_management/system-settings-gui.py']) + + def set_governor(self, gov): + subprocess.run(f"pkexec /usr/local/bin/system-settings-helper.sh cpu-governor {gov}", shell=True) + self.update_status() + if self.icon: + self.icon.title = self.get_tooltip() + + def set_battery(self, start, end): + subprocess.run(f"pkexec /usr/local/bin/system-settings-helper.sh battery-start {start}", shell=True) + subprocess.run(f"pkexec /usr/local/bin/system-settings-helper.sh battery-end {end}", shell=True) + self.update_status() + if self.icon: + self.icon.title = self.get_tooltip() + + def charge_now(self): + subprocess.run("pkexec /usr/local/bin/system-settings-helper.sh battery-start 0", shell=True) + subprocess.run("pkexec /usr/local/bin/system-settings-helper.sh battery-end 100", shell=True) + self.update_status() + if self.icon: + self.icon.title = self.get_tooltip() + + def set_fan(self, level): + if level == 2: + # Level 2 = automatic control + subprocess.run("pkexec /usr/local/bin/system-settings-helper.sh fan-start-thinkfan", shell=True) + else: + # Other levels = manual control + subprocess.run("pkexec /usr/local/bin/system-settings-helper.sh fan-stop-thinkfan", shell=True) + subprocess.run(f"pkexec /usr/local/bin/system-settings-helper.sh fan-level {level}", shell=True) + self.update_status() + if self.icon: + self.icon.title = self.get_tooltip() + + def get_tooltip(self): + """Generate tooltip text""" + return f"T14: {self.current_status['temp']}°C | {self.current_status['governor']} | {self.current_status['battery']}% {self.current_status['charging']}" + + def create_menu(self): + """Create system tray menu""" + return pystray.Menu( + pystray.MenuItem("T14 System Settings", pystray.Menu.SEPARATOR), + pystray.MenuItem("Open Full GUI", self.open_full_gui, default=True), + pystray.MenuItem("CPU Governor", pystray.Menu( + pystray.MenuItem("Performance", lambda: self.set_governor("performance")), + pystray.MenuItem("Powersave", lambda: self.set_governor("powersave")) + )), + pystray.MenuItem("Fan Level", pystray.Menu( + pystray.MenuItem("Level 1", lambda: self.set_fan(1)), + pystray.MenuItem("Level 2", lambda: self.set_fan(2)), + pystray.MenuItem("Level 3", lambda: self.set_fan(3)), + pystray.MenuItem("Level 4", lambda: self.set_fan(4)), + pystray.MenuItem("Level 5", lambda: self.set_fan(5)), + pystray.MenuItem("Level 6", lambda: self.set_fan(6)), + pystray.MenuItem("Level 7", lambda: self.set_fan(7)) + )), + pystray.MenuItem("Battery", pystray.Menu( + pystray.MenuItem("⚡ Charge to 100% NOW", self.charge_now), + pystray.MenuItem("Conservative (75-80%)", lambda: self.set_battery(75, 80)), + pystray.MenuItem("Moderate (80-90%)", lambda: self.set_battery(80, 90)), + pystray.MenuItem("Full (0-100%)", lambda: self.set_battery(0, 100)) + )), + pystray.MenuItem("Refresh Status", lambda: self.refresh_status()), + pystray.MenuItem("Quit", self.quit_app) + ) + + def refresh_status(self): + self.update_status() + if self.icon: + self.icon.title = self.get_tooltip() + + def quit_app(self, icon=None, item=None): + if self.icon: + self.icon.stop() + + def run(self): + """Run the system tray application""" + self.icon = pystray.Icon( + "t14_settings", + self.create_image(), + self.get_tooltip(), + self.create_menu() + ) + + # Update status every 30 seconds + def update_loop(): + import time + while True: + time.sleep(30) + self.refresh_status() + + thread = threading.Thread(target=update_loop, daemon=True) + thread.start() + + self.icon.run() + +if __name__ == "__main__": + app = SystemTrayApp() + app.run()