diff --git a/pyproject.toml b/pyproject.toml index a0b72fe..8c409f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ where = ["src"] [tool.setuptools.package-data] +"hwprobe.interops.win_old.bindings" = ["*.dll"] "hwprobe.interops.win.bindings" = ["*.dll"] "hwprobe.interops.mac.bindings" = ["*.dylib"] diff --git a/src/hwprobe/core/windows/cpu.py b/src/hwprobe/core/windows/cpu.py index ce7e43e..cba79c1 100644 --- a/src/hwprobe/core/windows/cpu.py +++ b/src/hwprobe/core/windows/cpu.py @@ -1,16 +1,24 @@ import ctypes -import os -import winreg from ctypes import wintypes -from hwprobe.core.windows.win_enum import FEATURE_ID_MAP +from hwprobe.core.windows.win_enum import CPU_ARCHITECTURES, FEATURE_ID_MAP from hwprobe.models.cpu_models import CPUInfo from hwprobe.models.status_models import StatusType +from hwprobe.interops.win.bindings import wmi kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.IsProcessorFeaturePresent.argtypes = [wintypes.DWORD] kernel32.IsProcessorFeaturePresent.restype = wintypes.BOOL +# ARM processor feature IDs for IsProcessorFeaturePresent. +# These are not defined in the Windows SDK headers — they're undocumented +# feature IDs discovered via testing on ARM hardware. +# ref: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-isprocessorfeaturepresent +PF_SSVE_FP8DOT2 = 78 # ARMv9 — FEAT_SSVE_FP8DOT2 +PF_SSVE_FP8DOT4 = 79 # ARMv9 — FEAT_SSVE_FP8DOT4 +PF_SSVE_FP8FMA = 80 # ARMv9 — FEAT_SSVE_FP8FMA +PF_SME_FA64 = 88 # ARMv8 — FEAT_SME_FA64 + def is_processor_feature_present(feature_id: int) -> bool: """ @@ -34,10 +42,12 @@ def get_arm_version() -> str: Otherwise - we can assume it's ARMv7 or lower. + + ref: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-isprocessorfeaturepresent """ - if any([is_processor_feature_present(i) for i in [78, 79, 80]]): + if any(is_processor_feature_present(i) for i in [PF_SSVE_FP8DOT2, PF_SSVE_FP8DOT4, PF_SSVE_FP8FMA]): return "9" - elif is_processor_feature_present(88): + elif is_processor_feature_present(PF_SME_FA64): return "8" else: return "7 or lower" @@ -60,119 +70,23 @@ def get_features() -> list[str]: return [k for k, v in FEATURE_ID_MAP.items() if is_processor_feature_present(v)] -def parse_registry(): - """ - We can get the CPU model name and vendor from the Windows Registry from the following path: - "HKEY_LOCAL_MACHINE -> HARDWARE -> DESCRIPTION -> System -> CentralProcessor -> 0" - """ - key_path = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0" - model_key = "ProcessorNameString" - vendor_key = "VendorIdentifier" - - with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path, 0, winreg.KEY_READ) as key: - model_name, _ = winreg.QueryValueEx(key, model_key) - vendor, _ = winreg.QueryValueEx(key, vendor_key) - return model_name, vendor - - -def get_core_count() -> int: - """ - Uses the GetLogicalProcessorInformation function in the Win32 API to get the number of physical cores. - https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getlogicalprocessorinformation - """ - - """ - typedef struct _SYSTEM_LOGICAL_PROCESSOR_INFORMATION { - ULONG_PTR ProcessorMask; - LOGICAL_PROCESSOR_RELATIONSHIP Relationship; - union { - struct { - BYTE Flags; - } ProcessorCore; - struct { - DWORD NodeNumber; - } NumaNode; - CACHE_DESCRIPTOR Cache; - ULONGLONG Reserved[2]; - }; - } SYSTEM_LOGICAL_PROCESSOR_INFORMATION; - - ProcessorMask - Pointer - 8 bytes - Relationship - DWORD - 4 bytes - Padding - 4 byt - Union - must be large enough to hold the largest member - 16 bytes - Total size = 8 + 4 + 4 + 16 = 32 bytes - """ - - class SYSTEM_LOGICAL_PROCESSOR_INFORMATION(ctypes.Structure): - _fields_ = [ - ("ProcessorMask", ctypes.c_size_t), - ("Relationship", ctypes.c_int), - ("Reserved", ctypes.c_byte * 20), - ] - - RelationProcessorCore = 0 - - buffer_size = ctypes.c_ulong(0) - ctypes.windll.kernel32.GetLogicalProcessorInformation(None, ctypes.byref(buffer_size)) - - count = buffer_size.value // ctypes.sizeof(SYSTEM_LOGICAL_PROCESSOR_INFORMATION) - buffer = (SYSTEM_LOGICAL_PROCESSOR_INFORMATION * count)() - - ctypes.windll.kernel32.GetLogicalProcessorInformation(buffer, ctypes.byref(buffer_size)) - - physical_cores = sum(1 for info in buffer if info.Relationship == RelationProcessorCore) - - return physical_cores - - def fetch_cpu_info() -> CPUInfo: cpu_info = CPUInfo() - try: - model_name, vendor = parse_registry() - cpu_info.name = model_name.strip() - cpu_info.vendor = "AMD" if "amd" in vendor.lower() else "Intel" if "intel" in vendor.lower() else vendor.strip() + + wmi_data = wmi.get_wmi_data("Win32_Processor", ["Name", "Manufacturer", "Architecture", "AddressWidth", "MaxClockSpeed", "NumberOfCores", "NumberOfLogicalProcessors"]) + if wmi_data: + cpu_info.name = wmi_data[0]["Name"].strip() + cpu_info.vendor = "AMD" if "amd" in wmi_data[0]["Manufacturer"].lower() else "Intel" if "intel" in wmi_data[0]["Manufacturer"].lower() else wmi_data[0]["Manufacturer"].strip() + cpu_info.architecture = CPU_ARCHITECTURES.get(int(wmi_data[0]["Architecture"]), "Unknown") + cpu_info.bitness = int(wmi_data[0]["AddressWidth"]) + cpu_info.cores = int(wmi_data[0]["NumberOfCores"]) + cpu_info.threads = int(wmi_data[0]["NumberOfLogicalProcessors"]) features = get_features() cpu_info.sse_flags = features - except Exception as e: - cpu_info.status.type = StatusType.FAILED - cpu_info.status.messages.append(f"Unable to obtain CPU Info: {e}") - return cpu_info - """ - The CPU Architecture is exposed as an environment variable on Windows systems. - https://www.tenforums.com/tutorials/176966-how-check-if-processor-32-bit-64-bit-arm-windows-10-a.html - - Possible outputs: - - x86 -> x86 32-bit - - AMD64 -> x86 64-bit - - ARM64 -> ARM 64-bit - - We account for "x86_64" and "i386" as well, just in case. - """ - architecture = os.environ.get("PROCESSOR_ARCHITECTURE", "").lower() - if "amd64" in architecture or "x86_64" in architecture: - cpu_info.architecture = "x86" - cpu_info.bitness = 64 - elif "x86" in architecture or "i386" in architecture: - cpu_info.architecture = "x86" - cpu_info.bitness = 32 - elif "arm64" in architecture: - cpu_info.architecture = "ARM" - cpu_info.bitness = 64 else: - cpu_info.status.type = StatusType.PARTIAL - cpu_info.status.messages.append("Unknown architecture: " + architecture) - - cpu_info.cores = get_core_count() - if not cpu_info.cores: - cpu_info.status.type = StatusType.PARTIAL - cpu_info.status.messages.append(f"Unable to fetch Core Count: {cpu_info.cores}") - - cpu_info.threads = os.cpu_count() - if not cpu_info.threads: - cpu_info.status.type = StatusType.PARTIAL - cpu_info.status.messages.append(f"Unable to fetch Threads: {cpu_info.threads}") + cpu_info.status.type = StatusType.FAILED + cpu_info.status.messages.append("Unable to obtain CPU Info: WMI query returned no data.") return cpu_info diff --git a/src/hwprobe/core/windows/display.py b/src/hwprobe/core/windows/display.py index 368e8df..aeaed05 100644 --- a/src/hwprobe/core/windows/display.py +++ b/src/hwprobe/core/windows/display.py @@ -1,772 +1,91 @@ """ Windows Display Information Module -This module provides functionality to enumerate and collect detailed information -about display monitors on Windows systems. It uses Windows SetupAPI and display -enumeration APIs to gather: -- Display resolution, refresh rate, and orientation -- EDID (Extended Display Identification Data) information -- Connection type (HDMI, DisplayPort, etc.) -- GPU association for each display +All Win32 calls go through display_info.dll (C++). Python only does: +- EDID parsing (shared hwprobe.core.common.edid) +- Connector type mapping (DISPLAY_CON_TYPE from win_enum) +- Data assembly into DisplayModuleInfo """ -import ctypes -import struct -from ctypes import wintypes -from typing import Optional - +from hwprobe.core.common.edid import parse_edid from hwprobe.core.windows.win_enum import DISPLAY_CON_TYPE - -# todo: refactor to new bindings -from hwprobe.interops.win.legacy.constants import ( - DICS_FLAG_GLOBAL, - DIGCF_DEVICEINTERFACE, - DIGCF_PRESENT, - DIREG_DEV, - DMDO_90, - DMDO_180, - DMDO_270, - DMDO_DEFAULT, - ENUM_CURRENT_SETTINGS, - GUID_DEVINTERFACE_MONITOR, - KEY_READ, - STATUS_OK, -) -from hwprobe.interops.win.legacy.signatures import ( - EnumDisplayDevicesA, - EnumDisplayMonitors, - EnumDisplaySettingsA, - GetDisplayPathInfo, - GetGPUForDisplay, - GetMonitorInfoA, - RegCloseKey, - RegQueryValueExA, - SetupDiDestroyDeviceInfoList, - SetupDiEnumDeviceInterfaces, - SetupDiGetClassDevsA, - SetupDiGetDeviceInterfaceDetailA, - SetupDiOpenDevRegKey, -) -from hwprobe.interops.win.legacy.structs import ( - DEVMODEA, - DISPLAY_DEVICEA, - MONITORENUMPROC, - MONITORINFOEXA, - SP_DEVICE_INTERFACE_DATA, - SP_DEVINFO_DATA, +from hwprobe.interops.win.bindings.display_info import ( + get_display_connectors, + get_edid, + get_gpu_for_display, + get_monitor_devices, ) -from hwprobe.models.display_models import DisplayInfo, DisplayModuleInfo -from hwprobe.models.status_models import Status, StatusType - -# ============================================================================= -# Constants -# ============================================================================= - -# EDID structure constants -_EDID_MIN_LENGTH = 128 -_EDID_VENDOR_OFFSET = 8 -_EDID_PRODUCT_OFFSET = 10 -_EDID_DESCRIPTOR_BASE_OFFSET = 0x48 -_EDID_DESCRIPTOR_SIZE = 18 -_EDID_DESCRIPTOR_COUNT = 3 -_EDID_WIDTH_CM_OFFSET = 21 -_EDID_HEIGHT_CM_OFFSET = 22 - -# EDID descriptor type markers -_EDID_SERIAL_MARKER = b"\x00\x00\x00\xff" -_EDID_NAME_MARKER = b"\x00\x00\x00\xfc" - -# Orientation display names -_ORIENTATION_NAMES = { - DMDO_DEFAULT: "Landscape", - DMDO_90: "Portrait", - DMDO_180: "Landscape (flipped)", - DMDO_270: "Portrait (flipped)", -} - -# Invalid registry key handle -_INVALID_HKEY = -1 - - -# ============================================================================= -# Utility Functions -# ============================================================================= - - -def _compute_gcd(a: int, b: int) -> int: - """Compute the greatest common divisor of two integers using Euclidean algorithm.""" - while b: - a, b = b, a % b - return a - - -def get_aspect_ratio(width: int, height: int) -> tuple[Optional[int], Optional[int]]: - """ - Calculate the aspect ratio for given dimensions. - - Args: - width: Display width in pixels - height: Display height in pixels - - Returns: - A tuple of (width_ratio, height_ratio), e.g., (16, 9) for 1920x1080. - Returns (None, None) if either dimension is zero. - - Examples: - >>> get_aspect_ratio(1920, 1080) - (16, 9) - >>> get_aspect_ratio(3440, 1440) - (43, 18) - """ - if width == 0 or height == 0: - return None, None - - gcd = _compute_gcd(width, height) - return width // gcd, height // gcd - - -def parse_connector_info(connector_info_string: str) -> Optional[dict]: - """ - Parse connector information string into a structured dictionary. - - The connector info string format (from interop DLL): - "DisplayID=\\\\.\\DISPLAY1|DisplayPath=\\\\?\\DISPLAY#...|OutputTechnology=5\\n - DisplayID=\\\\.\\DISPLAY2|DisplayPath=\\\\?\\DISPLAY#...|OutputTechnology=10" - - Args: - connector_info_string: Raw connector info string with newline-separated devices - - Returns: - Dictionary mapping display IDs to their properties: - { - "\\\\.\\DISPLAY1": {"DisplayPath": "...", "OutputTechnology": "5"}, - "\\\\.\\DISPLAY2": {"DisplayPath": "...", "OutputTechnology": "10"} - } - Returns None if parsing fails. - """ - result = {} - - try: - devices = connector_info_string.split("\n") - - for device in devices: - if not device.strip(): - continue - - parts = device.split("|") - current_display_id = None - - for part in parts: - if "=" not in part: - continue - - key, value = part.split("=", 1) - - if key == "DisplayID": - current_display_id = value - result[current_display_id] = {} - elif current_display_id is not None: - result[current_display_id][key] = value - - except (ValueError, AttributeError): - return None - - return result - - -# ============================================================================= -# EDID Parsing -# ============================================================================= - - -def _extract_descriptor_text(descriptor: bytes, marker: bytes) -> Optional[str]: - """ - Extract text from an EDID descriptor block if it matches the given marker. - - Args: - descriptor: 18-byte EDID descriptor block - marker: 4-byte marker identifying the descriptor type - - Returns: - Decoded and stripped text, or None if marker doesn't match or text is empty. - """ - if descriptor[0:4] != marker: - return None - - try: - # Text data starts at byte 5, terminated by 0x0A (newline) - raw_text = descriptor[5:18].split(b"\x0a")[0] - text = raw_text.decode(errors="ignore").strip() - return text if text else None - except Exception: - return None - - -def _decode_manufacturer_code(vendor_id: int) -> str: - """ - Decode the 3-letter manufacturer code from EDID vendor ID. - - EDID vendor IDs encode three 5-bit characters (A=1, B=2, etc.) packed into 16 bits. - - Args: - vendor_id: 16-bit vendor identifier from EDID - - Returns: - Three-letter manufacturer code (e.g., "SAM" for Samsung, "DEL" for Dell) - """ - char1 = chr(((vendor_id >> 10) & 0x1F) + ord("A") - 1) - char2 = chr(((vendor_id >> 5) & 0x1F) + ord("A") - 1) - char3 = chr((vendor_id & 0x1F) + ord("A") - 1) - return f"{char1}{char2}{char3}" - - -def _calculate_diagonal_inches(width_cm: int, height_cm: int) -> float: - """ - Calculate diagonal screen size in inches from physical dimensions. - - Args: - width_cm: Physical width in centimeters - height_cm: Physical height in centimeters - - Returns: - Diagonal size in inches, rounded to nearest integer. Returns 0 if dimensions invalid. - """ - if width_cm <= 0 or height_cm <= 0: - return 0.0 - - diagonal_cm = (width_cm**2 + height_cm**2) ** 0.5 - return round(diagonal_cm / 2.54) - - -def parse_edid(edid: bytes) -> Optional[dict]: - """ - Parse EDID (Extended Display Identification Data) binary data. - - EDID is a standard data format that displays use to describe their capabilities - to connected devices. This function extracts commonly needed identification info. - - Args: - edid: Raw EDID bytes (minimum 128 bytes for base EDID block) - - Returns: - Dictionary containing: - - manufacturer_code: 3-letter PNP ID (e.g., "SAM", "DEL") - - vendor_id: Numeric vendor identifier - - product_id: Numeric product identifier - - serial: Serial number string (if present in descriptors) - - name: Display model name (if present in descriptors) - - inches: Diagonal screen size in inches - - Returns None if EDID data is too short. - """ - if len(edid) < _EDID_MIN_LENGTH: - return None - - # Parse vendor and product IDs - vendor_id = struct.unpack(">H", edid[_EDID_VENDOR_OFFSET : _EDID_VENDOR_OFFSET + 2])[0] - product_id = struct.unpack(" Optional[str]: - """ - Extract the serial number from a display path for unique identification. - - Args: - display_path: Display path string (e.g., "\\\\?\\DISPLAY#MANUF#...") - - Returns: - Serial number string if found, None otherwise. - """ - edid = get_edid_by_hwid(display_path) - return edid.get("serial") if edid else None - - -# ============================================================================= -# GPU and Display Association -# ============================================================================= - - -def find_monitor_gpu(device_name: str) -> tuple[Optional[str], int]: - """ - Find the GPU that a monitor is connected to. - - Args: - device_name: Display device name (e.g., "\\\\.\\DISPLAY1") - - Returns: - Tuple of (gpu_name, status_code): - - gpu_name: Name of the GPU, or None if not found - - status_code: API result code (STATUS_OK on success) - """ - buffer = ctypes.create_string_buffer(256) - encoded_name = device_name.encode("utf-8") - - result_code = GetGPUForDisplay(encoded_name, buffer, 256) - - if result_code != STATUS_OK: - return None, result_code - - gpu_name = buffer.value.decode("utf-8") - return (gpu_name if gpu_name else None), result_code - - -# ============================================================================= -# Registry EDID Lookup -# ============================================================================= - - -def _get_device_interface_detail_size() -> int: - """Get the correct cbSize value for SP_DEVICE_INTERFACE_DETAIL_DATA based on architecture.""" - return 8 if ctypes.sizeof(ctypes.c_void_p) == 8 else 6 - - -def _read_edid_from_registry(hkey) -> Optional[dict]: - """ - Read and parse EDID data from an open registry key. - - Args: - hkey: Open registry key handle for a monitor device - - Returns: - Parsed EDID dictionary, or None if read fails. - """ - edid_size = wintypes.DWORD() - - # Query EDID value size first - if RegQueryValueExA(hkey, b"EDID", None, None, None, ctypes.byref(edid_size)) != 0: - return None - - # Read the actual EDID data - edid_buffer = (ctypes.c_ubyte * edid_size.value)() - if RegQueryValueExA(hkey, b"EDID", None, None, edid_buffer, ctypes.byref(edid_size)) != 0: - return None +from hwprobe.models.display_models import DisplayInfo, DisplayModuleInfo, ResolutionInfo +from hwprobe.models.status_models import StatusType - return parse_edid(bytes(edid_buffer)) +def _enrich_from_edid(module: DisplayModuleInfo, edid_bytes: bytes) -> DisplayModuleInfo: + """Fill in gaps on a DisplayModuleInfo from parsed EDID data. -def _extract_device_path(detail_buffer) -> str: - """Extract the device path string from a device interface detail buffer.""" - path_offset = ctypes.sizeof(wintypes.DWORD) - raw_path = ctypes.string_at(ctypes.addressof(detail_buffer) + path_offset) - return raw_path.decode("ascii", errors="ignore").upper() - - -def get_edid_by_hwid(hwid: str) -> Optional[dict]: - """ - Fetch EDID data from the Windows registry for a specific display device. - - Uses Windows SetupAPI to enumerate monitor device interfaces and find - the one matching the given hardware ID, then reads its EDID from registry. - - Args: - hwid: Hardware ID to match. Can be in formats like: - - "MONITOR\\AG326UD\\{...}" - - "\\\\?\\DISPLAY#MANUF#..." - - Returns: - Parsed EDID dictionary containing manufacturer_code, vendor_id, product_id, - serial, name, and inches. Returns None if not found or on error. - """ - if not hwid: - return None - - hwid_upper = hwid.upper() - - # Get device information set for monitor interfaces - device_info_set = SetupDiGetClassDevsA( - ctypes.byref(GUID_DEVINTERFACE_MONITOR), - None, - None, - DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, - ) - - if device_info_set == _INVALID_HKEY: - return None - - try: - return _enumerate_and_find_edid(device_info_set, hwid_upper) - finally: - SetupDiDestroyDeviceInfoList(device_info_set) - - -def _enumerate_and_find_edid(device_info_set, hwid_upper: str) -> Optional[dict]: - """ - Enumerate device interfaces and find EDID for matching hardware ID. - - Args: - device_info_set: Handle to device information set - hwid_upper: Uppercase hardware ID to match - - Returns: - Parsed EDID dictionary if found, None otherwise. - """ - interface_index = 0 - - while True: - interface_data = SP_DEVICE_INTERFACE_DATA() - interface_data.cbSize = ctypes.sizeof(SP_DEVICE_INTERFACE_DATA) - - # Enumerate next interface - if not SetupDiEnumDeviceInterfaces( - device_info_set, - None, - ctypes.byref(GUID_DEVINTERFACE_MONITOR), - interface_index, - ctypes.byref(interface_data), - ): - break - - edid = _try_get_edid_for_interface(device_info_set, interface_data, hwid_upper) - if edid is not None: - return edid - - interface_index += 1 - - return None - - -def _try_get_edid_for_interface(device_info_set, interface_data, hwid_upper: str) -> Optional[dict]: - """ - Attempt to retrieve EDID for a single device interface if it matches the hardware ID. - - Args: - device_info_set: Handle to device information set - interface_data: Device interface data structure - hwid_upper: Uppercase hardware ID to match - - Returns: - Parsed EDID dictionary if this interface matches and EDID is readable, None otherwise. - """ - # Get required buffer size for interface detail - required_size = wintypes.DWORD(0) - SetupDiGetDeviceInterfaceDetailA( - device_info_set, - ctypes.byref(interface_data), - None, - 0, - ctypes.byref(required_size), - None, - ) - - if required_size.value == 0: - return None - - # Prepare detail buffer with correct cbSize - detail_buffer = ctypes.create_string_buffer(required_size.value) - struct.pack_into("I", detail_buffer, 0, _get_device_interface_detail_size()) - - device_data = SP_DEVINFO_DATA() - device_data.cbSize = ctypes.sizeof(SP_DEVINFO_DATA) - - # Get interface detail and device info - if not SetupDiGetDeviceInterfaceDetailA( - device_info_set, - ctypes.byref(interface_data), - detail_buffer, - required_size, - None, - ctypes.byref(device_data), - ): - return None - - # Check if this device matches our hardware ID - device_path = _extract_device_path(detail_buffer) - if hwid_upper not in device_path: - return None - - # Open registry key and read EDID - registry_key = SetupDiOpenDevRegKey( - device_info_set, - ctypes.byref(device_data), - DICS_FLAG_GLOBAL, - 0, - DIREG_DEV, - KEY_READ, - ) - - if registry_key == _INVALID_HKEY or registry_key == 0: - return None - - try: - return _read_edid_from_registry(registry_key) - finally: - RegCloseKey(registry_key) - - -# ============================================================================= -# Monitor Enumeration -# ============================================================================= - - -def _get_orientation_name(orientation_code: int) -> str: - """Convert Windows display orientation code to human-readable name.""" - return _ORIENTATION_NAMES.get(orientation_code, "Unknown") - - -def _get_connection_type(connector_info: Optional[dict]) -> Optional[str]: - """ - Extract connection type name from connector info. - - Args: - connector_info: Dictionary containing OutputTechnology value - - Returns: - Human-readable connection type (e.g., "HDMI", "DisplayPort"), or None. - """ - if not connector_info: - return None - - try: - technology_code = int(connector_info.get("OutputTechnology", -2)) - return DISPLAY_CON_TYPE.get(technology_code) - except (ValueError, TypeError): - return None - - -def _fetch_edid_for_monitor(connector_info: Optional[dict], pnp_device_id: str) -> tuple[Optional[dict], Optional[str]]: + Only sets fields that are currently None — existing values win. + Resolution fields are merged individually. """ - Fetch EDID data for a monitor, preferring display path over PNP ID. + data = parse_edid(edid_bytes) - Args: - connector_info: Connector info dict with DisplayPath (may be None) - pnp_device_id: PNP device ID as fallback + for field in data.model_dump().keys(): + if field == "resolution": + continue + if getattr(module, field) is None: + setattr(module, field, getattr(data, field)) - Returns: - Tuple of (edid_dict, device_path) where device_path may be None. - """ - device_path = None - - # Prefer display path for more accurate EDID matching - if connector_info: - device_path = connector_info.get("DisplayPath") - if device_path: - edid = get_edid_by_hwid(device_path) - if edid: - return edid, device_path - - # Fallback to PNP device ID - if pnp_device_id: - parts = pnp_device_id.split("\\") - if len(parts) > 1: - edid = get_edid_by_hwid(parts[1]) - return edid, device_path - - return None, device_path - - -def _build_monitor_info( - device_id: str, - hardware_id: str, - device_path: Optional[str], - display_mode: DEVMODEA, - edid: Optional[dict], - gpu_name: Optional[str], - connection_type: Optional[str], -) -> DisplayModuleInfo: - """ - Construct a DisplayModuleInfo object from collected data. - - Args: - device_id: Display device identifier (e.g., "\\\\.\\DISPLAY1") - hardware_id: PNP hardware ID - device_path: Display path string (may be None) - display_mode: DEVMODEA structure with current display settings - edid: Parsed EDID dictionary (may be None) - gpu_name: Name of associated GPU (may be None) - connection_type: Connection type string (may be None) - - Returns: - Populated DisplayModuleInfo object. - """ - monitor = DisplayModuleInfo() - - # Basic identification - monitor.device_id = device_id - monitor.acpi_path = hardware_id - monitor.device_path = device_path - monitor.gpu_name = gpu_name - monitor.interface = connection_type - - # Resolution info - monitor.resolution.width = display_mode.dmPelsWidth - monitor.resolution.height = display_mode.dmPelsHeight - monitor.resolution.refresh_rate = display_mode.dmDisplayFrequency - monitor.resolution.aspect_ratio = get_aspect_ratio(display_mode.dmPelsWidth, display_mode.dmPelsHeight) - - # Orientation - monitor.orientation = _get_orientation_name(display_mode.dmDisplayOrientation) - - # EDID-derived information - if edid: - monitor.name = edid.get("name") - monitor.inches = edid.get("inches") - monitor.manufacturer_code = edid.get("manufacturer_code") - monitor.serial_number = str(edid["serial"]) if edid.get("serial") is not None else None - monitor.vendor_id = f"0x{edid['vendor_id']:04X}" if edid.get("vendor_id") else None - monitor.product_id = f"0x{edid['product_id']:04X}" if edid.get("product_id") else None - - return monitor - - -def _add_partial_status(display_info: DisplayInfo, message: str) -> None: - """Add a partial status message to the display info.""" - display_info.status = Status(type=StatusType.PARTIAL) - display_info.status.messages.append(message) - - -def _monitor_enum_callback(hmonitor, hdc, rect, lparam) -> bool: - """ - Callback function for EnumDisplayMonitors. - - Called for each active monitor. Collects display information and adds - it to the DisplayInfo list passed via lparam. - - Args: - hmonitor: Handle to the display monitor - hdc: Handle to device context (unused) - rect: Pointer to RECT with monitor coordinates (unused) - lparam: Pointer to DisplayInfo object being populated - - Returns: - True to continue enumeration, False to stop. - """ - # Retrieve the DisplayInfo object from the pointer - display_info_ptr = ctypes.cast(lparam, ctypes.POINTER(ctypes.py_object)) - display_info: DisplayInfo = display_info_ptr.contents.value - - # Get monitor info including device name - monitor_info = MONITORINFOEXA() - monitor_info.cbSize = ctypes.sizeof(monitor_info) - GetMonitorInfoA(hmonitor, ctypes.byref(monitor_info)) - - device_id = monitor_info.szDevice.decode() - if not device_id: - _add_partial_status(display_info, "Failed to fetch Display device information, DeviceID is empty!") - return True + if data.resolution is None: + return module + if module.resolution is None: + module.resolution = data.resolution + return module + for field in data.resolution.model_dump(): + if getattr(module.resolution, field) is None: + setattr(module.resolution, field, getattr(data.resolution, field)) - # Get current display settings - display_mode = DEVMODEA() - display_mode.dmSize = ctypes.sizeof(display_mode) - EnumDisplaySettingsA(monitor_info.szDevice, ENUM_CURRENT_SETTINGS, ctypes.byref(display_mode)) + return module - # Get PNP device ID - display_device = DISPLAY_DEVICEA(cb=ctypes.sizeof(DISPLAY_DEVICEA)) - EnumDisplayDevicesA(monitor_info.szDevice, 0, ctypes.byref(display_device), 0) - pnp_device_id = display_device.DeviceID.decode() - if not pnp_device_id: - _add_partial_status(display_info, "Failed to fetch Display device information, PNPDeviceID is empty!") - return True - - # Get connector info for this display - connector_info_map = getattr(display_info, "_connectorInfo", None) - connector_info = connector_info_map.get(device_id) if connector_info_map else None - - # Get GPU association - gpu_name, gpu_result_code = find_monitor_gpu(device_id) - - # Get EDID and connection type - edid, device_path = _fetch_edid_for_monitor(connector_info, pnp_device_id) - connection_type = _get_connection_type(connector_info) - - # Build and add monitor info - monitor = _build_monitor_info( - device_id=device_id, - hardware_id=pnp_device_id, - device_path=device_path, - display_mode=display_mode, - edid=edid, - gpu_name=gpu_name if gpu_result_code == STATUS_OK else None, - connection_type=connection_type, - ) - - display_info.modules.append(monitor) - return True - - -# Keep the original name for backward compatibility -monitor_enum_proc = _monitor_enum_callback - - -# ============================================================================= -# Public API -# ============================================================================= - - -def _fetch_connector_info() -> tuple[Optional[dict], Optional[tuple[StatusType, str]]]: - """ - Fetch display connector information from the interop DLL. - - Returns: - Tuple of (connector_info_dict, error_info): - - connector_info_dict: Parsed connector info, or None on failure - - error_info: Tuple of (StatusType, message) if failed, None on success - """ - buffer = ctypes.create_string_buffer(4096) - result_code = GetDisplayPathInfo(buffer, 4096) - - if result_code != STATUS_OK: - return None, (StatusType.PARTIAL, f"Failed to fetch Display connector information, error code: {result_code}") - - raw_string = buffer.value.decode("utf-8", errors="ignore").strip() - return parse_connector_info(raw_string), None - - -def fetch_display_info_internal() -> DisplayInfo: - """ - Enumerate all display monitors and collect their information. - - This is the main entry point for display enumeration. It: - 1. Fetches connector information from the interop DLL - 2. Enumerates all active monitors using EnumDisplayMonitors - 3. For each monitor, collects resolution, EDID data, GPU association, etc. - - Returns: - DisplayInfo object containing a list of DisplayModuleInfo objects, - one per detected monitor, along with status information. - """ +def fetch_display_info() -> DisplayInfo: display_info = DisplayInfo() - # Fetch connector information (display paths and connection types) - connector_info, error = _fetch_connector_info() - if error: - display_info.status.type = error[0] - display_info.status.messages.append(error[1]) - else: - display_info._connectorInfo = connector_info - - # Enumerate all monitors - display_info_ptr = ctypes.py_object(display_info) - enum_callback = MONITORENUMPROC(_monitor_enum_callback) - - EnumDisplayMonitors(0, 0, enum_callback, ctypes.addressof(display_info_ptr)) - - # Mark as failed if no monitors were found - if len(display_info.modules) == 0: + # Connector info from CCD API (display paths + output technology) + try: + connectors = {c.display_id: c for c in get_display_connectors()} + except RuntimeError: + connectors = {} + display_info.status.type = StatusType.PARTIAL + display_info.status.messages.append("Failed to fetch display connector information") + + for dev in get_monitor_devices(): + module = DisplayModuleInfo() + module.acpi_path = dev.pnp_device_id + module.resolution = ResolutionInfo( + width=dev.width, + height=dev.height, + refresh_rate=float(dev.refresh_rate), + ) + + # GPU association via DXGI + module.gpu_name = get_gpu_for_display(dev.device_id) + + # Connector type from CCD (more authoritative than EDID) + connector = connectors.get(dev.device_id) + if connector: + module.interface = DISPLAY_CON_TYPE.get(connector.output_technology) + + # EDID — prefer display path for matching, fall back to PNP ID + edid_key = ( + connector.display_path + if connector and connector.display_path + else dev.pnp_device_id.split("\\")[1] if "\\" in dev.pnp_device_id else dev.pnp_device_id + ) + edid_bytes = get_edid(edid_key) + if edid_bytes: + module = _enrich_from_edid(module, edid_bytes) + + display_info.modules.append(module) + + if not display_info.modules: display_info.status.type = StatusType.FAILED return display_info diff --git a/src/hwprobe/core/windows/graphics.py b/src/hwprobe/core/windows/graphics.py index c1c57f6..874c363 100644 --- a/src/hwprobe/core/windows/graphics.py +++ b/src/hwprobe/core/windows/graphics.py @@ -1,23 +1,49 @@ -from hwprobe.interops.win.bindings.gpu_info import GPUProperties, get_gpu_info +from hwprobe.core.windows.common import format_acpi_path, format_pci_path +from hwprobe.interops.win.bindings.gpu_info import GPURaw, get_gpu_info from hwprobe.models.gpu_models import GPUInfo, GraphicsInfo from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType +from hwprobe.util.location_paths import fetch_pcie_info, get_location_paths +_VENDOR_NAMES = { + 0x10DE: "NVIDIA", + 0x1002: "AMD", + 0x8086: "Intel", +} -def _map_gpu(raw: GPUProperties) -> GPUInfo: + +def _map_gpu(raw: GPURaw) -> GPUInfo: gpu = GPUInfo() gpu.name = raw.name - gpu.manufacturer = raw.manufacturer + gpu.manufacturer = _VENDOR_NAMES.get(raw.vendor_id, f"Not Recognized (0x{raw.vendor_id:04X})") gpu.vendor_id = f"0x{raw.vendor_id:04X}" gpu.device_id = f"0x{raw.device_id:04X}" - gpu.subsystem_manufacturer = f"0x{raw.subsystem_vendor_id:04X}" - gpu.subsystem_model = f"0x{raw.subsystem_device_id:04X}" - gpu.acpi_path = raw.acpi_path - gpu.pci_path = raw.pci_path - gpu.pcie_gen = raw.pcie_gen if raw.pcie_gen else None - gpu.pcie_width = raw.pcie_width if raw.pcie_width else None - if raw.vram_mb > 0: - gpu.vram = Megabyte(capacity=int(raw.vram_mb)) + + # Subsystem ID: DXGI gives a single uint32 — high 16 = vendor, low 16 = device + gpu.subsystem_manufacturer = f"0x{(raw.subsystem_id >> 16) & 0xFFFF:04X}" + gpu.subsystem_model = f"0x{raw.subsystem_id & 0xFFFF:04X}" + + # Location paths + PCIe: reuse util.location_paths (cfgmgr32 via ctypes) + if raw.pnp_device_id: + paths = get_location_paths(raw.pnp_device_id) + if paths: + for path in paths: + if path.startswith("ACPI") and not gpu.acpi_path: + gpu.acpi_path = format_acpi_path(path) + if path.startswith("PCIROOT") and not gpu.pci_path: + gpu.pci_path = format_pci_path(path) + + pcie = fetch_pcie_info(raw.pnp_device_id) + if pcie: + speed, width = pcie + gpu.pcie_gen = speed + gpu.pcie_width = width + + # VRAM: registry fallback wins if present, else DXGI value + vram_bytes = raw.vram_bytes or raw.dedicated_video_memory_bytes + if vram_bytes > 0: + gpu.vram = Megabyte(capacity=int(vram_bytes) // (1024 * 1024)) + return gpu diff --git a/src/hwprobe/core/windows/manager.py b/src/hwprobe/core/windows/manager.py index a7fb982..a5b64ee 100644 --- a/src/hwprobe/core/windows/manager.py +++ b/src/hwprobe/core/windows/manager.py @@ -1,7 +1,7 @@ from hwprobe.core.windows.audio import fetch_audio_info_fast from hwprobe.core.windows.baseboard import fetch_baseboard_info from hwprobe.core.windows.cpu import fetch_cpu_info -from hwprobe.core.windows.display import fetch_display_info_internal +from hwprobe.core.windows.display import fetch_display_info from hwprobe.core.windows.graphics import fetch_graphics_info from hwprobe.core.windows.memory import fetch_memory_info from hwprobe.core.windows.network import fetch_network_info_fast @@ -50,7 +50,7 @@ def fetch_network_info(self) -> NetworkInfo: return self.info.network def fetch_display_info(self) -> DisplayInfo: - self.info.display = fetch_display_info_internal() + self.info.display = fetch_display_info() return self.info.display def fetch_audio_info(self): diff --git a/src/hwprobe/core/windows/memory.py b/src/hwprobe/core/windows/memory.py index 536dee0..07b9b6d 100644 --- a/src/hwprobe/core/windows/memory.py +++ b/src/hwprobe/core/windows/memory.py @@ -1,10 +1,5 @@ -import ctypes - from hwprobe.core.windows.win_enum import ECC_MEMORY_TYPE, MEMORY_TYPE - -# todo: refactor to new bindings -from hwprobe.interops.win.legacy.constants import ECC_MULTI_BIT, ECC_SINGLE_BIT -from hwprobe.interops.win.legacy.signatures import GetWmiInfo +from hwprobe.interops.win.bindings.wmi import get_wmi_data from hwprobe.models.memory_models import ( MemoryInfo, MemoryModuleInfo, @@ -13,131 +8,91 @@ from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType +# Win32_PhysicalMemoryArray.MemoryErrorCorrection values +# https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-physicalmemoryarray +_ECC_SINGLE_BIT = 5 +_ECC_MULTI_BIT = 6 + def check_ecc() -> tuple[bool, str]: """ Checks if the system supports ECC memory by querying Win32_PhysicalMemoryArray. - More specifically, it only returns true if the "MemoryErrorCorrection" property is: + Returns true only if MemoryErrorCorrection is: 5 - Single-bit ECC 6 - Multi-bit ECC - - Returns: - Tuple[bool, str]: A tuple where the first element indicates if ECC is supported, - and the second element is the ECC type as a string. """ - query = b"SELECT MemoryErrorCorrection FROM Win32_PhysicalMemoryArray" - - # NOTE[kernel]: - # I don't really know how to implement support for multiple memory arrays, - # so we'll just check the first one for now. - buf_size = 256 * 1 - - buffer = ctypes.create_string_buffer(buf_size) - GetWmiInfo(query, b"ROOT\\CIMV2", buffer, buf_size) + rows = get_wmi_data("Win32_PhysicalMemoryArray", ["MemoryErrorCorrection"]) - raw_data = buffer.value.decode("utf-8", errors="ignore") - - if not raw_data: + if not rows: return False, "Unknown" - first_line = raw_data.split("\n")[0] - parsed_data = {x.split("=", 1)[0]: x.split("=", 1)[1] for x in first_line.split("|") if "=" in x} - ecc_type = parsed_data.get("MemoryErrorCorrection", "Unknown") - - supported = False - - if ecc_type.isdigit(): - ecc_type = int(ecc_type) - if ecc_type == ECC_SINGLE_BIT or ecc_type == ECC_MULTI_BIT: - supported = True + raw = rows[0].get("MemoryErrorCorrection", "") + if not raw.isdigit(): + return False, "Unknown" - return supported, (ECC_MEMORY_TYPE[ecc_type] if ecc_type in ECC_MEMORY_TYPE else "Unknown") + ecc_type = int(raw) + supported = ecc_type in (_ECC_SINGLE_BIT, _ECC_MULTI_BIT) + return supported, ECC_MEMORY_TYPE.get(ecc_type, "Unknown") def fetch_wmi_memory_info() -> MemoryInfo: memory_info = MemoryInfo() - # 256 bytes per property, 9 properties, 6 modules - buf_size = 256 * 9 * 8 - buffer = ctypes.create_string_buffer(buf_size) - - GetWmiInfo( - b"SELECT BankLabel, Capacity, Manufacturer, PartNumber, Speed, DeviceLocator, SMBIOSMemoryType, DataWidth, TotalWidth FROM Win32_PhysicalMemory", - b"ROOT\\CIMV2", - buffer, - buf_size, - ) + fields = [ + "BankLabel", "Capacity", "Manufacturer", "PartNumber", "Speed", + "DeviceLocator", "SMBIOSMemoryType", "DataWidth", "TotalWidth", + ] - """ - `raw_data` is in the following format: - BankLabel=...|Capacity=...|... - BankLabel=...|Capacity=...|... - ... - - Each module is separated by a newline; and for each module, - its properties are separated by a '|' character - """ - - raw_data = buffer.value.decode("utf-8", errors="ignore") + try: + rows = get_wmi_data("Win32_PhysicalMemory", fields) + except RuntimeError as e: + memory_info.status.type = StatusType.FAILED + memory_info.status.messages.append(str(e)) + return memory_info - if not raw_data: + if not rows: memory_info.status.type = StatusType.FAILED memory_info.status.messages.append("WMI query returned no data") return memory_info - for line in raw_data.split("\n"): - if not line or "|" not in line: - continue + # Query the array once — all modules share the same ECC capability. + ecc_supported, ecc_type = check_ecc() + for row in rows: module = MemoryModuleInfo() - unparsed = line.split("|") - - parsed_data = {x.split("=", 1)[0]: x.split("=", 1)[1] for x in unparsed if "=" in x} - - bank_label = parsed_data["BankLabel"] - capacity = parsed_data["Capacity"] - manufacturer = parsed_data["Manufacturer"] - part_number = parsed_data["PartNumber"] - speed = parsed_data["Speed"] - device_locator = parsed_data["DeviceLocator"] - smbios_mem_type = parsed_data["SMBIOSMemoryType"] - data_width = parsed_data["DataWidth"] - total_width = parsed_data["TotalWidth"] + capacity = row.get("Capacity", "") capacity = int(capacity) if capacity.isdigit() else 0 - module.capacity = Megabyte(capacity=capacity // (1024 * 1024)) + + manufacturer = row.get("Manufacturer", "") module.manufacturer = manufacturer.strip() if manufacturer else None + + part_number = row.get("PartNumber", "") module.part_number = part_number.strip() if part_number else None - slot = MemoryModuleSlot( + bank_label = row.get("BankLabel", "") + device_locator = row.get("DeviceLocator", "") + module.slot = MemoryModuleSlot( bank=bank_label.strip() if bank_label else None, channel=device_locator.strip() if device_locator else None, ) - module.slot = slot - # The speed is already reported as MHz + speed = row.get("Speed", "") module.frequency_mhz = int(speed) if speed.isdigit() else None - if smbios_mem_type: - smbios_mem_type = smbios_mem_type.strip() + smbios_mem_type = row.get("SMBIOSMemoryType", "") + if smbios_mem_type and smbios_mem_type.isdigit(): module.type = MEMORY_TYPE.get(int(smbios_mem_type), "Unknown") - if data_width and total_width: - if int(total_width) > int(data_width): - module.supports_ecc = True - else: - module.supports_ecc = False - - # NOTE[kernel]: - # I don't know if it's same to assume this, - # but I believe Win32_PhysicalMemoryArray indicates - # towards all memory modules in the system. - # - # Apparently, this isn't supposed to be like this, - # but I cannot find a better way to determine ECC support per module. - ecc_supported, ecc_type = check_ecc() + data_width = row.get("DataWidth", "") + total_width = row.get("TotalWidth", "") + if data_width.isdigit() and total_width.isdigit(): + module.supports_ecc = int(total_width) > int(data_width) + + # Win32_PhysicalMemoryArray reports ECC for the whole array, not per + # module. Override the per-module heuristic above with the array value. module.supports_ecc = ecc_supported module.ecc_type = ecc_type diff --git a/src/hwprobe/core/windows/network.py b/src/hwprobe/core/windows/network.py index bb3312a..2ae6c0f 100644 --- a/src/hwprobe/core/windows/network.py +++ b/src/hwprobe/core/windows/network.py @@ -1,10 +1,5 @@ -import ctypes - from hwprobe.core.windows.common import format_acpi_path, format_pci_path - -# todo: refactor to new bindings -from hwprobe.interops.win.legacy.constants import STATUS_OK -from hwprobe.interops.win.legacy.signatures import GetNetworkHardwareInfo +from hwprobe.interops.win.bindings.wmi import get_wmi_data from hwprobe.models.network_models import NetworkInfo, NICInfo from hwprobe.models.status_models import Status, StatusType from hwprobe.util.location_paths import get_location_paths @@ -13,46 +8,46 @@ def fetch_network_info_fast() -> NetworkInfo: network_info = NetworkInfo(status=Status(type=StatusType.SUCCESS)) - # 256 bytes per property, 3 properties, 5 modules - buf_size = 256 * 3 * 5 - raw_data = ctypes.create_string_buffer(buf_size) - - res = GetNetworkHardwareInfo(raw_data, buf_size) - - # the method couldn't execute successfully - if res != STATUS_OK: + try: + rows = get_wmi_data( + "Win32_NetworkAdapter", + ["Name", "Manufacturer", "PNPDeviceID", "AdapterType"], + ) + except RuntimeError as e: network_info.status.type = StatusType.FAILED - network_info.status.messages.append(f"Network HW info query failed with status code: {res}") + network_info.status.messages.append(str(e)) return network_info - decoded = raw_data.value.decode("utf-8", errors="ignore").strip() - - # data is empty - if not decoded: + if not rows: network_info.status.type = StatusType.FAILED - network_info.status.messages.append("Network HW info query returned no data") + network_info.status.messages.append("Network adapter query returned no data") return network_info - for line in decoded.split("\n"): - if not line or "|" not in line: + for row in rows: + # Skip loopback adapters + if "Loopback Interface" in row.get("AdapterType", "").strip(): continue - parsed = dict(x.split("=", 1) for x in line.split("|") if "=" in x) - - module = NICInfo() - pnp_device_id = parsed.get("PNPDeviceID", None) - manufacturer = parsed.get("Manufacturer", None) - name = parsed.get("Name", None) + pnp_device_id = row.get("PNPDeviceID", "").strip() + manufacturer = row.get("Manufacturer", "").strip() + name = row.get("Name", "").strip() if not pnp_device_id or not manufacturer or not name: network_info.status.type = StatusType.PARTIAL network_info.status.messages.append("Missing PNPDeviceID for network interface; skipping") continue - if "VEN_" in pnp_device_id and "DEV_" in pnp_device_id: + # Skip virtual/software adapters (ROOT\ prefix = software-enumerated) + upper_pnp = pnp_device_id.upper() + if upper_pnp.startswith("ROOT\\"): + continue + + module = NICInfo() + + if "VEN_" in upper_pnp and "DEV_" in upper_pnp: module.vendor_id = pnp_device_id.split("VEN_")[1][:4] module.device_id = pnp_device_id.split("DEV_")[1][:4] - elif "VID_" in pnp_device_id and "PID_" in pnp_device_id: + elif "VID_" in upper_pnp and "PID_" in upper_pnp: module.vendor_id = pnp_device_id.split("VID_")[1][:4] module.device_id = pnp_device_id.split("PID_")[1][:4] else: @@ -60,10 +55,8 @@ def fetch_network_info_fast() -> NetworkInfo: network_info.status.messages.append(f"Could not parse Vendor/Device ID from PNPDeviceID: {pnp_device_id}") loc = get_location_paths(pnp_device_id) - if loc is not None: pci, acpi = loc[:2] - module.pci_path = format_pci_path(pci) module.acpi_path = format_acpi_path(acpi) else: @@ -72,8 +65,8 @@ def fetch_network_info_fast() -> NetworkInfo: f"Could not determine location paths for NIC with PNPDeviceID: {pnp_device_id}" ) - module.manufacturer = manufacturer.strip() - module.name = name.strip() + module.manufacturer = manufacturer + module.name = name network_info.modules.append(module) return network_info diff --git a/src/hwprobe/core/windows/storage.py b/src/hwprobe/core/windows/storage.py index 69e52c5..0e532fb 100644 --- a/src/hwprobe/core/windows/storage.py +++ b/src/hwprobe/core/windows/storage.py @@ -1,9 +1,5 @@ -import ctypes - from hwprobe.core.windows.win_enum import BUS_TYPE, MEDIA_TYPE - -# todo: refactor to new bindings -from hwprobe.interops.win.legacy.signatures import GetWmiInfo +from hwprobe.interops.win.bindings.wmi import get_wmi_data from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType from hwprobe.models.storage_models import DiskInfo, StorageInfo @@ -11,45 +7,39 @@ def fetch_wmi_storage_info() -> StorageInfo: """ - Fetch storage information via WMI using the GetWmiInfo interop. - Returns a StorageInfo object with all detected disks. + Fetch storage information via WMI (MSFT_PhysicalDisk from the Storage + namespace). Returns a StorageInfo object with all detected disks. """ storage_info = StorageInfo() - # 256 bytes per property, 6 properties, 10 modules (mostly for NAS systems) - buf_size = 256 * 6 * 10 - buffer = ctypes.create_string_buffer(buf_size) - - query = b"SELECT FriendlyName, MediaType, BusType, Size, Manufacturer, Model FROM MSFT_PhysicalDisk" + fields = ["FriendlyName", "MediaType", "BusType", "Size", "Manufacturer", "Model"] - GetWmiInfo(query, b"ROOT\\Microsoft\\Windows\\Storage", buffer, buf_size) - - raw_data = buffer.value.decode("utf-8", errors="ignore") - if not raw_data: + try: + rows = get_wmi_data( + "MSFT_PhysicalDisk", + fields, + namespace=r"ROOT\Microsoft\Windows\Storage", + ) + except RuntimeError as e: storage_info.status.type = StatusType.FAILED - storage_info.status.messages.append("WMI query returned no data") + storage_info.status.messages.append(str(e)) return storage_info - for line in raw_data.split("\n"): - if not line or "|" not in line: - continue - + for row in rows: disk = DiskInfo() - props = {x.split("=", 1)[0]: x.split("=", 1)[1] for x in line.split("|") if "=" in x} - friendly_name = props.get("FriendlyName") - media_type = props.get("MediaType") - bus_type = props.get("BusType") - size = props.get("Size") - manufacturer = props.get("Manufacturer") - model = props.get("Model") + friendly_name = row.get("FriendlyName", "") + media_type = row.get("MediaType", "") + bus_type = row.get("BusType", "") + size = row.get("Size", "") + manufacturer = row.get("Manufacturer", "") + model = row.get("Model", "") disk.model = model.strip() if model else friendly_name.strip() if friendly_name else None disk.manufacturer = manufacturer.strip() if manufacturer else None disk.type = MEDIA_TYPE.get(int(media_type), "Unknown") if media_type and media_type.isdigit() else "Unknown" disk.size = Megabyte(capacity=int(size) // (1024 * 1024)) if size and size.isdigit() else None - # Map bus type conn_type, location = None, None if bus_type and bus_type.isdigit(): bt = BUS_TYPE.get(int(bus_type)) @@ -65,7 +55,6 @@ def fetch_wmi_storage_info() -> StorageInfo: storage_info.modules.append(disk) - # If at least one module was parsed, mark as success if storage_info.modules: storage_info.status.type = StatusType.SUCCESS else: diff --git a/src/hwprobe/core/windows/win_enum.py b/src/hwprobe/core/windows/win_enum.py index d8bdb22..a3d0ec0 100644 --- a/src/hwprobe/core/windows/win_enum.py +++ b/src/hwprobe/core/windows/win_enum.py @@ -119,3 +119,15 @@ 0x80000000: "INTERNAL", 0xFFFFFFFF: "FORCE_UINT32", } + +# https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-processor +CPU_ARCHITECTURES = { + 0: "x86", + 1: "MIPS", + 2: "Alpha", + 3: "PowerPC", + 5: "ARM", + 6: "ia64", + 9: "x64", + 12: "ARM64" +} \ No newline at end of file diff --git a/src/hwprobe/interops/win/CMakeLists.txt b/src/hwprobe/interops/win/CMakeLists.txt index b7f78a1..3006f91 100644 --- a/src/hwprobe/interops/win/CMakeLists.txt +++ b/src/hwprobe/interops/win/CMakeLists.txt @@ -9,41 +9,91 @@ if (NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE) endif () -# ---- Shared library (DLL) ---- -add_library(device_info SHARED - src/win_helpers.cpp +# Static runtime to avoid dependency issues when loaded by Python. +set(_STATIC_RUNTIME_OPTS -static-libgcc -static-libstdc++ -static) + +# ---- gpu_info.dll : GPU enumeration (DXGI + SetupAPI + registry VRAM) ---- +add_library(gpu_info SHARED src/gpu_info.cpp ) -# Force static linking of runtime libraries to avoid dependency issues in Python -target_link_options(device_info PRIVATE -static-libgcc -static-libstdc++ -static) +target_link_options(gpu_info PRIVATE ${_STATIC_RUNTIME_OPTS}) + +target_include_directories(gpu_info + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(gpu_info + PRIVATE + dxgi + setupapi + advapi32 +) + +set_target_properties(gpu_info PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + OUTPUT_NAME "gpu_info" + PREFIX "" +) + +# ---- wmi.dll : generic WMI wrapper (COM + WbemLocator) ---- +add_library(wmi SHARED + src/wmi.cpp +) + +target_link_options(wmi PRIVATE ${_STATIC_RUNTIME_OPTS}) + +target_include_directories(wmi + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(wmi + PRIVATE + ole32 + oleaut32 + wbemuuid +) + +set_target_properties(wmi PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + OUTPUT_NAME "wmi" + PREFIX "" +) + +# ---- display_info.dll : CCD connectors + DXGI GPU match + SetupAPI EDID ---- +add_library(display_info SHARED + src/display_info.cpp +) + +target_link_options(display_info PRIVATE ${_STATIC_RUNTIME_OPTS}) -target_include_directories(device_info +target_include_directories(display_info PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ) -target_link_libraries(device_info +target_link_libraries(display_info PRIVATE dxgi setupapi - cfgmgr32 advapi32 + user32 ) -# Output the DLL next to the Python binding -set_target_properties(device_info PROPERTIES +set_target_properties(display_info PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings - OUTPUT_NAME "device_info" + OUTPUT_NAME "display_info" PREFIX "" ) -# ---- Standalone test executable ---- +# ---- Standalone test executable (loads all DLLs at runtime) ---- add_executable(WinDeviceInfo main.cpp) target_include_directories(WinDeviceInfo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ) -# No longer linking directly to device_info to allow dynamic loading from custom path -# target_link_libraries(WinDeviceInfo PRIVATE device_info) +# Load both DLLs at runtime like the Python bindings do, so the test exe does +# not need matching import libs. diff --git a/src/hwprobe/interops/win/README.md b/src/hwprobe/interops/win/README.md index 587dd0e..b69ee50 100644 --- a/src/hwprobe/interops/win/README.md +++ b/src/hwprobe/interops/win/README.md @@ -1,23 +1,33 @@ -# WinDeviceInfo +# WinDeviceInfo (`interops/win/`) -A Windows utility and shared library that enumerates GPU hardware via DXGI and the Windows Configuration Manager API. +Two native bindings, each its own DLL, each its own Python ctypes module: -The native library lives in `src/` and `include/`, and is exposed via a command-line tester (`main.cpp`). -Also powers a thin Python `ctypes` binding in `bindings/gpu_info.py`. +| DLL | C++ export | Python binding | What it does | +|-----|-----------|----------------|--------------| +| `gpu_info.dll` | `get_gpu_info` | `bindings/gpu_info.py` | GPU enumeration via DXGI + SetupAPI (name, vendor/device IDs, VRAM, ACPI/PCI paths, PCIe gen/width). | +| `wmi.dll` | `get_wmi_data` | `bindings/wmi.py` | Generic WMI wrapper (COM + WbemLocator). Takes a class name + field list + namespace, returns one dict per row. | -This is intended to be used via each hardware component's respective python interface, like `gpu_info.py`. The CLI tool -is primarily for testing and demonstration purposes, but it can be used directly if desired. +Both DLLs land in `bindings/` next to their Python modules. The GPU binding is +the pre-existing one moved here from `win_old/`; the WMI binding is new and +replaces the legacy `GetWmiInfo` text-format export in `hw_helper.dll` (now in +`interops/win_old/`). Once `core/windows/` consumers are migrated, `win_old/` +can be deleted (see `windows-rewrite-llm-plan.md` §5). -Full disclosure: A big part of this C++ connector was written by Claude. -If you are someone with more know-how, and find lapses in this code, we'd be more than happy to welcome Pull Requests. +## Why two DLLs + +Different link dependencies, different APIs, no shared code: +- GPU: `dxgi`, `setupapi`, `cfgmgr32`, `advapi32`. +- WMI: `ole32`, `oleaut32`, `wbemuuid` (COM). + +Keeping them separate means a WMI-only consumer doesn't pull DXGI into its +process, and vice versa. Each Python module loads only the DLL it needs. ## Requirements -- Windows 10 or newer -- Visual Studio 2019+ or MSVC Build Tools (C++17 support required) +- Windows 10+ +- Visual Studio 2019+ / MSVC Build Tools (C++17) — or mingw-w64 (CI uses g++) - CMake 3.21+ -- Python 3.7+ (for the `gpu_info.py` binding) - Assuming you want to compile this to use with HWProbe. -- Windows SDK (for DXGI, SetupAPI, CfgMgr32 headers) +- Windows SDK (DXGI, SetupAPI, COM/WMI headers) ## Build @@ -26,86 +36,75 @@ cmake -S . -B build cmake --build build --config Release ``` -- `WinDeviceInfo.exe` (the CLI tool) is emitted to `build/Release/WinDeviceInfo.exe`. -- `device_info.dll` is copied automatically into `bindings/` for the Python binding. -- The default build type is **Release**. Pass `--config Debug` to the build command to include debug symbols. +Outputs: + +- `bindings/gpu_info.dll` — loaded by `bindings/gpu_info.py`. +- `bindings/wmi.dll` — loaded by `bindings/wmi.py`. +- `build/Release/WinDeviceInfo.exe` — standalone CLI self-test for both. -## CLI Usage +## Run + +C++ self-test (loads both DLLs, queries GPU + `Win32_Processor`): ```sh .\build\Release\WinDeviceInfo.exe ``` -The tool prints GPU info, and exits with code `0` when enumeration succeeds, or `1` if the underlying DXGI call fails. - -## Python Binding - -After building the project once (so that `bindings/device_info.dll` exists), you can inspect GPUs from Python: +Python self-checks: ```sh -cd bindings -python gpu_info.py +python -m hwprobe.interops.win.bindings.gpu_info # GPU +python -m hwprobe.interops.win.bindings.wmi # WMI (Win32_Processor) +python -m hwprobe.interops.win.bindings.verify_wmi # integration self-check ``` -or programmatically: +Programmatic use (the way `core/windows/*.py` calls them): ```python -from gpu_info import get_gpu_info - -for idx, gpu in enumerate(get_gpu_info()): - print(f"GPU {idx}:") - print(gpu) +# GPU +from hwprobe.interops.win.bindings.gpu_info import get_gpu_info +for g in get_gpu_info(): + print(g.name, f"0x{g.vendor_id:04X}", g.vram_mb) + +# WMI +from hwprobe.interops.win.bindings.wmi import get_wmi_data +rows = get_wmi_data( + "MSFT_PhysicalDisk", + ["FriendlyName", "MediaType", "BusType", "Size", "Manufacturer", "Model"], + namespace=r"ROOT\Microsoft\Windows\Storage", +) +for r in rows: + print(r["FriendlyName"], r["Size"]) ``` -On import, the script loads the colocated `device_info.dll`; ensure you rebuild the CMake project whenever you make -changes to the native code. - -## What the native library does - -For each GPU discovered via DXGI: +## WMI ABI caps -1. **Enumerates adapters** using `IDXGIFactory1::EnumAdapters1`, skipping software/virtual adapters. -2. **Resolves the PNP Device ID** by matching DXGI's VendorId/DeviceId/SubSysId against SetupAPI's display class. -3. **Parses vendor/device/subsystem IDs** from the PNP device ID string. -4. **Fetches VRAM** from DXGI's `DedicatedVideoMemory`; falls back to the registry - (`HardwareInformation.qwMemorySize`) for cards with >4 GB where DXGI may report a capped value. -5. **Resolves ACPI and PCI paths** via `CM_Get_DevNode_PropertyW` (location paths), formatted to match the - project's conventions (e.g. `\_SB_.PCI0.RP05.PXSX`, `PciRoot(0x0)/Pci(0x1C,0x5)/Pci(0x0,0x0)`). -6. **Fetches PCIe generation and lane width** via Configuration Manager device properties. +Defined in `include/wmi.h`, mirrored in `bindings/wmi.py`: -## Legacy bindings +| Cap | Value | Why | +|-----|-------|-----| +| `WMI_MAX_FIELDS` | 16 | Max fields any hwprobe query uses today is 9 (`Win32_PhysicalMemory`). | +| `WMI_FIELD_LEN` | 512 | Covers PNPDeviceID paths and uint64 string forms. | +| `WMI_MAX_ROWS` | 64 | Covers memory modules, disks, NICs on any realistic machine. | -The following files belong to the **old** monolithic binding approach and are kept for components that have not yet -been migrated. They are marked with `# todo: refactor to new bindings` in the consuming code. Once all components -are migrated, these files can be deleted: - -``` -interops/win/legacy/ - constants.py # Win32 constants, GUIDs, status codes - structs.py # ctypes Structure mirrors (MONITORINFOEXA, DEVMODEA, etc.) - signatures.py # Loads hw_helper.dll, sets argtypes/restypes for all exports - -interops/win/ - hw_helper.hpp # Monolithic C++ header (all structs + enums) - hw_helper.cpp # Monolithic C++ source (GPU, audio, network, SMBIOS, WMI - all in one file) - dll/ - hw_helper.dll # Pre-built monolithic DLL -``` +Raising any cap is a recompile on both sides — the struct is the ABI. No +runtime resizing. -Components still using the legacy bindings: +## Trust boundary (WMI) -- `core/windows/audio.py` -- `core/windows/baseboard.py` -- `core/windows/display.py` -- `core/windows/memory.py` -- `core/windows/network.py` -- `core/windows/storage.py` +`wmi_class`, `fields`, `namespace` are WMI identifiers, not free text. They +come from hardcoded literals in `core/windows/*.py`, never from end users. The +C++ side builds `SELECT f1,f2,... FROM ` with no escaping. If a future +caller ever passes user-supplied strings here, that caller must validate them — +do not push escaping into the C++ layer. -## Troubleshooting +## Status / scope -- **`device_info.dll not found`**: run the CMake build so the shared library is (re)generated in `bindings/`. -- **`get_gpu_info` returns -1**: verify that DXGI is available (Windows 10+ with a display driver installed). -- **VRAM shows 0 MB**: the registry fallback may not find a matching `DriverDesc`/`DriverVersion` entry. Check that - the GPU driver is properly installed. -- **PCIe gen/width shows 0**: the Configuration Manager property may not be exposed by all drivers. This is - driver-dependent and not a bug in the library. +- **In scope (this directory):** GPU binding (DXGI/SetupAPI) + WMI wrapper. +- **Out of scope (separate bindings, later):** audio (MMDevice), network (IP + Helper + SetupAPI), display (SetupAPI + EDID), baseboard (SMBIOS). These do + not go through WMI and are not served by the WMI binding. +- **Not yet wired:** no `core/windows/*.py` consumer imports the WMI binding + yet, per the rewrite plan. `memory.py` and `storage.py` are the first two + consumers once the greenlight is given. The GPU binding is already wired into + `core/windows/graphics.py`. diff --git a/src/hwprobe/interops/win/__init__.py b/src/hwprobe/interops/win/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hwprobe/interops/win/bindings/device_info.dll b/src/hwprobe/interops/win/bindings/device_info.dll deleted file mode 100644 index 92f8f2e..0000000 Binary files a/src/hwprobe/interops/win/bindings/device_info.dll and /dev/null differ diff --git a/src/hwprobe/interops/win/bindings/display_info.py b/src/hwprobe/interops/win/bindings/display_info.py new file mode 100644 index 0000000..76edaf4 --- /dev/null +++ b/src/hwprobe/interops/win/bindings/display_info.py @@ -0,0 +1,149 @@ +""" +display_info.py - Python ctypes binding for display_info.dll + +Four exports covering all Win32 display APIs: + - get_monitor_devices(): user32 monitor enumeration (EnumDisplayMonitors + settings) + - get_display_connectors(): CCD API (QueryDisplayConfig) — connector type + display path + - get_gpu_for_display(): DXGI output→adapter match — GPU name + - get_edid(): SetupAPI + registry EDID lookup — raw EDID bytes + +All Win32 calls live in C++ — Python only does EDID parsing and data assembly. + +Usage: + from hwprobe.interops.win.bindings.display_info import ( + get_monitor_devices, get_display_connectors, get_gpu_for_display, get_edid, + ) + +Source: interops/win/include/display_info.h and interops/win/src/display_info.cpp. +""" + +import ctypes +import pathlib +from dataclasses import dataclass +from typing import Optional + +_HERE = pathlib.Path(__file__).parent +_LIB_PATH = _HERE / "display_info.dll" + +if not _LIB_PATH.exists(): + raise FileNotFoundError( + f"display_info.dll not found at {_LIB_PATH}.\n" + f"Build the project first: cmake --build build --config Release" + ) + +_lib = ctypes.WinDLL(str(_LIB_PATH)) + + +# ---- mirror the C structs ---- + +class _MonitorDevice(ctypes.Structure): + _fields_ = [ + ("device_id", ctypes.c_char * 32), + ("pnp_device_id", ctypes.c_char * 128), + ("width", ctypes.c_int), + ("height", ctypes.c_int), + ("refresh_rate", ctypes.c_int), + ] + + +class _ConnectorInfo(ctypes.Structure): + _fields_ = [ + ("display_id", ctypes.c_char * 32), + ("display_path", ctypes.c_char * 512), + ("output_technology", ctypes.c_int), + ] + + +# ---- function signatures ---- + +_lib.get_monitor_devices.restype = ctypes.c_int +_lib.get_monitor_devices.argtypes = [ctypes.POINTER(_MonitorDevice), ctypes.c_int] + +_lib.get_display_connectors.restype = ctypes.c_int +_lib.get_display_connectors.argtypes = [ctypes.POINTER(_ConnectorInfo), ctypes.c_int] + +_lib.get_gpu_for_display.restype = ctypes.c_int +_lib.get_gpu_for_display.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int] + +_lib.get_edid.restype = ctypes.c_int +_lib.get_edid.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_ubyte), ctypes.c_int] + + +# ---- Python-facing dataclasses ---- + +@dataclass +class MonitorDevice: + device_id: str + pnp_device_id: str + width: int + height: int + refresh_rate: int + + +@dataclass +class ConnectorInfo: + display_id: str + display_path: str + output_technology: int + + +# ---- public API ---- + +_MAX = 8 + + +def get_monitor_devices() -> list[MonitorDevice]: + """Enumerate active monitors via user32. Returns one entry per attached display.""" + buf = (_MonitorDevice * _MAX)() + count = _lib.get_monitor_devices(buf, _MAX) + if count < 0: + raise RuntimeError("get_monitor_devices() failed (C library returned -1)") + + return [ + MonitorDevice( + device_id=buf[i].device_id.decode("utf-8", errors="replace").strip("\x00"), + pnp_device_id=buf[i].pnp_device_id.decode("utf-8", errors="replace").strip("\x00"), + width=buf[i].width, + height=buf[i].height, + refresh_rate=buf[i].refresh_rate, + ) + for i in range(count) + ] + + +def get_display_connectors() -> list[ConnectorInfo]: + """Return active display connector info from the CCD API.""" + buf = (_ConnectorInfo * _MAX)() + count = _lib.get_display_connectors(buf, _MAX) + if count < 0: + raise RuntimeError("get_display_connectors() failed (C library returned -1)") + + return [ + ConnectorInfo( + display_id=buf[i].display_id.decode("utf-8", errors="replace").strip("\x00"), + display_path=buf[i].display_path.decode("utf-8", errors="replace").strip("\x00"), + output_technology=buf[i].output_technology, + ) + for i in range(count) + ] + + +def get_gpu_for_display(device_name: str) -> Optional[str]: + """Find the GPU name driving a display device (e.g. r'\\.\DISPLAY1'). + Returns None if not found.""" + buf = ctypes.create_string_buffer(256) + rc = _lib.get_gpu_for_display(device_name.encode("utf-8"), buf, 256) + if rc != 0: + return None + name = buf.value.decode("utf-8", errors="replace").strip("\x00") + return name or None + + +def get_edid(pnp_device_id: str) -> Optional[bytes]: + """Read raw EDID bytes from the registry for a monitor matching the + given PNP device ID or display path. Returns None if not found.""" + buf = (ctypes.c_ubyte * 1024)() + count = _lib.get_edid(pnp_device_id.encode("utf-8"), buf, 1024) + if count <= 0: + return None + return bytes(buf[:count]) diff --git a/src/hwprobe/interops/win/bindings/gpu_info.dll b/src/hwprobe/interops/win/bindings/gpu_info.dll new file mode 100644 index 0000000..a062068 Binary files /dev/null and b/src/hwprobe/interops/win/bindings/gpu_info.dll differ diff --git a/src/hwprobe/interops/win/bindings/gpu_info.py b/src/hwprobe/interops/win/bindings/gpu_info.py index 6bafd28..c55b3c3 100644 --- a/src/hwprobe/interops/win/bindings/gpu_info.py +++ b/src/hwprobe/interops/win/bindings/gpu_info.py @@ -1,13 +1,17 @@ """ -gpu_info.py - Python ctypes binding for hw_helper.dll (GPU info) +gpu_info.py - Python ctypes binding for gpu_info.dll (raw GPU data) + +Returns raw values from DXGI + SetupAPI + registry. All parsing/derivation is +done in the consumer (core/windows/graphics.py): subsystem ID split, location +paths, PCIe info, manufacturer name, VRAM unit conversion. Usage: from hwprobe.interops.win.bindings.gpu_info import get_gpu_info gpus = get_gpu_info() for g in gpus: - print(g) + print(g.name, hex(g.vendor_id), g.pnp_device_id) -Source code is in `interops/win/include/` and `interops/win/src/`. +Source: interops/win/include/gpu_info.h and interops/win/src/gpu_info.cpp. """ import ctypes @@ -16,85 +20,56 @@ from typing import Optional _HERE = pathlib.Path(__file__).parent -_LIB_PATH = _HERE / "device_info.dll" +_LIB_PATH = _HERE / "gpu_info.dll" if not _LIB_PATH.exists(): raise FileNotFoundError( - f"device_info.dll not found at {_LIB_PATH}.\nBuild the project first: cmake --build build --config Release" + f"gpu_info.dll not found at {_LIB_PATH}.\nBuild the project first: cmake --build build --config Release" ) _lib = ctypes.WinDLL(str(_LIB_PATH)) -# ---- Mirror the C structs ---- - +# ---- mirror the C struct ---- -class _WinGPUProperties(ctypes.Structure): +class _WinGPURaw(ctypes.Structure): _fields_ = [ ("name", ctypes.c_char * 256), - ("manufacturer", ctypes.c_char * 256), ("vendor_id", ctypes.c_uint32), ("device_id", ctypes.c_uint32), - ("subsystem_vendor_id", ctypes.c_uint32), - ("subsystem_device_id", ctypes.c_uint32), - ("acpi_path", ctypes.c_char * 512), - ("pci_path", ctypes.c_char * 512), - ("vram_mb", ctypes.c_uint64), - ("pcie_gen", ctypes.c_int), - ("pcie_width", ctypes.c_int), + ("subsystem_id", ctypes.c_uint32), + ("dedicated_video_memory_bytes", ctypes.c_uint64), + ("pnp_device_id", ctypes.c_char * 512), + ("vram_bytes", ctypes.c_uint64), ] _lib.get_gpu_info.restype = ctypes.c_int -_lib.get_gpu_info.argtypes = [ctypes.POINTER(_WinGPUProperties), ctypes.c_int] +_lib.get_gpu_info.argtypes = [ctypes.POINTER(_WinGPURaw), ctypes.c_int] -# ---- Python-facing dataclass ---- +# ---- Python-facing dataclass (raw values, no parsing) ---- @dataclass -class GPUProperties: +class GPURaw: name: str - manufacturer: str vendor_id: int device_id: int - subsystem_vendor_id: int - subsystem_device_id: int - acpi_path: Optional[str] - pci_path: Optional[str] - vram_mb: int - pcie_gen: int - pcie_width: int - - def __str__(self) -> str: - lines = [ - f" Name: {self.name}", - f" Manufacturer: {self.manufacturer}", - f" Vendor ID: 0x{self.vendor_id:04X}", - f" Device ID: 0x{self.device_id:04X}", - f" Subsystem Vendor: 0x{self.subsystem_vendor_id:04X}", - f" Subsystem Device: 0x{self.subsystem_device_id:04X}", - f" VRAM: {self.vram_mb} MB", - ] - if self.pcie_gen: - lines.append(f" PCIe Gen: {self.pcie_gen}") - if self.pcie_width: - lines.append(f" PCIe Width: x{self.pcie_width}") - if self.acpi_path: - lines.append(f" ACPI Path: {self.acpi_path}") - if self.pci_path: - lines.append(f" PCI Path: {self.pci_path}") - return "\n".join(lines) - - -# ---- Public API ---- + subsystem_id: int + dedicated_video_memory_bytes: int + pnp_device_id: Optional[str] + vram_bytes: int + + +# ---- public API ---- _MAX_GPUS = 8 -def get_gpu_info() -> list[GPUProperties]: - """Return a list of GPUProperties for every GPU found on this machine.""" - buf = (_WinGPUProperties * _MAX_GPUS)() +def get_gpu_info() -> list[GPURaw]: + """Return a list of GPURaw for every GPU found on this machine.""" + buf = (_WinGPURaw * _MAX_GPUS)() count = _lib.get_gpu_info(buf, _MAX_GPUS) if count < 0: raise RuntimeError("get_gpu_info() failed (C library returned -1)") @@ -102,22 +77,16 @@ def get_gpu_info() -> list[GPUProperties]: result = [] for i in range(count): raw = buf[i] - acpi = raw.acpi_path.decode("utf-8", errors="replace").strip("\x00") or None - pci = raw.pci_path.decode("utf-8", errors="replace").strip("\x00") or None - + pnp = raw.pnp_device_id.decode("utf-8", errors="replace").strip("\x00") or None result.append( - GPUProperties( + GPURaw( name=raw.name.decode("utf-8", errors="replace").strip("\x00"), - manufacturer=raw.manufacturer.decode("utf-8", errors="replace").strip("\x00"), vendor_id=raw.vendor_id, device_id=raw.device_id, - subsystem_vendor_id=raw.subsystem_vendor_id, - subsystem_device_id=raw.subsystem_device_id, - acpi_path=acpi, - pci_path=pci, - vram_mb=raw.vram_mb, - pcie_gen=raw.pcie_gen, - pcie_width=raw.pcie_width, + subsystem_id=raw.subsystem_id, + dedicated_video_memory_bytes=raw.dedicated_video_memory_bytes, + pnp_device_id=pnp, + vram_bytes=raw.vram_bytes, ) ) return result @@ -128,5 +97,13 @@ def get_gpu_info() -> list[GPUProperties]: print(f"Found {len(gpus)} GPU(s):\n") for idx, g in enumerate(gpus): print(f"GPU {idx}:") - print(g) + print(f" Name: {g.name}") + print(f" Vendor ID: 0x{g.vendor_id:04X}") + print(f" Device ID: 0x{g.device_id:04X}") + print(f" Subsystem ID: 0x{g.subsystem_id:08X}") + print(f" Dedicated VRAM: {g.dedicated_video_memory_bytes} bytes") + if g.pnp_device_id: + print(f" PNP Device ID: {g.pnp_device_id}") + if g.vram_bytes: + print(f" Registry VRAM: {g.vram_bytes} bytes") print() diff --git a/src/hwprobe/interops/win/bindings/wmi.dll b/src/hwprobe/interops/win/bindings/wmi.dll new file mode 100644 index 0000000..0f4b82f Binary files /dev/null and b/src/hwprobe/interops/win/bindings/wmi.dll differ diff --git a/src/hwprobe/interops/win/bindings/wmi.py b/src/hwprobe/interops/win/bindings/wmi.py new file mode 100644 index 0000000..85b96c1 --- /dev/null +++ b/src/hwprobe/interops/win/bindings/wmi.py @@ -0,0 +1,122 @@ +""" +wmi.py - Python ctypes binding for the WMI wrapper in wmi.dll. + +Public API: + from hwprobe.interops.win.bindings.wmi import get_wmi_data + rows = get_wmi_data("Win32_PhysicalMemory", + ["BankLabel", "Capacity", "Manufacturer"], + namespace=r"ROOT\\CIMV2") + for row in rows: + print(row["BankLabel"], row["Capacity"]) + +Returns one dict per WMI row, keyed by the requested field names. Missing or +null properties come back as empty strings. Raises RuntimeError if the C++ +side returns -1 (COM/WMI failure). + +Source: interops/win/include/wmi.h and interops/win/src/wmi.cpp. +""" + +import ctypes +import pathlib + +# Mirror the C caps. Must match wmi.h exactly — these are the ABI. +WMI_MAX_FIELDS = 16 +WMI_FIELD_LEN = 512 +WMI_MAX_ROWS = 64 + +_HERE = pathlib.Path(__file__).parent +_LIB_PATH = _HERE / "wmi.dll" + +if not _LIB_PATH.exists(): + raise FileNotFoundError( + f"wmi.dll not found at {_LIB_PATH}.\n" + f"Build the project first: cmake -S { _HERE.parent } -B build && cmake --build build --config Release" + ) + +_lib = ctypes.WinDLL(str(_LIB_PATH)) + + +# ---- mirror the C struct ---- + +# ctypes: a fixed 2D char array. Field order matches WmiRow in wmi.h. +class _WmiRow(ctypes.Structure): + _fields_ = [ + ("values", (ctypes.c_char * WMI_FIELD_LEN) * WMI_MAX_FIELDS), + ] + + +# ---- function signature ---- +_lib.get_wmi_data.restype = ctypes.c_int +_lib.get_wmi_data.argtypes = [ + ctypes.c_char_p, # wmi_class + ctypes.POINTER(ctypes.c_char_p), # fields (array of c_char_p) + ctypes.c_int, # field_count + ctypes.c_char_p, # namespace_str + ctypes.POINTER(_WmiRow), # out + ctypes.c_int, # max_rows +] + + +# ---- public API ---- + +def get_wmi_data( + wmi_class: str, + fields: list[str], + namespace: str = r"ROOT\CIMV2", +) -> list[dict[str, str]]: + """ + Run `SELECT FROM ` against `namespace` via the C++ WMI + wrapper and return one dict per row, keyed by `fields` in order. + + Args: + wmi_class: WMI class name, e.g. "Win32_PhysicalMemory". + fields: Ordered list of property names to select. Max 16. + namespace: WMI namespace, e.g. r"ROOT\\CIMV2" or + r"ROOT\\Microsoft\\Windows\\Storage". Defaults to CIMV2. + + Returns: + list[dict[str, str]]: one entry per row. Missing/null properties are + empty strings. Empty list means the query succeeded but returned no + rows. + + Raises: + RuntimeError: the C++ side returned -1 (COM init / ConnectServer / + ExecQuery failure). + ValueError: too many fields requested. + """ + if len(fields) > WMI_MAX_FIELDS: + raise ValueError(f"too many fields: {len(fields)} > WMI_MAX_FIELDS={WMI_MAX_FIELDS}") + if not fields: + return [] + + class_b = wmi_class.encode("utf-8") + ns_b = namespace.encode("utf-8") + fields_b = (ctypes.c_char_p * len(fields))(*[f.encode("utf-8") for f in fields]) + + buf = (_WmiRow * WMI_MAX_ROWS)() + count = _lib.get_wmi_data(class_b, fields_b, len(fields), ns_b, buf, WMI_MAX_ROWS) + if count < 0: + raise RuntimeError("get_wmi_data() failed (C library returned -1)") + + result = [] + for i in range(count): + row = buf[i] + decoded = {} + for j, name in enumerate(fields): + raw = row.values[j] + # raw is c_char_Array; .value stops at the first NUL. + decoded[name] = raw.value.decode("utf-8", errors="replace") + result.append(decoded) + return result + + +# ---- quick self-check ---- +# Run with: python -m hwprobe.interops.win.bindings.wmi +if __name__ == "__main__": + rows = get_wmi_data("Win32_Processor", ["Name", "Manufacturer"]) + print(f"Found {len(rows)} CPU(s):\n") + for idx, r in enumerate(rows): + print(f"CPU {idx}:") + for k, v in r.items(): + print(f" {k}: {v}") + print() diff --git a/src/hwprobe/interops/win/include/display_info.h b/src/hwprobe/interops/win/include/display_info.h new file mode 100644 index 0000000..344c18a --- /dev/null +++ b/src/hwprobe/interops/win/include/display_info.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Active monitor device from user32 enumeration. +// One entry per attached display. +typedef struct { + char device_id[32]; // GDI device name, e.g. "\\.\DISPLAY1" + char pnp_device_id[128]; // PNP hardware ID, e.g. "MONITOR\SAMxxxx\{...}" + int width; // Current resolution width (pixels) + int height; // Current resolution height (pixels) + int refresh_rate; // Current refresh rate (Hz) +} MonitorDevice; + +// Display connector info from the CCD API (QueryDisplayConfig). +// One entry per active display path. +typedef struct { + char display_id[32]; // Source GDI device name, e.g. "\\.\DISPLAY1" + char display_path[512]; // Target monitor device path, e.g. "\\?\DISPLAY#..." + int output_technology; // DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY enum value +} ConnectorInfo; + +// Fill `out` with active monitor devices. Returns count, or -1 on error. +int get_monitor_devices(MonitorDevice *out, int max_count); + +// Fill `out` with active display connector info. Returns count, or -1 on error. +int get_display_connectors(ConnectorInfo *out, int max_count); + +// Find the GPU (adapter) name driving a given display device name. +// Returns 0 on success, -1 on failure. Writes a UTF-8 name into out_gpu_name. +int get_gpu_for_display(const char *device_name, char *out_gpu_name, int buf_size); + +// Read raw EDID bytes from the registry for a monitor matching `pnp_device_id`. +// Returns EDID byte count (>=128 on success), 0 if not found, -1 on error. +int get_edid(const char *pnp_device_id, unsigned char *out, int max_size); + +#ifdef __cplusplus +} +#endif diff --git a/src/hwprobe/interops/win/include/gpu_info.h b/src/hwprobe/interops/win/include/gpu_info.h index 4506ccd..a126fc8 100644 --- a/src/hwprobe/interops/win/include/gpu_info.h +++ b/src/hwprobe/interops/win/include/gpu_info.h @@ -6,28 +6,34 @@ extern "C" { #endif +// Raw GPU data from DXGI + SetupAPI. No parsing, no formatting — Python does +// all derivation (subsystem ID split, location paths, PCIe info, VRAM unit +// conversion, manufacturer name). C++ only does what Python can't: COM +// enumeration (DXGI) and SetupAPI handle flow. +// +// Fields: +// name — DXGI Description, UTF-8 +// vendor_id — DXGI VendorId +// device_id — DXGI DeviceId +// subsystem_id — DXGI SubSysId (raw uint32; high 16 = subsystem +// vendor, low 16 = subsystem device — Python splits) +// dedicated_video_memory_bytes — DXGI DedicatedVideoMemory, raw bytes +// pnp_device_id — from SetupAPI, raw "PCI\VEN_...&DEV_...&SUBSYS_..." +// (empty if SetupAPI match failed) +// vram_bytes — registry fallback for >4GB cards, raw bytes +// (0 if DXGI value was used or registry lookup failed) typedef struct { char name[256]; - char manufacturer[256]; uint32_t vendor_id; uint32_t device_id; - uint32_t subsystem_vendor_id; - uint32_t subsystem_device_id; - char acpi_path[512]; - char pci_path[512]; - uint64_t vram_mb; - int pcie_gen; - int pcie_width; -} WinGPUProperties; - -typedef enum { - GPU_STATUS_OK = 0, - GPU_STATUS_FAILURE = 1, - GPU_STATUS_INVALID_ARG = 2 -} GPUStatus; + uint32_t subsystem_id; + uint64_t dedicated_video_memory_bytes; + char pnp_device_id[512]; + uint64_t vram_bytes; +} WinGPURaw; // Fills `out` with GPU entries. Returns number of GPUs found, or -1 on error. -int get_gpu_info(WinGPUProperties *out, int max_count); +int get_gpu_info(WinGPURaw *out, int max_count); #ifdef __cplusplus } diff --git a/src/hwprobe/interops/win/include/wmi.h b/src/hwprobe/interops/win/include/wmi.h new file mode 100644 index 0000000..c741fb1 --- /dev/null +++ b/src/hwprobe/interops/win/include/wmi.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ABI caps. Raising any of these is a recompile on both sides. +// Fixed caps avoid heap ownership across the FFI boundary. +// 16 fields covers every WMI class hwprobe queries (max today: 9). +// 512 chars per field covers PNPDeviceID paths and uint64 string forms. +// 64 rows covers memory modules, disks, NICs on any realistic machine. +#define WMI_MAX_FIELDS 16 +#define WMI_FIELD_LEN 512 +#define WMI_MAX_ROWS 64 + +// One WMI row. values[i] corresponds to the i-th entry in the `fields` array +// passed to get_wmi_data. Missing/null/empty properties are written as "". +// Each slot is null-terminated and never overflows WMI_FIELD_LEN-1. +typedef struct { + char values[WMI_MAX_FIELDS][WMI_FIELD_LEN]; +} WmiRow; + +// Run a WQL query of the form `SELECT f1,f2,... FROM ` against +// `namespace_str` (e.g. "ROOT\\CIMV2") and fill `out` with up to `max_rows` +// rows. Returns the number of rows written, or -1 on any COM/WMI failure. +// +// `fields` is an array of `field_count` null-terminated UTF-8 strings, in the +// order the caller wants the values back. `field_count` must be <= +// WMI_MAX_FIELDS. `max_rows` must be <= WMI_MAX_ROWS. +// +// Trust boundary: `wmi_class`, `fields`, `namespace_str` are identifiers, not +// free text — they come from hardcoded literals in core/windows/*.py, never +// from end users. No escaping is applied. +int get_wmi_data(const char *wmi_class, + const char *const *fields, + int field_count, + const char *namespace_str, + WmiRow *out, + int max_rows); + +#ifdef __cplusplus +} +#endif diff --git a/src/hwprobe/interops/win/main.cpp b/src/hwprobe/interops/win/main.cpp index 4c4277b..4b93f24 100644 --- a/src/hwprobe/interops/win/main.cpp +++ b/src/hwprobe/interops/win/main.cpp @@ -1,65 +1,188 @@ +// Standalone CLI test for all bindings in interops/win/: +// gpu_info.dll -> get_gpu_info (DXGI + SetupAPI) +// wmi.dll -> get_wmi_data (COM + WbemLocator) +// display_info.dll -> get_display_connectors, get_gpu_for_display, get_edid +// +// Loads each DLL at runtime (same path the Python bindings use) so we exercise +// the real ABI without needing matching import libs. + #include "gpu_info.h" -#include +#include "wmi.h" +#include "display_info.h" #include -#include +#include +#include -typedef int (*get_gpu_info_ptr)(WinGPUProperties *, int); +typedef int (*get_gpu_info_ptr)(WinGPURaw *, int); +typedef int (*get_wmi_data_ptr)(const char *, const char *const *, int, + const char *, WmiRow *, int); -int main() { - // Try multiple paths to find the DLL: - // 1. Current directory - // 2. Relative to where it's built (cmake-build-debug/../bindings/device_info.dll) - // 3. Absolute "bindings/device_info.dll" from project root - - HMODULE hLib = LoadLibraryA("bindings/device_info.dll"); - if (!hLib) { - hLib = LoadLibraryA("../bindings/device_info.dll"); - } - if (!hLib) { - hLib = LoadLibraryA("device_info.dll"); - } +static HMODULE _load(const char *name) { + char path[128]; + std::snprintf(path, sizeof(path), "bindings/%s", name); + HMODULE h = LoadLibraryA(path); + if (!h) h = LoadLibraryA(name); + return h; +} +static int test_gpu() { + HMODULE hLib = _load("gpu_info.dll"); if (!hLib) { - DWORD err = GetLastError(); - printf("Error: Could not load device_info.dll (Error code: %lu)\n", err); + printf("[gpu] could not load gpu_info.dll (GetLastError=%lu)\n", GetLastError()); return 1; } - - auto get_gpu_info_func = reinterpret_cast(GetProcAddress(hLib, "get_gpu_info")); - if (!get_gpu_info_func) { - printf("Error: Could not find get_gpu_info in device_info.dll\n"); + auto fn = reinterpret_cast(GetProcAddress(hLib, "get_gpu_info")); + if (!fn) { + printf("[gpu] get_gpu_info not exported\n"); FreeLibrary(hLib); return 1; } constexpr int MAX_GPUS = 8; - WinGPUProperties gpus[MAX_GPUS] = {}; - - int count = get_gpu_info_func(gpus, MAX_GPUS); + WinGPURaw gpus[MAX_GPUS] = {}; + int count = fn(gpus, MAX_GPUS); if (count < 0) { - printf("Error: get_gpu_info() failed\n"); + printf("[gpu] get_gpu_info() failed\n"); FreeLibrary(hLib); return 1; } - printf("Found %d GPU(s):\n\n", count); + printf("[gpu] Found %d GPU(s):\n\n", count); for (int i = 0; i < count; ++i) { const auto &g = gpus[i]; printf("GPU %d:\n", i); - printf(" Name: %s\n", g.name); - printf(" Manufacturer: %s\n", g.manufacturer); - printf(" Vendor ID: 0x%04X\n", g.vendor_id); - printf(" Device ID: 0x%04X\n", g.device_id); - printf(" Subsystem Vendor: 0x%04X\n", g.subsystem_vendor_id); - printf(" Subsystem Device: 0x%04X\n", g.subsystem_device_id); - printf(" VRAM: %llu MB\n", g.vram_mb); - printf(" PCIe Gen: %d\n", g.pcie_gen); - printf(" PCIe Width: x%d\n", g.pcie_width); - if (g.acpi_path[0]) printf(" ACPI Path: %s\n", g.acpi_path); - if (g.pci_path[0]) printf(" PCI Path: %s\n", g.pci_path); + printf(" Name: %s\n", g.name); + printf(" Vendor ID: 0x%04X\n", g.vendor_id); + printf(" Device ID: 0x%04X\n", g.device_id); + printf(" Subsystem ID: 0x%08X\n", g.subsystem_id); + printf(" Dedicated VRAM: %llu bytes\n", (unsigned long long)g.dedicated_video_memory_bytes); + if (g.pnp_device_id[0]) + printf(" PNP Device ID: %s\n", g.pnp_device_id); + if (g.vram_bytes > 0) + printf(" Registry VRAM: %llu bytes\n", (unsigned long long)g.vram_bytes); + printf("\n"); + } + + FreeLibrary(hLib); + return 0; +} + +static int test_wmi() { + HMODULE hLib = _load("wmi.dll"); + if (!hLib) { + printf("[wmi] could not load wmi.dll (GetLastError=%lu)\n", GetLastError()); + return 1; + } + auto fn = reinterpret_cast(GetProcAddress(hLib, "get_wmi_data")); + if (!fn) { + printf("[wmi] get_wmi_data not exported\n"); + FreeLibrary(hLib); + return 1; + } + + const char *fields[] = {"Name", "Manufacturer", "NumberOfCores", "NumberOfLogicalProcessors"}; + const int field_count = sizeof(fields) / sizeof(fields[0]); + + WmiRow rows[WMI_MAX_ROWS] = {}; + int n = fn("Win32_Processor", fields, field_count, "ROOT\\CIMV2", rows, WMI_MAX_ROWS); + if (n < 0) { + printf("[wmi] get_wmi_data returned -1\n"); + FreeLibrary(hLib); + return 1; + } + + printf("[wmi] Found %d CPU(s):\n\n", n); + for (int i = 0; i < n; ++i) { + printf("CPU %d:\n", i); + for (int f = 0; f < field_count; ++f) { + printf(" %-26s %s\n", fields[f], rows[i].values[f]); + } printf("\n"); } FreeLibrary(hLib); return 0; } + +typedef int (*get_monitor_devices_ptr)(MonitorDevice *, int); +typedef int (*get_display_connectors_ptr)(ConnectorInfo *, int); +typedef int (*get_gpu_for_display_ptr)(const char *, char *, int); +typedef int (*get_edid_ptr)(const char *, unsigned char *, int); + +static int test_display() { + HMODULE hLib = _load("display_info.dll"); + if (!hLib) { + printf("[display] could not load display_info.dll (GetLastError=%lu)\n", GetLastError()); + return 1; + } + + auto fnMonitors = reinterpret_cast( + GetProcAddress(hLib, "get_monitor_devices")); + auto fnConnectors = reinterpret_cast( + GetProcAddress(hLib, "get_display_connectors")); + auto fnGpu = reinterpret_cast( + GetProcAddress(hLib, "get_gpu_for_display")); + auto fnEdid = reinterpret_cast( + GetProcAddress(hLib, "get_edid")); + + if (!fnMonitors || !fnConnectors || !fnGpu || !fnEdid) { + printf("[display] missing exports\n"); + FreeLibrary(hLib); + return 1; + } + + // Monitors + MonitorDevice monitors[8] = {}; + int nm = fnMonitors(monitors, 8); + if (nm < 0) { + printf("[display] get_monitor_devices failed\n"); + FreeLibrary(hLib); + return 1; + } + + // Connectors (for matching) + ConnectorInfo connectors[8] = {}; + int nc = fnConnectors(connectors, 8); + + printf("[display] Found %d monitor(s):\n\n", nm); + for (int i = 0; i < nm; ++i) { + printf("Monitor %d:\n", i); + printf(" DeviceID: %s\n", monitors[i].device_id); + printf(" PNPDeviceID: %s\n", monitors[i].pnp_device_id); + printf(" Resolution: %dx%d @ %d Hz\n", + monitors[i].width, monitors[i].height, monitors[i].refresh_rate); + + // GPU for this display + char gpuName[256] = {}; + if (fnGpu(monitors[i].device_id, gpuName, sizeof(gpuName)) == 0) { + printf(" GPU: %s\n", gpuName); + } + + // Connector info + for (int j = 0; j < nc; ++j) { + if (strcmp(connectors[j].display_id, monitors[i].device_id) == 0) { + printf(" Connector: %s (tech=%d)\n", + connectors[j].display_path, connectors[j].output_technology); + + // EDID via display path + unsigned char edidBuf[1024] = {}; + int edidLen = fnEdid(connectors[j].display_path, edidBuf, sizeof(edidBuf)); + if (edidLen > 0) { + printf(" EDID: %d bytes\n", edidLen); + } + break; + } + } + printf("\n"); + } + + FreeLibrary(hLib); + return 0; +} + +int main() { + int rc_gpu = test_gpu(); + int rc_wmi = test_wmi(); + int rc_display = test_display(); + return (rc_gpu || rc_wmi || rc_display) ? 1 : 0; +} diff --git a/src/hwprobe/interops/win/src/display_info.cpp b/src/hwprobe/interops/win/src/display_info.cpp new file mode 100644 index 0000000..8e4aa93 --- /dev/null +++ b/src/hwprobe/interops/win/src/display_info.cpp @@ -0,0 +1,264 @@ +// Display info: monitor enumeration + CCD connectors + DXGI GPU match + SetupAPI EDID. +// Returns raw values — Python does all parsing (EDID decode, connector type mapping). +// See display_info.h for the ABI. + +#include "display_info.h" + +#include +#include +#include +#include + +#include +#include +#include + +#ifdef _MSC_VER +#pragma comment(lib, "dxgi.lib") +#pragma comment(lib, "setupapi.lib") +#endif + +// ---- wide -> UTF-8 ---- + +static std::string WideToUtf8(const wchar_t *src) { + if (!src || !*src) return {}; + int len = WideCharToMultiByte(CP_UTF8, 0, src, -1, nullptr, 0, nullptr, nullptr); + if (len <= 1) return {}; + std::string out(len - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, src, -1, out.data(), len, nullptr, nullptr); + return out; +} + +// ===================================================================== +// get_monitor_devices — user32 monitor enumeration +// ===================================================================== + +struct MonitorEnumCtx { + MonitorDevice *devices; + int max_count; + int count; +}; + +static BOOL CALLBACK _monitorEnumProc(HMONITOR hMonitor, HDC, LPRECT, LPARAM lparam) { + auto *ctx = reinterpret_cast(lparam); + if (ctx->count >= ctx->max_count) return FALSE; + + MONITORINFOEXA mi = {}; + mi.cbSize = sizeof(mi); + if (!GetMonitorInfoA(hMonitor, &mi)) return TRUE; + if (mi.szDevice[0] == '\0') return TRUE; + + DEVMODEA dm = {}; + dm.dmSize = sizeof(dm); + EnumDisplaySettingsA(mi.szDevice, ENUM_CURRENT_SETTINGS, &dm); + + DISPLAY_DEVICEA dd = {}; + dd.cb = sizeof(dd); + EnumDisplayDevicesA(mi.szDevice, 0, &dd, 0); + if (dd.DeviceID[0] == '\0') return TRUE; + + MonitorDevice &md = ctx->devices[ctx->count]; + std::memset(&md, 0, sizeof(md)); + std::strncpy(md.device_id, mi.szDevice, sizeof(md.device_id) - 1); + std::strncpy(md.pnp_device_id, dd.DeviceID, sizeof(md.pnp_device_id) - 1); + md.width = static_cast(dm.dmPelsWidth); + md.height = static_cast(dm.dmPelsHeight); + md.refresh_rate = static_cast(dm.dmDisplayFrequency); + ++ctx->count; + + return TRUE; +} + +int get_monitor_devices(MonitorDevice *out, int max_count) { + if (!out || max_count <= 0) return -1; + + MonitorEnumCtx ctx = {out, max_count, 0}; + EnumDisplayMonitors(nullptr, nullptr, _monitorEnumProc, reinterpret_cast(&ctx)); + return ctx.count; +} + +// ===================================================================== +// get_display_connectors — CCD API (QueryDisplayConfig) +// ===================================================================== + +int get_display_connectors(ConnectorInfo *out, int max_count) { + if (!out || max_count <= 0) return -1; + + UINT32 pathCount = 0, modeCount = 0; + LONG rc = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount); + if (rc != ERROR_SUCCESS) return -1; + + std::vector paths(pathCount); + std::vector modes(modeCount); + + rc = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &pathCount, paths.data(), + &modeCount, modes.data(), nullptr); + if (rc != ERROR_SUCCESS) return -1; + + int count = 0; + for (UINT32 i = 0; i < pathCount && count < max_count; ++i) { + const auto &path = paths[i]; + + // Source device name (GDI device name like \\.\DISPLAY1) + DISPLAYCONFIG_SOURCE_DEVICE_NAME srcName = {}; + srcName.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME; + srcName.header.size = sizeof(srcName); + srcName.header.adapterId = path.sourceInfo.adapterId; + srcName.header.id = path.sourceInfo.id; + + if (DisplayConfigGetDeviceInfo(&srcName.header) != ERROR_SUCCESS) + continue; + + // Target device name (monitor device path like \\?\DISPLAY#...) + DISPLAYCONFIG_TARGET_DEVICE_NAME tgtName = {}; + tgtName.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME; + tgtName.header.size = sizeof(tgtName); + tgtName.header.adapterId = path.targetInfo.adapterId; + tgtName.header.id = path.targetInfo.id; + + if (DisplayConfigGetDeviceInfo(&tgtName.header) != ERROR_SUCCESS) + continue; + + ConnectorInfo &ci = out[count]; + std::memset(&ci, 0, sizeof(ci)); + + std::string src = WideToUtf8(srcName.viewGdiDeviceName); + std::strncpy(ci.display_id, src.c_str(), sizeof(ci.display_id) - 1); + + std::string tgt = WideToUtf8(tgtName.monitorDevicePath); + std::strncpy(ci.display_path, tgt.c_str(), sizeof(ci.display_path) - 1); + + ci.output_technology = static_cast(path.targetInfo.outputTechnology); + ++count; + } + + return count; +} + +// ===================================================================== +// get_gpu_for_display — DXGI output -> adapter name match +// ===================================================================== + +int get_gpu_for_display(const char *device_name, char *out_gpu_name, int buf_size) { + if (!device_name || !out_gpu_name || buf_size <= 0) return -1; + out_gpu_name[0] = '\0'; + + IDXGIFactory1 *factory = nullptr; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) + return -1; + + int result = -1; + IDXGIAdapter1 *adapter = nullptr; + + for (UINT a = 0; factory->EnumAdapters1(a, &adapter) != DXGI_ERROR_NOT_FOUND; ++a) { + DXGI_ADAPTER_DESC1 adesc; + if (FAILED(adapter->GetDesc1(&adesc))) { + adapter->Release(); + continue; + } + + IDXGIOutput *output = nullptr; + for (UINT o = 0; adapter->EnumOutputs(o, &output) != DXGI_ERROR_NOT_FOUND; ++o) { + DXGI_OUTPUT_DESC odesc; + if (FAILED(output->GetDesc(&odesc))) { + output->Release(); + continue; + } + + std::string devName = WideToUtf8(odesc.DeviceName); + if (devName == device_name) { + std::string gpuName = WideToUtf8(adesc.Description); + std::strncpy(out_gpu_name, gpuName.c_str(), buf_size - 1); + out_gpu_name[buf_size - 1] = '\0'; + result = 0; + output->Release(); + adapter->Release(); + factory->Release(); + return result; + } + output->Release(); + } + adapter->Release(); + } + + factory->Release(); + return result; +} + +// ===================================================================== +// get_edid — SetupAPI + registry EDID lookup +// ===================================================================== + +// {E6F07B5F-EE97-4A90-B076-33F57B4F4EA7} +static const GUID GUID_DEVINTERFACE_MONITOR = { + 0xE6F07B5F, 0xEE97, 0x4A90, + {0xB0, 0x76, 0x33, 0xF5, 0x7B, 0xF4, 0xEA, 0xA7} +}; + +int get_edid(const char *pnp_device_id, unsigned char *out, int max_size) { + if (!pnp_device_id || !out || max_size <= 0) return -1; + + // Convert the search key to uppercase wide string for case-insensitive matching. + std::string key(pnp_device_id); + for (auto &ch : key) ch = static_cast(toupper(static_cast(ch))); + + HDEVINFO devInfoSet = SetupDiGetClassDevsW( + &GUID_DEVINTERFACE_MONITOR, nullptr, nullptr, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + if (devInfoSet == INVALID_HANDLE_VALUE) return -1; + + int result = 0; + SP_DEVICE_INTERFACE_DATA ifaceData = {}; + ifaceData.cbSize = sizeof(ifaceData); + + for (DWORD i = 0; SetupDiEnumDeviceInterfaces(devInfoSet, nullptr, + &GUID_DEVINTERFACE_MONITOR, i, &ifaceData); ++i) { + + // Get required buffer size for interface detail. + DWORD requiredSize = 0; + SetupDiGetDeviceInterfaceDetailW(devInfoSet, &ifaceData, nullptr, 0, + &requiredSize, nullptr); + if (requiredSize == 0) continue; + + std::vector detailBuf(requiredSize); + auto *detail = reinterpret_cast(detailBuf.data()); + detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); + + SP_DEVINFO_DATA devData = {}; + devData.cbSize = sizeof(devData); + + if (!SetupDiGetDeviceInterfaceDetailW(devInfoSet, &ifaceData, detail, + requiredSize, nullptr, &devData)) + continue; + + // Device path is a wide string starting after the cbSize field. + std::string devPath = WideToUtf8(detail->DevicePath); + for (auto &ch : devPath) ch = static_cast(toupper(static_cast(ch))); + + // Match against the search key (substring match — the PNP ID is + // typically a portion of the full device path). + if (key.find(devPath) == std::string::npos && devPath.find(key) == std::string::npos) + continue; + + // Open the device registry key and read EDID. + HKEY hKey = SetupDiOpenDevRegKey(devInfoSet, &devData, DICS_FLAG_GLOBAL, + 0, DIREG_DEV, KEY_READ); + if (hKey == INVALID_HANDLE_VALUE || hKey == nullptr) + continue; + + DWORD edidSize = 0; + if (RegQueryValueExW(hKey, L"EDID", nullptr, nullptr, nullptr, &edidSize) == ERROR_SUCCESS) { + if (edidSize > 0 && static_cast(edidSize) <= max_size) { + if (RegQueryValueExW(hKey, L"EDID", nullptr, nullptr, out, &edidSize) == ERROR_SUCCESS) { + result = static_cast(edidSize); + } + } + } + + RegCloseKey(hKey); + if (result > 0) break; + } + + SetupDiDestroyDeviceInfoList(devInfoSet); + return result; +} diff --git a/src/hwprobe/interops/win/src/gpu_info.cpp b/src/hwprobe/interops/win/src/gpu_info.cpp index 3aa7f53..0ee28bb 100644 --- a/src/hwprobe/interops/win/src/gpu_info.cpp +++ b/src/hwprobe/interops/win/src/gpu_info.cpp @@ -1,25 +1,29 @@ +// Lean GPU enumeration: DXGI + SetupAPI + registry VRAM fallback only. +// Returns raw values — all parsing (subsystem split, location paths, PCIe, +// manufacturer name, VRAM unit conversion) is done in Python. +// See gpu_info.h for the ABI. + #include "gpu_info.h" -#include "win_helpers.h" #include #include +#include // DXGI_ADAPTER_FLAG_SOFTWARE #include #include +#include #include #include -#include -#include #include +#ifdef _MSC_VER #pragma comment(lib, "dxgi.lib") #pragma comment(lib, "setupapi.lib") +#endif -// ---- WMI-free GPU enumeration via DXGI + SetupAPI ---- +// ---- PNP device ID from DXGI vendor/device/subsys via SetupAPI ---- static std::wstring PnpDeviceIdFromDXGI(const DXGI_ADAPTER_DESC1 &desc) { - // DXGI gives us VendorId / DeviceId / SubSysId / Revision. - // We need to find the matching PNP device instance via SetupAPI. wchar_t match_hw_id[128]; swprintf_s(match_hw_id, L"PCI\\VEN_%04X&DEV_%04X", desc.VendorId, desc.DeviceId); @@ -62,13 +66,17 @@ static std::wstring PnpDeviceIdFromDXGI(const DXGI_ADAPTER_DESC1 &desc) { } // ---- Registry VRAM fallback for >4GB cards ---- +// DXGI's DedicatedVideoMemory is a UINT that can cap at 4GB on some drivers. +// The registry stores the real size as HardwareInformation.qwMemorySize (uint64) +// or HardwareInformation.MemorySize (uint32) under the display class key. -static uint64_t FetchVramFromRegistry(const std::string &device_name, const std::string &driver_version) { +static uint64_t FetchVramFromRegistry(const char *device_name, const char *driver_version) { const char *key_path = "SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}"; HKEY hKey; if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, key_path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) return 0; + uint64_t result = 0; for (DWORD i = 0; i < 100; ++i) { char sub_key_name[32]; DWORD name_size = sizeof(sub_key_name); @@ -90,24 +98,24 @@ static uint64_t FetchVramFromRegistry(const std::string &device_name, const std: reinterpret_cast(drv_ver), &drv_ver_size) == ERROR_SUCCESS); if (got_desc && got_ver && - device_name == drv_desc && driver_version == drv_ver) { + strcmp(device_name, drv_desc) == 0 && strcmp(driver_version, drv_ver) == 0) { uint64_t vram_bytes = 0; DWORD vram_size = sizeof(vram_bytes); if (RegQueryValueExA(hSubKey, "HardwareInformation.qwMemorySize", nullptr, nullptr, reinterpret_cast(&vram_bytes), &vram_size) == ERROR_SUCCESS && vram_bytes > 0) { + result = vram_bytes; RegCloseKey(hSubKey); - RegCloseKey(hKey); - return vram_bytes / (1024 * 1024); + break; } DWORD alt_vram = 0; DWORD alt_size = sizeof(alt_vram); if (RegQueryValueExA(hSubKey, "HardwareInformation.MemorySize", nullptr, nullptr, reinterpret_cast(&alt_vram), &alt_size) == ERROR_SUCCESS && alt_vram > 0) { + result = static_cast(alt_vram); RegCloseKey(hSubKey); - RegCloseKey(hKey); - return static_cast(alt_vram) / (1024 * 1024); + break; } } @@ -115,22 +123,30 @@ static uint64_t FetchVramFromRegistry(const std::string &device_name, const std: } RegCloseKey(hKey); - return 0; + return result; } // ---- Get driver version from registry for a PNP device ---- -static std::string GetDriverVersion(const std::wstring &pnp_device_id) { +static std::string GetDriverVersion(const wchar_t *pnp_device_id) { const char *key_path = "SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}"; HKEY hKey; if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, key_path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) return {}; - std::string pnp_utf8 = WideToUtf8(pnp_device_id.c_str()); - // Extract VEN_XXXX&DEV_XXXX portion for matching + // Convert PNP ID to UTF-8 for substring matching + int utf8_len = WideCharToMultiByte(CP_UTF8, 0, pnp_device_id, -1, nullptr, 0, nullptr, nullptr); + if (utf8_len <= 0) { RegCloseKey(hKey); return {}; } + std::string pnp_utf8(utf8_len - 1, '\0'); + if (WideCharToMultiByte(CP_UTF8, 0, pnp_device_id, -1, pnp_utf8.data(), utf8_len, nullptr, nullptr) <= 0) { + RegCloseKey(hKey); + return {}; + } + std::string upper_pnp = pnp_utf8; - for (auto &ch : upper_pnp) ch = toupper(ch); + for (auto &ch : upper_pnp) ch = static_cast(toupper(static_cast(ch))); + std::string result; for (DWORD i = 0; i < 100; ++i) { char sub_key_name[32]; DWORD name_size = sizeof(sub_key_name); @@ -146,15 +162,15 @@ static std::string GetDriverVersion(const std::wstring &pnp_device_id) { if (RegQueryValueExA(hSubKey, "MatchingDeviceId", nullptr, nullptr, reinterpret_cast(matching_id), &mid_size) == ERROR_SUCCESS) { std::string upper_mid = matching_id; - for (auto &ch : upper_mid) ch = toupper(ch); - if (upper_pnp.find(upper_mid) != std::string::npos || upper_mid.find("VEN_") != std::string::npos) { + for (auto &ch : upper_mid) ch = static_cast(toupper(static_cast(ch))); + if (upper_pnp.find(upper_mid) != std::string::npos) { char drv_ver[256] = {}; DWORD ver_size = sizeof(drv_ver); if (RegQueryValueExA(hSubKey, "DriverVersion", nullptr, nullptr, reinterpret_cast(drv_ver), &ver_size) == ERROR_SUCCESS) { + result = drv_ver; RegCloseKey(hSubKey); - RegCloseKey(hKey); - return drv_ver; + break; } } } @@ -162,35 +178,12 @@ static std::string GetDriverVersion(const std::wstring &pnp_device_id) { } RegCloseKey(hKey); - return {}; -} - -// ---- Vendor/Device/Subsystem ID parsing from PNP Device ID ---- - -struct PciIds { - uint32_t vendor_id; - uint32_t device_id; - uint32_t subsystem_vendor_id; - uint32_t subsystem_device_id; -}; - -static PciIds ParsePnpDeviceId(const std::string &pnp) { - PciIds ids = {}; - std::regex re(R"(VEN_([0-9A-Fa-f]{4}).*DEV_([0-9A-Fa-f]{4}).*SUBSYS_([0-9A-Fa-f]{4})([0-9A-Fa-f]{4}))", - std::regex::icase); - std::smatch m; - if (std::regex_search(pnp, m, re)) { - ids.vendor_id = std::stoul(m[1].str(), nullptr, 16); - ids.device_id = std::stoul(m[2].str(), nullptr, 16); - ids.subsystem_device_id = std::stoul(m[3].str(), nullptr, 16); - ids.subsystem_vendor_id = std::stoul(m[4].str(), nullptr, 16); - } - return ids; + return result; } // ---- Public API ---- -int get_gpu_info(WinGPUProperties *out, int max_count) { +int get_gpu_info(WinGPURaw *out, int max_count) { if (!out || max_count <= 0) return -1; IDXGIFactory1 *factory = nullptr; @@ -208,17 +201,16 @@ int get_gpu_info(WinGPUProperties *out, int max_count) { continue; } - // Skip software/remote adapters (DXGI_ADAPTER_FLAG_SOFTWARE = 2, defined in DXGI 1.2+) - // On Windows 7 no adapter sets this flag, so the check is a safe no-op. - if (desc.Flags & 2u) { + // Skip software/remote adapters + if (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) { adapter->Release(); continue; } // Deduplicate: DXGI can enumerate the same physical GPU multiple times (common on AMD APUs) - std::wstring pnp_id_early = PnpDeviceIdFromDXGI(desc); - if (!pnp_id_early.empty()) { - std::wstring upper_pnp = pnp_id_early; + std::wstring pnp_id = PnpDeviceIdFromDXGI(desc); + if (!pnp_id.empty()) { + std::wstring upper_pnp = pnp_id; for (auto &ch : upper_pnp) ch = towupper(ch); if (seen_pnp_ids.count(upper_pnp)) { adapter->Release(); @@ -227,65 +219,37 @@ int get_gpu_info(WinGPUProperties *out, int max_count) { seen_pnp_ids.insert(upper_pnp); } - WinGPUProperties gpu = {}; + WinGPURaw gpu = {}; - // Name from DXGI - char name_buf[256]; - WideCharToMultiByte(CP_UTF8, 0, desc.Description, -1, name_buf, sizeof(name_buf), nullptr, nullptr); - strncpy_s(gpu.name, name_buf, _TRUNCATE); + // Name from DXGI (wide -> UTF-8) + if (WideCharToMultiByte(CP_UTF8, 0, desc.Description, -1, gpu.name, sizeof(gpu.name), nullptr, nullptr) <= 0) + gpu.name[0] = '\0'; - // IDs from DXGI + // Raw IDs from DXGI gpu.vendor_id = desc.VendorId; gpu.device_id = desc.DeviceId; + gpu.subsystem_id = desc.SubSysId; - // PNP device instance already resolved above for dedup - const std::wstring &pnp_id = pnp_id_early; - std::string pnp_utf8 = WideToUtf8(pnp_id.c_str()); - - // Parse subsystem IDs from PNP device ID string - if (!pnp_utf8.empty()) { - PciIds ids = ParsePnpDeviceId(pnp_utf8); - gpu.subsystem_vendor_id = ids.subsystem_vendor_id; - gpu.subsystem_device_id = ids.subsystem_device_id; - } - - // VRAM: DXGI reports DedicatedVideoMemory in bytes - uint64_t vram_mb = desc.DedicatedVideoMemory / (1024 * 1024); - - // WMI/DXGI may report capped VRAM for >4GB cards; fall back to registry - if (vram_mb == 0 || desc.DedicatedVideoMemory >= 4194304000ULL) { - std::string drv_ver = GetDriverVersion(pnp_id); - uint64_t reg_vram = FetchVramFromRegistry(std::string(gpu.name), drv_ver); - if (reg_vram > 0) vram_mb = reg_vram; - } - gpu.vram_mb = vram_mb; + // Raw VRAM bytes from DXGI + gpu.dedicated_video_memory_bytes = desc.DedicatedVideoMemory; - // Location paths (ACPI + PCI) and PCIe info via Configuration Manager + // PNP device ID (UTF-8) for Python-side location/PCIe lookup if (!pnp_id.empty()) { - std::string acpi, pci; - if (GetDevNodeLocationPaths(pnp_id, acpi, pci)) { - strncpy_s(gpu.acpi_path, acpi.c_str(), _TRUNCATE); - strncpy_s(gpu.pci_path, pci.c_str(), _TRUNCATE); - } - - int gen = 0, width = 0; - if (GetDevNodePCIeInfo(pnp_id, gen, width)) { - gpu.pcie_gen = gen; - gpu.pcie_width = width; - } + if (WideCharToMultiByte(CP_UTF8, 0, pnp_id.c_str(), -1, + gpu.pnp_device_id, sizeof(gpu.pnp_device_id), nullptr, nullptr) <= 0) + gpu.pnp_device_id[0] = '\0'; } - // Manufacturer: map common vendor IDs - switch (gpu.vendor_id) { - case 0x10DE: strncpy_s(gpu.manufacturer, "NVIDIA", _TRUNCATE); break; - case 0x1002: strncpy_s(gpu.manufacturer, "AMD", _TRUNCATE); break; - case 0x8086: strncpy_s(gpu.manufacturer, "Intel", _TRUNCATE); break; - default: { - char hex[16]; - snprintf(hex, sizeof(hex), "0x%04X", gpu.vendor_id); - strncpy_s(gpu.manufacturer, hex, _TRUNCATE); - break; - } + // Registry VRAM fallback for >4GB cards (DXGI may cap at 4GB). + // Skip when SetupAPI failed to resolve a PNP ID — the registry lookup + // matches on DriverDesc + DriverVersion, and GetDriverVersion needs + // the PNP ID to find the right subkey. + if ((gpu.dedicated_video_memory_bytes == 0 || + gpu.dedicated_video_memory_bytes >= 4194304000ULL) && + !pnp_id.empty()) { + std::string drv_ver = GetDriverVersion(pnp_id.c_str()); + uint64_t reg_vram = FetchVramFromRegistry(gpu.name, drv_ver.c_str()); + if (reg_vram > 0) gpu.vram_bytes = reg_vram; } out[count++] = gpu; diff --git a/src/hwprobe/interops/win/src/wmi.cpp b/src/hwprobe/interops/win/src/wmi.cpp new file mode 100644 index 0000000..b73ae48 --- /dev/null +++ b/src/hwprobe/interops/win/src/wmi.cpp @@ -0,0 +1,222 @@ +// WMI wrapper for the new Windows interop. One generic function, no +// delimiter-based text format. See include/wmi.h for the ABI. + +#include "wmi.h" + +#include +#include +#include +#include + +#include +#include + +// #pragma comment(lib, ...) is MSVC-only; mingw ignores it. Linking is +// handled by CMakeLists.txt (target_link_libraries ... ole32 oleaut32 wbemuuid). +#ifdef _MSC_VER +#pragma comment(lib, "ole32.lib") +#pragma comment(lib, "oleaut32.lib") +#pragma comment(lib, "wbemuuid.lib") +#endif + +// ---- RAII BSTR from UTF-8 ---- +// _bstr_t's const-char* constructor calls _com_util::ConvertStringToBSTR, +// which lives in libcomsupp — a separate lib MSVC auto-links via pragma and +// mingw does not (and whose name varies across mingw distributions). Roll the +// one thing we need: SysAllocString from a wide string. No comsupp, no comdef. +class Bstr { +public: + explicit Bstr(const char *utf8) { + if (!utf8 || !*utf8) { b_ = SysAllocString(L""); return; } + int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); + if (wlen <= 0) { b_ = nullptr; return; } + std::wstring w(wlen - 1, L'\0'); + if (MultiByteToWideChar(CP_UTF8, 0, utf8, -1, w.data(), wlen) <= 0) { + b_ = nullptr; + return; + } + b_ = SysAllocString(w.c_str()); + } + ~Bstr() { if (b_) SysFreeString(b_); } + Bstr(const Bstr &) = delete; + Bstr &operator=(const Bstr &) = delete; + operator BSTR() const { return b_; } +private: + BSTR b_; +}; + +// ---- VARIANT -> fixed UTF-8 slot ---- +// Writes a null-terminated UTF-8 rendering of vt into dst[0..dst_size-1]. +// Missing/null/empty -> "". Overlong values are truncated cleanly at +// dst_size-1 (WideCharToMultiByte fails rather than truncates when the +// output doesn't fit, so we convert into a temp buffer first). +static void WideToUtf8Slot(const wchar_t *src, char *dst, int dst_size) { + if (!dst || dst_size <= 0) return; + + int written = 0; + if (src) { + int needed = WideCharToMultiByte(CP_UTF8, 0, src, -1, nullptr, 0, nullptr, nullptr); + if (needed > 0) { + if (needed <= dst_size) { + int rc = WideCharToMultiByte(CP_UTF8, 0, src, -1, dst, dst_size, nullptr, nullptr); + if (rc > 0) return; // success — null-terminated by WideCharToMultiByte + // fall through: dst[0] = '\0' + } else { + // Value exceeds the slot: convert fully into a temp buffer, then copy + // the prefix. Walk back from the cut point to avoid splitting a UTF-8 + // multi-byte sequence (continuation bytes have the high bits 10xxxxxx). + std::string tmp(needed - 1, '\0'); + int rc = WideCharToMultiByte(CP_UTF8, 0, src, -1, tmp.data(), needed, nullptr, nullptr); + if (rc > 0) { + int cut = dst_size - 1; + while (cut > 0 && (static_cast(tmp[cut]) & 0xC0) == 0x80) + --cut; + std::memcpy(dst, tmp.data(), cut); + written = cut; + } + } + } + } + dst[written] = '\0'; +} + +static void VariantToUtf8Slot(VARIANT &vt, char *dst, int dst_size) { + if (!dst || dst_size <= 0) return; + + const wchar_t *src = nullptr; + VARIANT vtBstr; + VariantInit(&vtBstr); + + if (vt.vt == VT_BSTR) { + src = vt.bstrVal; + } else if (vt.vt != VT_NULL && vt.vt != VT_EMPTY) { + HRESULT hr = VariantChangeType(&vtBstr, &vt, 0, VT_BSTR); + if (SUCCEEDED(hr)) src = vtBstr.bstrVal; + } + + WideToUtf8Slot(src, dst, dst_size); + VariantClear(&vtBstr); +} + +// ---- public entry ---- +extern "C" __declspec(dllexport) int get_wmi_data(const char *wmi_class, + const char *const *fields, + int field_count, + const char *namespace_str, + WmiRow *out, + int max_rows) +{ + if (!wmi_class || !fields || !out || field_count <= 0 || max_rows <= 0) + return -1; + if (field_count > WMI_MAX_FIELDS) return -1; + if (max_rows > WMI_MAX_ROWS) max_rows = WMI_MAX_ROWS; + if (!namespace_str || !*namespace_str) namespace_str = "ROOT\\CIMV2"; + + // Per-call CoInitializeEx. Idempotent via RPC_E_CHANGED_MODE. + HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(hr) && hr != RPC_E_CHANGED_MODE) return -1; + bool did_init = (hr != RPC_E_CHANGED_MODE && hr != S_FALSE); + + // CoInitializeSecurity may legitimately already be set on this thread; + // RPC_E_TOO_LATE is harmless. + CoInitializeSecurity(nullptr, -1, nullptr, nullptr, + RPC_C_AUTHN_LEVEL_DEFAULT, + RPC_C_IMP_LEVEL_IMPERSONATE, + nullptr, EOAC_NONE, nullptr); + + IWbemLocator *pLoc = nullptr; + hr = CoCreateInstance(CLSID_WbemLocator, 0, CLSCTX_INPROC_SERVER, + IID_IWbemLocator, reinterpret_cast(&pLoc)); + if (FAILED(hr)) { + if (did_init) CoUninitialize(); + return -1; + } + + // ConnectServer signature: + // HRESULT ConnectServer(BSTR strNetworkResource, BSTR strUser, + // BSTR strPassword, BSTR strLocale, + // LONG lSecurityFlags, BSTR strAuthority, + // IWbemContext *pCtx, IWbemServices **ppNamespace) + // lSecurityFlags is LONG — pass 0, not nullptr. nullptr won't convert to + // long under mingw (MSVC tolerates it via NULL==0, mingw does not). + IWbemServices *pSvc = nullptr; + Bstr nsBstr(namespace_str); + hr = pLoc->ConnectServer(nsBstr, + nullptr, // strUser + nullptr, // strPassword + nullptr, // strLocale + 0, // lSecurityFlags + nullptr, // strAuthority + nullptr, // pCtx + &pSvc); // ppNamespace + if (FAILED(hr)) { + pLoc->Release(); + if (did_init) CoUninitialize(); + return -1; + } + + CoSetProxyBlanket(pSvc, RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, nullptr, + RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, + nullptr, EOAC_NONE); + + // Build "SELECT f1,f2,... FROM ". Identifiers only — no escaping. + std::string wql = "SELECT "; + for (int i = 0; i < field_count; ++i) { + if (i) wql += ","; + wql += fields[i]; + } + wql += " FROM "; + wql += wmi_class; + + IEnumWbemClassObject *pEnum = nullptr; + Bstr lang("WQL"), query(wql.c_str()); + hr = pSvc->ExecQuery(lang, query, + WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, + nullptr, &pEnum); + if (FAILED(hr)) { + pSvc->Release(); + pLoc->Release(); + if (did_init) CoUninitialize(); + return -1; + } + + int rows = 0; + while (rows < max_rows) { + IWbemClassObject *pObj = nullptr; + ULONG uReturn = 0; + hr = pEnum->Next(WBEM_INFINITE, 1, &pObj, &uReturn); + if (uReturn == 0) break; + if (FAILED(hr)) { + if (pObj) pObj->Release(); + break; + } + + WmiRow &row = out[rows]; + for (int i = 0; i < field_count; ++i) { + VARIANT vtProp; + VariantInit(&vtProp); + Bstr field(fields[i]); + hr = pObj->Get(field, 0, &vtProp, nullptr, nullptr); + if (SUCCEEDED(hr)) { + VariantToUtf8Slot(vtProp, row.values[i], WMI_FIELD_LEN); + } else { + row.values[i][0] = '\0'; + } + VariantClear(&vtProp); + } + // Zero any unused slots beyond field_count for clean dict-building on + // the Python side (it zips only field_count entries, but defensive). + for (int i = field_count; i < WMI_MAX_FIELDS; ++i) { + row.values[i][0] = '\0'; + } + + pObj->Release(); + ++rows; + } + + pEnum->Release(); + pSvc->Release(); + pLoc->Release(); + if (did_init) CoUninitialize(); + return rows; +} diff --git a/src/hwprobe/interops/win_old/CMakeLists.txt b/src/hwprobe/interops/win_old/CMakeLists.txt new file mode 100644 index 0000000..b7f78a1 --- /dev/null +++ b/src/hwprobe/interops/win_old/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.21) +project(WinDeviceInfo LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE) +endif () + +# ---- Shared library (DLL) ---- +add_library(device_info SHARED + src/win_helpers.cpp + src/gpu_info.cpp +) + +# Force static linking of runtime libraries to avoid dependency issues in Python +target_link_options(device_info PRIVATE -static-libgcc -static-libstdc++ -static) + +target_include_directories(device_info + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(device_info + PRIVATE + dxgi + setupapi + cfgmgr32 + advapi32 +) + +# Output the DLL next to the Python binding +set_target_properties(device_info PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + OUTPUT_NAME "device_info" + PREFIX "" +) + +# ---- Standalone test executable ---- +add_executable(WinDeviceInfo main.cpp) + +target_include_directories(WinDeviceInfo + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +# No longer linking directly to device_info to allow dynamic loading from custom path +# target_link_libraries(WinDeviceInfo PRIVATE device_info) diff --git a/src/hwprobe/interops/win_old/README.md b/src/hwprobe/interops/win_old/README.md new file mode 100644 index 0000000..587dd0e --- /dev/null +++ b/src/hwprobe/interops/win_old/README.md @@ -0,0 +1,111 @@ +# WinDeviceInfo + +A Windows utility and shared library that enumerates GPU hardware via DXGI and the Windows Configuration Manager API. + +The native library lives in `src/` and `include/`, and is exposed via a command-line tester (`main.cpp`). +Also powers a thin Python `ctypes` binding in `bindings/gpu_info.py`. + +This is intended to be used via each hardware component's respective python interface, like `gpu_info.py`. The CLI tool +is primarily for testing and demonstration purposes, but it can be used directly if desired. + +Full disclosure: A big part of this C++ connector was written by Claude. +If you are someone with more know-how, and find lapses in this code, we'd be more than happy to welcome Pull Requests. + +## Requirements + +- Windows 10 or newer +- Visual Studio 2019+ or MSVC Build Tools (C++17 support required) +- CMake 3.21+ +- Python 3.7+ (for the `gpu_info.py` binding) - Assuming you want to compile this to use with HWProbe. +- Windows SDK (for DXGI, SetupAPI, CfgMgr32 headers) + +## Build + +```sh +cmake -S . -B build +cmake --build build --config Release +``` + +- `WinDeviceInfo.exe` (the CLI tool) is emitted to `build/Release/WinDeviceInfo.exe`. +- `device_info.dll` is copied automatically into `bindings/` for the Python binding. +- The default build type is **Release**. Pass `--config Debug` to the build command to include debug symbols. + +## CLI Usage + +```sh +.\build\Release\WinDeviceInfo.exe +``` + +The tool prints GPU info, and exits with code `0` when enumeration succeeds, or `1` if the underlying DXGI call fails. + +## Python Binding + +After building the project once (so that `bindings/device_info.dll` exists), you can inspect GPUs from Python: + +```sh +cd bindings +python gpu_info.py +``` + +or programmatically: + +```python +from gpu_info import get_gpu_info + +for idx, gpu in enumerate(get_gpu_info()): + print(f"GPU {idx}:") + print(gpu) +``` + +On import, the script loads the colocated `device_info.dll`; ensure you rebuild the CMake project whenever you make +changes to the native code. + +## What the native library does + +For each GPU discovered via DXGI: + +1. **Enumerates adapters** using `IDXGIFactory1::EnumAdapters1`, skipping software/virtual adapters. +2. **Resolves the PNP Device ID** by matching DXGI's VendorId/DeviceId/SubSysId against SetupAPI's display class. +3. **Parses vendor/device/subsystem IDs** from the PNP device ID string. +4. **Fetches VRAM** from DXGI's `DedicatedVideoMemory`; falls back to the registry + (`HardwareInformation.qwMemorySize`) for cards with >4 GB where DXGI may report a capped value. +5. **Resolves ACPI and PCI paths** via `CM_Get_DevNode_PropertyW` (location paths), formatted to match the + project's conventions (e.g. `\_SB_.PCI0.RP05.PXSX`, `PciRoot(0x0)/Pci(0x1C,0x5)/Pci(0x0,0x0)`). +6. **Fetches PCIe generation and lane width** via Configuration Manager device properties. + +## Legacy bindings + +The following files belong to the **old** monolithic binding approach and are kept for components that have not yet +been migrated. They are marked with `# todo: refactor to new bindings` in the consuming code. Once all components +are migrated, these files can be deleted: + +``` +interops/win/legacy/ + constants.py # Win32 constants, GUIDs, status codes + structs.py # ctypes Structure mirrors (MONITORINFOEXA, DEVMODEA, etc.) + signatures.py # Loads hw_helper.dll, sets argtypes/restypes for all exports + +interops/win/ + hw_helper.hpp # Monolithic C++ header (all structs + enums) + hw_helper.cpp # Monolithic C++ source (GPU, audio, network, SMBIOS, WMI - all in one file) + dll/ + hw_helper.dll # Pre-built monolithic DLL +``` + +Components still using the legacy bindings: + +- `core/windows/audio.py` +- `core/windows/baseboard.py` +- `core/windows/display.py` +- `core/windows/memory.py` +- `core/windows/network.py` +- `core/windows/storage.py` + +## Troubleshooting + +- **`device_info.dll not found`**: run the CMake build so the shared library is (re)generated in `bindings/`. +- **`get_gpu_info` returns -1**: verify that DXGI is available (Windows 10+ with a display driver installed). +- **VRAM shows 0 MB**: the registry fallback may not find a matching `DriverDesc`/`DriverVersion` entry. Check that + the GPU driver is properly installed. +- **PCIe gen/width shows 0**: the Configuration Manager property may not be exposed by all drivers. This is + driver-dependent and not a bug in the library. diff --git a/src/hwprobe/interops/win_old/bindings/__init__.py b/src/hwprobe/interops/win_old/bindings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hwprobe/interops/win_old/bindings/gpu_info.py b/src/hwprobe/interops/win_old/bindings/gpu_info.py new file mode 100644 index 0000000..6bafd28 --- /dev/null +++ b/src/hwprobe/interops/win_old/bindings/gpu_info.py @@ -0,0 +1,132 @@ +""" +gpu_info.py - Python ctypes binding for hw_helper.dll (GPU info) + +Usage: + from hwprobe.interops.win.bindings.gpu_info import get_gpu_info + gpus = get_gpu_info() + for g in gpus: + print(g) + +Source code is in `interops/win/include/` and `interops/win/src/`. +""" + +import ctypes +import pathlib +from dataclasses import dataclass +from typing import Optional + +_HERE = pathlib.Path(__file__).parent +_LIB_PATH = _HERE / "device_info.dll" + +if not _LIB_PATH.exists(): + raise FileNotFoundError( + f"device_info.dll not found at {_LIB_PATH}.\nBuild the project first: cmake --build build --config Release" + ) + +_lib = ctypes.WinDLL(str(_LIB_PATH)) + + +# ---- Mirror the C structs ---- + + +class _WinGPUProperties(ctypes.Structure): + _fields_ = [ + ("name", ctypes.c_char * 256), + ("manufacturer", ctypes.c_char * 256), + ("vendor_id", ctypes.c_uint32), + ("device_id", ctypes.c_uint32), + ("subsystem_vendor_id", ctypes.c_uint32), + ("subsystem_device_id", ctypes.c_uint32), + ("acpi_path", ctypes.c_char * 512), + ("pci_path", ctypes.c_char * 512), + ("vram_mb", ctypes.c_uint64), + ("pcie_gen", ctypes.c_int), + ("pcie_width", ctypes.c_int), + ] + + +_lib.get_gpu_info.restype = ctypes.c_int +_lib.get_gpu_info.argtypes = [ctypes.POINTER(_WinGPUProperties), ctypes.c_int] + + +# ---- Python-facing dataclass ---- + + +@dataclass +class GPUProperties: + name: str + manufacturer: str + vendor_id: int + device_id: int + subsystem_vendor_id: int + subsystem_device_id: int + acpi_path: Optional[str] + pci_path: Optional[str] + vram_mb: int + pcie_gen: int + pcie_width: int + + def __str__(self) -> str: + lines = [ + f" Name: {self.name}", + f" Manufacturer: {self.manufacturer}", + f" Vendor ID: 0x{self.vendor_id:04X}", + f" Device ID: 0x{self.device_id:04X}", + f" Subsystem Vendor: 0x{self.subsystem_vendor_id:04X}", + f" Subsystem Device: 0x{self.subsystem_device_id:04X}", + f" VRAM: {self.vram_mb} MB", + ] + if self.pcie_gen: + lines.append(f" PCIe Gen: {self.pcie_gen}") + if self.pcie_width: + lines.append(f" PCIe Width: x{self.pcie_width}") + if self.acpi_path: + lines.append(f" ACPI Path: {self.acpi_path}") + if self.pci_path: + lines.append(f" PCI Path: {self.pci_path}") + return "\n".join(lines) + + +# ---- Public API ---- + +_MAX_GPUS = 8 + + +def get_gpu_info() -> list[GPUProperties]: + """Return a list of GPUProperties for every GPU found on this machine.""" + buf = (_WinGPUProperties * _MAX_GPUS)() + count = _lib.get_gpu_info(buf, _MAX_GPUS) + if count < 0: + raise RuntimeError("get_gpu_info() failed (C library returned -1)") + + result = [] + for i in range(count): + raw = buf[i] + acpi = raw.acpi_path.decode("utf-8", errors="replace").strip("\x00") or None + pci = raw.pci_path.decode("utf-8", errors="replace").strip("\x00") or None + + result.append( + GPUProperties( + name=raw.name.decode("utf-8", errors="replace").strip("\x00"), + manufacturer=raw.manufacturer.decode("utf-8", errors="replace").strip("\x00"), + vendor_id=raw.vendor_id, + device_id=raw.device_id, + subsystem_vendor_id=raw.subsystem_vendor_id, + subsystem_device_id=raw.subsystem_device_id, + acpi_path=acpi, + pci_path=pci, + vram_mb=raw.vram_mb, + pcie_gen=raw.pcie_gen, + pcie_width=raw.pcie_width, + ) + ) + return result + + +if __name__ == "__main__": + gpus = get_gpu_info() + print(f"Found {len(gpus)} GPU(s):\n") + for idx, g in enumerate(gpus): + print(f"GPU {idx}:") + print(g) + print() diff --git a/src/hwprobe/interops/win/hw_helper.cpp b/src/hwprobe/interops/win_old/hw_helper.cpp similarity index 100% rename from src/hwprobe/interops/win/hw_helper.cpp rename to src/hwprobe/interops/win_old/hw_helper.cpp diff --git a/src/hwprobe/interops/win/hw_helper.hpp b/src/hwprobe/interops/win_old/hw_helper.hpp similarity index 100% rename from src/hwprobe/interops/win/hw_helper.hpp rename to src/hwprobe/interops/win_old/hw_helper.hpp diff --git a/src/hwprobe/interops/win_old/include/gpu_info.h b/src/hwprobe/interops/win_old/include/gpu_info.h new file mode 100644 index 0000000..4506ccd --- /dev/null +++ b/src/hwprobe/interops/win_old/include/gpu_info.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char name[256]; + char manufacturer[256]; + uint32_t vendor_id; + uint32_t device_id; + uint32_t subsystem_vendor_id; + uint32_t subsystem_device_id; + char acpi_path[512]; + char pci_path[512]; + uint64_t vram_mb; + int pcie_gen; + int pcie_width; +} WinGPUProperties; + +typedef enum { + GPU_STATUS_OK = 0, + GPU_STATUS_FAILURE = 1, + GPU_STATUS_INVALID_ARG = 2 +} GPUStatus; + +// Fills `out` with GPU entries. Returns number of GPUs found, or -1 on error. +int get_gpu_info(WinGPUProperties *out, int max_count); + +#ifdef __cplusplus +} +#endif diff --git a/src/hwprobe/interops/win/include/win_helpers.h b/src/hwprobe/interops/win_old/include/win_helpers.h similarity index 100% rename from src/hwprobe/interops/win/include/win_helpers.h rename to src/hwprobe/interops/win_old/include/win_helpers.h diff --git a/src/hwprobe/interops/win/legacy/constants.py b/src/hwprobe/interops/win_old/legacy/constants.py similarity index 100% rename from src/hwprobe/interops/win/legacy/constants.py rename to src/hwprobe/interops/win_old/legacy/constants.py diff --git a/src/hwprobe/interops/win/legacy/signatures.py b/src/hwprobe/interops/win_old/legacy/signatures.py similarity index 100% rename from src/hwprobe/interops/win/legacy/signatures.py rename to src/hwprobe/interops/win_old/legacy/signatures.py diff --git a/src/hwprobe/interops/win/legacy/structs.py b/src/hwprobe/interops/win_old/legacy/structs.py similarity index 100% rename from src/hwprobe/interops/win/legacy/structs.py rename to src/hwprobe/interops/win_old/legacy/structs.py diff --git a/src/hwprobe/interops/win_old/main.cpp b/src/hwprobe/interops/win_old/main.cpp new file mode 100644 index 0000000..4c4277b --- /dev/null +++ b/src/hwprobe/interops/win_old/main.cpp @@ -0,0 +1,65 @@ +#include "gpu_info.h" +#include +#include +#include + +typedef int (*get_gpu_info_ptr)(WinGPUProperties *, int); + +int main() { + // Try multiple paths to find the DLL: + // 1. Current directory + // 2. Relative to where it's built (cmake-build-debug/../bindings/device_info.dll) + // 3. Absolute "bindings/device_info.dll" from project root + + HMODULE hLib = LoadLibraryA("bindings/device_info.dll"); + if (!hLib) { + hLib = LoadLibraryA("../bindings/device_info.dll"); + } + if (!hLib) { + hLib = LoadLibraryA("device_info.dll"); + } + + if (!hLib) { + DWORD err = GetLastError(); + printf("Error: Could not load device_info.dll (Error code: %lu)\n", err); + return 1; + } + + auto get_gpu_info_func = reinterpret_cast(GetProcAddress(hLib, "get_gpu_info")); + if (!get_gpu_info_func) { + printf("Error: Could not find get_gpu_info in device_info.dll\n"); + FreeLibrary(hLib); + return 1; + } + + constexpr int MAX_GPUS = 8; + WinGPUProperties gpus[MAX_GPUS] = {}; + + int count = get_gpu_info_func(gpus, MAX_GPUS); + if (count < 0) { + printf("Error: get_gpu_info() failed\n"); + FreeLibrary(hLib); + return 1; + } + + printf("Found %d GPU(s):\n\n", count); + for (int i = 0; i < count; ++i) { + const auto &g = gpus[i]; + printf("GPU %d:\n", i); + printf(" Name: %s\n", g.name); + printf(" Manufacturer: %s\n", g.manufacturer); + printf(" Vendor ID: 0x%04X\n", g.vendor_id); + printf(" Device ID: 0x%04X\n", g.device_id); + printf(" Subsystem Vendor: 0x%04X\n", g.subsystem_vendor_id); + printf(" Subsystem Device: 0x%04X\n", g.subsystem_device_id); + printf(" VRAM: %llu MB\n", g.vram_mb); + printf(" PCIe Gen: %d\n", g.pcie_gen); + printf(" PCIe Width: x%d\n", g.pcie_width); + if (g.acpi_path[0]) printf(" ACPI Path: %s\n", g.acpi_path); + if (g.pci_path[0]) printf(" PCI Path: %s\n", g.pci_path); + printf("\n"); + } + + FreeLibrary(hLib); + return 0; +} diff --git a/src/hwprobe/interops/win_old/src/gpu_info.cpp b/src/hwprobe/interops/win_old/src/gpu_info.cpp new file mode 100644 index 0000000..3aa7f53 --- /dev/null +++ b/src/hwprobe/interops/win_old/src/gpu_info.cpp @@ -0,0 +1,297 @@ +#include "gpu_info.h" +#include "win_helpers.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#pragma comment(lib, "dxgi.lib") +#pragma comment(lib, "setupapi.lib") + +// ---- WMI-free GPU enumeration via DXGI + SetupAPI ---- + +static std::wstring PnpDeviceIdFromDXGI(const DXGI_ADAPTER_DESC1 &desc) { + // DXGI gives us VendorId / DeviceId / SubSysId / Revision. + // We need to find the matching PNP device instance via SetupAPI. + wchar_t match_hw_id[128]; + swprintf_s(match_hw_id, L"PCI\\VEN_%04X&DEV_%04X", desc.VendorId, desc.DeviceId); + + HDEVINFO devInfo = SetupDiGetClassDevsW(&GUID_DEVCLASS_DISPLAY, nullptr, nullptr, DIGCF_PRESENT); + if (devInfo == INVALID_HANDLE_VALUE) return {}; + + SP_DEVINFO_DATA devData = {sizeof(SP_DEVINFO_DATA)}; + std::wstring result; + + for (DWORD i = 0; SetupDiEnumDeviceInfo(devInfo, i, &devData); ++i) { + wchar_t pnpBuffer[MAX_DEVICE_ID_LEN]; + if (!SetupDiGetDeviceInstanceIdW(devInfo, &devData, pnpBuffer, MAX_DEVICE_ID_LEN, nullptr)) + continue; + + std::wstring pnp(pnpBuffer); + // Case-insensitive prefix match for VEN_XXXX&DEV_XXXX + std::wstring upper_pnp = pnp; + for (auto &ch : upper_pnp) ch = towupper(ch); + std::wstring upper_match = match_hw_id; + for (auto &ch : upper_match) ch = towupper(ch); + + if (upper_pnp.find(upper_match) != std::wstring::npos) { + // Further match SubSysId if there are multiple GPUs with the same vendor+device + wchar_t subsys_match[64]; + swprintf_s(subsys_match, L"SUBSYS_%08X", desc.SubSysId); + std::wstring upper_subsys = subsys_match; + for (auto &ch : upper_subsys) ch = towupper(ch); + + if (upper_pnp.find(upper_subsys) != std::wstring::npos) { + result = pnp; + break; + } + // If no subsys match yet, keep this as a fallback + if (result.empty()) result = pnp; + } + } + + SetupDiDestroyDeviceInfoList(devInfo); + return result; +} + +// ---- Registry VRAM fallback for >4GB cards ---- + +static uint64_t FetchVramFromRegistry(const std::string &device_name, const std::string &driver_version) { + const char *key_path = "SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}"; + HKEY hKey; + if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, key_path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return 0; + + for (DWORD i = 0; i < 100; ++i) { + char sub_key_name[32]; + DWORD name_size = sizeof(sub_key_name); + if (RegEnumKeyExA(hKey, i, sub_key_name, &name_size, nullptr, nullptr, nullptr, nullptr) != ERROR_SUCCESS) + continue; + + HKEY hSubKey; + if (RegOpenKeyExA(hKey, sub_key_name, 0, KEY_READ, &hSubKey) != ERROR_SUCCESS) + continue; + + char drv_desc[256] = {}; + DWORD drv_desc_size = sizeof(drv_desc); + char drv_ver[256] = {}; + DWORD drv_ver_size = sizeof(drv_ver); + + bool got_desc = (RegQueryValueExA(hSubKey, "DriverDesc", nullptr, nullptr, + reinterpret_cast(drv_desc), &drv_desc_size) == ERROR_SUCCESS); + bool got_ver = (RegQueryValueExA(hSubKey, "DriverVersion", nullptr, nullptr, + reinterpret_cast(drv_ver), &drv_ver_size) == ERROR_SUCCESS); + + if (got_desc && got_ver && + device_name == drv_desc && driver_version == drv_ver) { + + uint64_t vram_bytes = 0; + DWORD vram_size = sizeof(vram_bytes); + if (RegQueryValueExA(hSubKey, "HardwareInformation.qwMemorySize", nullptr, nullptr, + reinterpret_cast(&vram_bytes), &vram_size) == ERROR_SUCCESS && vram_bytes > 0) { + RegCloseKey(hSubKey); + RegCloseKey(hKey); + return vram_bytes / (1024 * 1024); + } + + DWORD alt_vram = 0; + DWORD alt_size = sizeof(alt_vram); + if (RegQueryValueExA(hSubKey, "HardwareInformation.MemorySize", nullptr, nullptr, + reinterpret_cast(&alt_vram), &alt_size) == ERROR_SUCCESS && alt_vram > 0) { + RegCloseKey(hSubKey); + RegCloseKey(hKey); + return static_cast(alt_vram) / (1024 * 1024); + } + } + + RegCloseKey(hSubKey); + } + + RegCloseKey(hKey); + return 0; +} + +// ---- Get driver version from registry for a PNP device ---- + +static std::string GetDriverVersion(const std::wstring &pnp_device_id) { + const char *key_path = "SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}"; + HKEY hKey; + if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, key_path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return {}; + + std::string pnp_utf8 = WideToUtf8(pnp_device_id.c_str()); + // Extract VEN_XXXX&DEV_XXXX portion for matching + std::string upper_pnp = pnp_utf8; + for (auto &ch : upper_pnp) ch = toupper(ch); + + for (DWORD i = 0; i < 100; ++i) { + char sub_key_name[32]; + DWORD name_size = sizeof(sub_key_name); + if (RegEnumKeyExA(hKey, i, sub_key_name, &name_size, nullptr, nullptr, nullptr, nullptr) != ERROR_SUCCESS) + continue; + + HKEY hSubKey; + if (RegOpenKeyExA(hKey, sub_key_name, 0, KEY_READ, &hSubKey) != ERROR_SUCCESS) + continue; + + char matching_id[512] = {}; + DWORD mid_size = sizeof(matching_id); + if (RegQueryValueExA(hSubKey, "MatchingDeviceId", nullptr, nullptr, + reinterpret_cast(matching_id), &mid_size) == ERROR_SUCCESS) { + std::string upper_mid = matching_id; + for (auto &ch : upper_mid) ch = toupper(ch); + if (upper_pnp.find(upper_mid) != std::string::npos || upper_mid.find("VEN_") != std::string::npos) { + char drv_ver[256] = {}; + DWORD ver_size = sizeof(drv_ver); + if (RegQueryValueExA(hSubKey, "DriverVersion", nullptr, nullptr, + reinterpret_cast(drv_ver), &ver_size) == ERROR_SUCCESS) { + RegCloseKey(hSubKey); + RegCloseKey(hKey); + return drv_ver; + } + } + } + RegCloseKey(hSubKey); + } + + RegCloseKey(hKey); + return {}; +} + +// ---- Vendor/Device/Subsystem ID parsing from PNP Device ID ---- + +struct PciIds { + uint32_t vendor_id; + uint32_t device_id; + uint32_t subsystem_vendor_id; + uint32_t subsystem_device_id; +}; + +static PciIds ParsePnpDeviceId(const std::string &pnp) { + PciIds ids = {}; + std::regex re(R"(VEN_([0-9A-Fa-f]{4}).*DEV_([0-9A-Fa-f]{4}).*SUBSYS_([0-9A-Fa-f]{4})([0-9A-Fa-f]{4}))", + std::regex::icase); + std::smatch m; + if (std::regex_search(pnp, m, re)) { + ids.vendor_id = std::stoul(m[1].str(), nullptr, 16); + ids.device_id = std::stoul(m[2].str(), nullptr, 16); + ids.subsystem_device_id = std::stoul(m[3].str(), nullptr, 16); + ids.subsystem_vendor_id = std::stoul(m[4].str(), nullptr, 16); + } + return ids; +} + +// ---- Public API ---- + +int get_gpu_info(WinGPUProperties *out, int max_count) { + if (!out || max_count <= 0) return -1; + + IDXGIFactory1 *factory = nullptr; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) + return -1; + + int count = 0; + IDXGIAdapter1 *adapter = nullptr; + std::set seen_pnp_ids; + + for (UINT a = 0; factory->EnumAdapters1(a, &adapter) != DXGI_ERROR_NOT_FOUND && count < max_count; ++a) { + DXGI_ADAPTER_DESC1 desc; + if (FAILED(adapter->GetDesc1(&desc))) { + adapter->Release(); + continue; + } + + // Skip software/remote adapters (DXGI_ADAPTER_FLAG_SOFTWARE = 2, defined in DXGI 1.2+) + // On Windows 7 no adapter sets this flag, so the check is a safe no-op. + if (desc.Flags & 2u) { + adapter->Release(); + continue; + } + + // Deduplicate: DXGI can enumerate the same physical GPU multiple times (common on AMD APUs) + std::wstring pnp_id_early = PnpDeviceIdFromDXGI(desc); + if (!pnp_id_early.empty()) { + std::wstring upper_pnp = pnp_id_early; + for (auto &ch : upper_pnp) ch = towupper(ch); + if (seen_pnp_ids.count(upper_pnp)) { + adapter->Release(); + continue; + } + seen_pnp_ids.insert(upper_pnp); + } + + WinGPUProperties gpu = {}; + + // Name from DXGI + char name_buf[256]; + WideCharToMultiByte(CP_UTF8, 0, desc.Description, -1, name_buf, sizeof(name_buf), nullptr, nullptr); + strncpy_s(gpu.name, name_buf, _TRUNCATE); + + // IDs from DXGI + gpu.vendor_id = desc.VendorId; + gpu.device_id = desc.DeviceId; + + // PNP device instance already resolved above for dedup + const std::wstring &pnp_id = pnp_id_early; + std::string pnp_utf8 = WideToUtf8(pnp_id.c_str()); + + // Parse subsystem IDs from PNP device ID string + if (!pnp_utf8.empty()) { + PciIds ids = ParsePnpDeviceId(pnp_utf8); + gpu.subsystem_vendor_id = ids.subsystem_vendor_id; + gpu.subsystem_device_id = ids.subsystem_device_id; + } + + // VRAM: DXGI reports DedicatedVideoMemory in bytes + uint64_t vram_mb = desc.DedicatedVideoMemory / (1024 * 1024); + + // WMI/DXGI may report capped VRAM for >4GB cards; fall back to registry + if (vram_mb == 0 || desc.DedicatedVideoMemory >= 4194304000ULL) { + std::string drv_ver = GetDriverVersion(pnp_id); + uint64_t reg_vram = FetchVramFromRegistry(std::string(gpu.name), drv_ver); + if (reg_vram > 0) vram_mb = reg_vram; + } + gpu.vram_mb = vram_mb; + + // Location paths (ACPI + PCI) and PCIe info via Configuration Manager + if (!pnp_id.empty()) { + std::string acpi, pci; + if (GetDevNodeLocationPaths(pnp_id, acpi, pci)) { + strncpy_s(gpu.acpi_path, acpi.c_str(), _TRUNCATE); + strncpy_s(gpu.pci_path, pci.c_str(), _TRUNCATE); + } + + int gen = 0, width = 0; + if (GetDevNodePCIeInfo(pnp_id, gen, width)) { + gpu.pcie_gen = gen; + gpu.pcie_width = width; + } + } + + // Manufacturer: map common vendor IDs + switch (gpu.vendor_id) { + case 0x10DE: strncpy_s(gpu.manufacturer, "NVIDIA", _TRUNCATE); break; + case 0x1002: strncpy_s(gpu.manufacturer, "AMD", _TRUNCATE); break; + case 0x8086: strncpy_s(gpu.manufacturer, "Intel", _TRUNCATE); break; + default: { + char hex[16]; + snprintf(hex, sizeof(hex), "0x%04X", gpu.vendor_id); + strncpy_s(gpu.manufacturer, hex, _TRUNCATE); + break; + } + } + + out[count++] = gpu; + adapter->Release(); + } + + factory->Release(); + return count; +} diff --git a/src/hwprobe/interops/win/src/win_helpers.cpp b/src/hwprobe/interops/win_old/src/win_helpers.cpp similarity index 100% rename from src/hwprobe/interops/win/src/win_helpers.cpp rename to src/hwprobe/interops/win_old/src/win_helpers.cpp diff --git a/src/hwprobe/models/memory_models.py b/src/hwprobe/models/memory_models.py index 0d4e0c7..c545c71 100644 --- a/src/hwprobe/models/memory_models.py +++ b/src/hwprobe/models/memory_models.py @@ -7,8 +7,8 @@ class MemoryModuleSlot(BaseModel): - channel: str = "" - bank: str = "" + channel: Optional[str] = "" + bank: Optional[str] = "" class MemoryModuleInfo(BaseModel): diff --git a/src/hwprobe/util/location_paths.py b/src/hwprobe/util/location_paths.py index 7aa947c..fb5e043 100644 --- a/src/hwprobe/util/location_paths.py +++ b/src/hwprobe/util/location_paths.py @@ -1,70 +1,24 @@ -from ctypes import ( - Structure, - WinDLL, - byref, - c_buffer, - c_char, - c_ulong, - c_ushort, - c_wchar_p, - sizeof, -) +""" +Thin ctypes wrapper over cfgmgr32.dll device-node properties. + +Public API (used by core/windows/graphics.py and core/windows/network.py): + get_location_paths(pnp_device_id) -> list[str] | None + fetch_pcie_info(pnp_device_id) -> (speed, width) | None + +Internal: one _get_devnode_property(pnp_id, key) -> bytes | None handles the +two-call buffer sizing pattern. Decoders for string-list and uint32 are +module-level. DEVPROPKEY constants are built once at import, not per call. +""" + +from ctypes import Structure, WinDLL, byref, c_buffer, c_char, c_ulong, c_ushort, c_wchar_p, sizeof from typing import Optional -cfgmgr = WinDLL("cfgmgr32.dll") - -# DEVPKEY definitions -location_paths_key = [ - "location_paths", - 0xA45C254E, - 0xDF1C, - 0x4EFD, - [0x80, 0x20, 0x67, 0xD1, 0x46, 0xA8, 0x50, 0xE0], - 37, -] - -bus_number_key = [ - "bus_number", - 0xA45C254E, - 0xDF1C, - 0x4EFD, - [0x80, 0x20, 0x67, 0xD1, 0x46, 0xA8, 0x50, 0xE0], - 23, -] - -device_address_key = [ - "device_address", - 0xA45C254E, - 0xDF1C, - 0x4EFD, - [0x80, 0x20, 0x67, 0xD1, 0x46, 0xA8, 0x50, 0xE0], - 30, -] - -pcie_link_speed_key = [ - "current_link_speed", - 0x3AB22E31, - 0x8264, - 0x4B4E, - [0x9A, 0xF5, 0xA8, 0xD2, 0xD8, 0xE3, 0x3E, 0x62], - 9, -] - -pcie_link_width_key = [ - "current_link_width", - 0x3AB22E31, - 0x8264, - 0x4B4E, - [0x9A, 0xF5, 0xA8, 0xD2, 0xD8, 0xE3, 0x3E, 0x62], - 10, -] +_cfgmgr = WinDLL("cfgmgr32.dll") +# ---- ctypes structs (built once) ---- -class GUID(Structure): - """ - Source: https://github.com/tpn/winsdk-10/blob/master/Include/10.0.10240.0/shared/guiddef.h#L22-L26 - """ +class GUID(Structure): _fields_ = [ ("Data1", c_ulong), ("Data2", c_ushort), @@ -74,241 +28,142 @@ class GUID(Structure): class DEVPROPKEY(Structure): - """ - Source: https://github.com/tpn/winsdk-10/blob/master/Include/10.0.10240.0/um/devpropdef.h#L118-L124 - """ - _fields_ = [("fmtid", GUID), ("pid", c_ulong)] -def get_device_instance(pnp_device_id: str) -> c_ulong: - """ - Get the device node instance (dnDevInst) from a PNP Device ID. - - Args: - pnp_device_id: The PNP Device ID string (e.g., "PCI\\VEN_8086&DEV_9A09&...") - - Returns: - Device node instance handle, or None if not found - """ - dev_node = c_ulong() - - result = cfgmgr.CM_Locate_DevNodeW( - byref(dev_node), - c_wchar_p(pnp_device_id), - c_ulong(0), # CM_LOCATE_DEVNODE_NORMAL +def _key(data1, data2, data3, data4_bytes, pid) -> DEVPROPKEY: + return DEVPROPKEY( + fmtid=GUID(Data1=data1, Data2=data2, Data3=data3, Data4=bytes(data4_bytes)), + pid=pid, ) - if result != 0: # CR_SUCCESS - return None - return dev_node +# ---- DEVPROPKEY constants (module-level, built once) ---- +# Source: DEVPKEY_Device_LocationPaths, DEVPKEY_Device_BusNumber, +# DEVPKEY_Device_Address, DEVPKEY_PCIExpress_CurrentLinkSpeed/Width +_LOCATION_PATHS = _key(0xA45C254E, 0xDF1C, 0x4EFD, [0x80, 0x20, 0x67, 0xD1, 0x46, 0xA8, 0x50, 0xE0], 37) +_BUS_NUMBER = _key(0xA45C254E, 0xDF1C, 0x4EFD, [0x80, 0x20, 0x67, 0xD1, 0x46, 0xA8, 0x50, 0xE0], 23) +_DEVICE_ADDRESS = _key(0xA45C254E, 0xDF1C, 0x4EFD, [0x80, 0x20, 0x67, 0xD1, 0x46, 0xA8, 0x50, 0xE0], 30) +_PCIE_LINK_SPEED = _key(0x3AB22E31, 0x8264, 0x4B4E, [0x9A, 0xF5, 0xA8, 0xD2, 0xD8, 0xE3, 0x3E, 0x62], 9) +_PCIE_LINK_WIDTH = _key(0x3AB22E31, 0x8264, 0x4B4E, [0x9A, 0xF5, 0xA8, 0xD2, 0xD8, 0xE3, 0x3E, 0x62], 10) -def CM_Get_DevNode_PropertyW( - dnDevInst=c_ulong(), - propKey=None, - propType=c_ulong(), - propBuff=None, - propBuffSize=c_ulong(), -): - if propKey is None: - return None +# CR_SUCCESS = 0, CR_BUFFER_SMALL = 0x1A, CR_NO_SUCH_DEVNODE = 0x02 +_CR_SUCCESS = 0 +_CR_BUFFER_SMALL = 0x1A - status = cfgmgr.CM_Get_DevNode_PropertyW( - dnDevInst, - byref(propKey), - byref(propType), - propBuff, - byref(propBuffSize), - c_ulong(0), - ) - if status == 0x02: # Ran out of memory - return None +# ---- core: locate devnode + get property (two-call pattern) ---- - """ - Buffer is just barely not big enough - try again with a larger buffer - """ - if status == 0x1A or propBuff is None: - return CM_Get_DevNode_PropertyW( - dnDevInst, - propKey, - propType, - propBuff=c_buffer(b"", sizeof(c_ulong) * propBuffSize.value), - propBuffSize=propBuffSize, - ) - - return (propType, propBuff, propBuffSize) - - -def decode_location_paths(raw_bytes: bytes) -> list[str]: - """ - Decode the raw location paths bytes into a list of strings. - - Args: - raw_bytes: The raw bytes returned from CM_Get_DevNode_PropertyW - - Returns: - List of location path strings - """ - text = raw_bytes.decode("utf-16-le", errors="ignore") - - paths = [p for p in text.split("\x00") if p] - - return paths +def _locate_devnode(pnp_device_id: str) -> Optional[c_ulong]: + """Get the device node instance handle from a PNP Device ID string.""" + dev_node = c_ulong() + result = _cfgmgr.CM_Locate_DevNodeW( + byref(dev_node), + c_wchar_p(pnp_device_id), + c_ulong(0), # CM_LOCATE_DEVNODE_NORMAL + ) + return dev_node if result == _CR_SUCCESS else None -def decode_uint32(raw_bytes: bytes) -> Optional[int]: +def _get_devnode_property(pnp_device_id: str, prop_key: DEVPROPKEY) -> Optional[bytes]: """ - Decode a 32-bit unsigned integer from raw bytes. - - Args: - raw_bytes: The raw bytes returned from CM_Get_DevNode_PropertyW - - Returns: - Integer value, or None if decoding fails + Fetch a raw property buffer from CM_Get_DevNode_PropertyW. + Two-call pattern: query size, alloc, query data. + Returns raw bytes or None if the property doesn't exist / lookup fails. """ - try: - return int.from_bytes(raw_bytes[:4], byteorder="little") - except Exception: + dn = _locate_devnode(pnp_device_id) + if dn is None: return None + prop_type = c_ulong() + buf_size = c_ulong(0) -def _fetch_property(pnp_device_id: str, key_def: list): # type: ignore[type-arg] - """ - Generic property fetcher using CM_Get_DevNode_PropertyW. - - Args: - pnp_device_id: The PNP Device ID string - key_def: List containing [name, Data1, Data2, Data3, Data4_list, pid] - - Returns: - Tuple of (propType, buffer, propBuffSize) or None - """ - mGUID = GUID( - Data1=c_ulong(key_def[1]), - Data2=c_ushort(key_def[2]), - Data3=c_ushort(key_def[3]), - Data4=bytes(key_def[4]), + # First call: get required buffer size. + status = _cfgmgr.CM_Get_DevNode_PropertyW( + dn, byref(prop_key), byref(prop_type), None, byref(buf_size), c_ulong(0) ) - - dpKey = DEVPROPKEY(fmtid=mGUID, pid=c_ulong(key_def[5])) - - dnDevInst = get_device_instance(pnp_device_id) - - if dnDevInst is None: + if status == _CR_SUCCESS: + # Property exists with zero-size payload (rare). Return empty. + return b"" + if status != _CR_BUFFER_SMALL: + return None # CR_NO_SUCH_DEVNODE or other failure + + # Second call: fill the buffer. + buf = c_buffer(buf_size.value) + status = _cfgmgr.CM_Get_DevNode_PropertyW( + dn, byref(prop_key), byref(prop_type), buf, byref(buf_size), c_ulong(0) + ) + if status != _CR_SUCCESS: return None + return buf.raw - return CM_Get_DevNode_PropertyW(dnDevInst, dpKey) +# ---- decoders ---- -def get_location_paths(pnp_device_id: str) -> Optional[list[str]]: - """ - Get the location paths for a PNP device. +def _decode_string_list(raw: bytes) -> list[str]: + """Decode a REG_MULTI_SZ-style buffer (UTF-16-LE, NUL-separated strings).""" + text = raw.decode("utf-16-le", errors="ignore") + return [p for p in text.split("\x00") if p] - Args: - pnp_device_id: The PNP Device ID string - Returns: - List of location path strings, or None if not found - """ - result = _fetch_property(pnp_device_id, location_paths_key) - - if result is None: +def _decode_uint32(raw: bytes) -> Optional[int]: + """Decode a 32-bit unsigned integer from little-endian bytes.""" + if len(raw) < 4: return None + return int.from_bytes(raw[:4], byteorder="little") - raw_bytes = result[1].raw - return decode_location_paths(raw_bytes) - - -def get_bus_number(pnp_device_id: str) -> Optional[str]: - """ - Get the bus number for a PNP device. - - Args: - pnp_device_id: The PNP Device ID string - Returns: - Bus number as string, or None if not found - """ - result = _fetch_property(pnp_device_id, bus_number_key) +# ---- public API ---- - if result is None: +def get_location_paths(pnp_device_id: str) -> Optional[list[str]]: + """Get the location paths for a PNP device. Returns list of raw path + strings (e.g. ['ACPI(_SB_)#ACPI(PCI0)#...', 'PCIROOT(0)#PCI(1C05)#...']) + or None if the property doesn't exist. Caller formats via + core.windows.common.format_acpi_path / format_pci_path.""" + raw = _get_devnode_property(pnp_device_id, _LOCATION_PATHS) + if raw is None: return None + return _decode_string_list(raw) - raw_bytes = result[1].raw - value = decode_uint32(raw_bytes) - return str(value) if value is not None else None - - -def get_device_address(pnp_device_id: str) -> Optional[str]: - """ - Get the device address for a PNP device. - - Args: - pnp_device_id: The PNP Device ID string - - Returns: - Device address as string, or None if not found - """ - result = _fetch_property(pnp_device_id, device_address_key) - if result is None: +def fetch_pcie_info(pnp_device_id: str) -> Optional[tuple[Optional[int], Optional[int]]]: + """Fetch PCIe link speed (gen) and width for a PNP device. + Returns (speed, width) where either may be None if that property is + absent. Returns None only if both lookups fail.""" + speed_raw = _get_devnode_property(pnp_device_id, _PCIE_LINK_SPEED) + width_raw = _get_devnode_property(pnp_device_id, _PCIE_LINK_WIDTH) + speed = _decode_uint32(speed_raw) if speed_raw is not None else None + width = _decode_uint32(width_raw) if width_raw is not None else None + if speed is None and width is None: return None + return (speed, width) - raw_bytes = result[1].raw - value = decode_uint32(raw_bytes) - return str(value) if value is not None else None - - -def get_pcie_link_speed(pnp_device_id: str) -> Optional[int]: - result = _fetch_property(pnp_device_id, pcie_link_speed_key) - if result is None: +def get_bus_number(pnp_device_id: str) -> Optional[str]: + """Get the bus number for a PNP device as a string, or None.""" + raw = _get_devnode_property(pnp_device_id, _BUS_NUMBER) + if raw is None: return None - raw_bytes = result[1].raw - return decode_uint32(raw_bytes) + val = _decode_uint32(raw) + return str(val) if val is not None else None -def get_pcie_link_width(pnp_device_id: str) -> Optional[int]: - result = _fetch_property(pnp_device_id, pcie_link_width_key) - if result is None: +def get_device_address(pnp_device_id: str) -> Optional[str]: + """Get the device address for a PNP device as a string, or None.""" + raw = _get_devnode_property(pnp_device_id, _DEVICE_ADDRESS) + if raw is None: return None - raw_bytes = result[1].raw - return decode_uint32(raw_bytes) + val = _decode_uint32(raw) + return str(val) if val is not None else None def fetch_device_properties( pnp_device_id: str, ) -> tuple[Optional[list[str]], Optional[str], Optional[str]]: - """ - Fetch location paths, bus number, and device address in one call. - - Args: - pnp_device_id: The PNP Device ID string - - Returns: - Tuple of (location_paths, bus_number, device_address) - """ + """Fetch location paths, bus number, and device address in one call.""" return ( get_location_paths(pnp_device_id), get_bus_number(pnp_device_id), get_device_address(pnp_device_id), ) - - -def fetch_pcie_info(pnp_device_id: str) -> Optional[tuple[Optional[int], Optional[int]]]: - """ - Fetch PCIe link speed and width for a PNP device. - - Args: - pnp_device_id: The PNP Device ID string - """ - speed = get_pcie_link_speed(pnp_device_id) - width = get_pcie_link_width(pnp_device_id) - - if speed is None and width is None: - return None - - return (speed, width) diff --git a/tests/core/windows/test_display.py b/tests/core/windows/test_display.py index f5873c9..f4a693c 100644 --- a/tests/core/windows/test_display.py +++ b/tests/core/windows/test_display.py @@ -1,356 +1,242 @@ -import ctypes +""" +Tests for hwprobe.core.windows.display + +All Win32 calls go through display_info.dll, so tests just mock the four +binding functions. No ctypes patching needed. +Uses direct-load pattern to bypass core.windows.__init__ which chains +into broken legacy imports. +""" + +import importlib +import importlib.util +import pathlib import struct -from ctypes import addressof, py_object +import sys +import types +from dataclasses import dataclass +from typing import Optional import pytest -from hwprobe.core.windows import display -from hwprobe.interops.win.legacy.constants import ( - STATUS_FAILURE, - STATUS_INVALID_ARG, - STATUS_NOK, - STATUS_OK, -) -from hwprobe.models.display_models import DisplayInfo +from hwprobe.models.display_models import DisplayInfo, DisplayModuleInfo, ResolutionInfo from hwprobe.models.status_models import StatusType +_MODULE_PATH = pathlib.Path(__file__).resolve().parents[3] / "src" / "hwprobe" / "core" / "windows" / "display.py" + + +@dataclass +class MonitorDevice: + device_id: str + pnp_device_id: str + width: int + height: int + refresh_rate: int + + +@dataclass +class ConnectorInfo: + display_id: str + display_path: str + output_technology: int + + +def _load_display_module(): + """Load display.py directly without triggering core.windows.__init__.""" + # Stub the display_info binding — it loads a DLL at import time. + # Use real dataclasses so tests can construct them. + _binding = types.ModuleType("hwprobe.interops.win.bindings.display_info") + _binding.MonitorDevice = MonitorDevice + _binding.ConnectorInfo = ConnectorInfo + _binding.get_monitor_devices = lambda: [] + _binding.get_display_connectors = lambda: [] + _binding.get_gpu_for_display = lambda name: None + _binding.get_edid = lambda pnp: None + sys.modules.setdefault("hwprobe.interops.win.bindings.display_info", _binding) + + # Stub win_enum — display.py imports DISPLAY_CON_TYPE from it, but + # importing the real module triggers core.windows.__init__. + _win_enum = types.ModuleType("hwprobe.core.windows.win_enum") + _win_enum.DISPLAY_CON_TYPE = {4: "DVI", 5: "HDMI", 10: "DisplayPort", 11: "eDP"} + sys.modules.setdefault("hwprobe.core.windows.win_enum", _win_enum) + + mod_name = "hwprobe.core.windows.display" + if mod_name in sys.modules: + return sys.modules[mod_name] + spec = importlib.util.spec_from_file_location(mod_name, _MODULE_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return mod + + +display = _load_display_module() + + # ============================================================ # Helpers # ============================================================ -def deref(ptr, ctype): - """Dereference a ctypes byref() argument safely.""" - return ctypes.cast(ptr, ctypes.POINTER(ctype)).contents - - -def build_minimal_edid(name=b"TEST-MONITOR", width_cm=60, height_cm=34): - """Build a minimal 128-byte EDID for testing.""" +def _build_edid(name=b"TEST-MONITOR", width_cm=60, height_cm=34, year=2023): + """Build a minimal 128-byte EDID matching the shared parser's expectations.""" edid = bytearray(128) - # decoded: vendor = TST - vendor = (1 << 10) | (2 << 5) | 3 + vendor = (1 << 10) | (2 << 5) | 3 # "ABC" edid[8:10] = struct.pack(">H", vendor) edid[10:12] = struct.pack(" 0 - - @pytest.mark.parametrize( - "width,height", - [(0, 1080), (1920, 0), (0, 0)], - ) - def test_invalid_dimensions(self, width, height): - ratio, real, friendly = display.get_aspect_ratio(width, height) - - assert ratio is None - assert real is None - assert friendly is None +def _mock_monitor(device_id=r"\\.\DISPLAY1", pnp_id=r"MONITOR\ABC123\{GUID}", w=2560, h=1440, rr=144): + return MonitorDevice(device_id=device_id, pnp_device_id=pnp_id, width=w, height=h, refresh_rate=rr) # ============================================================ -# EDID parsing tests +# EDID enrichment tests # ============================================================ -class TestEDIDParsing: - def test_minimal_edid(self): - edid, vendor = build_minimal_edid() - parsed = display.parse_edid(edid) +class TestEnrichFromEdid: + def test_fills_name_year_serial_manufacturer(self): + module = DisplayModuleInfo() + module = display._enrich_from_edid(module, _build_edid()) - assert parsed["manufacturer_code"] == "ABC" - assert parsed["vendor_id"] == vendor - assert parsed["product_id"] == 0x1234 - assert parsed["serial"] == 0xDEADBEEF - assert parsed["name"] == "TEST-MONITOR" - assert parsed["inches"] > 0 + assert module.name == "TEST-MONITOR" + assert module.year == 2023 + assert module.serial_number == "SN12345" + assert module.manufacturer_code == "ABC" - def test_missing_name_descriptor(self): - edid, _ = build_minimal_edid() - edid = bytearray(edid) - edid[54:58] = b"\x00\x00\x00\x00" - parsed = display.parse_edid(bytes(edid)) + def test_fills_bit_depth_into_existing_resolution(self): + module = DisplayModuleInfo() + module.resolution = ResolutionInfo(width=2560, height=1440, refresh_rate=144.0) + module = display._enrich_from_edid(module, _build_edid()) - assert parsed["name"] is None or parsed["name"] == "" + assert module.resolution.width == 2560 # not overwritten + assert module.resolution.height == 1440 # not overwritten + assert module.resolution.refresh_rate == 144.0 # not overwritten + assert module.resolution.bit_depth is not None # filled from EDID - def test_invalid_length(self): - assert display.parse_edid(b"short") is None + def test_fills_resolution_when_none(self): + module = DisplayModuleInfo() + module = display._enrich_from_edid(module, _build_edid()) - def test_invalid_hdev(self, monkeypatch): - def mockfail_SetupDiGetClassDevsA(cGuidPtr, enumerator, hwndParent, flags): - return -1 # simulate failure + assert module.resolution is not None - monkeypatch.setattr(display, "SetupDiGetClassDevsA", mockfail_SetupDiGetClassDevsA) + def test_does_not_overwrite_existing_fields(self): + module = DisplayModuleInfo(name="Custom Name", year=2020) + module = display._enrich_from_edid(module, _build_edid()) - assert display.get_edid_by_hwid(None) is None - - def test_device_interfaces_enum_fail(self, monkeypatch): - def mockfail_SetupDiEnumDeviceInterfaces(hDev, devData, cGuidPtr, memberIdx, devIntData): - return False - - monkeypatch.setattr(display, "SetupDiEnumDeviceInterfaces", mockfail_SetupDiEnumDeviceInterfaces) - - assert display.get_edid_by_hwid(None) is None + assert module.name == "Custom Name" + assert module.year == 2020 + assert module.serial_number == "SN12345" # still fills None fields # ============================================================ -# monitor_enum_proc tests +# fetch_display_info tests # ============================================================ -@pytest.fixture -def fake_win32(monkeypatch): - """Mock Win32 API calls for monitor enumeration.""" - - def fake_GetMonitorInfoA(hmonitor, mi_ptr): - mi = deref(mi_ptr, display.MONITORINFOEXA) - mi.szDevice = b"\\\\.\\DISPLAY1" - return True - - def fake_EnumDisplaySettingsA(device, mode, dm_ptr): - dm = deref(dm_ptr, display.DEVMODEA) - dm.dmPelsWidth = 2560 - dm.dmPelsHeight = 1440 - dm.dmDisplayFrequency = 144 - dm.dmDisplayOrientation = 0 - return True - - def fake_EnumDisplayDevicesA(device, idx, dd_ptr, flags): - dd = deref(dd_ptr, display.DISPLAY_DEVICEA) - dd.DeviceID = b"MONITOR\\AG326UD\\{SOME-GUID}" - return True - - monkeypatch.setattr(display, "GetMonitorInfoA", fake_GetMonitorInfoA) - monkeypatch.setattr(display, "EnumDisplaySettingsA", fake_EnumDisplaySettingsA) - monkeypatch.setattr(display, "EnumDisplayDevicesA", fake_EnumDisplayDevicesA) - - -class TestMonitorEnumProc: - def test_happy_path(self, fake_win32, monkeypatch): - monitors = DisplayInfo() - monitors_ptr = py_object(monitors) - lparam = addressof(monitors_ptr) - - monkeypatch.setattr(display, "find_monitor_gpu", lambda name: ("GPU-0", STATUS_OK)) - monkeypatch.setattr( - display, - "get_edid_by_hwid", - lambda hwid: { - "name": "AG326UD", - "vendor_id": 0x1234, - "product_id": 0x5678, - "serial": "42", - "inches": 32, - "manufacturer_code": "TST", - }, - ) - - ret = display.monitor_enum_proc(1, 0, None, lparam) - assert ret is True - assert len(monitors.modules) == 1 - - mod = monitors.modules[0] - assert mod.name == "AG326UD" - assert mod.gpu_name == "GPU-0" - assert mod.resolution.width == 2560 - assert mod.resolution.height == 1440 - assert mod.resolution.refresh_rate == 144 - assert int(mod.vendor_id, 16) == 0x1234 - assert int(mod.product_id, 16) == 0x5678 - assert mod.serial_number == "42" - assert mod.manufacturer_code == "TST" - - def test_no_edid_found(self, fake_win32, monkeypatch): - monitors = DisplayInfo() - monitors_ptr = py_object(monitors) - lparam = addressof(monitors_ptr) - - monkeypatch.setattr(display, "find_monitor_gpu", lambda name: (None, STATUS_NOK)) - monkeypatch.setattr(display, "get_edid_by_hwid", lambda hwid: None) - - ret = display.monitor_enum_proc(1, 0, None, lparam) - assert ret is True - assert len(monitors.modules) == 1 - assert monitors.modules[0].name is None - - def test_enum_display_settings_fail(self, monkeypatch): - def fake_EnumDisplaySettingsA(device, mode, dm_ptr): - return False - - monitors = DisplayInfo() - monitors_ptr = py_object(monitors) - lparam = addressof(monitors_ptr) - - monkeypatch.setattr(display, "EnumDisplaySettingsA", fake_EnumDisplaySettingsA) - - ret = display.monitor_enum_proc(1, 0, None, lparam) - - assert ret is True - assert monitors.status.type == StatusType.PARTIAL - assert monitors.modules == [] - - -class TestDisplayInfoFetch: - def test_fetch_display_info_internal_real(self): - monitors = display.fetch_display_info_internal() - - assert monitors.status.type == StatusType.SUCCESS - assert len(monitors.modules) > 0 - - module = monitors.modules[0] - assert module.name is not None - assert module.gpu_name is not None - assert module.device_id is not None - assert module.acpi_path is not None - assert module.resolution.width > 0 - assert module.resolution.height > 0 - assert module.resolution.aspect_ratio > 0 - assert module.resolution.aspect_ratio_real is not None - assert module.resolution.aspect_ratio_friendly is not None - assert module.orientation != "Unknown" - assert module.inches > 0 - assert module.vendor_id is not None - assert module.product_id is not None - assert module.serial_number is not None - assert module.manufacturer_code is not None - - def test_fetch_display_info_internal_failure(self, monkeypatch): - def mockfail_EnumDisplayMonitors(hdc, lprcClip, lpfnEnum, dwData): - return False - - monkeypatch.setattr(display, "EnumDisplayMonitors", mockfail_EnumDisplayMonitors) - - assert display.fetch_display_info_internal().status.type == StatusType.FAILED - - @pytest.mark.parametrize( - "orientation, expected", - [ - (0, "Landscape"), - (1, "Portrait"), - (2, "Landscape (flipped)"), - (3, "Portrait (flipped)"), - (-1, "Unknown"), - ], - ) - def test_fetch_display_info_internal_orientations(self, orientation, expected, monkeypatch): - def fake_EnumDisplaySettingsA(device, mode, dm_ptr): - dm = deref(dm_ptr, display.DEVMODEA) - dm.dmPelsWidth = 2560 - dm.dmPelsHeight = 1440 - dm.dmDisplayFrequency = 144 - dm.dmDisplayOrientation = orientation - - return True - - monkeypatch.setattr(display, "EnumDisplaySettingsA", fake_EnumDisplaySettingsA) - - data = display.fetch_display_info_internal() - - assert data.status.type == StatusType.SUCCESS - assert data.modules[0].orientation.lower() == expected.lower() - - def test_fetch_display_info_internal_missing_pnp(self, monkeypatch): - def fake_EnumDisplayDevicesA(device, idx, dd_ptr, flags): - dd = deref(dd_ptr, display.DISPLAY_DEVICEA) - dd.DeviceID = b"" - - return True - - monkeypatch.setattr(display, "EnumDisplayDevicesA", fake_EnumDisplayDevicesA) - - data = display.fetch_display_info_internal() - - assert data.status.type == StatusType.FAILED - assert data.status.messages[0] == "Failed to fetch Display device information, PNPDeviceID is empty!" - - -class TestGPU: - """Coverage for GPU helper method: GetGPUForDisplay(...)""" - - @pytest.mark.parametrize( - "enc_name, out_buf, buf_size, exp_status", - [ - (b"\\\\.\\DISPLAY1", ctypes.create_string_buffer(256), 256, STATUS_OK), - (b"", ctypes.create_string_buffer(256), 256, STATUS_INVALID_ARG), - (b"\\\\.\\DISPLAY1", None, 256, STATUS_INVALID_ARG), - ( - b"\\\\.\\DISPLAY1", - ctypes.create_string_buffer(256), - 0, - STATUS_INVALID_ARG, - ), - ( - b"\\\\.\\DISPLAY420", - ctypes.create_string_buffer(256), - 256, - STATUS_FAILURE, - ), - ], - ) - def test_fetch_display_info_gpu_display(self, enc_name, out_buf, buf_size, exp_status, monkeypatch): - def mock_find_monitor_gpu(device_name): - res = display.GetGPUForDisplay(enc_name, out_buf, buf_size) - result = (None, res) - - if res != STATUS_OK: - return result - - val = out_buf.value.decode("utf-8") +class TestFetchDisplayInfo: + def test_no_monitors_returns_failed(self, monkeypatch): + monkeypatch.setattr(display, "get_monitor_devices", lambda: []) + monkeypatch.setattr(display, "get_display_connectors", lambda: []) - if val and len(val) > 0: - result = (val, res) + result = display.fetch_display_info() + assert result.status.type == StatusType.FAILED - return result + def test_connector_failure_sets_partial(self, monkeypatch): + monkeypatch.setattr(display, "get_monitor_devices", lambda: [_mock_monitor()]) + monkeypatch.setattr(display, "get_gpu_for_display", lambda name: None) + monkeypatch.setattr(display, "get_edid", lambda key: None) - monkeypatch.setattr(display, "find_monitor_gpu", mock_find_monitor_gpu) + def fail_connectors(): + raise RuntimeError("CCD API failed") - assert display.find_monitor_gpu(enc_name.decode())[1] == exp_status + monkeypatch.setattr(display, "get_display_connectors", fail_connectors) - @pytest.mark.parametrize( - "set_status, exp_status", - [ - (STATUS_NOK, STATUS_NOK), - (STATUS_INVALID_ARG, STATUS_INVALID_ARG), - (STATUS_FAILURE, STATUS_FAILURE), - ], - ) - def test_fetch_display_info_gpu_display_failures(self, set_status, exp_status, monkeypatch): - def mockfail_GetGPUForDisplay(enc_name, out_buf, buf_size): - return set_status # Simulate failure + result = display.fetch_display_info() + assert result.status.type == StatusType.PARTIAL + assert any("connector" in m.lower() for m in result.status.messages) - monkeypatch.setattr(display, "GetGPUForDisplay", mockfail_GetGPUForDisplay) + def test_happy_path(self, monkeypatch): + monkeypatch.setattr(display, "get_monitor_devices", lambda: [_mock_monitor()]) + monkeypatch.setattr(display, "get_display_connectors", lambda: [ + ConnectorInfo( + display_id=r"\\.\DISPLAY1", + display_path=r"\\?\DISPLAY#ABC123", + output_technology=10, + ), + ]) + monkeypatch.setattr(display, "get_gpu_for_display", lambda name: "GPU-0") + monkeypatch.setattr(display, "get_edid", lambda key: _build_edid()) - data = display.find_monitor_gpu("Empty") + result = display.fetch_display_info() - assert data[1] == exp_status + assert len(result.modules) == 1 + mod = result.modules[0] + assert mod.name == "TEST-MONITOR" + assert mod.gpu_name == "GPU-0" + assert mod.interface == "DisplayPort" + assert mod.acpi_path == r"MONITOR\ABC123\{GUID}" + assert mod.resolution.width == 2560 + assert mod.resolution.height == 1440 + assert mod.resolution.refresh_rate == 144.0 + assert mod.serial_number == "SN12345" + assert mod.manufacturer_code == "ABC" + assert mod.year == 2023 + + def test_no_edid_still_returns_module(self, monkeypatch): + monkeypatch.setattr(display, "get_monitor_devices", lambda: [ + _mock_monitor(pnp_id=r"MONITOR\XYZ\{GUID}", w=1920, h=1080, rr=60), + ]) + monkeypatch.setattr(display, "get_display_connectors", lambda: []) + monkeypatch.setattr(display, "get_gpu_for_display", lambda name: None) + monkeypatch.setattr(display, "get_edid", lambda key: None) + + result = display.fetch_display_info() + + assert len(result.modules) == 1 + mod = result.modules[0] + assert mod.name is None + assert mod.gpu_name is None + assert mod.resolution.width == 1920 + assert mod.acpi_path == r"MONITOR\XYZ\{GUID}" + + def test_multiple_monitors(self, monkeypatch): + monkeypatch.setattr(display, "get_monitor_devices", lambda: [ + _mock_monitor(device_id=r"\\.\DISPLAY1", pnp_id=r"MONITOR\AAA\{1}", w=2560, h=1440, rr=144), + _mock_monitor(device_id=r"\\.\DISPLAY2", pnp_id=r"MONITOR\BBB\{2}", w=1920, h=1080, rr=60), + ]) + monkeypatch.setattr(display, "get_display_connectors", lambda: []) + monkeypatch.setattr(display, "get_gpu_for_display", lambda name: "GPU-0") + monkeypatch.setattr(display, "get_edid", lambda key: None) + + result = display.fetch_display_info() + + assert len(result.modules) == 2 + assert result.modules[0].acpi_path == r"MONITOR\AAA\{1}" + assert result.modules[1].acpi_path == r"MONITOR\BBB\{2}" + assert result.modules[0].resolution.width == 2560 + assert result.modules[1].resolution.width == 1920 diff --git a/tests/core/windows/test_graphics.py b/tests/core/windows/test_graphics.py index 6e8cb72..bb5a609 100644 --- a/tests/core/windows/test_graphics.py +++ b/tests/core/windows/test_graphics.py @@ -1,13 +1,14 @@ """ Tests for hwprobe.core.windows.graphics -Strategy: patch the binding import so we never load the real device_info.dll. -We build fake GPUProperties dataclass instances that mirror the real binding. - -The module under test (hwprobe.core.windows.graphics) only depends on the -binding module, but importing it via the package triggers __init__.py which -chains into Win32-only ctypes structs. We use importlib to load the module -directly, bypassing the package __init__. +Strategy: patch the binding + util modules so we never load the real DLLs. +We build fake GPURaw dataclass instances that mirror the real binding, and +mock util.location_paths + core.windows.common for location/PCIe formatting. + +The module under test (hwprobe.core.windows.graphics) depends on the binding +module and util.location_paths, both of which touch Win32-only DLLs. We patch +both via sys.modules and load graphics.py directly via importlib, bypassing +the package __init__. """ import importlib @@ -21,6 +22,7 @@ from hwprobe.models.status_models import StatusType _MODULE_PATH = pathlib.Path(__file__).resolve().parents[3] / "src" / "hwprobe" / "core" / "windows" / "graphics.py" +_COMMON_PATH = pathlib.Path(__file__).resolve().parents[3] / "src" / "hwprobe" / "core" / "windows" / "common.py" def _load_graphics_module(): @@ -35,63 +37,103 @@ def _load_graphics_module(): return mod +def _load_common_module(): + """Load common.py directly (format_acpi_path / format_pci_path) without + triggering core.windows.__init__ which chains into legacy imports.""" + mod_name = "hwprobe.core.windows.common" + if mod_name in sys.modules: + return sys.modules[mod_name] + spec = importlib.util.spec_from_file_location(mod_name, _COMMON_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return mod + + +# ---- Fake binding: GPURaw with raw fields only ---- + @dataclass -class FakeGPUProperties: +class FakeGPURaw: name: str - manufacturer: str vendor_id: int device_id: int - subsystem_vendor_id: int - subsystem_device_id: int - acpi_path: Optional[str] = None - pci_path: Optional[str] = None - vram_mb: int = 0 - pcie_gen: int = 0 - pcie_width: int = 0 + subsystem_id: int + dedicated_video_memory_bytes: int + pnp_device_id: Optional[str] = None + vram_bytes: int = 0 def _gpu( name="NVIDIA GeForce RTX 4090", - manufacturer="NVIDIA", vendor_id=0x10DE, device_id=0x2684, - subsystem_vendor_id=0x1043, - subsystem_device_id=0x8888, - acpi_path=r"\_SB.PCI0.PEG0.PEGP", - pci_path="PciRoot(0x0)/Pci(0x1,0x0)/Pci(0x0,0x0)", - vram_mb=24576, - pcie_gen=4, - pcie_width=16, -) -> FakeGPUProperties: - return FakeGPUProperties( + # subsystem_id encodes vendor (high 16) + device (low 16) + # 0x10438888 -> subsystem_vendor=0x1043, subsystem_device=0x8888 + subsystem_id=0x10438888, + dedicated_video_memory_bytes=24576 * 1024 * 1024, + pnp_device_id=r"PCI\VEN_10DE&DEV_2684&SUBSYS_10438888&REV_A1", + vram_bytes=0, +) -> FakeGPURaw: + return FakeGPURaw( name=name, - manufacturer=manufacturer, vendor_id=vendor_id, device_id=device_id, - subsystem_vendor_id=subsystem_vendor_id, - subsystem_device_id=subsystem_device_id, - acpi_path=acpi_path, - pci_path=pci_path, - vram_mb=vram_mb, - pcie_gen=pcie_gen, - pcie_width=pcie_width, + subsystem_id=subsystem_id, + dedicated_video_memory_bytes=dedicated_video_memory_bytes, + pnp_device_id=pnp_device_id, + vram_bytes=vram_bytes, ) -def _patch_binding(gpu_list): - mock_module = MagicMock() - mock_module.get_gpu_info.return_value = gpu_list - mock_module.GPUProperties = FakeGPUProperties +# ---- Mock helpers for location_paths + common ---- + +_DEFAULT_PATHS = [ + r"ACPI(_SB_)#ACPI(PCI0)#ACPI(PEG0)#ACPI(PEGP)", + r"PCIROOT(0)#PCI(1C05)#PCI(0000)", +] +_DEFAULT_PCIE = (4, 16) +# Sentinel so callers can pass pcie=None explicitly (distinct from "use default"). +_UNSET = object() + + +def _patch_modules(gpu_list, paths=None, pcie=_UNSET): + """Patch gpu_info binding + util.location_paths + core.windows.common.""" + if paths is None: + paths = _DEFAULT_PATHS + if pcie is _UNSET: + pcie = _DEFAULT_PCIE + + gpu_mock = MagicMock() + gpu_mock.get_gpu_info.return_value = gpu_list + gpu_mock.GPURaw = FakeGPURaw + + loc_mock = MagicMock() + loc_mock.get_location_paths.return_value = paths + loc_mock.fetch_pcie_info.return_value = pcie + + common_mock = MagicMock() + # Use the real format functions so path formatting is exercised. + common_mod = _load_common_module() + common_mock.format_acpi_path.side_effect = common_mod.format_acpi_path + common_mock.format_pci_path.side_effect = common_mod.format_pci_path + return patch.dict( "sys.modules", - {"hwprobe.interops.win.bindings.gpu_info": mock_module}, + { + "hwprobe.interops.win.bindings.gpu_info": gpu_mock, + "hwprobe.util.location_paths": loc_mock, + "hwprobe.core.windows.common": common_mock, + }, ) -def _run(gpu_list): - sys.modules.pop("hwprobe.interops.win.bindings.gpu_info", None) - sys.modules.pop("hwprobe.core.windows.graphics", None) - with _patch_binding(gpu_list): +def _run(gpu_list, paths=None, pcie=None): + for m in ("hwprobe.interops.win.bindings.gpu_info", + "hwprobe.util.location_paths", + "hwprobe.core.windows.common", + "hwprobe.core.windows.graphics"): + sys.modules.pop(m, None) + with _patch_modules(gpu_list, paths, pcie): mod = _load_graphics_module() return mod.fetch_graphics_info() @@ -111,30 +153,32 @@ def test_single_gpu_success(self): assert gpu.subsystem_manufacturer == "0x1043" assert gpu.subsystem_model == "0x8888" - def test_vram_populated(self): - info = _run([_gpu(vram_mb=24576)]) + def test_vram_populated_from_dxgi(self): + info = _run([_gpu(dedicated_video_memory_bytes=24576 * 1024 * 1024)]) + gpu = info.modules[0] + assert gpu.vram is not None + assert gpu.vram.capacity == 24576 + + def test_vram_registry_fallback_wins(self): + info = _run([_gpu( + dedicated_video_memory_bytes=4096 * 1024 * 1024, + vram_bytes=24576 * 1024 * 1024, + )]) gpu = info.modules[0] assert gpu.vram is not None assert gpu.vram.capacity == 24576 def test_pcie_fields_populated(self): - info = _run([_gpu(pcie_gen=4, pcie_width=16)]) + info = _run([_gpu()], pcie=(4, 16)) gpu = info.modules[0] assert gpu.pcie_gen == 4 assert gpu.pcie_width == 16 def test_acpi_and_pci_paths_populated(self): - info = _run( - [ - _gpu( - acpi_path=r"\_SB.PCI0.PEG0.PEGP", - pci_path="PciRoot(0x0)/Pci(0x1,0x0)/Pci(0x0,0x0)", - ) - ] - ) + info = _run([_gpu()]) gpu = info.modules[0] - assert gpu.acpi_path == r"\_SB.PCI0.PEG0.PEGP" - assert gpu.pci_path == "PciRoot(0x0)/Pci(0x1,0x0)/Pci(0x0,0x0)" + assert gpu.acpi_path == r"\_SB_.PCI0.PEG0.PEGP" + assert gpu.pci_path == "PciRoot(0x0)/Pci(0x1C,0x5)/Pci(0x0,0x0)" def test_return_type_is_graphics_info(self): from hwprobe.models.gpu_models import GraphicsInfo @@ -147,21 +191,15 @@ class TestMultipleGPUs: def test_igpu_plus_dgpu(self): igpu = _gpu( name="Intel UHD Graphics 630", - manufacturer="Intel", vendor_id=0x8086, device_id=0x3E92, - vram_mb=0, - pcie_gen=0, - pcie_width=0, + dedicated_video_memory_bytes=0, ) dgpu = _gpu( name="NVIDIA GeForce RTX 3080", - manufacturer="NVIDIA", vendor_id=0x10DE, device_id=0x2206, - vram_mb=10240, - pcie_gen=4, - pcie_width=16, + dedicated_video_memory_bytes=10240 * 1024 * 1024, ) info = _run([igpu, dgpu]) @@ -170,8 +208,8 @@ def test_igpu_plus_dgpu(self): assert info.modules[1].name == "NVIDIA GeForce RTX 3080" def test_dual_amd_gpus(self): - gpu1 = _gpu(name="AMD Radeon RX 7900 XTX", manufacturer="AMD", vendor_id=0x1002, device_id=0x744C) - gpu2 = _gpu(name="AMD Radeon RX 7900 XT", manufacturer="AMD", vendor_id=0x1002, device_id=0x744C) + gpu1 = _gpu(name="AMD Radeon RX 7900 XTX", vendor_id=0x1002, device_id=0x744C) + gpu2 = _gpu(name="AMD Radeon RX 7900 XT", vendor_id=0x1002, device_id=0x744C) info = _run([gpu1, gpu2]) assert len(info.modules) == 2 @@ -179,35 +217,46 @@ def test_dual_amd_gpus(self): class TestZeroAndMissingFields: def test_zero_vram_results_in_none(self): - info = _run([_gpu(vram_mb=0)]) + info = _run([_gpu(dedicated_video_memory_bytes=0, vram_bytes=0)]) assert info.modules[0].vram is None - def test_zero_pcie_gen_results_in_none(self): - info = _run([_gpu(pcie_gen=0)]) + def test_none_pcie_returns_none(self): + info = _run([_gpu()], pcie=None) assert info.modules[0].pcie_gen is None - - def test_zero_pcie_width_results_in_none(self): - info = _run([_gpu(pcie_width=0)]) assert info.modules[0].pcie_width is None - def test_none_acpi_path_preserved(self): - info = _run([_gpu(acpi_path=None)]) - assert info.modules[0].acpi_path is None + def test_no_pnp_device_id_skips_location_lookup(self): + info = _run([_gpu(pnp_device_id=None)]) + gpu = info.modules[0] + assert gpu.acpi_path is None + assert gpu.pci_path is None - def test_none_pci_path_preserved(self): - info = _run([_gpu(pci_path=None)]) - assert info.modules[0].pci_path is None + def test_empty_location_paths(self): + info = _run([_gpu()], paths=[]) + gpu = info.modules[0] + assert gpu.acpi_path is None + assert gpu.pci_path is None class TestFailurePaths: def test_runtime_error_returns_failed(self): - mock_module = MagicMock() - mock_module.get_gpu_info.side_effect = RuntimeError("get_gpu_info() failed (C library returned -1)") - - sys.modules.pop("hwprobe.interops.win.bindings.gpu_info", None) - sys.modules.pop("hwprobe.core.windows.graphics", None) - - with patch.dict("sys.modules", {"hwprobe.interops.win.bindings.gpu_info": mock_module}): + gpu_mock = MagicMock() + gpu_mock.get_gpu_info.side_effect = RuntimeError("get_gpu_info() failed (C library returned -1)") + + loc_mock = MagicMock() + common_mock = MagicMock() + + for m in ("hwprobe.interops.win.bindings.gpu_info", + "hwprobe.util.location_paths", + "hwprobe.core.windows.common", + "hwprobe.core.windows.graphics"): + sys.modules.pop(m, None) + + with patch.dict("sys.modules", { + "hwprobe.interops.win.bindings.gpu_info": gpu_mock, + "hwprobe.util.location_paths": loc_mock, + "hwprobe.core.windows.common": common_mock, + }): mod = _load_graphics_module() info = mod.fetch_graphics_info() @@ -233,7 +282,11 @@ def test_device_id_hex_format(self): assert info.modules[0].device_id == "0x2684" def test_subsystem_ids_hex_format(self): - info = _run([_gpu(subsystem_vendor_id=0x1043, subsystem_device_id=0x8888)]) + info = _run([_gpu(subsystem_id=0x10438888)]) gpu = info.modules[0] assert gpu.subsystem_manufacturer == "0x1043" assert gpu.subsystem_model == "0x8888" + + def test_unknown_vendor_uses_hex(self): + info = _run([_gpu(vendor_id=0x1234)]) + assert "0x1234" in info.modules[0].manufacturer diff --git a/tests/core/windows/test_network.py b/tests/core/windows/test_network.py index 2ec5dbb..252e736 100644 --- a/tests/core/windows/test_network.py +++ b/tests/core/windows/test_network.py @@ -1,16 +1,70 @@ +import importlib +import importlib.util +import pathlib +import sys +from unittest.mock import MagicMock, patch + import pytest -from hwprobe.core.windows import network -from hwprobe.interops.win.legacy.constants import ( - STATUS_FAILURE, -) from hwprobe.models.network_models import NetworkInfo, NICInfo from hwprobe.models.status_models import StatusType +_MODULE_PATH = pathlib.Path(__file__).resolve().parents[3] / "src" / "hwprobe" / "core" / "windows" / "network.py" +_COMMON_PATH = pathlib.Path(__file__).resolve().parents[3] / "src" / "hwprobe" / "core" / "windows" / "common.py" + + +def _load_common_module(): + """Load common.py directly (format_acpi_path / format_pci_path) without + triggering core.windows.__init__ which chains into legacy imports.""" + mod_name = "hwprobe.core.windows.common" + if mod_name in sys.modules: + return sys.modules[mod_name] + spec = importlib.util.spec_from_file_location(mod_name, _COMMON_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return mod + + +def _load_network_module(): + """Load network.py directly without triggering core.windows.__init__.""" + # Pre-load common.py so network.py's import doesn't trigger __init__. + _load_common_module() + + # Stub the WMI binding + location_paths — both load DLLs at import time, + # which fails on non-Windows. network.py only needs get_wmi_data and + # get_location_paths, both of which tests mock anyway. + sys.modules.setdefault("hwprobe.interops.win.bindings.wmi", MagicMock(get_wmi_data=lambda *a, **kw: [])) + sys.modules.setdefault("hwprobe.util.location_paths", MagicMock(get_location_paths=lambda *a, **kw: None)) + + mod_name = "hwprobe.core.windows.network" + if mod_name in sys.modules: + return sys.modules[mod_name] + spec = importlib.util.spec_from_file_location(mod_name, _MODULE_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return mod + + +network = _load_network_module() + # ============================================================ # Helpers # ============================================================ +def _row(name, manufacturer, pnp_device_id, adapter_type="Ethernet 802.3"): + return { + "Name": name, + "Manufacturer": manufacturer, + "PNPDeviceID": pnp_device_id, + "AdapterType": adapter_type, + } + + +def _patch_wmi(rows, monkeypatch): + monkeypatch.setattr(network, "get_wmi_data", lambda *a, **kw: rows) + # ============================================================ # Basic parsing tests @@ -18,20 +72,12 @@ class TestBasicParsing: - """Tests for basic parsing of network device output""" - def test_successful_network_info_fetch(self, monkeypatch): - """Test successful retrieval and parsing of network hardware info""" - mock_output = ( - "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Intel(R) Ethernet Connection (10) I219-V\n" - "Manufacturer=Realtek|PNPDeviceID=PCI\\VEN_10EC&DEV_8168|Name=Realtek PCIe GBE Family Controller\n" - ) - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [ + _row("Intel(R) Ethernet Connection (10) I219-V", "Intel", r"PCI\VEN_8086&DEV_15B8"), + _row("Realtek PCIe GBE Family Controller", "Realtek", r"PCI\VEN_10EC&DEV_8168"), + ] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() @@ -41,13 +87,7 @@ def mock_func(buf, size): assert network_info.modules[1].manufacturer == "Realtek" def test_empty_response_returns_failed_status(self, monkeypatch): - """Test handling of empty response from GetNetworkHardwareInfo""" - - def mock_func(buf, size): - buf.value = b"" - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + _patch_wmi([], monkeypatch) network_info = network.fetch_network_info_fast() @@ -55,67 +95,17 @@ def mock_func(buf, size): assert len(network_info.modules) == 0 assert any("no data" in msg for msg in network_info.status.messages) - def test_malformed_output_skipped(self, monkeypatch): - """Test that malformed lines are skipped""" - mock_output = ( - "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Valid NIC\n" - "This is malformed and has no pipe separator\n" - "Manufacturer=Realtek|PNPDeviceID=PCI\\VEN_10EC&DEV_8168|Name=Another Valid NIC\n" - ) - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) - - network_info = network.fetch_network_info_fast() - - assert len(network_info.modules) == 2 - assert network_info.modules[0].name == "Valid NIC" - assert network_info.modules[1].name == "Another Valid NIC" - - def test_bad_vendor_device_id_format(self, monkeypatch): - """Test handling of bad Vendor/Device ID format in PNPDeviceID""" - mock_output = "Manufacturer=Intel|PNPDeviceID=PCI\\INVALID_FORMAT|Name=Intel NIC\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) - - network_info = network.fetch_network_info_fast() - - assert len(network_info.modules) == 1 - assert network_info.modules[0].vendor_id is None - assert network_info.modules[0].device_id is None - assert network_info.status.type == StatusType.PARTIAL - assert any("Could not parse Vendor/Device ID" in msg for msg in network_info.status.messages) - def test_missing_manufacturer_field(self, monkeypatch): - """Test handling of missing Manufacturer field""" - mock_output = "PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Intel NIC\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [_row("Intel NIC", "", r"PCI\VEN_8086&DEV_15B8")] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() assert len(network_info.modules) == 0 def test_missing_pnpdeviceid_field(self, monkeypatch): - """Test handling of missing PNPDeviceID field""" - mock_output = "Manufacturer=Intel|Name=Intel NIC\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [_row("Intel NIC", "Intel", "")] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() @@ -128,33 +118,37 @@ def mock_func(buf, size): class TestVendorDeviceParsing: - """Tests for vendor and device ID parsing""" - @pytest.mark.parametrize( "pnp_id,expected_vendor_id,expected_device_id", [ - ("PCI\\VEN_8086&DEV_15B8", "8086", "15B8"), - ("PCI\\VEN_10EC&DEV_8168", "10EC", "8168"), - ("PCI\\VEN_14E4&DEV_1643", "14E4", "1643"), - ("USB\\VID_0BDA&PID_4938", "0BDA", "4938"), - ("USB\\VID_0525&PID_A4A5", "0525", "A4A5"), + (r"PCI\VEN_8086&DEV_15B8", "8086", "15B8"), + (r"PCI\VEN_10EC&DEV_8168", "10EC", "8168"), + (r"PCI\VEN_14E4&DEV_1643", "14E4", "1643"), + (r"USB\VID_0BDA&PID_4938", "0BDA", "4938"), + (r"USB\VID_0525&PID_A4A5", "0525", "A4A5"), ], ) def test_parse_vendor_device_ids(self, pnp_id, expected_vendor_id, expected_device_id, monkeypatch): - """Test parsing various vendor/device ID combinations""" - mock_output = f"Manufacturer=Test|PNPDeviceID={pnp_id}|Name=Test Device\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [_row("Test Device", "Test", pnp_id)] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() assert network_info.modules[0].vendor_id == expected_vendor_id assert network_info.modules[0].device_id == expected_device_id + def test_bad_vendor_device_id_format(self, monkeypatch): + rows = [_row("Intel NIC", "Intel", r"PCI\INVALID_FORMAT")] + _patch_wmi(rows, monkeypatch) + + network_info = network.fetch_network_info_fast() + + assert len(network_info.modules) == 1 + assert network_info.modules[0].vendor_id is None + assert network_info.modules[0].device_id is None + assert network_info.status.type == StatusType.PARTIAL + assert any("Could not parse Vendor/Device ID" in msg for msg in network_info.status.messages) + # ============================================================ # Multiple adapters and formatting tests @@ -162,22 +156,14 @@ def mock_func(buf, size): class TestMultipleAdaptersAndFormatting: - """Tests for multiple adapters and output formatting""" - def test_multiple_network_adapters(self, monkeypatch): - """Test parsing multiple network adapters""" - mock_output = ( - "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Intel NIC 1\n" - "Manufacturer=Realtek|PNPDeviceID=PCI\\VEN_10EC&DEV_8168|Name=Realtek NIC\n" - "Manufacturer=Broadcom|PNPDeviceID=PCI\\VEN_14E4&DEV_1643|Name=Broadcom NIC\n" - "Manufacturer=Generic|PNPDeviceID=USB\\VID_0BDA&PID_4938|Name=USB Adapter\n" - ) - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [ + _row("Intel NIC 1", "Intel", r"PCI\VEN_8086&DEV_15B8"), + _row("Realtek NIC", "Realtek", r"PCI\VEN_10EC&DEV_8168"), + _row("Broadcom NIC", "Broadcom", r"PCI\VEN_14E4&DEV_1643"), + _row("USB Adapter", "Generic", r"USB\VID_0BDA&PID_4938"), + ] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() @@ -188,40 +174,52 @@ def mock_func(buf, size): assert network_info.modules[3].manufacturer == "Generic" def test_whitespace_stripping(self, monkeypatch): - """Test that whitespace is properly stripped from fields""" - mock_output = "Manufacturer= Intel |PNPDeviceID= PCI\\VEN_8086&DEV_15B8 |Name= Intel NIC \n" + rows = [_row(" Intel NIC ", " Intel ", r"PCI\VEN_8086&DEV_15B8")] + _patch_wmi(rows, monkeypatch) - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 + network_info = network.fetch_network_info_fast() - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + assert len(network_info.modules) == 1 + assert network_info.modules[0].manufacturer == "Intel" + assert network_info.modules[0].name == "Intel NIC" + + def test_loopback_adapter_skipped(self, monkeypatch): + rows = [ + _row("Loopback", "Microsoft", r"ROOT\\MS_NDISWANIP", adapter_type="Software Loopback Interface 1"), + _row("Intel NIC", "Intel", r"PCI\VEN_8086&DEV_15B8"), + ] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() assert len(network_info.modules) == 1 - # Whitespace should be stripped - assert network_info.modules[0].manufacturer == "Intel" assert network_info.modules[0].name == "Intel NIC" - def test_blank_lines_ignored(self, monkeypatch): - """Test that blank lines are properly ignored""" - mock_output = ( - "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Intel NIC\n" - "\n" - "\n" - "Manufacturer=Realtek|PNPDeviceID=PCI\\VEN_10EC&DEV_8168|Name=Realtek NIC\n" - ) + def test_root_adapters_skipped(self, monkeypatch): + rows = [ + _row("Root Device", "Microsoft", r"ROOT\\SOMETHING"), + _row("Intel NIC", "Intel", r"PCI\VEN_8086&DEV_15B8"), + ] + _patch_wmi(rows, monkeypatch) - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 + network_info = network.fetch_network_info_fast() - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + assert len(network_info.modules) == 1 + assert network_info.modules[0].name == "Intel NIC" + + def test_non_pci_usb_hardware_nic_included(self, monkeypatch): + """NICs on non-PCI/USB buses (e.g. SDIO) should not be filtered out.""" + rows = [ + _row("Broadcom SDIO WiFi", "Broadcom", r"SD\VID_02D0&PID_A4A5"), + _row("Intel NIC", "Intel", r"PCI\VEN_8086&DEV_15B8"), + ] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() assert len(network_info.modules) == 2 + assert network_info.modules[0].name == "Broadcom SDIO WiFi" + assert network_info.modules[1].name == "Intel NIC" # ============================================================ @@ -230,17 +228,9 @@ def mock_func(buf, size): class TestModelStructure: - """Tests for data model structure and fields""" - def test_nic_model_fields(self, monkeypatch): - """Test that NICInfo model fields are correctly populated""" - mock_output = "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Test NIC\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [_row("Test NIC", "Intel", r"PCI\VEN_8086&DEV_15B8")] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() nic = network_info.modules[0] @@ -252,14 +242,8 @@ def mock_func(buf, size): assert nic.device_id == "15B8" def test_network_info_model_structure(self, monkeypatch): - """Test that NetworkInfo model has correct structure""" - mock_output = "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Test NIC\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows = [_row("Test NIC", "Intel", r"PCI\VEN_8086&DEV_15B8")] + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() @@ -270,19 +254,6 @@ def mock_func(buf, size): class TestFunctionCallAndErrors: - """Tests for function behavior and error conditions""" - - def test_function_call(self, monkeypatch): - """Test that GetNetworkHardwareInfo is called successfully""" - - def mock_func(buf, size): - buf.value = b"" - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) - - network.fetch_network_info_fast() - @pytest.mark.parametrize( "device_count,manufacturers", [ @@ -292,19 +263,12 @@ def mock_func(buf, size): ], ) def test_various_device_counts(self, device_count, manufacturers, monkeypatch): - """Test parsing various numbers of network devices""" - lines = [] + rows = [] for i, mfg in enumerate(manufacturers): vendor = f"VEN_{0x8086 + i:04X}" if i == 0 else f"VEN_{0x10EC + i:04X}" device = f"DEV_{0x15B8 + i:04X}" - lines.append(f"Manufacturer={mfg}|PNPDeviceID=PCI\\{vendor}&{device}|Name={mfg} NIC {i + 1}") - mock_output = "\n".join(lines) + "\n" - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + rows.append(_row(f"{mfg} NIC {i + 1}", mfg, f"PCI\\{vendor}&{device}")) + _patch_wmi(rows, monkeypatch) network_info = network.fetch_network_info_fast() @@ -312,42 +276,16 @@ def mock_func(buf, size): for i, mfg in enumerate(manufacturers): assert network_info.modules[i].manufacturer == mfg - def test_multiple_network_adapters(self, monkeypatch): - """Test parsing multiple network adapters""" - mock_output = ( - "Manufacturer=Intel|PNPDeviceID=PCI\\VEN_8086&DEV_15B8|Name=Intel NIC 1\n" - "Manufacturer=Realtek|PNPDeviceID=PCI\\VEN_10EC&DEV_8168|Name=Realtek NIC\n" - "Manufacturer=Broadcom|PNPDeviceID=PCI\\VEN_14E4&DEV_1643|Name=Broadcom NIC\n" - "Manufacturer=Generic|PNPDeviceID=USB\\VID_0BDA&PID_4938|Name=USB Adapter\n" - ) - - def mock_func(buf, size): - buf.value = mock_output.encode("utf-8") - return 0 - - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) - - network_info = network.fetch_network_info_fast() - - assert len(network_info.modules) == 4 - assert network_info.modules[0].manufacturer == "Intel" - assert network_info.modules[1].manufacturer == "Realtek" - assert network_info.modules[2].manufacturer == "Broadcom" - assert network_info.modules[3].manufacturer == "Generic" - - def test_function_call_with_bad_status(self, monkeypatch): - """Test handling of non-OK status from GetNetworkHardwareInfo""" - - def mock_func(buf, size): - buf.value = b"" - return STATUS_FAILURE + def test_runtime_error_returns_failed(self, monkeypatch): + def mock_wmi(*a, **kw): + raise RuntimeError("get_wmi_data() failed (C library returned -1)") - monkeypatch.setattr(network, "GetNetworkHardwareInfo", mock_func) + monkeypatch.setattr(network, "get_wmi_data", mock_wmi) network_info = network.fetch_network_info_fast() assert network_info.status.type == StatusType.FAILED - assert any("status code:" in msg for msg in network_info.status.messages) + assert any("-1" in msg for msg in network_info.status.messages) @pytest.mark.parametrize( "pci_path, acpi_path, exp_pci_path, exp_acpi_path", @@ -373,13 +311,11 @@ def mock_func(buf, size): ], ) def test_format_paths(self, pci_path, acpi_path, exp_pci_path, exp_acpi_path, monkeypatch): - """Test that format_{pci|acpi}_path returns correct PCI and ACPI path""" + rows = [_row("Test NIC", "Intel", r"PCI\VEN_8086&DEV_15B8")] + _patch_wmi(rows, monkeypatch) def mock_get_location_paths(pnp_device_id): - return ( - pci_path, - acpi_path, - ) + return (pci_path, acpi_path) monkeypatch.setattr(network, "get_location_paths", mock_get_location_paths)