diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 1b7a3ac..8d6deda 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -89,7 +89,7 @@ jobs: Section: utils Priority: optional Architecture: all - Depends: python3, python3-gi, python3-dbus, gir1.2-gtk-3.0, gir1.2-appindicator3-0.1, gir1.2-notify-0.7, gir1.2-upowerglib-1.0, polkitd, udev + Depends: python3, python3-gi, python3-dbus, gir1.2-gtk-3.0, gir1.2-appindicator3-0.1, gir1.2-notify-0.7, gir1.2-upowerglib-1.0, polkitd, udev, mesa-utils Maintainer: Matt Hartley Description: Manual GPU switching and automatic power profile management System tray application for manual GPU mode switching on NVIDIA Optimus diff --git a/GPUMode.py b/GPUMode.py index 77ea7d2..a8e7f62 100644 --- a/GPUMode.py +++ b/GPUMode.py @@ -11,13 +11,30 @@ import sys import fcntl import logging +import json from pathlib import Path -VERSION = "1.01" +VERSION = "1.02" LOCK_FILE = "/tmp/gpumode.lock" LOG_DIR = Path.home() / ".local/share/gpumode" LOG_FILE = LOG_DIR / "gpumode.log" SETTINGS_FILE = LOG_DIR / "settings.conf" +STATE_FILE = LOG_DIR / "state.json" + +# Power consumption indicators +POWER_INDICATORS = { + 'integrated': '⚡', + 'hybrid': '⚡⚡', + 'nvidia': '⚡⚡⚡' +} + +# Icon names for each mode (used in tray) +MODE_ICONS = { + 'integrated': 'drive-harddisk-solidstate-symbolic', + 'hybrid': 'video-single-display-symbolic', + 'nvidia': 'video-display-symbolic' +} + class GPUIndicator: def __init__(self): @@ -44,18 +61,24 @@ def __init__(self): self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) self.switching = False - self.current_mode = self.get_current_mode() + + # State tracking for reboot indicator + self.boot_mode = self.get_current_mode() # Actual GPU from glxinfo + self.target_mode = None # Pending mode (differs from boot_mode if reboot needed) + self.load_state() # Load target_mode and check for reboot + self.update_icon() - + self.power_prompts_enabled = self.load_power_prompts_setting() - + self.indicator.set_menu(self.build_menu()) - + self.upower_client = UPowerGlib.Client.new() self.upower_client.connect('notify::on-battery', self.on_power_changed) self.last_power_state = self.upower_client.get_on_battery() - - logging.info(f"Initial GPU mode: {self.current_mode}") + + logging.info(f"Boot GPU mode: {self.boot_mode}") + logging.info(f"Target GPU mode: {self.target_mode}") logging.info(f"Initial power state: {'battery' if self.last_power_state else 'AC'}") logging.info(f"Power prompts enabled: {self.power_prompts_enabled}") @@ -66,18 +89,18 @@ def check_startup_mismatch(self): if not self.power_prompts_enabled: logging.info("Power prompts disabled, skipping startup check") return False - + on_battery = self.upower_client.get_on_battery() - - if on_battery and self.current_mode in ['nvidia', 'hybrid']: + + if on_battery and self.boot_mode in ['nvidia', 'hybrid']: logging.info("Startup mismatch: On battery but using NVIDIA/Hybrid") self.prompt_switch_on_battery() - elif not on_battery and self.current_mode == 'integrated': + elif not on_battery and self.boot_mode == 'integrated': logging.info("Startup mismatch: On AC but using Integrated") self.prompt_switch_on_ac() else: logging.info("No startup mismatch detected") - + return False def load_power_prompts_setting(self): @@ -99,6 +122,75 @@ def save_power_prompts_setting(self, enabled): except Exception as e: logging.error(f"Failed to save power prompts setting: {e}") + def get_system_boot_time(self): + """Get system boot time from /proc/stat""" + try: + with open('/proc/stat', 'r') as f: + for line in f: + if line.startswith('btime'): + return int(line.split()[1]) + except Exception as e: + logging.error(f"Failed to get boot time: {e}") + return None + + def load_state(self): + """Load target_mode from state file, clearing if reboot occurred""" + if not STATE_FILE.exists(): + self.target_mode = None + return + + try: + state = json.loads(STATE_FILE.read_text()) + saved_boot_time = state.get('boot_time') + saved_target_mode = state.get('target_mode') + current_boot_time = self.get_system_boot_time() + + # If boot time changed, system rebooted - clear pending state + if saved_boot_time != current_boot_time: + logging.info("Reboot detected, clearing pending state") + self.target_mode = None + self.save_state() + return + + # If target_mode matches boot_mode, no pending change + if saved_target_mode == self.boot_mode: + logging.info("Target mode matches boot mode, no pending change") + self.target_mode = None + self.save_state() + return + + self.target_mode = saved_target_mode + logging.info(f"Loaded pending target mode: {self.target_mode}") + + except Exception as e: + logging.error(f"Failed to load state: {e}") + self.target_mode = None + + def save_state(self): + """Save target_mode and boot time to state file""" + try: + state = { + 'boot_time': self.get_system_boot_time(), + 'target_mode': self.target_mode + } + STATE_FILE.write_text(json.dumps(state)) + logging.info(f"Saved state: target_mode={self.target_mode}") + except Exception as e: + logging.error(f"Failed to save state: {e}") + + def has_pending_change(self): + """Check if there's a pending GPU mode change requiring reboot""" + return self.target_mode is not None and self.target_mode != self.boot_mode + + def set_target_mode(self, mode): + """Set target mode and save state""" + if mode == self.boot_mode: + # Switching back to boot mode - no reboot needed + self.target_mode = None + else: + self.target_mode = mode + self.save_state() + def toggle_power_prompts(self, widget): """Toggle power change prompts on/off""" self.power_prompts_enabled = widget.get_active() @@ -132,7 +224,7 @@ def on_power_changed(self, client, pspec): def prompt_switch_on_battery(self): """Prompt to switch to integrated when on battery""" - if self.current_mode == "integrated": + if self.boot_mode == "integrated": logging.info("Already on integrated, skipping battery prompt") return @@ -160,7 +252,7 @@ def prompt_switch_on_battery(self): def prompt_switch_on_ac(self): """Prompt to switch to Hybrid when on AC""" - if self.current_mode == "hybrid": + if self.boot_mode == "hybrid": logging.info("Already on Hybrid, skipping AC prompt") return @@ -191,38 +283,44 @@ def switch_and_reboot(self, mode): self.switching = True self.update_icon() self.indicator.set_menu(self.build_menu()) - + notification = Notify.Notification.new( "GPUMode", f"Switching to {mode} mode and rebooting...", "emblem-synchronizing" ) notification.show() - + def switch_reboot_thread(): try: cmd = ['pkexec', 'envycontrol', '-s', mode] if mode == 'hybrid': - cmd.extend(['--rtd3', '2']) - + cmd.extend(['--rtd3', '3']) + result = subprocess.run( cmd, - capture_output=True, - text=True, + capture_output=True, + text=True, timeout=60 ) - + if result.returncode == 0: logging.info(f"Switched to {mode}, initiating reboot") - subprocess.run(['systemctl', 'reboot'], timeout=5) + GLib.idle_add(self.set_target_mode, mode) + # Reboot requires privileges - use pkexec + reboot_result = subprocess.run(['pkexec', 'systemctl', 'reboot'], timeout=60) + if reboot_result.returncode != 0: + # Reboot cancelled/failed - update UI to show pending state + logging.info("Reboot cancelled or failed, showing pending state") + GLib.idle_add(self.switch_complete, mode, True, None) else: logging.error(f"Failed to switch: {result.stderr}") GLib.idle_add(self.switch_complete, mode, False, result.stderr) - + except Exception as e: logging.error(f"Switch and reboot error: {e}") GLib.idle_add(self.switch_complete, mode, False, str(e)) - + thread = threading.Thread(target=switch_reboot_thread) thread.daemon = True thread.start() @@ -264,9 +362,10 @@ def get_current_mode(self): logging.info("Detected NVIDIA-only mode via glxinfo (BIOS-set)") return "nvidia" elif 'AMD' in renderer and 'NVIDIA' not in renderer: - # AMD only - check envycontrol to confirm integrated mode + # AMD only in glxinfo - query envycontrol for actual mode + # (hybrid mode uses AMD by default, NVIDIA on-demand) try: - result = subprocess.run(['envycontrol', '--query'], + result = subprocess.run(['envycontrol', '--query'], capture_output=True, text=True, timeout=2) if result.returncode == 0: mode = result.stdout.strip().lower() @@ -274,7 +373,7 @@ def get_current_mode(self): return mode except: pass - logging.info("Detected integrated mode via glxinfo") + logging.info("Detected integrated mode via glxinfo (AMD only)") return "integrated" elif 'AMD' in renderer and 'NVIDIA' in renderer: # Both GPUs in renderer string - likely hybrid @@ -300,126 +399,140 @@ def update_icon(self): """Update tray icon based on current state""" if self.switching: self.indicator.set_icon("emblem-synchronizing-symbolic") - elif self.current_mode == "integrated": - self.indicator.set_icon("drive-harddisk-solidstate-symbolic") - elif self.current_mode == "nvidia": - self.indicator.set_icon("video-display-symbolic") - elif self.current_mode == "hybrid": - self.indicator.set_icon("video-single-display-symbolic") + elif self.boot_mode in MODE_ICONS: + self.indicator.set_icon(MODE_ICONS[self.boot_mode]) else: self.indicator.set_icon("dialog-question-symbolic") def refresh_mode(self): - """Refresh current GPU mode""" - if not self.switching: - old_mode = self.current_mode - self.current_mode = self.get_current_mode() - if old_mode != self.current_mode: - logging.info(f"GPU mode changed: {old_mode} -> {self.current_mode}") - self.update_icon() - self.indicator.set_menu(self.build_menu()) + """Refresh menu state (boot_mode is fixed at startup, only changes after reboot)""" + # boot_mode is intentionally NOT refreshed here - it represents the actual + # GPU mode at boot time. envycontrol config may change before reboot, + # but the actual GPU doesn't change until reboot. + pass def build_menu(self): """Build the indicator menu""" + # Reload setting fresh each time menu is built + self.power_prompts_enabled = self.load_power_prompts_setting() + menu = Gtk.Menu() - - menu.connect('show', lambda _: self.refresh_mode()) - + + # Status line if self.switching: status = Gtk.MenuItem(label='━━━ SWITCHING... ━━━') else: - status = Gtk.MenuItem(label=f'━━━ Current: {self.current_mode.upper()} ━━━') + status = Gtk.MenuItem(label=f'ACTIVE MODE: {self.boot_mode.upper()}') status.set_sensitive(False) menu.append(status) - + menu.append(Gtk.SeparatorMenuItem()) - - # If in NVIDIA mode, show blocking message - if self.current_mode == 'nvidia': - blocked_warning = Gtk.MenuItem(label='⚠ NVIDIA Mode Active') - blocked_warning.set_sensitive(False) - menu.append(blocked_warning) - - blocked_msg = Gtk.MenuItem(label='Set BIOS to Hybrid (F2) to enable switching') - blocked_msg.set_sensitive(False) - menu.append(blocked_msg) - - menu.append(Gtk.SeparatorMenuItem()) - - # Show all modes as disabled - integrated = Gtk.MenuItem(label='⚪ Integrated GPU') + + # If in NVIDIA mode (BIOS-set), show switchable modes as disabled + if self.boot_mode == 'nvidia': + # Integrated - disabled in NVIDIA mode + integrated = Gtk.CheckMenuItem(label=f'Integrated {POWER_INDICATORS["integrated"]}') + integrated.set_active(False) integrated.set_sensitive(False) menu.append(integrated) - - hybrid = Gtk.MenuItem(label='⚪ Hybrid Mode') + + # Hybrid - disabled in NVIDIA mode + hybrid = Gtk.CheckMenuItem(label=f'Hybrid {POWER_INDICATORS["hybrid"]}') + hybrid.set_active(False) hybrid.set_sensitive(False) menu.append(hybrid) - - nvidia = Gtk.MenuItem(label='● NVIDIA GPU (ACTIVE)') - nvidia.set_sensitive(False) + + # Info message at bottom of switchable section + bios_msg = Gtk.MenuItem(label=' Set BIOS to Hybrid (F2) to enable switching') + bios_msg.set_sensitive(False) + menu.append(bios_msg) + + menu.append(Gtk.SeparatorMenuItem()) + + # NVIDIA - active and white text (sensitive=True but not clickable) + nvidia = Gtk.CheckMenuItem(label=f'NVIDIA {POWER_INDICATORS["nvidia"]}') + nvidia.set_active(True) + # Keep sensitive for white text, but don't connect a handler menu.append(nvidia) else: - # Normal mode - Warning about NVIDIA mode - warning = Gtk.MenuItem(label='⚠ NVIDIA mode: Use BIOS (F2)') - warning.set_sensitive(False) - menu.append(warning) - - menu.append(Gtk.SeparatorMenuItem()) - - # Integrated - switchable - integrated = Gtk.MenuItem( - label='⚪ Integrated GPU' if self.current_mode != 'integrated' - else '● Integrated GPU (ACTIVE)' - ) - integrated.connect('activate', self.switch_integrated) - if self.current_mode == 'integrated' or self.switching: + # Normal mode - switchable between integrated and hybrid + # User's selection is target_mode if set, otherwise boot_mode + selected_mode = self.target_mode if self.target_mode else self.boot_mode + + # Integrated + integrated_label = f'Integrated {POWER_INDICATORS["integrated"]}' + if selected_mode == 'integrated' and self.boot_mode != 'integrated': + integrated_label += ' (ON REBOOT)' + integrated = Gtk.CheckMenuItem(label=integrated_label) + integrated.set_active(selected_mode == 'integrated') + integrated.connect('toggled', self.on_integrated_toggled) + if self.switching: integrated.set_sensitive(False) menu.append(integrated) - - # Hybrid - switchable - hybrid = Gtk.MenuItem( - label='⚪ Hybrid Mode' if self.current_mode != 'hybrid' - else '● Hybrid Mode (ACTIVE)' - ) - hybrid.connect('activate', self.switch_hybrid) - if self.current_mode == 'hybrid' or self.switching: + + # Hybrid + hybrid_label = f'Hybrid {POWER_INDICATORS["hybrid"]}' + if selected_mode == 'hybrid' and self.boot_mode != 'hybrid': + hybrid_label += ' (ON REBOOT)' + hybrid = Gtk.CheckMenuItem(label=hybrid_label) + hybrid.set_active(selected_mode == 'hybrid') + hybrid.connect('toggled', self.on_hybrid_toggled) + if self.switching: hybrid.set_sensitive(False) menu.append(hybrid) - - # NVIDIA - show status only, not switchable - nvidia = Gtk.MenuItem(label='⚪ NVIDIA GPU') + + menu.append(Gtk.SeparatorMenuItem()) + + # NVIDIA - info only, not switchable from software + nvidia = Gtk.MenuItem(label=f'NVIDIA {POWER_INDICATORS["nvidia"]}') nvidia.set_sensitive(False) menu.append(nvidia) - + + nvidia_info = Gtk.MenuItem(label=' Set in BIOS (F2)') + nvidia_info.set_sensitive(False) + menu.append(nvidia_info) + menu.append(Gtk.SeparatorMenuItem()) - + power_prompts_item = Gtk.CheckMenuItem(label='Prompt on Power Change') power_prompts_item.set_active(self.power_prompts_enabled) - power_prompts_item.connect('activate', self.toggle_power_prompts) - if self.switching or self.current_mode == 'nvidia': + power_prompts_item.connect('toggled', self.toggle_power_prompts) + if self.switching or self.boot_mode == 'nvidia': power_prompts_item.set_sensitive(False) menu.append(power_prompts_item) - + + menu.append(Gtk.SeparatorMenuItem()) + about_item = Gtk.MenuItem(label='About') about_item.connect('activate', self.show_about) menu.append(about_item) - - menu.append(Gtk.SeparatorMenuItem()) - + quit_item = Gtk.MenuItem(label='Quit') quit_item.connect('activate', Gtk.main_quit) if self.switching: quit_item.set_sensitive(False) menu.append(quit_item) - + menu.show_all() return menu - def switch_integrated(self, _): - self.switch_gpu('integrated') + def on_integrated_toggled(self, widget): + """Handle integrated mode toggle""" + selected_mode = self.target_mode if self.target_mode else self.boot_mode + if widget.get_active() and selected_mode != 'integrated': + self.switch_gpu('integrated') + elif not widget.get_active() and selected_mode == 'integrated': + # User unchecked the active mode - recheck it + widget.set_active(True) - def switch_hybrid(self, _): - self.switch_gpu('hybrid') + def on_hybrid_toggled(self, widget): + """Handle hybrid mode toggle""" + selected_mode = self.target_mode if self.target_mode else self.boot_mode + if widget.get_active() and selected_mode != 'hybrid': + self.switch_gpu('hybrid') + elif not widget.get_active() and selected_mode == 'hybrid': + # User unchecked the active mode - recheck it + widget.set_active(True) def switch_gpu(self, mode): """Switch GPU mode""" @@ -442,7 +555,7 @@ def switch_thread(): try: cmd = ['pkexec', 'envycontrol', '-s', mode] if mode == 'hybrid': - cmd.extend(['--rtd3', '2']) + cmd.extend(['--rtd3', '3']) result = subprocess.run( cmd, @@ -487,33 +600,41 @@ def switch_cancelled(self): def switch_complete(self, mode, success, error_msg): """Handle switch completion""" self.switching = False - + if success: logging.info(f"Successfully switched to {mode}") - self.current_mode = mode + self.set_target_mode(mode) self.update_icon() self.indicator.set_menu(self.build_menu()) - - notification = Notify.Notification.new( - "✓ GPU Switched Successfully!", - f"Switched to {mode.upper()} mode.\n\n⚠️ REBOOT NOW for changes to take effect!", - "dialog-warning" - ) - notification.set_urgency(Notify.Urgency.CRITICAL) - notification.set_timeout(10000) + + if self.has_pending_change(): + notification = Notify.Notification.new( + "✓ GPU Mode Changed", + f"⚠️ Reboot to finish the switch to {mode.upper()} mode.", + "dialog-warning" + ) + notification.set_urgency(Notify.Urgency.CRITICAL) + notification.set_timeout(10000) + else: + # Switched back to boot mode - no reboot needed + notification = Notify.Notification.new( + "✓ GPU Mode Restored", + f"Restored to {mode.upper()} mode. No reboot required.", + "dialog-information" + ) notification.show() else: logging.error(f"Failed to switch to {mode}: {error_msg}") self.update_icon() self.indicator.set_menu(self.build_menu()) - + notification = Notify.Notification.new( "✗ GPU Switch Failed", f"Error: {error_msg if error_msg else 'Command failed'}", "dialog-error" ) notification.show() - + return False def show_about(self, _):