""" PowerShell Counter Backend ========================== Real-time GPU monitoring via Windows Performance Counters. Ultra-fast: uses Get-Counter only (reads pre-existing Windows counters, no HW query). VRAM total auto-detected once at init (supports APU UMA shared memory). Temperature reads from WMI thermal zone even without LHM. BUG FIXES v4.1 (APU/iGPU Stability): - Cached temperature/fan readings (refresh every 10s, NOT per 500ms poll) - Added top-level try/except in get_stats() to catch ALL exceptions - Added timeout guard for all subprocess calls """ import logging import subprocess import platform import json import re import time from typing import Optional from .base import MonitorBackend, HardwareStats logger = logging.getLogger(__name__) class PowerShellBackend(MonitorBackend): name = "powershell-counters" # Cache temperature/fan — refresh every 10 seconds _TEMP_CACHE_SECONDS = 10.0 def __init__(self): super().__init__() self._vram_total = 0 # Dedicated VRAM (UMA buffer for APU) in bytes self._vram_total_mb = 0 self._shared_vram_total = 0 # Shared system memory for APU in bytes self._gpu_name_cached = "" self._is_apu = False # Integrated GPU (APU) flag # Temperature/Fan cache self._cached_temp: float = 0.0 self._cached_fan: int = 0 self._last_temp_check: float = 0.0 def initialize(self) -> bool: if platform.system() != "Windows": logger.debug("PS Backend: Windows only") return False if not self._init_gpu_and_vram(): return False self.available = True return True def _init_gpu_and_vram(self) -> bool: """Detect GPU + cache VRAM total at init.""" ps_script = ( "$gpu = Get-WmiObject Win32_VideoController | Select-Object -First 1; " "$name = ''; $vram = 0; $isAPU = $false; " "if ($gpu) { " " $name = $gpu.Name; " " # Detect APU: check if GPU name contains typical APU keywords\n" " if ($name -match 'Radeon.*Graphics|Radeon.*Vega|RX Vega|Radeon 6') { $isAPU = $true }; " "}; " # Try UMA_FB_SIZE (APU shared memory) "$paths = Get-ChildItem 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\*' " "-ErrorAction SilentlyContinue; " "foreach ($p in $paths) { " " $uma = $p.GetValue('UMA_FB_SIZE'); " " if ($uma) { $vram = $uma * 1MB; break }; " " $gpuMem = $p.GetValue('HardwareInformation.GpuMemorySize'); " " if ($gpuMem) { $vram = $gpuMem; break }; " " $mem = $p.GetValue('HardwareInformation.qwMemorySize'); " " if ($mem) { $vram = $mem; break }; " "}; " # Fallback: WMI AdapterRAM "if ($vram -eq 0 -and $gpu.AdapterRAM) { $vram = $gpu.AdapterRAM }; " # For APU: use half of system RAM as estimated VRAM "if ($vram -eq 0 -and $isAPU) { " " $ram = Get-WmiObject Win32_ComputerSystem | Select-Object -First 1; " " $vram = [int]($ram.TotalPhysicalMemory * 0.25); " # 25% of system RAM "}; " # Absolute fallback "if ($vram -eq 0) { $vram = 512MB }; " "[PSCustomObject]@{ Name = $name; Vram = $vram; IsAPU = $isAPU } | ConvertTo-Json" ) try: result = subprocess.run( ["powershell", "-NoProfile", "-Command", ps_script], capture_output=True, text=True, timeout=15, creationflags=subprocess.CREATE_NO_WINDOW ) if result.returncode == 0 and result.stdout.strip(): data = json.loads(result.stdout.strip()) name = data.get('Name', '').strip() vram = int(data.get('Vram', 0)) is_apu = data.get('IsAPU', False) if name: self.gpu_names = [name] self.gpu_count = 1 self._gpu_name_cached = name else: self.gpu_names = ["AMD APU"] self.gpu_count = 1 self._gpu_name_cached = "AMD APU" self._is_apu = is_apu # For APU: use full system RAM / 4 as VRAM estimate if is_apu and vram < 1024 * 1024 * 1024: # vram is too small for APU, recalculate try: import psutil vram = int(psutil.virtual_memory().total * 0.25) except: vram = 4 * 1024 * 1024 * 1024 # 4GB APU default if vram > 0: self._vram_total = vram self._vram_total_mb = vram / (1024 * 1024) logger.info(f"PS Backend: {name} — VRAM: {vram/(1024*1024):.0f}MB (APU={is_apu})") else: self._vram_total = 512 * 1024 * 1024 self._vram_total_mb = 512 logger.warning(f"PS Backend: VRAM detection failed, using 512MB fallback") return True except Exception as e: logger.debug(f"PS Backend init error: {e}") return False def _run_ps(self, cmd: str, timeout: int = 2) -> Optional[str]: """Run a quick PowerShell command with timeout. FIXED: Added timeout to prevent blocking on APU systems.""" try: result = subprocess.run( ["powershell", "-NoProfile", "-Command", cmd], capture_output=True, text=True, timeout=timeout, creationflags=subprocess.CREATE_NO_WINDOW ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() except subprocess.TimeoutExpired: logger.debug(f"PS Backend: command timed out ({timeout}s)") except Exception: pass return None def get_stats(self, gpu_id: int = 0) -> HardwareStats: """Real-time GPU stats via fast Get-Counter + cached temperature. For APU systems, VRAM = Dedicated VRAM + Shared System Memory. The memory_shared field holds shared memory portion. The memory_total reflects combined total. """ try: name = self._gpu_name_cached or "AMD GPU" # For APU: use dedicated + shared memory as total VRAM vram_total = self._vram_total shared_total = self._shared_vram_total # If APU and we have shared memory info, combine them if self._is_apu: try: if shared_total == 0: # Estimate shared memory as 50% of system RAM try: import psutil shared_total = int(psutil.virtual_memory().total * 0.5) self._shared_vram_total = shared_total except Exception: shared_total = 4 * 1024 * 1024 * 1024 # 4GB fallback vram_total = self._vram_total + shared_total except Exception: # Fallback: just use dedicated VRAM logger.debug("PS Backend: shared VRAM calc failed — using dedicated only") shared_total = 0 vram_total = self._vram_total stats = HardwareStats( gpu_id=gpu_id, gpu_name=name, memory_total=vram_total, memory_shared=shared_total, is_apu=self._is_apu, is_available=True, ) # GPU Utilization (REAL-TIME) — using percentage-direct method stats.utilization_gpu = self._get_utilization_percent() # VRAM Usage if self._is_apu: # For APU: read shared usage + use system RAM percentage import psutil svmem = psutil.virtual_memory() # Estimate the shared portion usage based on system memory pressure shared_used = int(shared_total * (svmem.percent / 100)) dedicated_used = self._vram_total # assume UMA buffer is fully committed stats.memory_used = dedicated_used + shared_used stats.memory_shared = shared_used stats.memory_free = max(0, vram_total - stats.memory_used) if vram_total > 0: stats.utilization_memory = (stats.memory_used / vram_total) * 100.0 else: vram_used = self._get_vram_used_fast() if vram_used > 0 and self._vram_total > 0: stats.memory_used = vram_used stats.memory_free = max(0, self._vram_total - vram_used) stats.utilization_memory = (vram_used / self._vram_total) * 100.0 else: import psutil svmem = psutil.virtual_memory() vram_used = int(self._vram_total * (svmem.percent / 100)) stats.memory_used = vram_used stats.memory_free = self._vram_total - vram_used stats.utilization_memory = svmem.percent # Temperature (CACHED — only refreshed every 10s) temp_val, fan_val = self._get_cached_temp_fan() stats.temperature = temp_val stats.fan_speed = fan_val return stats except Exception as e: logger.error(f"PS Backend get_stats error: {e}") return HardwareStats( gpu_id=gpu_id, gpu_name=self._gpu_name_cached or "AMD GPU", is_available=False, error_message=str(e) ) def _get_cached_temp_fan(self): """Return cached temperature/fan. FIXED: Only refreshes every _TEMP_CACHE_SECONDS to avoid spawning PowerShell subprocesses every 500ms.""" now = time.time() if now - self._last_temp_check >= self._TEMP_CACHE_SECONDS: temp, fan = self._get_temp_and_fan() if temp > 0: self._cached_temp = temp self._cached_fan = fan self._last_temp_check = now return self._cached_temp, self._cached_fan def _get_utilization_percent(self) -> float: """ Get GPU utilization as percentage using the same source as Task Manager. FIXED: Removed excessive fallback chains that cause timeout on APU. Now tries only 2 fast methods, then returns 0. """ # Method 1: GPU Adapter Utilization Percentage (Task Manager data) - FAST for query_pattern in [ "Get-Counter '\\GPU Adapter(*)\\*'", "Get-Counter '\\GPU(*)\\*'", ]: ps_cmd = ( f"{query_pattern} -ErrorAction SilentlyContinue " "| Select-Object -ExpandProperty CounterSamples " "| Where-Object { $_.Path -match 'Utilization Percentage' -or $_.Path -match 'Utilization\\%' } " "| Select-Object -First 1 -ExpandProperty CookedValue" ) result = self._run_ps(ps_cmd, timeout=1) if result: try: val = float(result) if 0 <= val <= 100: return min(100.0, max(0.0, val)) if 0 <= val <= 1: return min(100.0, max(0.0, val * 100.0)) except: pass # Method 2: GPU Engine 3D sum - FAST try: ps_cmd = ( "Get-Counter '\\GPU Engine(*engtype_3D)\\*' -ErrorAction SilentlyContinue " "| Select-Object -ExpandProperty CounterSamples " "| Where-Object { $_.Path -notmatch 'pid_\\\\d+_' } " "| Measure-Object -Property CookedValue -Sum " "| Select-Object -ExpandProperty Sum" ) result = self._run_ps(ps_cmd, timeout=1) if result: val = float(result) if val > 1000: pct = val / 100000.0 elif val <= 1: pct = val * 100.0 else: pct = val return min(100.0, max(0.0, pct)) except: pass return 0.0 def _get_vram_used_fast(self) -> int: """REAL-TIME VRAM used in bytes via Get-Counter""" result = self._run_ps( "Get-Counter '\\GPU Adapter(*)\\*' -ErrorAction SilentlyContinue " "| Select-Object -ExpandProperty CounterSamples " "| Where-Object { $_.Path -match 'Dedicated Memory Used' } " "| Select-Object -First 1 -ExpandProperty CookedValue", timeout=1 ) if result: try: return int(float(result)) except: pass return 0 def _get_temp_and_fan(self) -> tuple: """ Get temperature and fan speed. Tries multiple WMI methods including LHM, thermal zone, and WMI. FIXED: All subprocess calls have timeouts to prevent blocking on APU. """ # Method 1: LHM/OHM WMI (if LHM is running with --wmi) result = self._run_ps( "$sensors = @(); " "try { $sensors += Get-WmiObject -Namespace 'root/LibreHardwareMonitor' -Class Sensor 2>$null } catch {}; " "if (-not $sensors) { try { $sensors += Get-WmiObject -Namespace 'root/OpenHardwareMonitor' -Class Sensor 2>$null } catch {} }; " "if ($sensors) { " " $sensors | Where-Object { $_.Value -gt 0 } | " " Select-Object SensorType, Name, Value | ConvertTo-Json -Compress " "}", timeout=2 ) if result and result != 'null': try: data = json.loads(result) if isinstance(data, dict): data = [data] temp_val = 0.0 fan_val = 0 for item in data: stype = item.get('SensorType', '') sname = item.get('Name', '') svalue = float(item.get('Value', 0)) if stype == 'Temperature': if 30 <= svalue <= 100: if temp_val == 0: temp_val = svalue if any(x in sname for x in ['GPU', 'Radeon', 'AMD', 'Core', 'Tctl', 'Tdie']): temp_val = svalue elif stype == 'Fan': if fan_val == 0: fan_val = int(svalue) if any(x in sname for x in ['GPU', 'Radeon', 'AMD']): fan_val = int(svalue) if temp_val > 0: return temp_val, fan_val except: pass # Method 2: Windows Thermal Zone (works on ALL laptops, no admin needed) result = self._run_ps( "$temps = Get-WmiObject -Namespace 'root/WMI' -Class MSAcpi_ThermalZoneTemperature " "-ErrorAction SilentlyContinue | Where-Object { $_.Active -eq $true }; " "if ($temps) { " " $temps | Select-Object @{N='Temp';E={[math]::Round(($_.CurrentTemperature - 2732) / 10, 1)}}, InstanceName | " " Where-Object { $_.Temp -gt 0 -and $_.Temp -lt 120 } | " " Select-Object -First 1 -ExpandProperty Temp " "}", timeout=2 ) if result: try: val = float(result) if 30 <= val <= 100: logger.debug(f"PS Backend: thermal zone temp = {val}°C") return val, 0 except: pass # Method 3: Thermal Zone Information counter - FAST result = self._run_ps( "Get-Counter '\\Thermal Zone Information(*)\\*' -ErrorAction SilentlyContinue " "| Select-Object -ExpandProperty CounterSamples " "| Where-Object { $_.Path -match 'Temperature' -and $_.CookedValue -gt 0 } " "| Select-Object -First 1 -ExpandProperty CookedValue", timeout=1 ) if result: try: val = float(result) temp_c = (val - 2732) / 10 if 30 <= temp_c <= 100: logger.debug(f"PS Backend: thermal counter temp = {temp_c}°C") return temp_c, 0 except: pass return 0.0, 0