566 lines
20 KiB
Python
566 lines
20 KiB
Python
"""
|
|
Hardware Detector - OS and GPU vendor auto-detection
|
|
====================================================
|
|
Detects the operating system and available GPU vendor(s) using
|
|
zero or minimal external dependencies. This is the first step
|
|
in the Universal Monitor initialization chain.
|
|
|
|
Detection Strategy:
|
|
1. OS detection (Windows vs Linux)
|
|
2. GPU vendor detection (AMD, NVIDIA, Intel, or unknown)
|
|
3. APU/iGPU detection (shared memory GPUs)
|
|
|
|
All methods use standard library only — no pip installs required.
|
|
"""
|
|
|
|
import logging
|
|
import platform
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import json
|
|
from typing import List, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# -------------------- OS Detection --------------------
|
|
|
|
def detect_os() -> str:
|
|
"""Detect the operating system.
|
|
|
|
Returns:
|
|
"windows", "linux", or "unknown"
|
|
"""
|
|
system = platform.system().lower()
|
|
if system == "windows":
|
|
return "windows"
|
|
elif system == "linux":
|
|
return "linux"
|
|
return "unknown"
|
|
|
|
|
|
# -------------------- GPU Vendor Detection --------------------
|
|
|
|
def detect_gpu_vendors_windows() -> List[dict]:
|
|
"""Detect GPU vendors on Windows using Registry (win32api) or fallback to WMI/PowerShell.
|
|
|
|
Returns:
|
|
List of dicts: [{'name': str, 'vendor': str, 'vram_bytes': int}, ...]
|
|
"""
|
|
gpus = []
|
|
|
|
# Method 1: Windows Registry (zero dependency, fastest)
|
|
try:
|
|
import winreg
|
|
gpu_key_path = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
|
|
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, gpu_key_path) as key:
|
|
i = 0
|
|
while True:
|
|
try:
|
|
subkey_name = winreg.EnumKey(key, i)
|
|
with winreg.OpenKey(key, subkey_name) as subkey:
|
|
try:
|
|
desc, _ = winreg.QueryValueEx(subkey, "DriverDesc")
|
|
if desc and isinstance(desc, str) and len(desc) > 3:
|
|
vendor = _identify_vendor(desc)
|
|
vram = _read_vram_registry(subkey)
|
|
gpus.append({
|
|
'name': desc,
|
|
'vendor': vendor,
|
|
'vram_bytes': vram,
|
|
})
|
|
except (FileNotFoundError, OSError):
|
|
pass
|
|
i += 1
|
|
except OSError:
|
|
break
|
|
except ImportError:
|
|
logger.debug("Detector: winreg not available")
|
|
except Exception as e:
|
|
logger.debug(f"Detector: registry scan error: {e}")
|
|
|
|
if gpus:
|
|
logger.info(f"Detector: Found {len(gpus)} GPU(s) via registry: {[g['vendor'] for g in gpus]}")
|
|
return gpus
|
|
|
|
# Method 2: PowerShell WMI fallback (one-time)
|
|
try:
|
|
ps_script = (
|
|
"Get-WmiObject Win32_VideoController | "
|
|
"Select-Object Name, AdapterRAM, VideoModeDescription, DriverVersion | "
|
|
"ConvertTo-Json -Compress"
|
|
)
|
|
result = subprocess.run(
|
|
["powershell", "-NoProfile", "-Command", ps_script],
|
|
capture_output=True, text=True, timeout=15,
|
|
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0,
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip() and result.stdout.strip() != 'null':
|
|
data = json.loads(result.stdout.strip())
|
|
if isinstance(data, dict):
|
|
data = [data]
|
|
for item in data:
|
|
name = item.get('Name', '') or ''
|
|
vram = int(item.get('AdapterRAM', 0) or 0)
|
|
vendor = _identify_vendor(name)
|
|
gpus.append({
|
|
'name': name,
|
|
'vendor': vendor,
|
|
'vram_bytes': vram,
|
|
})
|
|
except Exception as e:
|
|
logger.debug(f"Detector: WMI fallback error: {e}")
|
|
|
|
if not gpus:
|
|
logger.warning("Detector: No GPUs found on Windows")
|
|
|
|
return gpus
|
|
|
|
|
|
def detect_gpu_vendors_linux() -> List[dict]:
|
|
"""Detect GPU vendors on Linux by parsing /sys/class/drm/ and /proc/bus/pci/devices.
|
|
|
|
Uses standard library only — reads sysfs virtual files.
|
|
|
|
Returns:
|
|
List of dicts: [{'name': str, 'vendor': str, 'vram_bytes': int}, ...]
|
|
"""
|
|
gpus = []
|
|
|
|
# Method 1: /sys/class/drm/ — the standard Linux GPU interface
|
|
drm_path = "/sys/class/drm"
|
|
if os.path.exists(drm_path):
|
|
try:
|
|
for entry in os.listdir(drm_path):
|
|
device_path = os.path.join(drm_path, entry)
|
|
|
|
# Only look at card devices, not renderD*
|
|
if not entry.startswith("card") or "card" not in entry:
|
|
continue
|
|
# Skip the -HDMI/-DP sub-devices
|
|
if "-" in entry:
|
|
continue
|
|
|
|
# Read device vendor/name via PCI
|
|
vendor_file = os.path.join(device_path, "device", "vendor")
|
|
if not os.path.exists(vendor_file):
|
|
# Try symlink resolution
|
|
try:
|
|
real = os.path.realpath(device_path)
|
|
# Navigate up to find PCI device
|
|
pci_path = real
|
|
for _ in range(5):
|
|
pci_path = os.path.dirname(pci_path)
|
|
vendor_f = os.path.join(pci_path, "vendor")
|
|
if os.path.exists(vendor_f):
|
|
vendor_file = vendor_f
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
vendor_id = ""
|
|
device_name = ""
|
|
vram_bytes = 0
|
|
|
|
# Read vendor ID
|
|
if os.path.exists(vendor_file):
|
|
try:
|
|
with open(vendor_file, 'r') as f:
|
|
vendor_id = f.read().strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# Determine vendor from PCI vendor ID
|
|
vendor = _pci_vendor_to_name(vendor_id)
|
|
|
|
# Read GPU name from device subdirectory
|
|
dev_name_file = os.path.join(device_path, "device", "device")
|
|
if os.path.exists(dev_name_file):
|
|
try:
|
|
with open(dev_name_file, 'r') as f:
|
|
device_id = f.read().strip()
|
|
device_name = f"GPU ({vendor} 0x{device_id})"
|
|
except Exception:
|
|
device_name = f"GPU ({vendor})"
|
|
else:
|
|
device_name = f"GPU ({vendor})"
|
|
|
|
# Read VRAM size for AMD via amdgpu sysfs
|
|
if vendor == "amd":
|
|
vram_bytes = _read_amdgpu_vram_linux(device_path)
|
|
|
|
# Read VRAM for Intel
|
|
if vendor == "intel":
|
|
vram_bytes = _read_intel_vram_linux()
|
|
|
|
# Avoid duplicates (same card may appear multiple times)
|
|
if not any(g['vendor'] == vendor for g in gpus):
|
|
gpus.append({
|
|
'name': device_name,
|
|
'vendor': vendor,
|
|
'vram_bytes': vram_bytes,
|
|
})
|
|
|
|
except PermissionError:
|
|
logger.debug("Detector: Permission denied reading /sys/class/drm (try sudo or non-root)")
|
|
except Exception as e:
|
|
logger.debug(f"Detector: /sys/class/drm error: {e}")
|
|
|
|
# Method 2: /proc/bus/pci/devices fallback
|
|
if not gpus:
|
|
gpus = _detect_gpu_via_pci_proc()
|
|
|
|
# Method 3: nvidia-smi for NVIDIA (even for vendor detection)
|
|
if not gpus or not any(g['vendor'] == 'nvidia' for g in gpus):
|
|
_detect_nvidia_via_smi(gpus)
|
|
|
|
# Method 4: lspci as last resort
|
|
if not gpus:
|
|
_detect_gpu_via_lspci(gpus)
|
|
|
|
if not gpus:
|
|
logger.warning("Detector: No GPUs found on Linux")
|
|
|
|
return gpus
|
|
|
|
|
|
# -------------------- Vendor Identification --------------------
|
|
|
|
def _identify_vendor(name: str) -> str:
|
|
"""Identify GPU vendor from a device name string."""
|
|
name_lower = name.lower()
|
|
if 'nvidia' in name_lower or 'geforce' in name_lower or 'quadro' in name_lower or 'tesla' in name_lower:
|
|
return 'nvidia'
|
|
if 'amd' in name_lower or 'radeon' in name_lower or 'firepro' in name_lower or 'instinct' in name_lower:
|
|
return 'amd'
|
|
if 'intel' in name_lower or 'arc' in name_lower or 'iris' in name_lower or 'uhd graphics' in name_lower or 'hd graphics' in name_lower:
|
|
return 'intel'
|
|
if 'microsoft' in name_lower or 'virtualbox' in name_lower or 'vmware' in name_lower:
|
|
return 'virtual'
|
|
return 'unknown'
|
|
|
|
|
|
def _pci_vendor_to_name(vendor_id: str) -> str:
|
|
"""Map PCI vendor ID to vendor name."""
|
|
vid = vendor_id.strip().lower().replace('0x', '')
|
|
mapping = {
|
|
'1002': 'amd',
|
|
'10de': 'nvidia',
|
|
'8086': 'intel',
|
|
'102b': 'matrox',
|
|
'1a03': 'aspeed',
|
|
'1414': 'microsoft',
|
|
}
|
|
return mapping.get(vid, 'unknown')
|
|
|
|
|
|
def _read_vram_registry(subkey) -> int:
|
|
"""Read VRAM from a Windows registry subkey."""
|
|
try:
|
|
import winreg
|
|
vram_names = [
|
|
'HardwareInformation.qwMemorySize',
|
|
'HardwareInformation.GpuMemorySize',
|
|
'HardwareInformation.MemorySize',
|
|
'UMA_FB_SIZE',
|
|
]
|
|
for val_name in vram_names:
|
|
try:
|
|
val, _ = winreg.QueryValueEx(subkey, val_name)
|
|
if val is None or val == 0:
|
|
continue
|
|
if isinstance(val, bytes) and len(val) >= 8:
|
|
import struct
|
|
val = struct.unpack('Q', val[:8])[0]
|
|
elif isinstance(val, bytes) and len(val) >= 4:
|
|
import struct
|
|
val = struct.unpack('I', val[:4])[0]
|
|
elif isinstance(val, str):
|
|
try:
|
|
val = int(val)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
elif not isinstance(val, (int, float)):
|
|
continue
|
|
|
|
val = int(val)
|
|
if val <= 0:
|
|
continue
|
|
|
|
# APU UMA_FB_SIZE is in MB
|
|
if val_name == 'UMA_FB_SIZE' and val < 1024 * 1024:
|
|
val = val * 1024 * 1024
|
|
|
|
# Sanity check: 128MB to 256GB
|
|
if 128 * 1024 * 1024 <= val <= 256 * 1024 * 1024 * 1024:
|
|
return val
|
|
except (FileNotFoundError, OSError):
|
|
pass
|
|
except ImportError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
|
|
|
|
# -------------------- Linux-specific Helpers --------------------
|
|
|
|
def _read_amdgpu_vram_linux(device_path: str) -> int:
|
|
"""Read VRAM size for AMD GPUs on Linux via amdgpu sysfs."""
|
|
# Try amdgpu specific path
|
|
vram_paths = [
|
|
os.path.join(device_path, "device", "gpu_vram_size"), # Some kernels
|
|
os.path.join(device_path, "gt"), # Fallback
|
|
]
|
|
|
|
for vp in vram_paths:
|
|
if os.path.exists(vp):
|
|
try:
|
|
with open(vp, 'r') as f:
|
|
val = f.read().strip()
|
|
return int(val) # In bytes on newer kernels
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
# Try reading from amdgpu firmware info in debugfs
|
|
try:
|
|
mem_info = "/sys/kernel/debug/dri/0/amdgpu_vram_mm"
|
|
if os.path.exists(mem_info):
|
|
with open(mem_info, 'r') as f:
|
|
content = f.read()
|
|
match = re.search(r'total: (\d+)', content)
|
|
if match:
|
|
return int(match.group(1))
|
|
except Exception:
|
|
pass
|
|
|
|
return 0
|
|
|
|
|
|
def _read_intel_vram_linux() -> int:
|
|
"""Read VRAM for Intel GPUs on Linux."""
|
|
# Intel integrated GPUs use shared memory — read system RAM
|
|
try:
|
|
with open('/proc/meminfo', 'r') as f:
|
|
for line in f:
|
|
if line.startswith('MemTotal:'):
|
|
parts = line.split()
|
|
if len(parts) >= 2:
|
|
kb = int(parts[1])
|
|
return kb * 1024 # Convert KB to bytes
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
|
|
|
|
def _detect_gpu_via_pci_proc() -> List[dict]:
|
|
"""Fallback: read /proc/bus/pci/devices for GPU detection."""
|
|
gpus = []
|
|
try:
|
|
pci_path = "/proc/bus/pci/devices"
|
|
if not os.path.exists(pci_path):
|
|
return gpus
|
|
|
|
with open(pci_path, 'r') as f:
|
|
for line in f:
|
|
parts = line.strip().split()
|
|
if len(parts) < 4:
|
|
continue
|
|
# Bus address is in parts[0]
|
|
# Vendor:Device is in parts[1] as hex
|
|
vendor_device = parts[1].split()
|
|
if not vendor_device:
|
|
continue
|
|
|
|
pci_id = vendor_device[0].lower()
|
|
# Class 0x03xxxx is display controller
|
|
cls = parts[2].lower() if len(parts) > 2 else ""
|
|
|
|
if not cls.startswith("03"):
|
|
continue
|
|
|
|
# Extract vendor
|
|
if pci_id.startswith("1002"):
|
|
vendor = "amd"
|
|
elif pci_id.startswith("10de"):
|
|
vendor = "nvidia"
|
|
elif pci_id.startswith("8086"):
|
|
vendor = "intel"
|
|
else:
|
|
vendor = "unknown"
|
|
|
|
if not any(g['vendor'] == vendor for g in gpus):
|
|
gpus.append({
|
|
'name': f"GPU ({vendor})",
|
|
'vendor': vendor,
|
|
'vram_bytes': 0,
|
|
})
|
|
except Exception as e:
|
|
logger.debug(f"Detector: PCI proc error: {e}")
|
|
return gpus
|
|
|
|
|
|
def _detect_nvidia_via_smi(gpus: List[dict]):
|
|
"""Check for NVIDIA GPU presence via nvidia-smi."""
|
|
try:
|
|
result = subprocess.run(
|
|
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
for line in result.stdout.strip().split('\n'):
|
|
parts = line.split(',')
|
|
name = parts[0].strip() if parts else "NVIDIA GPU"
|
|
vram_mb = int(parts[1].strip()) if len(parts) > 1 and parts[1].strip().isdigit() else 0
|
|
vram_bytes = vram_mb * 1024 * 1024
|
|
if not any(g['vendor'] == 'nvidia' for g in gpus):
|
|
gpus.append({
|
|
'name': name,
|
|
'vendor': 'nvidia',
|
|
'vram_bytes': vram_bytes,
|
|
})
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
pass
|
|
except Exception as e:
|
|
logger.debug(f"Detector: nvidia-smi error: {e}")
|
|
|
|
|
|
def _detect_gpu_via_lspci(gpus: List[dict]):
|
|
"""Last resort: parse lspci output for GPUs."""
|
|
try:
|
|
result = subprocess.run(
|
|
["lspci", "-nn"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.split('\n'):
|
|
if 'VGA' in line or '3D' in line or 'Display' in line:
|
|
vendor = 'unknown'
|
|
name = line.strip()
|
|
if '1002' in line or 'AMD' in line or 'Radeon' in line:
|
|
vendor = 'amd'
|
|
elif '10de' in line or 'NVIDIA' in line:
|
|
vendor = 'nvidia'
|
|
elif '8086' in line or 'Intel' in line:
|
|
vendor = 'intel'
|
|
|
|
if not any(g['vendor'] == vendor for g in gpus):
|
|
gpus.append({
|
|
'name': name,
|
|
'vendor': vendor,
|
|
'vram_bytes': 0,
|
|
})
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
pass
|
|
except Exception as e:
|
|
logger.debug(f"Detector: lspci error: {e}")
|
|
|
|
|
|
# -------------------- APU Detection --------------------
|
|
|
|
def is_apu_windows(gpu_name: str, vram_bytes: int) -> bool:
|
|
"""Detect if a Windows GPU is an APU/iGPU with shared memory.
|
|
|
|
APU characteristics:
|
|
- Name contains APU-related keywords
|
|
- VRAM is suspiciously small (< 512MB dedicated)
|
|
|
|
Returns True if the GPU appears to be an APU.
|
|
"""
|
|
name_lower = gpu_name.lower()
|
|
apu_keywords = [
|
|
'radeon graphics', 'radeon™ graphics', 'radeon(tm) graphics',
|
|
'radeon 6', 'radeon 7', 'radeon 8', 'radeon 9',
|
|
'radeon veg', 'amd apu', 'accelerated processing unit',
|
|
'intel(r) iris', 'intel(r) hd', 'intel(r) uhd',
|
|
'intel arc', 'intel graphics',
|
|
]
|
|
|
|
# Check name keywords
|
|
if any(kw in name_lower for kw in apu_keywords):
|
|
return True
|
|
|
|
# Check VRAM: if it's < 512MB, it's likely an APU with shared memory
|
|
# (Dedicated GPUs always have >= 1GB since ~2010)
|
|
if 0 < vram_bytes < 512 * 1024 * 1024:
|
|
return True
|
|
|
|
# Check if vendor is Intel (almost always integrated)
|
|
if 'intel' in name_lower and 'arc' not in name_lower:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def is_apu_linux(vendor: str, vram_bytes: int) -> bool:
|
|
"""Detect APU on Linux.
|
|
|
|
Intel integrated GPUs are always APUs.
|
|
AMD APUs typically have < 1GB dedicated VRAM or VRAM = 0 (shared).
|
|
"""
|
|
if vendor == 'intel':
|
|
return True
|
|
if vendor == 'amd' and (vram_bytes == 0 or vram_bytes < 512 * 1024 * 1024):
|
|
return True
|
|
return False
|
|
|
|
|
|
# -------------------- Main API --------------------
|
|
|
|
def detect_hardware() -> dict:
|
|
"""Main detection function. Returns a dict with OS and GPU information.
|
|
|
|
Returns:
|
|
{
|
|
'os': 'windows' | 'linux' | 'unknown',
|
|
'gpus': [{'name': str, 'vendor': str, 'vram_bytes': int}, ...],
|
|
'primary_vendor': 'amd' | 'nvidia' | 'intel' | 'unknown',
|
|
'has_apu': bool,
|
|
}
|
|
"""
|
|
os_name = detect_os()
|
|
|
|
if os_name == 'windows':
|
|
gpus = detect_gpu_vendors_windows()
|
|
elif os_name == 'linux':
|
|
gpus = detect_gpu_vendors_linux()
|
|
else:
|
|
gpus = []
|
|
|
|
# Determine primary vendor (first real GPU, prefer dGPU over iGPU)
|
|
primary_vendor = 'unknown'
|
|
for gpu in gpus:
|
|
vendor = gpu.get('vendor', 'unknown')
|
|
vram = gpu.get('vram_bytes', 0)
|
|
# Prefer discrete GPUs (have VRAM > 512MB)
|
|
if vendor in ('nvidia',) or (vendor in ('amd', 'intel') and vram > 512 * 1024 * 1024):
|
|
primary_vendor = vendor
|
|
break
|
|
|
|
if primary_vendor == 'unknown' and gpus:
|
|
primary_vendor = gpus[0].get('vendor', 'unknown')
|
|
|
|
# Detect APUs
|
|
has_apu = False
|
|
for gpu in gpus:
|
|
vendor = gpu.get('vendor', '')
|
|
name = gpu.get('name', '')
|
|
vram = gpu.get('vram_bytes', 0)
|
|
if os_name == 'windows' and is_apu_windows(name, vram):
|
|
has_apu = True
|
|
break
|
|
if os_name == 'linux' and is_apu_linux(vendor, vram):
|
|
has_apu = True
|
|
break
|
|
|
|
result = {
|
|
'os': os_name,
|
|
'gpus': gpus,
|
|
'primary_vendor': primary_vendor,
|
|
'has_apu': has_apu,
|
|
'gpu_count': len(gpus),
|
|
}
|
|
|
|
logger.info(f"Detector: {result}")
|
|
return result |