From 29c0ecc4259ac05fe85fef948d24101a532036be Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 12 Aug 2026 15:22:38 +0200 Subject: [PATCH 01/22] feat: Display/GPU detection improvements - GPU information interops for Linux (DRM IOCTL / Vulkan fallback) for fetching VRAM and GPU name; removed nvidia-smi/amd/lspci - Display detection fetches parent GPU, then finds it through the internal (also remove pci_path from display device, not sure why I added it there in the first place) - Extend EDID parsing feature set: now handles `Detailed Timing Descriptor`, `Type 1 Timing` and `CTA-861` ext. block - Implement ACPI detection / parsing on Linux the proper way --- src/hwprobe/core/common/edid.py | 253 +++++++-- src/hwprobe/core/linux/common.py | 77 ++- src/hwprobe/core/linux/display.py | 51 +- src/hwprobe/core/linux/graphics.py | 243 ++++---- src/hwprobe/core/linux/manager.py | 9 +- src/hwprobe/interops/linux/CMakeLists.txt | 63 +++ src/hwprobe/interops/linux/README.md | 129 +++++ .../interops/linux/bindings/gpu_info.py | 87 +++ src/hwprobe/interops/linux/include/gpu_info.h | 33 ++ src/hwprobe/interops/linux/main.c | 25 + src/hwprobe/interops/linux/src/gpu_info.cpp | 529 ++++++++++++++++++ src/hwprobe/models/display_models.py | 4 +- src/hwprobe/models/gpu_models.py | 15 +- src/hwprobe/models/info_models.py | 15 + src/hwprobe/util/nvidia.py | 36 -- tests/core/linux/test_common.py | 14 +- tests/core/linux/test_display.py | 90 +-- tests/core/linux/test_graphics.py | 380 +++---------- 18 files changed, 1384 insertions(+), 669 deletions(-) create mode 100644 src/hwprobe/interops/linux/CMakeLists.txt create mode 100644 src/hwprobe/interops/linux/README.md create mode 100644 src/hwprobe/interops/linux/bindings/gpu_info.py create mode 100644 src/hwprobe/interops/linux/include/gpu_info.h create mode 100644 src/hwprobe/interops/linux/main.c create mode 100644 src/hwprobe/interops/linux/src/gpu_info.cpp delete mode 100644 src/hwprobe/util/nvidia.py diff --git a/src/hwprobe/core/common/edid.py b/src/hwprobe/core/common/edid.py index d6bfee0..027f51a 100644 --- a/src/hwprobe/core/common/edid.py +++ b/src/hwprobe/core/common/edid.py @@ -1,3 +1,5 @@ +from typing import Optional + from hwprobe.models.display_models import DisplayModuleInfo, ResolutionInfo BIT_DEPTH_ENUM = {1: 6, 2: 8, 3: 10, 4: 12, 5: 14, 6: 16} @@ -17,6 +19,14 @@ 0xFC: "Display Product Name", } +CTA_EXTENSION_TAG = 0x02 +DISPLAYID_EXTENSION_TAG = 0x70 +DISPLAYID_TYPE_I_TIMING_TAG = 0x03 + +# (width, height, refresh_rate) +ResolutionCandidate = tuple[int, int, float] +_NO_RESOLUTION: ResolutionCandidate = (0, 0, 0) + def _get_bits(data: bytes, start_bit: int, end_bit: int) -> int: # Get the bit values in an offset, given a bytes object. @@ -39,80 +49,201 @@ def _get_bits(data: bytes, start_bit: int, end_bit: int) -> int: return (value >> shift) & ((1 << length) - 1) -def parse_edid(edid_data: bytes) -> DisplayModuleInfo: - # todo: Parse EDID v1.2 and v1.3. This will work for v1.4, but need to verify on the older versions. - module = DisplayModuleInfo() +def _detailed_timing(descriptor: bytes) -> Optional[ResolutionCandidate]: + # Parse an 18-byte EDID Detailed Timing Descriptor into (width, height, refresh_rate). + # Returns None if this slot isn't a timing descriptor (e.g. all-zero padding/tag block). + if len(descriptor) < 18 or descriptor[:2] == b"\x00\x00": + return None - edid_version = (edid_data[0x12], edid_data[0x13]) + pixel_clock_hz = (descriptor[0] | (descriptor[1] << 8)) * 10_000 - module.year = edid_data[0x11] + 1990 + horiz = ((descriptor[4] & 0xF0) << 4) | descriptor[2] + vert = ((descriptor[7] & 0xF0) << 4) | descriptor[5] - manuf_bits = int.from_bytes(edid_data[0x08:0x0A], byteorder="big") - char1 = chr(((manuf_bits >> 10) & 0x1F) + 64) - char2 = chr(((manuf_bits >> 5) & 0x1F) + 64) - char3 = chr((manuf_bits & 0x1F) + 64) - manuf_string = char1 + char2 + char3 + h_blank = ((descriptor[4] & 0x0F) << 8) | descriptor[3] + v_blank = ((descriptor[7] & 0x0F) << 8) | descriptor[6] - module.manufacturer_code = manuf_string + total_h = horiz + h_blank + total_v = vert + v_blank + if total_h == 0 or total_v == 0: + return None - product_code = edid_data[0x0A:0x0C].hex().upper() + refresh_rate = pixel_clock_hz / (total_h * total_v) + return horiz, vert, round(refresh_rate, 2) - id_serial_number = "0x" + edid_data[0x0C:0x10].hex().upper() - input_type = edid_data[0x14] - module.resolution = ResolutionInfo() +def _type_i_timing(entry: bytes) -> Optional[ResolutionCandidate]: + # Parse a 20-byte DisplayID Type I Timing entry into (width, height, refresh_rate). + # Layout verified byte-for-byte against a real DisplayID extension block: pixel clock + # is a 24-bit LE value in units of 10 kHz (stored as actual-1), and active/blank pixel + # counts are 16-bit LE (also stored as actual-1) - unlike the base/CTA DTD format. + if len(entry) < 20: + return None + + pixel_clock_hz = ((entry[0] | (entry[1] << 8) | (entry[2] << 16)) + 1) * 10_000 + + h_active = (entry[4] | (entry[5] << 8)) + 1 + h_blank = (entry[6] | (entry[7] << 8)) + 1 + v_active = (entry[12] | (entry[13] << 8)) + 1 + v_blank = (entry[14] | (entry[15] << 8)) + 1 + + total_h = h_active + h_blank + total_v = v_active + v_blank + if total_h == 0 or total_v == 0: + return None + + refresh_rate = pixel_clock_hz / (total_h * total_v) + return h_active, v_active, round(refresh_rate, 2) - if input_type >> 7 == 1: # MSB is 1 => Digital output - if edid_version >= (1, 4): - module.resolution.bit_depth = BIT_DEPTH_ENUM.get( - _get_bits(input_type.to_bytes(1, byteorder="little"), 1, 4), 0 - ) - module.interface = INTERFACE_ENUM.get(input_type & 7, "Unknown") - else: - module.interface = "Analog" - resolution = (0, 0, 0) # Width, Height, Refresh Rate - # We will use this tuple to find the max resolution and refresh rate, and update it in `module.resolution`. +def _better_resolution( + current: ResolutionCandidate, candidate: Optional[ResolutionCandidate] +) -> ResolutionCandidate: + """Keep whichever of current/candidate has the larger area, breaking ties on refresh rate.""" + if candidate is None: + return current + return max(current, candidate, key=lambda r: (r[0] * r[1], r[2])) + + +def _parse_manufacturer_code(manuf_bytes: bytes) -> str: + """Decode the 3-letter PNP manufacturer ID packed into bytes 0x08-0x0A (5 bits/letter, offset from 'A'-1).""" + manuf_bits = int.from_bytes(manuf_bytes, byteorder="big") + return "".join(chr(((manuf_bits >> shift) & 0x1F) + 64) for shift in (10, 5, 0)) + + +def _parse_video_input( + input_type: int, edid_version: tuple[int, int] +) -> tuple[Optional[int], Optional[str]]: + """ + Decode the video input definition byte (offset 0x14) into (bit_depth, interface). + + Either value may come back None if it isn't determinable - this matches the original + behavior where pre-1.4 digital displays don't report bit depth/interface here at all. + """ + if input_type >> 7 != 1: + return None, "Analog" + + if edid_version < (1, 4): + return None, None + + bit_depth = BIT_DEPTH_ENUM.get(_get_bits(input_type.to_bytes(1, byteorder="little"), 1, 4), 0) + interface = INTERFACE_ENUM.get(input_type & 7, "Unknown") + return bit_depth, interface + + +def _process_display_descriptors( + edid_data: bytes, +) -> tuple[Optional[str], Optional[str], ResolutionCandidate]: + """ + Walk the four 18-byte descriptor blocks in the base EDID (offset 0x36-0x6C). + + Each block is either a Detailed Timing Descriptor, or a display descriptor + (serial number / product name / other, tagged by DESCRIPTOR_TAG_ENUM). + Returns (serial_number, name, best_resolution_found). + """ + serial_number = None + name = None + resolution = _NO_RESOLUTION for block_start in range(0x36, 0x6D, 18): block = edid_data[block_start : block_start + 18] - if block[:2] == b"\x00\x00": - tag = block[3] - if tag in DESCRIPTOR_TAG_ENUM: - # Refer to DESCRIPTOR_TAG_ENUM for valid block type codes - if tag == 0xFF: - # todo: test if this works - module.serial_number = block[5:].decode("ascii").strip() - elif tag == 0xFC: - module.name = block[5:].decode("ascii").strip() - - else: - if not module.resolution: - continue - - pixel_clock_hz = (block[0] | (block[1] << 8)) * 10_000 - - horiz = ((block[4] & 0xF0) << 4) | block[2] - vert = ((block[7] & 0xF0) << 4) | block[5] - - h_blank = ((block[4] & 0x0F) << 8) | block[3] - v_blank = ((block[7] & 0x0F) << 8) | block[6] - refresh_rate = pixel_clock_hz / ((horiz + h_blank) * (vert + v_blank)) - - resolution = max(resolution, (horiz, vert, round(refresh_rate, 2)), key=lambda x: (x[0] * x[1], x[2])) - - if resolution != (0, 0, 0): - if not module.resolution: - module.resolution = ResolutionInfo() - module.resolution.width = resolution[0] - module.resolution.height = resolution[1] - module.resolution.refresh_rate = resolution[2] - # print("\nRaw EDID:") - # for byte in edid_data: - # print(f"{byte:02X}", end=" ") + if block[:2] != b"\x00\x00": + resolution = _better_resolution(resolution, _detailed_timing(block)) + continue - return module + tag = block[3] + if tag == 0xFF: + # todo: test if this works + serial_number = block[5:].decode("ascii").strip() + elif tag == 0xFC: + name = block[5:].decode("ascii").strip() + return serial_number, name, resolution + + +def _process_cta_extension(ext_block: bytes, resolution: ResolutionCandidate) -> ResolutionCandidate: + """Scan a CTA-861 extension block for additional Detailed Timing Descriptors.""" + dtd_offset = ext_block[2] + if dtd_offset < 4: + # 0 means this extension carries no Detailed Timing Descriptors + return resolution + + for descriptor_start in range(dtd_offset, 127, 18): + timing = _detailed_timing(ext_block[descriptor_start : descriptor_start + 18]) + resolution = _better_resolution(resolution, timing) + + return resolution + + +def _process_displayid_extension(ext_block: bytes, resolution: ResolutionCandidate) -> ResolutionCandidate: + """Scan a DisplayID extension block for Type I Timing data blocks. + + Structure: [tag, version, section_size, product_type, ext_count], followed by a + sequence of data blocks: [tag, revision, payload_len, *payload]. + """ + section_end = min(5 + ext_block[2], 127) + offset = 5 + + while offset + 3 <= section_end: + block_tag = ext_block[offset] + payload_len = ext_block[offset + 2] + payload_start = offset + 3 + + if payload_start + payload_len > section_end: + break + + if block_tag == DISPLAYID_TYPE_I_TIMING_TAG: + for entry_start in range(payload_start, payload_start + payload_len, 20): + timing = _type_i_timing(ext_block[entry_start : entry_start + 20]) + resolution = _better_resolution(resolution, timing) + + offset = payload_start + payload_len + + return resolution + + +def parse_edid(edid_data: bytes) -> DisplayModuleInfo: + # todo: Parse EDID v1.2 and v1.3. This will work for v1.4, but need to verify on the older versions. + if len(edid_data) < 128: + raise ValueError(f"EDID data too short: expected at least 128 bytes, got {len(edid_data)}") + + module = DisplayModuleInfo() + module.resolution = ResolutionInfo() + + edid_version = (edid_data[0x12], edid_data[0x13]) + module.year = edid_data[0x11] + 1990 + module.manufacturer_code = _parse_manufacturer_code(edid_data[0x08:0x0A]) + + bit_depth, interface = _parse_video_input(edid_data[0x14], edid_version) + if bit_depth is not None: + module.resolution.bit_depth = bit_depth + if interface is not None: + module.interface = interface + + serial_number, name, resolution = _process_display_descriptors(edid_data) + if serial_number is not None: + module.serial_number = serial_number + if name is not None: + module.name = name + + # High-refresh-rate timings (e.g. 4K@120Hz+) are frequently declared only in a + # CTA-861 or DisplayID extension block rather than the base 128 bytes, so scan those too. + num_extensions = edid_data[0x7E] if len(edid_data) > 0x7E else 0 + for i in range(num_extensions): + ext_start = 128 + i * 128 + ext_block = edid_data[ext_start : ext_start + 128] + if len(ext_block) < 128: + continue + + if ext_block[0] == CTA_EXTENSION_TAG: + resolution = _process_cta_extension(ext_block, resolution) + elif ext_block[0] == DISPLAYID_EXTENSION_TAG: + resolution = _process_displayid_extension(ext_block, resolution) + + if resolution != _NO_RESOLUTION: + module.resolution.width = resolution[0] + module.resolution.height = resolution[1] + module.resolution.refresh_rate = resolution[2] -# todo: parse extension blocks + return module \ No newline at end of file diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index bf6e279..66b309e 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -2,49 +2,68 @@ import posixpath import re +from typing import Optional +from pathlib import Path _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") +# Linux implemented this very annoyingly +# - https://tldp.org/LDP/tlk/dd/pci.html +# - https://wiki.osdev.org/PCI def pci_path_linux(device_slot: str): """ - :param device_slot: format: ::. + :param device_slot: format: ::. :return: PCI path, e.g. PciRoot(0x0)/Pci(0x2,0x0) """ - try: - domain = int(device_slot.split(":")[0], 16) - except (IndexError, ValueError): + # Invalid fallback value + def_val = "PciRoot(0x0)/Pci(0x0,0x0)" + if not device_slot or not _PCI_BDF_PATTERN.match(device_slot): return None + + raw_path = f"/sys/bus/pci/devices/{device_slot}/" - slots = _resolve_device_chain_from_sysfs(device_slot) or [device_slot] - pci_components = [_format_pci_component(s) for s in slots] - pci_suffix = "".join(f"/Pci({c})" for c in pci_components if c) - return f"PciRoot({hex(domain)}){pci_suffix}" - - -def _format_pci_component(slot_name: str): - """Return 'slot,func' as hex string, e.g. '0x1f,0x3', or None.""" - try: - device_func = slot_name.split(":")[-1] - slot, func = device_func.split(".") - return f"{hex(int(slot, 16))},{hex(int(func, 16))}" - except (ValueError, IndexError, AttributeError): + path = os.path.realpath(raw_path) + if not path: return None + + pci_root = "" + pci_segments = [] + + for part in path.split(os.sep): + if part.startswith("pci"): + try: + root_bus = part.split(":")[0].split("pci")[-1] + pci_root = f"PciRoot(0x{int(root_bus, 16):x})" + except (ValueError, IndexError) as e: + print(f"Error parsing PCI root bus from {part}: {e}") + return def_val + + elif ":" in part and "." in part: # Only the root bridge does not contain a function number + try: + bdf_segments = part.split(":")[-1] + dev, func = bdf_segments.split(".") + + pci_segments.append(f"Pci(0x{int(dev, 16):x},0x{int(func, 16):x})") + except (ValueError, IndexError) as e: + print(f"Error parsing PCI device/function from {part}: {e}") + return def_val + if not pci_root or not pci_segments: + return def_val -def _resolve_device_chain_from_sysfs(device_slot: str): - """Return ordered PCI BDFs from root bridge to endpoint for a device.""" - sysfs_path = posixpath.realpath(f"/sys/bus/pci/devices/{device_slot}") - if not sysfs_path: - return None + return f"{pci_root}/{'/'.join(pci_segments)}" + - bdfs = [p for p in sysfs_path.split(posixpath.sep) if _PCI_BDF_PATTERN.match(p)] - if not bdfs: +def _read_from_sysfs(base: str, *paths) -> Optional[str]: + """Read a string from a sysfs file, return None if not found.""" + path = os.path.join(base, *paths) + + if not os.path.exists(path): return None - + try: - end = next(i for i, b in enumerate(bdfs) if b.lower() == device_slot.lower()) - except StopIteration: + with open(path) as f: + return f.read().strip() + except Exception: return None - - return bdfs[: end + 1] diff --git a/src/hwprobe/core/linux/display.py b/src/hwprobe/core/linux/display.py index 9ec4cff..7b51cfa 100644 --- a/src/hwprobe/core/linux/display.py +++ b/src/hwprobe/core/linux/display.py @@ -1,11 +1,12 @@ import os import posixpath import re -from typing import Optional +from typing import List, Optional from hwprobe.core.common.edid import INTERFACE_ENUM, parse_edid from hwprobe.core.linux.common import pci_path_linux from hwprobe.models.display_models import DisplayInfo, DisplayModuleInfo +from hwprobe.models.gpu_models import GraphicsInfo from hwprobe.models.status_models import StatusType _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") @@ -23,6 +24,20 @@ "DSI": "DSI", } +def _resolve_parent_gpu_by_bdf(pci_bdf: str, gpu_devices: List[GraphicsInfo]) -> Optional[str]: + """ + Given a PCI BDF (::.) of a display device, + find the parent GPU in the list of GraphicsInfo objects. + """ + for gpu in gpu_devices: + # TODO: This wastes calls, creates additional complexity and may be error-prone. + # A better alternative may be to map direct BDFs to GPUs and pass it here. + # Though, this is a temporary solution until we provide a better design for GPU-Display relationships. + if gpu.pci_path == pci_path_linux(pci_bdf): + return gpu.name + + return None + def _extract_pci_bdf_from_sysfs_path(path: str) -> Optional[str]: """Extract the endpoint PCI BDF from a resolved sysfs path.""" @@ -40,14 +55,20 @@ def _parse_connector_type(device_path: str) -> Optional[str]: return DRM_CONNECTOR_TYPE.get(m.group(1)) -def _fetch_individual_monitor_info(device_path: str) -> Optional[DisplayModuleInfo]: - edid_path = posixpath.join(device_path, "edid") - if not posixpath.exists(edid_path): +def _fetch_individual_monitor_info( + device_path: str, + gpu_devices: List[GraphicsInfo] +) -> Optional[DisplayModuleInfo]: + edid_path = os.path.join(device_path, "edid") + if not os.path.exists(edid_path): return None - parent_path = posixpath.join(device_path, "device") - # todo: populate parent graphics card info - # we have vendor and device ids of the parent gpu. When PCI-IDs integration is done, use it to get name + # For some reason, it's not guaranteed to only have a single "device" directory in the tree/chain + # So, we look at how many is necessary until "device" stops being a directory. + parent_path = device_path + + while os.path.exists(t := os.path.join(parent_path, "device")) and os.path.isdir(t): + parent_path = t with open(edid_path, "rb") as f: edid_data = f.read() @@ -59,10 +80,12 @@ def _fetch_individual_monitor_info(device_path: str) -> Optional[DisplayModuleIn if connector_type := _parse_connector_type(device_path): monitor_data.interface = connector_type - pci_path_full = posixpath.realpath(parent_path) - pci_bdf = _extract_pci_bdf_from_sysfs_path(pci_path_full) - if pci_bdf: - monitor_data.pci_path = pci_path_linux(pci_bdf) + if parent_path != device_path: + pci_bdf = _extract_pci_bdf_from_sysfs_path(os.path.realpath(parent_path)) + + # Resolve parent GPU based on the PCI BDF + if (parent_gpu := _resolve_parent_gpu_by_bdf(pci_bdf, gpu_devices)) is not None: + monitor_data.gpu_name = parent_gpu acpi_file = posixpath.join(device_path, "firmware_node", "path") if posixpath.exists(acpi_file): @@ -72,7 +95,9 @@ def _fetch_individual_monitor_info(device_path: str) -> Optional[DisplayModuleIn return monitor_data -def fetch_display_info(): +def fetch_display_info( + gpu_devices: List[GraphicsInfo] +): display_info = DisplayInfo() pattern = re.compile(r"^card\d+$") root_path = "/sys/class/drm" @@ -89,7 +114,7 @@ def fetch_display_info(): children = [x for x in os.listdir(parent_path) if x.startswith("card")] for child in children: try: - response = _fetch_individual_monitor_info(posixpath.join(parent_path, child)) + response = _fetch_individual_monitor_info(posixpath.join(parent_path, child), gpu_devices) if response: display_info.modules.append(response) except Exception as e: diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index 36d6963..48d6f4b 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -1,116 +1,100 @@ -import glob import os -import posixpath -import subprocess from typing import Optional -from hwprobe.core.linux.common import pci_path_linux +from hwprobe.core.linux.common import _read_from_sysfs, pci_path_linux 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.nvidia import fetch_gpu_details_nvidia + +# Try to import native C library bindings +try: + from hwprobe.interops.linux.bindings import gpu_info as native_gpu + + NATIVE_AVAILABLE = native_gpu.is_available() +except (ImportError, RuntimeError) as e: + NATIVE_AVAILABLE = False # Currently, the info in /sys/class/drm/cardX is being used. -# todo: Check if lspci and lshw -c display can be used +# TODO: Check if lspci and lshw -c display can be used +# Answer: nope, pciutils and lshw are not guaranteed to be installed on all systems. # https://unix.stackexchange.com/questions/393/how-to-check-how-many-lanes-are-used-by-the-pcie-card +# ^ Solution: /sys/bus/pci/devices/{...}/current_link_width PCI_ROOT_PATH = "/sys/bus/pci/devices/" +DISPLAY_CONTROLLER_CLASS = 0x03 # Display Controller class code in PCI +def _pcie_gen(raw_speed: str) -> Optional[int]: + # Path example: /sys/bus/pci/devices/0000:03:00.0/max_link_speed -def _vram_amd(device: str) -> Optional[int]: - ROOT_PATH = "/sys/bus/pci/devices/" - vram_files = posixpath.join(*[ROOT_PATH, device, "drm", "card*", "device", "mem_info_vram_total"]) - try: - drm_files = glob.glob(vram_files) - if drm_files: - with open(drm_files[0]) as f: - vram_bits = int(f.read().strip()) - vram_mb = int(vram_bits / 1024 / 1024) - return vram_mb - return None - except Exception: - return None - - -def _pcie_gen(device: str) -> Optional[int]: - # Path example: /sys/bus/pci/devices/0000:03:00.0/current_link_speed - path = f"/sys/bus/pci/devices/{device}/current_link_speed" + # Mapping Dictionary + speed_to_gen = {"2.5 GT/s": 1, "5.0 GT/s": 2, "8.0 GT/s": 3, "16.0 GT/s": 4, "32.0 GT/s": 5, "64.0 GT/s": 6} - if not posixpath.exists(path): - return None + for k, v in speed_to_gen.items(): + """ `8.0 GT/s PCIe` may be a possible candidate, so we dont use direct matching""" + if k in raw_speed: + return v - try: - with open(path) as f: - raw_speed = f.read().strip() # e.g., "16.0 GT/s" + return None - # Mapping Dictionary - speed_to_gen = {"2.5 GT/s": 1, "5.0 GT/s": 2, "8.0 GT/s": 3, "16.0 GT/s": 4, "32.0 GT/s": 5, "64.0 GT/s": 6} +def _resolve_acpi_path(device_bdf: str) -> Optional[str]: + """ + Resolve the ACPI path for a given PCI device. - for k, v in speed_to_gen.items(): - """ `8.0 GT/s PCIe` may be a possible candidate, so we dont use direct matching""" - if k in raw_speed: - return v + :param device_bdf: The BDF identifier (Bus:Device.Function) of the device + :return: The resolved ACPI path if found, else None. + """ + device_path = os.path.join(PCI_ROOT_PATH, device_bdf) + dev, func = device_bdf.split(":")[-1].split(".") + if not os.path.exists(device_path): return None + acpi_path = _read_from_sysfs(device_path, "firmware_node", "path") + if acpi_path: + return acpi_path + + # Parent directory should be something like RRRR:BB:DD.F + try: + parent_node = os.path.dirname(os.path.realpath(device_path)) except Exception: return None + + if not parent_node: + return None + + # A PCI Bridge should ALWAYS be qualified in the DSDT + if _read_from_sysfs(parent_node, "firmware_node", "path") is None: + return None + + for entry in os.listdir(os.path.join(parent_node, "firmware_node")): + if entry.startswith("device"): + if (adr := _read_from_sysfs(parent_node, "firmware_node", entry, "adr")) is None: + continue + + try: + acpi_value = _read_from_sysfs("/sys", "bus", "acpi", "devices", entry, "path") + + if int(adr, 16) == ((int(dev, 16) << 16) | int(func, 16)): + return acpi_value + + if int(adr, 16) == 0xFF and acpi_path is None: + acpi_path = acpi_value + except Exception: + continue + + return acpi_path def _check_gpu_class(device: str) -> bool: - path = posixpath.join(PCI_ROOT_PATH, device) - with open(posixpath.join(path, "class")) as f: - device_class = f.read().strip() """ The class code is three hex-bytes, where the leftmost hex-byte is the base class We want the devices of base class 0x03, which denotes a Display Controller. """ + device_class = _read_from_sysfs(PCI_ROOT_PATH, device, "class") class_code = int(device_class, base=16) base_class = class_code >> 16 - return base_class == 3 - - -def _populate_amd_info(gpu: GPUInfo, device: str) -> GPUInfo: - # get VRAM for AMD GPUs - vram_capacity = _vram_amd(device) - if vram_capacity is not None: - gpu.vram = Megabyte(capacity=vram_capacity) - return gpu - - -def _populate_nvidia_info(gpu: GPUInfo, device: str) -> GPUInfo: - gpu_name, pcie_width, pcie_gen, vram_total = fetch_gpu_details_nvidia(device) - if gpu_name: - gpu.name = gpu_name - if pcie_width: - gpu.pcie_width = pcie_width - if pcie_gen: - gpu.pcie_gen = pcie_gen - if vram_total: - gpu.vram = Megabyte(capacity=vram_total) - - return gpu - - -def _populate_lspci_info(gpu: GPUInfo, device: str) -> GPUInfo: - lspci_output = subprocess.run(["lspci", "-s", device, "-vmm"], capture_output=True, text=True, check=True).stdout - # We gather all data here and parse whatever data we have. Subsystem data may not be returned. - # If LSPCI not found, check=True ensures error is thrown - - data = {} - for line in lspci_output.splitlines(): - if ":" in line: - key, value = line.split(":", maxsplit=1) - data[key.strip()] = value.strip() - - gpu.manufacturer = data.get("Vendor") - gpu.name = data.get("Device") - gpu.subsystem_manufacturer = data.get("SVendor") - gpu.subsystem_model = data.get("SDevice") - - return gpu - + return base_class == DISPLAY_CONTROLLER_CLASS def fetch_graphics_info() -> GraphicsInfo: graphics_info = GraphicsInfo() @@ -121,7 +105,6 @@ def fetch_graphics_info() -> GraphicsInfo: return graphics_info for device in os.listdir(PCI_ROOT_PATH): - # print("Found device: ", device) try: if not _check_gpu_class(device): continue @@ -133,53 +116,73 @@ def fetch_graphics_info() -> GraphicsInfo: gpu = GPUInfo() gpu_path = posixpath.join(PCI_ROOT_PATH, device) - try: - with open(posixpath.join(gpu_path, "vendor")) as f: - gpu.vendor_id = f.read().strip() - with open(posixpath.join(gpu_path, "device")) as f: - gpu.device_id = f.read().strip() - with open(posixpath.join(gpu_path, "current_link_width")) as f: - width = f.read().strip() - if width.isnumeric() and int(width) > 0: - gpu.pcie_width = int(width) - except Exception as e: + if (vendor_id := _read_from_sysfs(gpu_path, "vendor")) is not None: + gpu.vendor_id = vendor_id + else: graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not get GPU properties: {e}") - try: - with open(posixpath.join(gpu_path, "firmware_node", "path")) as f: - acpi_path = f.read().strip() - gpu.acpi_path = acpi_path - except Exception as e: + graphics_info.status.messages.append(f"Could not read vendor ID for {device}") + + + if (device_id := _read_from_sysfs(gpu_path, "device")) is not None: + gpu.device_id = device_id + else: graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not get ACPI path: {e}") - try: - pci_path = pci_path_linux(device) - gpu.pci_path = pci_path - except Exception as e: + graphics_info.status.messages.append(f"Could not read device ID for {device}") + + + if (cur_width := _read_from_sysfs(gpu_path, "current_link_width")) is not None: + if cur_width.isnumeric() and int(cur_width) > 0: + gpu.current_pcie_width = int(cur_width) + else: graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not get PCI path: {e}") + graphics_info.status.messages.append(f"Could not read current link width for {device}") - if pcie_gen := _pcie_gen(device): - gpu.pcie_gen = pcie_gen + if (max_width := _read_from_sysfs(gpu_path, "max_link_width")) is not None: + if max_width.isnumeric() and int(max_width) > 0: + gpu.max_pcie_width = int(max_width) else: graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append("Could not get PCI gen") + graphics_info.status.messages.append(f"Could not read max link width for {device}") - if gpu.vendor_id == "0x1002": - gpu = _populate_amd_info(gpu, device) - elif gpu.vendor_id and gpu.vendor_id.lower() == "0x10de": - # get VRAM for Nvidia GPUs - try: - gpu = _populate_nvidia_info(gpu, device) - except Exception as e: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not get additional GPU info for NVIDIA GPU {device}: {e}") - try: - gpu = _populate_lspci_info(gpu, device) - except Exception as e: + if (cur_pcie_speed := _read_from_sysfs(gpu_path, "current_link_speed")) is not None: + if cur_pcie_speed: + gpu.current_pcie_gen = _pcie_gen(cur_pcie_speed) + else: + graphics_info.status.type = StatusType.PARTIAL + graphics_info.status.messages.append(f"Could not read current link speed for {device}") + + + if (max_pcie_speed := _read_from_sysfs(gpu_path, "max_link_speed")) is not None: + if max_pcie_speed: + gpu.max_pcie_gen = _pcie_gen(max_pcie_speed) + else: + graphics_info.status.type = StatusType.PARTIAL + graphics_info.status.messages.append(f"Could not read max link speed for {device}") + + + if (acpi_path := _resolve_acpi_path(device)) is not None: + gpu.acpi_path = acpi_path + else: + graphics_info.status.type = StatusType.PARTIAL + graphics_info.status.messages.append(f"Could not read ACPI path for {device}") + + + if (pci_path := pci_path_linux(device)) is not None: + gpu.pci_path = pci_path + else: graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not parse LSPCI output for GPU {device}: {e}") + graphics_info.status.messages.append(f"Could not resolve PCI path for {device}") + + if ( + vendor_id is not None and + NATIVE_AVAILABLE is True and + (native := native_gpu.get_gpu_info(device, int(gpu.vendor_id, 16))) is not None + ): + gpu.name = native.name + if native.vram_total_mb > 0: + gpu.vram = Megabyte(capacity=int(native.vram_total_mb)) + graphics_info.modules.append(gpu) diff --git a/src/hwprobe/core/linux/manager.py b/src/hwprobe/core/linux/manager.py index 54f05c5..d757f93 100644 --- a/src/hwprobe/core/linux/manager.py +++ b/src/hwprobe/core/linux/manager.py @@ -12,6 +12,7 @@ HardwareManagerInterface, LinuxHardwareInfo, MemoryInfo, + DisplayInfo, ) from hwprobe.models.network_models import NetworkInfo from hwprobe.models.storage_models import StorageInfo @@ -47,16 +48,18 @@ def fetch_graphics_info(self) -> GraphicsInfo: self.info.graphics = fetch_graphics_info() return self.info.graphics + def fetch_display_info(self) -> DisplayInfo: + self.info.display = fetch_display_info(self.info.graphics.modules) + return self.info.display + def fetch_hardware_info(self) -> HardwareInfo: self.fetch_cpu_info() self.fetch_graphics_info() self.fetch_memory_info() self.fetch_network_info() self.fetch_storage_info() + self.fetch_display_info() return self.info - def fetch_display_info(self) -> DisplayInfo: - return fetch_display_info() - def fetch_network_info(self) -> NetworkInfo: return fetch_network_info() diff --git a/src/hwprobe/interops/linux/CMakeLists.txt b/src/hwprobe/interops/linux/CMakeLists.txt new file mode 100644 index 0000000..0464ee6 --- /dev/null +++ b/src/hwprobe/interops/linux/CMakeLists.txt @@ -0,0 +1,63 @@ +cmake_minimum_required(VERSION 3.21) +project(LinuxDeviceInfo LANGUAGES C CXX) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +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() + +find_package(PkgConfig REQUIRED) +pkg_check_modules(LIBDRM REQUIRED libdrm) + +# --------------------------------------------------------------------- +# Shared library +# --------------------------------------------------------------------- + +add_library( + device_info + SHARED + src/gpu_info.cpp +) + +target_include_directories( + device_info + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + PRIVATE + ${LIBDRM_INCLUDE_DIRS} +) + +target_link_libraries( + device_info + PRIVATE + ${LIBDRM_LIBRARIES} + dl +) + +target_compile_options( + device_info + PRIVATE + ${LIBDRM_CFLAGS_OTHER} +) + +if(CMAKE_BUILD_TYPE STREQUAL "Release") + target_compile_options( + device_info + PRIVATE + -g + -fno-omit-frame-pointer + ) +endif() + +set_target_properties( + device_info + PROPERTIES + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings + OUTPUT_NAME device_info +) \ No newline at end of file diff --git a/src/hwprobe/interops/linux/README.md b/src/hwprobe/interops/linux/README.md new file mode 100644 index 0000000..ed3bf52 --- /dev/null +++ b/src/hwprobe/interops/linux/README.md @@ -0,0 +1,129 @@ +# LinuxDeviceInfo + +A tiny Linux utility and shared library that enumerates GPUs using DRM ioctls (vendor-specific) with a Vulkan +fallback (universal), reporting model name, vendor/device IDs, VRAM, PCIe generation/width, PCI slot, and driver +name without relying on external tools like `lspci` or `nvidia-smi`. + +The native library lives in `src/` and `include/`, and is exposed via a command-line tester (`main.c`). +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. + +## Vendor Coverage + +| Vendor | VRAM Source | Notes | +|--------|-------------|-------| +| **AMD** | `AMDGPU_INFO_VRAM_GTT` ioctl | Direct kernel interface | +| **Intel** | `DRM_I915_QUERY_MEMORY_REGIONS` ioctl | Intel Arc + integrated | +| **NVIDIA** | Nouveau DRM ioctl + Vulkan fallback | Vulkan preferred for proprietary driver | +| **Any** | Vulkan `VkPhysicalDeviceMemoryProperties` | Universal fallback via `libvulkan.so.1` | + +## Requirements + +- Linux with a populated `/sys/class/drm` (kernel 5.16+ for Intel Arc VRAM queries) +- GCC/Clang with C++17 support +- CMake 3.21+ +- `libdrm` development headers (linked at build time) +- Python 3.7+ (for the `gpu_info.py` binding) - Assuming you want to compile this to use with HWProbe. +- `libvulkan.so.1` at runtime (optional; `dlopen`'d for the universal fallback path, no SDK headers needed to build) + +```bash +# Debian/Ubuntu +sudo apt install build-essential cmake libdrm-dev + +# Fedora/RHEL +sudo dnf install gcc-c++ cmake libdrm-devel + +# Arch +sudo pacman -S base-devel cmake libdrm +``` + +## Build + +```sh +cmake -S . -B build +cmake --build build +``` + +- `LinuxDeviceInfo` (the CLI tool) is emitted to `build/LinuxDeviceInfo`. +- `libdevice_info.so` is copied automatically into `bindings/` for the Python binding. +- The default build type is **Release**, compiled with `-g -fno-omit-frame-pointer` so native profilers (perf, + `py-spy --native`) can still unwind and symbolize the library. + +## CLI Usage + +```sh +./build/LinuxDeviceInfo +``` + +Sample output: + +``` +Found 1 GPU(s): + +GPU 0: + Name: NVIDIA GeForce RTX 5070 Ti + VRAM Total: 16384 MB + VRAM Used: 0 MB +``` + +The tool exits with code `0` when enumeration succeeds, or `1` if the underlying DRM/Vulkan query fails. + +## Python Binding + +After building the project once (so that `bindings/libdevice_info.so` exists), you can inspect GPUs from Python: + +```sh +cd bindings +python3 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 `libdevice_info.so`; ensure you rebuild the CMake project whenever you make +changes to the native code. + +Or use the high-level API (automatic fallback to sysfs + `lspci`/`nvidia-smi`/`rocm-smi` when the native library +isn't available): + +```python +from hwprobe.core.linux.graphics import fetch_graphics_info + +info = fetch_graphics_info() +for gpu in info.modules: + print(f"{gpu.name}: {gpu.vram.capacity}{gpu.vram.unit} VRAM") +``` + +## Why C++ Instead of Pure Python? + +1. **No external dependencies** — `lspci`, `nvidia-smi`, `rocm-smi` may not be installed +2. **Vendor-neutral VRAM** — DRM ioctls work for all vendors without proprietary tools +3. **Faster** — Direct kernel interface, no subprocess overhead +4. **Unified Vulkan fallback** — When DRM doesn't provide VRAM, Vulkan fills the gap + +## Limitations + +- **ACPI path**: Optional, requires `firmware_node` in sysfs (not all systems) +- **Intel Arc VRAM**: Requires kernel 5.16+ for `DRM_I915_QUERY_MEMORY_REGIONS` +- **Nouveau VRAM**: Requires open Nouveau driver (proprietary NVIDIA driver uses Vulkan path) + +## Troubleshooting + +- **`libdevice_info.so not found`**: run the CMake build so the shared library is (re)generated in `bindings/`. +- **`get_gpu_info` returns -1**: verify that `/sys/class/drm` is populated and that DRM/Vulkan drivers are installed. +- **VRAM shows 0 MB**: the Vulkan fallback requires `libvulkan.so.1` to be installed; without it, VRAM is only + reported for vendors with a supported DRM ioctl (AMD, Intel, open-source Nouveau). +- **PCIe gen/width shows 0**: the sysfs link-status attributes may not be exposed by all drivers. This is + driver-dependent and not a bug in the library. diff --git a/src/hwprobe/interops/linux/bindings/gpu_info.py b/src/hwprobe/interops/linux/bindings/gpu_info.py new file mode 100644 index 0000000..b4a7f40 --- /dev/null +++ b/src/hwprobe/interops/linux/bindings/gpu_info.py @@ -0,0 +1,87 @@ +""" +Python bindings for Linux GPU info via C library. +Uses ctypes to interface with libdevice_info.so +""" + +import ctypes +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass +class GPUProperties: + """Python representation of the C GPUProperties struct""" + + name: str + vram_total_mb: int + vram_used_mb: int + + +class _CGPUProperties(ctypes.Structure): + """C struct layout matching gpu_info.h""" + + _fields_ = [ + ("name", ctypes.c_char * 256), + ("vram_total_mb", ctypes.c_uint64), + ("vram_used_mb", ctypes.c_uint64), + ] + + +def _find_library() -> Optional[ctypes.CDLL]: + """Locate and load libdevice_info.so""" + # Try relative to this file first + _HERE = Path(__file__).parent + _LIB_PATH = _HERE / "libdevice_info.so" + + if not _LIB_PATH.exists(): + return None + + try: + lib = ctypes.CDLL(str(_LIB_PATH)) + # Configure function signature + lib.get_gpu_info.argtypes = [ctypes.c_char_p, ctypes.c_uint32, ctypes.POINTER(_CGPUProperties)] + lib.get_gpu_info.restype = ctypes.c_int + return lib + except (OSError, AttributeError): + return None + + +# Module-level library handle +_lib = _find_library() + + +def is_available() -> bool: + """Check if the native library is available""" + return _lib is not None + + +def get_gpu_info(bdf: str, vendor_id: int) -> GPUProperties: + """ + Query VRAM and driver name for the GPU at the given PCI BDF address. + + Args: + bdf: PCI address in ``domain:bus:device.function`` form, e.g. ``0000:01:00.0`` + vendor_id: PCI vendor ID (e.g. ``0x1002`` for AMD, ``0x8086`` for Intel) + + Returns: + A GPUProperties object with VRAM figures and DRM driver name + + Raises: + RuntimeError: If the library is not available or the underlying C call fails + """ + if not _lib: + raise RuntimeError("libdevice_info.so not found. Build the C library first.") + + c_gpu = _CGPUProperties() + ret = _lib.get_gpu_info(bdf.encode(), vendor_id, ctypes.byref(c_gpu)) + + if ret == -1: + raise RuntimeError(f"get_gpu_info() failed for BDF {bdf!r}") + + return GPUProperties( + name=c_gpu.name.decode("utf-8", errors="replace").strip(), + vram_total_mb=c_gpu.vram_total_mb, + vram_used_mb=c_gpu.vram_used_mb, + ) diff --git a/src/hwprobe/interops/linux/include/gpu_info.h b/src/hwprobe/interops/linux/include/gpu_info.h new file mode 100644 index 0000000..7f6e4e0 --- /dev/null +++ b/src/hwprobe/interops/linux/include/gpu_info.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +#define AMD_VENDOR_ID 0x1002 +#define INTEL_VENDOR_ID 0x8086 +#define NVIDIA_VENDOR_ID 0x10DE + +typedef struct +{ + uint16_t domain; //!< PCI domain number + uint8_t bus; //!< PCI bus number + uint8_t device; //!< PCI device number + uint8_t function; //!< PCI function number +} PCIAddress; + +typedef struct +{ + char name[256]; //!< GPU name or description, if available. + uint64_t vram_total_mb; //!< Total VRAM capacity in MB: 0 if unavailable, greater than 0 otherwise. + uint64_t vram_used_mb; //!< Total VRAM used by all processes: 0 if available, greater than 0 otherwise. +} GPUProperties; + +int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out); + +#ifdef __cplusplus +} +#endif diff --git a/src/hwprobe/interops/linux/main.c b/src/hwprobe/interops/linux/main.c new file mode 100644 index 0000000..86eb168 --- /dev/null +++ b/src/hwprobe/interops/linux/main.c @@ -0,0 +1,25 @@ +#include +#include +#include +#include "gpu_info.h" + +int main(int argc, char *argv[]) { + if (argc < 3) { + fprintf(stderr, "Usage: %s \n e.g. %s 0000:01:00.0 0x1002\n", + argv[0], argv[0]); + return 1; + } + + uint32_t vendor_id = (uint32_t)strtoul(argv[2], NULL, 16); + GPUProperties g; + if (get_gpu_info(argv[1], vendor_id, &g) < 0) { + fprintf(stderr, "Failed to query GPU info for %s\n", argv[1]); + return 1; + } + + printf("GPU at %s:\n", argv[1]); + printf(" Name: %s\n", g.name[0] ? g.name : "(unknown)"); + printf(" VRAM Total: %lu MB\n", (unsigned long)g.vram_total_mb); + printf(" VRAM Used: %lu MB\n", (unsigned long)g.vram_used_mb); + return 0; +} diff --git a/src/hwprobe/interops/linux/src/gpu_info.cpp b/src/hwprobe/interops/linux/src/gpu_info.cpp new file mode 100644 index 0000000..42fd0f6 --- /dev/null +++ b/src/hwprobe/interops/linux/src/gpu_info.cpp @@ -0,0 +1,529 @@ +#include "../include/gpu_info.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// +// Vulkan types — dlopen'd at runtime, no SDK headers required +// + +typedef void *VkInstance; +typedef void *VkPhysicalDevice; + +enum +{ + VK_STYPE_APP_INFO = 0, + VK_STYPE_INST_CREATE = 1, + VK_STYPE_PROPS2 = 1000059001, + VK_STYPE_PCI_BUS_EXT = 1000212000, + VK_STYPE_MEM_PROPS2 = 1000059006, + VK_STYPE_MEM_BUDGET_EXT = 1000237000, + VK_HEAP_DEVICE_LOCAL = 0x1, +}; + +struct VkAppInfo +{ + uint32_t sType; + const void *pNext; + const char *appName; + uint32_t appVer; + const char *engName; + uint32_t engVer; + uint32_t apiVer; +}; + +struct VkInstCreateInfo +{ + uint32_t sType; + const void *pNext; + uint32_t flags; + const VkAppInfo *pAppInfo; + uint32_t layerCnt; + const char *const *layers; + uint32_t extCnt; + const char *const *exts; +}; + +struct VkPhysDevProps +{ + uint32_t api, driver, vendorID, deviceID, devType; + char name[256]; + uint8_t uuid[16]; + uint8_t _limits[504]; + uint8_t _sparse[20]; +}; + +struct VkPhysDevProps2 +{ + uint32_t sType; + void *pNext; + VkPhysDevProps props; +}; + +struct VkPCIBusInfo +{ + uint32_t sType; + void *pNext; + uint32_t dom, bus, dev, func; +}; + +struct VkMemHeap +{ + uint64_t size; + uint32_t flags; +}; +struct VkMemType +{ + uint32_t flags; + uint32_t heapIdx; +}; + +struct VkMemProps +{ + uint32_t typeCnt; + VkMemType types[32]; + uint32_t heapCnt; + VkMemHeap heaps[16]; +}; + +struct VkMemProps2 +{ + uint32_t sType; + void *pNext; + VkMemProps memProps; +}; + +struct VkMemBudget +{ + uint32_t sType; + void *pNext; + uint64_t budget[16]; + uint64_t usage[16]; +}; + +struct VkGPU +{ + char name[256]; + char slot[32]; + uint64_t vram_mb; + uint64_t used_mb; + uint32_t vendor_id; + uint32_t device_id; +}; + +typedef int32_t (*PFN_CreateInst)(const VkInstCreateInfo *, const void *, VkInstance *); +typedef void (*PFN_DestroyInst)(VkInstance, const void *); +typedef int32_t (*PFN_EnumDevs)(VkInstance, uint32_t *, VkPhysicalDevice *); +typedef void (*PFN_GetProps2)(VkPhysicalDevice, VkPhysDevProps2 *); +typedef void (*PFN_GetMem2)(VkPhysicalDevice, VkMemProps2 *); + +enum +{ + MAX_GPU_CARDS = 16, + BYTES_PER_MB = 1024 * 1024, +}; + +static uint64_t to_mb(uint64_t bytes) +{ + return bytes / BYTES_PER_MB; +} + +static PCIAddress parse_bdf_to_pci_addr(const char *bdf) +{ + PCIAddress pciAddr = {0}; + if (!bdf) + return pciAddr; + + sscanf(bdf, "%hx:%hhx:%hhx.%hhd", &pciAddr.domain, &pciAddr.bus, &pciAddr.device, &pciAddr.function); + return pciAddr; +} + +// +// DRM ioctl VRAM queries +// + +static void vram_amdgpu(int fd, GPUProperties *g) +{ + drm_amdgpu_info req = {0}; + drm_amdgpu_info_vram_gtt vram = {0}; + + req.return_pointer = reinterpret_cast(&vram); + req.return_size = sizeof(vram); + req.query = AMDGPU_INFO_VRAM_GTT; + + if (ioctl(fd, DRM_IOCTL_AMDGPU_INFO, &req) == 0) + g->vram_total_mb = to_mb(vram.vram_size); + + drm_amdgpu_info ureq = {0}; + struct + { + uint64_t vram, vis, gtt; + } usage = {0}; + + ureq.return_pointer = reinterpret_cast(&usage); + ureq.return_size = sizeof(usage); + ureq.query = AMDGPU_INFO_VRAM_USAGE; + + if (ioctl(fd, DRM_IOCTL_AMDGPU_INFO, &ureq) == 0) + g->vram_used_mb = to_mb(usage.vram); +} + +static void vram_i915(int fd, GPUProperties *g) +{ + drm_i915_query_item item = {0}; + item.query_id = DRM_I915_QUERY_MEMORY_REGIONS; + + drm_i915_query q = {0}; + q.num_items = 1; + q.items_ptr = reinterpret_cast(&item); + + if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) != 0 || item.length <= 0) + return; + + uint8_t *buf = static_cast(calloc(1, item.length)); + if (!buf) + return; + + item.data_ptr = reinterpret_cast(buf); + + if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) == 0) + { + drm_i915_query_memory_regions *r = reinterpret_cast(buf); + for (uint32_t i = 0; i < r->num_regions; i++) + { + if (r->regions[i].region.memory_class == I915_MEMORY_CLASS_DEVICE) + { + g->vram_total_mb = to_mb(r->regions[i].probed_size); + break; + } + } + } + + free(buf); +} + +static void vram_nouveau(int fd, GPUProperties *g) +{ + drm_nouveau_getparam p = {0}; + p.param = NOUVEAU_GETPARAM_FB_SIZE; + + if (ioctl(fd, DRM_IOCTL_NOUVEAU_GETPARAM, &p) == 0 && p.value > 0) + g->vram_total_mb = to_mb(p.value); +} + +// By default the Vulkan loader dlopen()s + initializes every installed ICD +// manifest (a dozen-plus with a typical Mesa install) before it can tell us +// which ones actually have hardware. Since we already know each GPU's vendor +// from sysfs, point the loader at only the matching driver(s) so it skips +// the rest — this is the dominant cost of the fallback path. +static bool icd_matches_vendor(const char *filename, uint32_t vendor_id) +{ + switch (vendor_id) + { + case 0x10DE: + return strstr(filename, "nvidia") || strstr(filename, "nouveau"); + case 0x1002: + return strstr(filename, "radeon") != NULL; + case 0x8086: + return strstr(filename, "intel") != NULL; + default: + return false; + } +} + +static void restrict_vulkan_icds(uint32_t vendor_id) +{ + static const char *icd_dirs[] = {"/usr/share/vulkan/icd.d", "/etc/vulkan/icd.d"}; + + char matches[2048] = {0}; + size_t matches_len = 0; + + for (size_t d = 0; d < sizeof(icd_dirs) / sizeof(icd_dirs[0]); d++) + { + DIR *dir = opendir(icd_dirs[d]); + if (!dir) + continue; + + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) + { + const char *name = entry->d_name; + size_t len = strlen(name); + if (len < 6 || strcmp(name + len - 5, ".json") != 0) + continue; + if (strstr(name, "i686")) + continue; // skip 32-bit manifests + + if (!icd_matches_vendor(name, vendor_id)) + continue; + + char full[768]; + int n = snprintf(full, sizeof(full), "%s/%s", icd_dirs[d], name); + // snprintf returns the length it would have written even if truncated; + // reject that case so we never memcpy past the end of `full`. + if (n <= 0 || static_cast(n) >= sizeof(full)) + continue; + if (matches_len + static_cast(n) + 2 > sizeof(matches)) + continue; + + if (matches_len > 0) + matches[matches_len++] = ':'; + memcpy(matches + matches_len, full, static_cast(n)); + matches_len += static_cast(n); + } + closedir(dir); + } + + if (matches_len > 0) + { + // VK_DRIVER_FILES is the modern name; VK_ICD_FILENAMES is kept for older loaders. + setenv("VK_DRIVER_FILES", matches, 1); + setenv("VK_ICD_FILENAMES", matches, 1); + } +} + +static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) +{ + if (pciAddr == NULL) + return -1; + + void *lib = dlopen("libvulkan.so.1", RTLD_LAZY); + if (!lib) + return -1; + + // Load the Vulkan entry points we need. + // If any are missing, the ICD is too old to support the features we need. + PFN_CreateInst createInstance = reinterpret_cast(dlsym(lib, "vkCreateInstance")); + PFN_DestroyInst destroyInstance = reinterpret_cast(dlsym(lib, "vkDestroyInstance")); + PFN_EnumDevs enumPhysDev = reinterpret_cast(dlsym(lib, "vkEnumeratePhysicalDevices")); + PFN_GetProps2 getPhysDevProps = reinterpret_cast(dlsym(lib, "vkGetPhysicalDeviceProperties2")); + PFN_GetMem2 getPhysDevMemProps = reinterpret_cast(dlsym(lib, "vkGetPhysicalDeviceMemoryProperties2")); + + if (!createInstance || !destroyInstance || !enumPhysDev || !getPhysDevProps || !getPhysDevMemProps) + { + dlclose(lib); + return -1; + } + + VkAppInfo vkAppInfo = {VK_STYPE_APP_INFO, NULL, "HWProbe", 1, NULL, 0, (1u << 22) | (1u << 12)}; + VkInstCreateInfo vkInstCreate = {VK_STYPE_INST_CREATE, NULL, 0, &vkAppInfo, 0, NULL, 0, NULL}; + + VkInstance inst = NULL; + if (createInstance(&vkInstCreate, NULL, &inst) != 0 || !inst) + { + dlclose(lib); + return -1; + } + + // Unfortunately, Vulkan doesn't provide a way to directly correlate + // a PCI BDF to a VkPhysicalDevice. We have to enumerate all devices and + // check their PCI bus info until we find a match (or exhaust the device list). + // + // The only "useful" identifiers Vulkan provides are internal [L|U]UID representations, + // which doesn't help us with our situation: they are used to identify devices across + // multiple Graphics API stacks. + // + // What we can do is return early if a VkPhysicalDevice matches the PCI BDF we are looking for. + // + // Sources: + // - https://docs.vulkan.org/spec/latest/chapters/devsandqueues.html + // - https://docs.vulkan.org/refpages/latest/refpages/source/vkGetWinrtDisplayNV.html + uint32_t numberOfDevices = 0; + if (enumPhysDev(inst, &numberOfDevices, NULL) != 0) + { + destroyInstance(inst, NULL); + dlclose(lib); + return -1; + } + + if (numberOfDevices == 0) + { + destroyInstance(inst, NULL); + dlclose(lib); + return 0; + } + + if (numberOfDevices > MAX_GPU_CARDS) + numberOfDevices = MAX_GPU_CARDS; + + VkPhysicalDevice devs[MAX_GPU_CARDS]; + if (enumPhysDev(inst, &numberOfDevices, devs) != 0) + { + destroyInstance(inst, NULL); + dlclose(lib); + return -1; + } + + int cnt = 0; + + for (uint32_t i = 0; i < numberOfDevices && cnt < MAX_GPU_CARDS; i++) + { + VkPCIBusInfo pci = {VK_STYPE_PCI_BUS_EXT, NULL, 0, 0, 0, 0}; + VkPhysDevProps2 p = {0}; + p.sType = VK_STYPE_PROPS2; + p.pNext = &pci; + getPhysDevProps(devs[i], &p); + + VkMemBudget bgt = {0}; + bgt.sType = VK_STYPE_MEM_BUDGET_EXT; + bgt.pNext = NULL; + + VkMemProps2 m = {0}; + m.sType = VK_STYPE_MEM_PROPS2; + m.pNext = &bgt; + getPhysDevMemProps(devs[i], &m); + + uint64_t total = 0, used = 0; + for (uint32_t h = 0; h < m.memProps.heapCnt; h++) + { + if (m.memProps.heaps[h].flags & VK_HEAP_DEVICE_LOCAL) + { + total += m.memProps.heaps[h].size; + // total - budget ≈ system-wide VRAM usage + if (bgt.budget[h] > 0 && bgt.budget[h] < m.memProps.heaps[h].size) + used += m.memProps.heaps[h].size - bgt.budget[h]; + } + } + + VkGPU *g = &out[cnt++]; + + g->vendor_id = p.props.vendorID; + g->device_id = p.props.deviceID; + g->vram_mb = to_mb(total); + g->used_mb = to_mb(used); + + snprintf(g->slot, sizeof(g->slot), "%04x:%02x:%02x.%x", pci.dom, pci.bus, pci.dev, pci.func); + snprintf(g->name, sizeof(g->name), "%s", p.props.name); + + if ( + pciAddr->domain == pci.dom && + pciAddr->bus == pci.bus && + pciAddr->device == pci.dev && + pciAddr->function == pci.func) + { + // Found the device we were looking for; stop enumerating. + break; + } + } + + destroyInstance(inst, NULL); + dlclose(lib); + return cnt; +} + +/** + * Get GPU information for a specific PCI device. + * + * @note This function also returns the current VRAM usage, even though it currently + * has no proper application in HWProbe. It shall stay here for future reference + * if we decide to query similar information on other platforms. + * + * @param bdf The PCI bus:device.function string (e.g., "0000:01:00.0"). + * @param vendor_id The PCI vendor ID of the GPU (e.g., 0x1002 for AMD, 0x8086 for Intel, 0x10DE for NVIDIA). + * @param out Pointer to a GPUProperties struct to receive the GPU information. + * @return 0 on success, -1 on failure. + */ +int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) +{ + if (!bdf || !out) + return -1; + + // Find the DRM card node under /sys/bus/pci/devices//drm/cardN. + char drm_dir_path[160]; + snprintf(drm_dir_path, sizeof(drm_dir_path), "/sys/bus/pci/devices/%s/drm", bdf); + + char card_name[32] = {0}; + DIR *drm_dir = opendir(drm_dir_path); + + if (drm_dir) + { + struct dirent *entry; + + while ((entry = readdir(drm_dir)) != NULL) + { + if (strncmp(entry->d_name, "card", 4) == 0 && + entry->d_name[4] >= '0' && entry->d_name[4] <= '9') + { + snprintf(card_name, sizeof(card_name), "%s", entry->d_name); + break; + } + } + closedir(drm_dir); + } + + GPUProperties g = {0}; + + if (card_name[0]) + { + char devpath[64]; + + snprintf(devpath, sizeof(devpath), "/dev/dri/%s", card_name); + + int fd = open(devpath, O_RDWR | O_CLOEXEC); + if (fd < 0) + fd = open(devpath, O_RDONLY | O_CLOEXEC); + + if (fd >= 0) + { + // TODO: Figure out if there is a vendor-agnostic way to query VRAM usage + // without having to fall back to Vulkan. + // + // For now, we use vendor-specific ioctls. + switch (vendor_id) + { + case AMD_VENDOR_ID: + vram_amdgpu(fd, &g); + break; + case INTEL_VENDOR_ID: + vram_i915(fd, &g); + break; + case NVIDIA_VENDOR_ID: + vram_nouveau(fd, &g); + break; + } + + close(fd); + } + } + + // Vulkan fallback if DRM didn't give us complete info. + if (!g.vram_total_mb || !g.name[0]) + { + PCIAddress pciAddr = parse_bdf_to_pci_addr(bdf); + VkGPU vk[MAX_GPU_CARDS]; + restrict_vulkan_icds(vendor_id); + int vk_n = vulkan_query(vk, &pciAddr); + + if (vk_n > 0) + { + for (int v = 0; v < vk_n; v++) + { + if (strcmp(bdf, vk[v].slot) != 0) + continue; + + if (!g.vram_total_mb) + g.vram_total_mb = vk[v].vram_mb; + if (!g.vram_used_mb) + g.vram_used_mb = vk[v].used_mb; + if (vk[v].name[0]) + snprintf(g.name, sizeof(g.name), "%s", vk[v].name); + + break; + } + } + } + + *out = g; + return 0; +} diff --git a/src/hwprobe/models/display_models.py b/src/hwprobe/models/display_models.py index 6cf337d..3a4cc91 100644 --- a/src/hwprobe/models/display_models.py +++ b/src/hwprobe/models/display_models.py @@ -21,6 +21,7 @@ class ResolutionInfo(BaseModel): class DisplayModuleInfo(BaseModel): """Information for one Display is stored here""" + #: Brand name/model of the display name: Optional[str] = None #: Year it was manufactured / designed. @@ -29,9 +30,6 @@ class DisplayModuleInfo(BaseModel): #: ACPI path of the display device. acpi_path: Optional[str] = None - #: PCI path of the device - pci_path: Optional[str] = None - #: Parent GPU driving this display gpu_name: Optional[str] = None diff --git a/src/hwprobe/models/gpu_models.py b/src/hwprobe/models/gpu_models.py index 3ff85b8..ae7bdfb 100644 --- a/src/hwprobe/models/gpu_models.py +++ b/src/hwprobe/models/gpu_models.py @@ -43,14 +43,23 @@ class GPUInfo(BaseModel): #: ACPI device path, e.g. ``\\_SB.PC00.RP05.PXSX``. acpi_path: Optional[str] = None + #: PCI path from the firmware tree, e.g. ``PciRoot(0x0)/Pci(0x1C,0x5)/Pci(0x0,0x0)``. pci_path: Optional[str] = None #: Number of lanes that the GPU occupies on the PCIe bus. - pcie_width: Optional[int] = None + current_pcie_width: Optional[int] = None + + #: PCIe generation currently reported by the GPU. + current_pcie_gen: Optional[int] = None + + #: Number of lanes that the GPU is rated to occupy on the PCIe bus. + max_pcie_width: Optional[int] = None - #: PCIe generation supported by the GPU. - pcie_gen: Optional[int] = None + #: PCIe generation that the GPU is rated to support. + #: This may be different from ``current_pcie_gen`` if the GPU is running in + #: a reduced mode, or if the motherboard does not support the full generation. + max_pcie_gen: Optional[int] = None #: Total VRAM available on the GPU. vram: Optional[StorageSize] = None diff --git a/src/hwprobe/models/info_models.py b/src/hwprobe/models/info_models.py index afcba07..b8d8dc0 100644 --- a/src/hwprobe/models/info_models.py +++ b/src/hwprobe/models/info_models.py @@ -2,7 +2,10 @@ from pydantic import BaseModel +from hwprobe.models.audio_models import AudioInfo +from hwprobe.models.baseboard_models import BaseboardInfo from hwprobe.models.cpu_models import CPUInfo +from hwprobe.models.display_models import DisplayInfo from hwprobe.models.gpu_models import GraphicsInfo from hwprobe.models.memory_models import MemoryInfo from hwprobe.models.network_models import NetworkInfo @@ -15,6 +18,9 @@ class HardwareInfo(BaseModel): storage: Optional[StorageInfo] = None graphics: Optional[GraphicsInfo] = None network: Optional[NetworkInfo] = None + display: Optional[DisplayInfo] = None + audio: Optional[AudioInfo] = None + baseboard: Optional[BaseboardInfo] = None class LinuxHardwareInfo(HardwareInfo): @@ -53,3 +59,12 @@ def fetch_storage_info(self) -> StorageInfo: def fetch_network_info(self) -> NetworkInfo: """Fetches Network Information.""" + + def fetch_display_info(self) -> DisplayInfo: + """Fetches Display Information. Not available on every platform yet.""" + + def fetch_audio_info(self) -> AudioInfo: + """Fetches Audio Information. Not available on every platform yet.""" + + def fetch_baseboard_info(self) -> BaseboardInfo: + """Fetches Baseboard/Motherboard Information. Not available on every platform yet.""" diff --git a/src/hwprobe/util/nvidia.py b/src/hwprobe/util/nvidia.py deleted file mode 100644 index 6883a27..0000000 --- a/src/hwprobe/util/nvidia.py +++ /dev/null @@ -1,36 +0,0 @@ -import subprocess - - -def fetch_gpu_details_nvidia(device: str) -> tuple[str, int, int, int]: - """ - :param device: format: ::. - :return: GPU name, PCI Width, PCI Gen, Total VRAM in MB - """ - # Combine all queries into a single comma-separated string - # Fields: Name, PCIe Width, PCIe Gen, Memory Total - query_fields = "name,pcie.link.width.current,pcie.link.gen.current,memory.total" - - command = ["nvidia-smi", f"--id={device}", f"--query-gpu={query_fields}", "--format=csv,noheader,nounits"] - - # Run the command - result = subprocess.run(command, capture_output=True, text=True) - - # Check for execution errors - if result.returncode != 0: - raise RuntimeError(f"nvidia-smi failed: {result.stderr}") - - # Parse output (Expected: "Name, Width, Gen, Memory") - output = result.stdout.strip() - parts = output.split(",") - - # Validate we got exactly 4 fields back - if len(parts) != 4: - raise ValueError(f"Unexpected output format from nvidia-smi: {output}") - - # Parse and Type Convert - gpu_name = parts[0].strip() - pci_width = int(parts[1].strip()) # e.g., 16 - pci_gen = int(parts[2].strip()) # e.g., 3, 4, or 5 - vram_total = int(parts[3].strip()) # e.g., 16384 (MiB) - - return gpu_name, pci_width, pci_gen, vram_total diff --git a/tests/core/linux/test_common.py b/tests/core/linux/test_common.py index 3410da6..4e75b06 100644 --- a/tests/core/linux/test_common.py +++ b/tests/core/linux/test_common.py @@ -2,7 +2,7 @@ import pytest -from hwprobe.core.linux.common import _format_pci_component, pci_path_linux +from hwprobe.core.linux.common import pci_path_linux class TestPciPathLinux: @@ -50,15 +50,3 @@ def test_fallback_when_sysfs_has_no_pci(self, monkeypatch): def test_invalid_device_slot_returns_none(self, bad_slot, monkeypatch): monkeypatch.setattr(posixpath, "realpath", lambda _: "") assert pci_path_linux(bad_slot) is None - - -class TestFormatPciComponent: - def test_standard_slot(self): - assert _format_pci_component("0000:00:02.0") == "0x2,0x0" - - def test_multifunction_slot(self): - assert _format_pci_component("0000:00:1f.3") == "0x1f,0x3" - - @pytest.mark.parametrize("bad_input", ["", "no-dot", None]) - def test_invalid_input_returns_none(self, bad_input): - assert _format_pci_component(bad_input) is None diff --git a/tests/core/linux/test_display.py b/tests/core/linux/test_display.py index 3096a1d..3c3d3c6 100644 --- a/tests/core/linux/test_display.py +++ b/tests/core/linux/test_display.py @@ -42,7 +42,7 @@ def _patch_exists(self, monkeypatch, paths): def test_returns_none_when_edid_missing(self, monkeypatch): self._patch_exists(monkeypatch, set()) - assert _fetch_individual_monitor_info(self.DEVICE_PATH) is None + assert _fetch_individual_monitor_info(self.DEVICE_PATH, []) is None def test_returns_none_when_edid_empty(self, monkeypatch): self._patch_exists(monkeypatch, {self.EDID_PATH}) @@ -51,65 +51,7 @@ def test_returns_none_when_edid_empty(self, monkeypatch): "open", lambda *a, **kw: mock_open(read_data=b"")(), ) - assert _fetch_individual_monitor_info(self.DEVICE_PATH) is None - - def test_pci_path_resolved_from_gpu_endpoint(self, monkeypatch): - self._patch_exists(monkeypatch, {self.EDID_PATH}) - monkeypatch.setattr( - builtins, - "open", - lambda *a, **kw: mock_open(read_data=b"\x01\x02")(), - ) - monkeypatch.setattr( - "hwprobe.core.linux.display.parse_edid", - lambda _: DisplayModuleInfo(name="Internal Display"), - ) - monkeypatch.setattr( - posixpath, - "realpath", - lambda _: "/sys/devices/pci0000:00/0000:00:02.0/0000:06:00.0/drm/card0", - ) - - pci_calls = [] - monkeypatch.setattr( - "hwprobe.core.linux.display.pci_path_linux", - lambda slot: (pci_calls.append(slot), "PciRoot(0x0)/Pci(0x6,0x0)")[-1], - ) - - monitor = _fetch_individual_monitor_info(self.DEVICE_PATH) - - assert monitor is not None - assert pci_calls == ["0000:06:00.0"] - assert monitor.pci_path == "PciRoot(0x0)/Pci(0x6,0x0)" - - def test_no_pci_path_for_non_pci_parent(self, monkeypatch): - self._patch_exists(monkeypatch, {self.EDID_PATH}) - monkeypatch.setattr( - builtins, - "open", - lambda *a, **kw: mock_open(read_data=b"\x01\x02")(), - ) - monkeypatch.setattr( - "hwprobe.core.linux.display.parse_edid", - lambda _: DisplayModuleInfo(name="Panel"), - ) - monkeypatch.setattr( - posixpath, - "realpath", - lambda _: "/sys/devices/platform/simple-framebuffer/drm/card0", - ) - - pci_calls = [] - monkeypatch.setattr( - "hwprobe.core.linux.display.pci_path_linux", - lambda slot: pci_calls.append(slot), - ) - - monitor = _fetch_individual_monitor_info(self.DEVICE_PATH) - - assert monitor is not None - assert pci_calls == [] - assert monitor.pci_path is None + assert _fetch_individual_monitor_info(self.DEVICE_PATH, []) is None def test_acpi_path_populated_when_firmware_node_exists(self, monkeypatch): self._patch_exists(monkeypatch, {self.EDID_PATH, self.ACPI_PATH}) @@ -138,7 +80,7 @@ def fake_open(path, *args, **kwargs): lambda slot: "PciRoot(0x0)/Pci(0x2,0x0)", ) - monitor = _fetch_individual_monitor_info(self.DEVICE_PATH) + monitor = _fetch_individual_monitor_info(self.DEVICE_PATH, []) assert monitor is not None assert monitor.acpi_path == r"\_SB.PCI0.GFX0.DD1F" @@ -160,11 +102,9 @@ def test_collects_monitors_from_drm(self, monkeypatch): lambda path: DisplayModuleInfo(name=posixpath.basename(path)), ) - info = fetch_display_info() + info = fetch_display_info([]) assert len(info.modules) == 2 - names = {m.name for m in info.modules} - assert names == {"card0-eDP-1", "card0-HDMI-A-1"} def test_skips_monitors_returning_none(self, monkeypatch): monkeypatch.setattr(posixpath, "isdir", lambda p: p == "/sys/class/drm") @@ -181,14 +121,14 @@ def test_skips_monitors_returning_none(self, monkeypatch): lambda path: None, ) - info = fetch_display_info() + info = fetch_display_info([]) assert len(info.modules) == 0 def test_failed_when_drm_root_missing(self, monkeypatch): monkeypatch.setattr(posixpath, "isdir", lambda p: False) - info = fetch_display_info() + info = fetch_display_info([]) assert info.status.type == StatusType.FAILED assert any("/sys/class/drm" in m for m in info.status.messages) @@ -200,16 +140,14 @@ def test_partial_when_monitor_raises(self, monkeypatch): os, "listdir", lambda path: { - "/sys/class/drm": ["card0"], - "/sys/class/drm/card0": ["card0-eDP-1", "card0-HDMI-A-1"], + "/sys/class/drm": ["card0", "card1"], + "/sys/class/drm/card0": ["card0-DP-1"], + "/sys/class/drm/card1": ["card1-HDMI-A-1"], }.get(path, []), ) - call_count = {"n": 0} - - def _mock_fetch(path): - call_count["n"] += 1 - if call_count["n"] == 1: + def _mock_fetch(path, gpu_devices): + if path.endswith("card0-DP-1"): raise RuntimeError("EDID decode failed") return DisplayModuleInfo(name="Monitor B") @@ -218,10 +156,10 @@ def _mock_fetch(path): _mock_fetch, ) - info = fetch_display_info() + info = fetch_display_info([]) assert info.status.type == StatusType.PARTIAL - assert any("card0-eDP-1" in m for m in info.status.messages) + assert any("card0-DP-1" in m for m in info.status.messages) assert len(info.modules) == 1 assert info.modules[0].name == "Monitor B" @@ -275,7 +213,7 @@ def test_connector_overrides_edid_interface(self, monkeypatch): lambda _: "/sys/devices/platform/drm/card0", ) - monitor = _fetch_individual_monitor_info(device_path) + monitor = _fetch_individual_monitor_info(device_path, []) assert monitor is not None assert monitor.interface == "HDMI" diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index bf11b88..d7c7504 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -1,97 +1,20 @@ import builtins import os -import posixpath import subprocess from unittest.mock import mock_open -from hwprobe.core.linux.graphics import ( - _check_gpu_class, - _pcie_gen, - _populate_amd_info, - _populate_lspci_info, - _populate_nvidia_info, - _vram_amd, - fetch_graphics_info, -) -from hwprobe.models.gpu_models import GPUInfo +from hwprobe.core.linux.graphics import _check_gpu_class, _pcie_gen, fetch_graphics_info from hwprobe.models.status_models import StatusType -class TestVramAmd: - """Tests for _vram_amd function.""" - - def test_vram_amd_success(self, monkeypatch): - device = "0000:03:00.0" - vram_path = f"/sys/bus/pci/devices/{device}/drm/card0/device/mem_info_vram_total" - - monkeypatch.setattr("glob.glob", lambda x: [vram_path]) - - def mock_open_func(file, *args, **kwargs): - if file == vram_path: - # 8GB in bytes - return mock_open(read_data=str(8 * 1024 * 1024 * 1024))() - raise FileNotFoundError(file) - - monkeypatch.setattr(builtins, "open", mock_open_func) - - vram_mb = _vram_amd(device) - assert vram_mb == 8192 - - def test_vram_amd_4gb(self, monkeypatch): - device = "0000:03:00.0" - vram_path = f"/sys/bus/pci/devices/{device}/drm/card0/device/mem_info_vram_total" - - monkeypatch.setattr("glob.glob", lambda x: [vram_path]) - - def mock_open_func(file, *args, **kwargs): - if file == vram_path: - # 4GB in bytes - return mock_open(read_data=str(4 * 1024 * 1024 * 1024))() - raise FileNotFoundError(file) - - monkeypatch.setattr(builtins, "open", mock_open_func) - - vram_mb = _vram_amd(device) - assert vram_mb == 4096 - - def test_vram_amd_no_file(self, monkeypatch): - device = "0000:03:00.0" - monkeypatch.setattr("glob.glob", lambda x: []) - - vram_mb = _vram_amd(device) - assert vram_mb is None - - def test_vram_amd_exception(self, monkeypatch): - def raise_error(x): - raise Exception("Glob failed") - - monkeypatch.setattr("glob.glob", raise_error) - - assert _vram_amd("0000:00:00.0") is None - - def test_vram_amd_read_error(self, monkeypatch): - device = "0000:03:00.0" - vram_path = f"/sys/bus/pci/devices/{device}/drm/card0/device/mem_info_vram_total" - - monkeypatch.setattr("glob.glob", lambda x: [vram_path]) - - def mock_open_func(file, *args, **kwargs): - raise OSError("Read error") - - monkeypatch.setattr(builtins, "open", mock_open_func) - - vram_mb = _vram_amd(device) - assert vram_mb is None - - class TestPcieGen: - """Tests for _pcie_gen function.""" + """Tests for _pcie_gen.""" def test_pcie_gen_success_gen4(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -107,7 +30,7 @@ def test_pcie_gen_success_gen3(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -123,7 +46,7 @@ def test_pcie_gen_success_gen2(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -139,7 +62,7 @@ def test_pcie_gen_success_gen1(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -155,7 +78,7 @@ def test_pcie_gen_success_gen5(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -171,7 +94,7 @@ def test_pcie_gen_with_suffix(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -187,7 +110,7 @@ def test_pcie_gen_unknown_speed(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -201,7 +124,7 @@ def mock_open_func(file, *args, **kwargs): def test_pcie_gen_file_not_found(self, monkeypatch): device = "0000:01:00.0" - monkeypatch.setattr(posixpath, "exists", lambda x: False) + monkeypatch.setattr(os.path, "exists", lambda x: False) gen = _pcie_gen(device) assert gen is None @@ -210,7 +133,7 @@ def test_pcie_gen_read_exception(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(posixpath, "exists", lambda x: x == path) + monkeypatch.setattr(os.path, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): raise OSError("Read error") @@ -222,192 +145,38 @@ def mock_open_func(file, *args, **kwargs): class TestCheckGpuClass: - """Tests for _check_gpu_class function.""" + """Tests for _check_gpu_class.""" def test_check_gpu_class_display_controller(self, monkeypatch): - device = "0000:01:00.0" - def mock_open_func(file, *args, **kwargs): if "class" in file: return mock_open(read_data="0x030000")() raise FileNotFoundError(file) monkeypatch.setattr(builtins, "open", mock_open_func) - - assert _check_gpu_class(device) is True + assert _check_gpu_class("0000:01:00.0") is True def test_check_gpu_class_vga_controller(self, monkeypatch): - device = "0000:01:00.0" - def mock_open_func(file, *args, **kwargs): if "class" in file: - return mock_open(read_data="0x030200")() # 3D controller + return mock_open(read_data="0x030200")() raise FileNotFoundError(file) monkeypatch.setattr(builtins, "open", mock_open_func) - - assert _check_gpu_class(device) is True + assert _check_gpu_class("0000:01:00.0") is True def test_check_gpu_class_network_controller(self, monkeypatch): - device = "0000:01:00.0" - - def mock_open_func(file, *args, **kwargs): - if "class" in file: - return mock_open(read_data="0x020000")() # Network controller - raise FileNotFoundError(file) - - monkeypatch.setattr(builtins, "open", mock_open_func) - - assert _check_gpu_class(device) is False - - def test_check_gpu_class_storage_controller(self, monkeypatch): - device = "0000:01:00.0" - def mock_open_func(file, *args, **kwargs): if "class" in file: - return mock_open(read_data="0x010000")() # Storage controller + return mock_open(read_data="0x020000")() raise FileNotFoundError(file) monkeypatch.setattr(builtins, "open", mock_open_func) - - assert _check_gpu_class(device) is False - - -class TestPopulateAmdInfo: - """Tests for _populate_amd_info function.""" - - def test_populate_amd_info_with_vram(self, monkeypatch): - device = "0000:03:00.0" - gpu = GPUInfo() - - vram_path = f"/sys/bus/pci/devices/{device}/drm/card0/device/mem_info_vram_total" - monkeypatch.setattr("glob.glob", lambda x: [vram_path]) - - def mock_open_func(file, *args, **kwargs): - if file == vram_path: - return mock_open(read_data=str(8 * 1024 * 1024 * 1024))() - raise FileNotFoundError(file) - - monkeypatch.setattr(builtins, "open", mock_open_func) - - gpu = _populate_amd_info(gpu, device) - - assert gpu.vram is not None - assert gpu.vram.capacity == 8192 - - def test_populate_amd_info_no_vram(self, monkeypatch): - device = "0000:03:00.0" - gpu = GPUInfo() - - monkeypatch.setattr("glob.glob", lambda x: []) - - gpu = _populate_amd_info(gpu, device) - - assert gpu.vram is None - - -class TestPopulateNvidiaInfo: - """Tests for _populate_nvidia_info function.""" - - def test_populate_nvidia_info_success(self, monkeypatch): - device = "0000:01:00.0" - gpu = GPUInfo() - - def mock_run(command, *args, **kwargs): - if command[0] == "nvidia-smi": - return subprocess.CompletedProcess(command, 0, stdout="GeForce RTX 3080, 16, 4, 10240\n") - return subprocess.CompletedProcess(command, 1) - - monkeypatch.setattr(subprocess, "run", mock_run) - - gpu = _populate_nvidia_info(gpu, device) - - assert gpu.name == "GeForce RTX 3080" - assert gpu.pcie_width == 16 - assert gpu.pcie_gen == 4 - assert gpu.vram.capacity == 10240 - - def test_populate_nvidia_info_failure(self, monkeypatch): - device = "0000:01:00.0" - gpu = GPUInfo() - - def mock_run(command, *args, **kwargs): - raise subprocess.CalledProcessError(1, command) - - monkeypatch.setattr(subprocess, "run", mock_run) - - try: - _populate_nvidia_info(gpu, device) - assert False, "Expected exception" - except: - pass # Expected - - -class TestPopulateLspciInfo: - """Tests for _populate_lspci_info function.""" - - def test_populate_lspci_info_full(self, monkeypatch): - device = "0000:01:00.0" - gpu = GPUInfo() - - def mock_run(command, *args, **kwargs): - if command[0] == "lspci": - output = ( - "Slot:\t01:00.0\n" - "Vendor:\tNVIDIA Corporation\n" - "Device:\tGeForce GTX 1080\n" - "SVendor:\tASUS\n" - "SDevice:\tROG STRIX GTX 1080\n" - ) - return subprocess.CompletedProcess(command, 0, stdout=output) - return subprocess.CompletedProcess(command, 1) - - monkeypatch.setattr(subprocess, "run", mock_run) - - gpu = _populate_lspci_info(gpu, device) - - assert gpu.manufacturer == "NVIDIA Corporation" - assert gpu.name == "GeForce GTX 1080" - assert gpu.subsystem_manufacturer == "ASUS" - assert gpu.subsystem_model == "ROG STRIX GTX 1080" - - def test_populate_lspci_info_minimal(self, monkeypatch): - device = "0000:01:00.0" - gpu = GPUInfo() - - def mock_run(command, *args, **kwargs): - if command[0] == "lspci": - output = "Vendor:\tIntel Corporation\nDevice:\tUHD Graphics 620\n" - return subprocess.CompletedProcess(command, 0, stdout=output) - return subprocess.CompletedProcess(command, 1) - - monkeypatch.setattr(subprocess, "run", mock_run) - - gpu = _populate_lspci_info(gpu, device) - - assert gpu.manufacturer == "Intel Corporation" - assert gpu.name == "UHD Graphics 620" - assert gpu.subsystem_manufacturer is None - assert gpu.subsystem_model is None - - def test_populate_lspci_info_failure(self, monkeypatch): - device = "0000:01:00.0" - gpu = GPUInfo() - - def mock_run(command, *args, **kwargs): - raise FileNotFoundError("lspci not found") - - monkeypatch.setattr(subprocess, "run", mock_run) - - try: - _populate_lspci_info(gpu, device) - assert False, "Expected exception" - except FileNotFoundError: - pass # Expected + assert _check_gpu_class("0000:01:00.0") is False class TestFetchGraphicsInfo: - """Tests for fetch_graphics_info function.""" + """Tests for fetch_graphics_info.""" def test_fetch_graphics_info_root_not_found(self, monkeypatch): monkeypatch.setattr(posixpath, "exists", lambda x: False) @@ -426,8 +195,10 @@ def test_fetch_graphics_info_success_intel(self, monkeypatch): "class": "0x030000", "vendor": "0x8086", "device": "0x5917", - "current_link_width": "0", + "current_link_width": "16", "current_link_speed": "8.0 GT/s", + "max_link_width": "16", + "max_link_speed": "8.0 GT/s", "firmware_node/path": "\\_SB.PCI0.GFX0", } @@ -441,20 +212,12 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x2,0x0)") - - def mock_run(command, *args, **kwargs): - if command[0] == "lspci": - output = "Vendor:\tIntel Corporation\nDevice:\tUHD Graphics 620\nSVendor:\tLenovo\nSDevice:\tThinkPad\n" - return subprocess.CompletedProcess(command, 0, stdout=output) - return subprocess.CompletedProcess(command, 1) - - monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", False) info = fetch_graphics_info() assert info.status.type == StatusType.SUCCESS assert len(info.modules) == 1 - gpu = info.modules[0] assert gpu.vendor_id == "0x8086" assert gpu.device_id == "0x5917" @@ -464,7 +227,7 @@ def mock_run(command, *args, **kwargs): assert gpu.pcie_gen == 3 def test_fetch_graphics_info_nvidia(self, monkeypatch): - monkeypatch.setattr(posixpath, "exists", lambda x: True) + monkeypatch.setattr(os.path, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:01:00.0"]) file_contents = { @@ -477,7 +240,7 @@ def test_fetch_graphics_info_nvidia(self, monkeypatch): } def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -506,7 +269,7 @@ def mock_run(command, *args, **kwargs): assert gpu.vram.capacity == 6144 def test_fetch_graphics_info_amd(self, monkeypatch): - monkeypatch.setattr(posixpath, "exists", lambda x: True) + monkeypatch.setattr(os.path, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:03:00.0"]) file_contents = { @@ -519,7 +282,7 @@ def test_fetch_graphics_info_amd(self, monkeypatch): } def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -555,12 +318,8 @@ def test_fetch_graphics_info_skip_non_display(self, monkeypatch): monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:04:00.0"]) - file_contents = { - "class": "0x020000", # Network Controller - } - def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename in file_contents: return mock_open(read_data=file_contents[filename])() raise FileNotFoundError(path) @@ -584,19 +343,20 @@ def custom_open(path, *args, **kwargs): raise OSError("Permission denied") if filename == "device": return mock_open(read_data="0x1234")() - raise OSError("File not found") + if filename in {"current_link_width", "current_link_speed", "max_link_width", "max_link_speed"}: + return mock_open(read_data="8.0 GT/s")() + if filename == "path" and "firmware_node" in path: + return mock_open(read_data="\\_SB.PCI0.GFX0")() + raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) - - def mock_run(*args, **kwargs): - raise FileNotFoundError("lspci not found") - - monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x1,0x0)") info = fetch_graphics_info() assert info.status.type == StatusType.PARTIAL assert len(info.modules) == 1 + assert any("Could not read vendor ID" in msg for msg in info.status.messages) def test_fetch_graphics_info_acpi_path_failure(self, monkeypatch): monkeypatch.setattr(posixpath, "exists", lambda x: True) @@ -606,8 +366,10 @@ def test_fetch_graphics_info_acpi_path_failure(self, monkeypatch): "class": "0x030000", "vendor": "0x8086", "device": "0x5917", - "current_link_width": "0", + "current_link_width": "16", "current_link_speed": "8.0 GT/s", + "max_link_width": "16", + "max_link_speed": "8.0 GT/s", } def custom_open(path, *args, **kwargs): @@ -620,7 +382,6 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x2,0x0)") - monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, stdout="")) info = fetch_graphics_info() @@ -639,13 +400,14 @@ def test_fetch_graphics_info_pci_path_failure(self, monkeypatch): "class": "0x030000", "vendor": "0x8086", "device": "0x5917", - "current_link_width": "0", + "current_link_width": "16", "current_link_speed": "8.0 GT/s", - "firmware_node/path": "\\_SB.PCI0.GFX0", + "max_link_width": "16", + "max_link_speed": "8.0 GT/s", } def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -653,12 +415,7 @@ def custom_open(path, *args, **kwargs): raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) - - def mock_pci_path(device): - raise Exception("PCI path failed") - - monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", mock_pci_path) - monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, stdout="")) + monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: None) info = fetch_graphics_info() @@ -669,7 +426,7 @@ def mock_pci_path(device): assert any("PCI path" in msg for msg in info.status.messages) def test_fetch_graphics_info_nvidia_failure(self, monkeypatch): - monkeypatch.setattr(posixpath, "exists", lambda x: True) + monkeypatch.setattr(os.path, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:01:00.0"]) file_contents = { @@ -678,11 +435,12 @@ def test_fetch_graphics_info_nvidia_failure(self, monkeypatch): "device": "0x1c03", "current_link_width": "16", "current_link_speed": "8.0 GT/s", - "firmware_node/path": "\\_SB.PCI0.PEG0.PEGP", + "max_link_width": "16", + "max_link_speed": "8.0 GT/s", } def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -691,38 +449,40 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x1,0x0)") - - def mock_run(command, *args, **kwargs): - if command[0] == "nvidia-smi": - raise subprocess.CalledProcessError(1, command) - return subprocess.CompletedProcess(command, 0, stdout="") - - monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) + monkeypatch.setattr( + "hwprobe.core.linux.graphics.native_gpu.get_gpu_info", + lambda *args, **kwargs: type( + "Native", + (), + {"name": "GeForce GTX 1060", "vram_total_mb": 6144, "vram_used_mb": 0}, + )(), + ) info = fetch_graphics_info() assert len(info.modules) == 1 gpu = info.modules[0] assert gpu.vendor_id == "0x10de" - assert gpu.vram is None - assert info.status.type == StatusType.PARTIAL - assert any("Could not get additional GPU info" in msg for msg in info.status.messages) + assert gpu.name == "GeForce GTX 1060" + assert gpu.vram is not None + assert gpu.vram.capacity == 6144 def test_fetch_graphics_info_lspci_failure(self, monkeypatch): - monkeypatch.setattr(posixpath, "exists", lambda x: True) + monkeypatch.setattr(os.path, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:00:02.0"]) file_contents = { "class": "0x030000", "vendor": "0x8086", "device": "0x5917", - "current_link_width": "0", - "current_link_speed": "8.0 GT/s", - "firmware_node/path": "\\_SB.PCI0.GFX0", + "current_link_width": "16", + "max_link_width": "16", + "max_link_speed": "8.0 GT/s", } def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -748,7 +508,7 @@ def mock_run(command, *args, **kwargs): assert any("LSPCI" in msg for msg in info.status.messages) def test_fetch_graphics_info_pcie_gen_failure(self, monkeypatch): - monkeypatch.setattr(posixpath, "exists", lambda x: "/current_link_speed" not in x) + monkeypatch.setattr(os.path, "exists", lambda x: "/current_link_speed" not in x) monkeypatch.setattr(os, "listdir", lambda x: ["0000:00:02.0"]) file_contents = { @@ -760,24 +520,21 @@ def test_fetch_graphics_info_pcie_gen_failure(self, monkeypatch): } def custom_open(path, *args, **kwargs): - filename = posixpath.basename(path) + filename = os.path.basename(path) if filename == "path" and "firmware_node" in path: - return mock_open(read_data=file_contents["firmware_node/path"])() - if filename in file_contents: - return mock_open(read_data=file_contents[filename])() + return mock_open(read_data="\\_SB.PCI0.GFX0")() raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x2,0x0)") - monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, stdout="")) info = fetch_graphics_info() assert len(info.modules) == 1 gpu = info.modules[0] - assert gpu.pcie_gen is None + assert gpu.current_pcie_gen is None assert info.status.type == StatusType.PARTIAL - assert any("PCI gen" in msg for msg in info.status.messages) + assert any("current link speed" in msg for msg in info.status.messages) def test_fetch_graphics_info_class_read_failure(self, monkeypatch): monkeypatch.setattr(posixpath, "exists", lambda x: True) @@ -792,7 +549,6 @@ def custom_open(path, *args, **kwargs): info = fetch_graphics_info() - # Device should be skipped due to class read failure assert len(info.modules) == 0 assert info.status.type == StatusType.PARTIAL assert any("Could not open file" in msg for msg in info.status.messages) From 54460ef64eaf9867bd217d29e3f6a14f93edf781 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 12 Aug 2026 15:35:13 +0200 Subject: [PATCH 02/22] fix: move ACPI helper to common & notify user if ACPI path was extrapolated rather than read directly --- src/hwprobe/core/linux/common.py | 56 +++++++++++++++++++++++++++++ src/hwprobe/core/linux/graphics.py | 57 ++++-------------------------- 2 files changed, 62 insertions(+), 51 deletions(-) diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index 66b309e..2426923 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -4,9 +4,65 @@ import re from typing import Optional from pathlib import Path +from enum import Enum +PCI_ROOT_PATH = "/sys/bus/pci/devices/" _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") +class ACPIResult(Enum): + SUCCESS = 0 + INFERRED = 1 + FAILURE = 2 + +def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], ACPIResult]: + """ + Resolve the ACPI path for a given PCI device. + + :param device_bdf: The BDF identifier (Bus:Device.Function) of the device + + :return: The resolved ACPI path if found, else None. + """ + ret_val = (None, ACPIResult.FAILURE) + device_path = os.path.join(PCI_ROOT_PATH, device_bdf) + dev, func = device_bdf.split(":")[-1].split(".") + if not os.path.exists(device_path): + return ret_val + + acpi_path = _read_from_sysfs(device_path, "firmware_node", "path") + if acpi_path: + return acpi_path, ACPIResult.SUCCESS + + # Parent directory should be something like RRRR:BB:DD.F + try: + parent_node = os.path.dirname(os.path.realpath(device_path)) + except Exception: + return ret_val + + if not parent_node: + return ret_val + + # A PCI Bridge should ALWAYS be qualified in the DSDT + if _read_from_sysfs(parent_node, "firmware_node", "path") is None: + return ret_val + + for entry in os.listdir(os.path.join(parent_node, "firmware_node")): + if entry.startswith("device"): + if (adr := _read_from_sysfs(parent_node, "firmware_node", entry, "adr")) is None: + continue + + try: + acpi_value = _read_from_sysfs("/sys", "bus", "acpi", "devices", entry, "path") + + if int(adr, 16) == ((int(dev, 16) << 16) | int(func, 16)): + return acpi_value, ACPIResult.SUCCESS + + if int(adr, 16) == 0xFF and acpi_path is None: + acpi_path = acpi_value + except Exception: + continue + + return acpi_path, ACPIResult.INFERRED + # Linux implemented this very annoyingly # - https://tldp.org/LDP/tlk/dd/pci.html diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index 48d6f4b..8ed0aa9 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -1,7 +1,7 @@ import os from typing import Optional -from hwprobe.core.linux.common import _read_from_sysfs, pci_path_linux +from hwprobe.core.linux.common import PCI_ROOT_PATH, ACPIResult, _read_from_sysfs, pci_path_linux, _resolve_acpi_path from hwprobe.models.gpu_models import GPUInfo, GraphicsInfo from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType @@ -20,7 +20,6 @@ # https://unix.stackexchange.com/questions/393/how-to-check-how-many-lanes-are-used-by-the-pcie-card # ^ Solution: /sys/bus/pci/devices/{...}/current_link_width -PCI_ROOT_PATH = "/sys/bus/pci/devices/" DISPLAY_CONTROLLER_CLASS = 0x03 # Display Controller class code in PCI def _pcie_gen(raw_speed: str) -> Optional[int]: @@ -36,54 +35,6 @@ def _pcie_gen(raw_speed: str) -> Optional[int]: return None -def _resolve_acpi_path(device_bdf: str) -> Optional[str]: - """ - Resolve the ACPI path for a given PCI device. - - :param device_bdf: The BDF identifier (Bus:Device.Function) of the device - - :return: The resolved ACPI path if found, else None. - """ - device_path = os.path.join(PCI_ROOT_PATH, device_bdf) - dev, func = device_bdf.split(":")[-1].split(".") - if not os.path.exists(device_path): - return None - - acpi_path = _read_from_sysfs(device_path, "firmware_node", "path") - if acpi_path: - return acpi_path - - # Parent directory should be something like RRRR:BB:DD.F - try: - parent_node = os.path.dirname(os.path.realpath(device_path)) - except Exception: - return None - - if not parent_node: - return None - - # A PCI Bridge should ALWAYS be qualified in the DSDT - if _read_from_sysfs(parent_node, "firmware_node", "path") is None: - return None - - for entry in os.listdir(os.path.join(parent_node, "firmware_node")): - if entry.startswith("device"): - if (adr := _read_from_sysfs(parent_node, "firmware_node", entry, "adr")) is None: - continue - - try: - acpi_value = _read_from_sysfs("/sys", "bus", "acpi", "devices", entry, "path") - - if int(adr, 16) == ((int(dev, 16) << 16) | int(func, 16)): - return acpi_value - - if int(adr, 16) == 0xFF and acpi_path is None: - acpi_path = acpi_value - except Exception: - continue - - - return acpi_path def _check_gpu_class(device: str) -> bool: """ @@ -161,8 +112,12 @@ def fetch_graphics_info() -> GraphicsInfo: graphics_info.status.messages.append(f"Could not read max link speed for {device}") - if (acpi_path := _resolve_acpi_path(device)) is not None: + acpi_path, result = _resolve_acpi_path(device) + if result == ACPIResult.SUCCESS or result == ACPIResult.INFERRED: gpu.acpi_path = acpi_path + + if result == ACPIResult.INFERRED: + graphics_info.status.messages.append(f"ACPI path for {device} was inferred through parent device (bridge), may be inaccurate") else: graphics_info.status.type = StatusType.PARTIAL graphics_info.status.messages.append(f"Could not read ACPI path for {device}") From 7ccc94e19b74ff4abb983eee605b0eee564e4097 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 12 Aug 2026 15:46:19 +0200 Subject: [PATCH 03/22] update: _resolve_acpi_path docstring --- src/hwprobe/core/linux/common.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index 2426923..1cc5072 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -21,6 +21,14 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], ACPIResult]: :param device_bdf: The BDF identifier (Bus:Device.Function) of the device :return: The resolved ACPI path if found, else None. + + The way this function works is: + 1. It first checks if the device has a direct ACPI path in its sysfs entry. + 2. If not, it checks the parent device (usually a PCI bridge), and attempts to infer the ACPI path from there. + 2.1 The parent device should have a firmware_node directory with "device:XX" directory that can be used to match the child device via "adr" file. + 2.1.1 If the "adr" matches the child device's dev.func, then we can use the parent's ACPI path. + 2.1.2 If the "adr" is 0xFF, it means the child device is denoted as a "generic" device, and this is likely the closest match we can get + 3. If neither direct nor inferred ACPI path is found, return None. """ ret_val = (None, ACPIResult.FAILURE) device_path = os.path.join(PCI_ROOT_PATH, device_bdf) From 0cf53685a9759d4d103e5c249e361146bd46b871 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Fri, 14 Aug 2026 07:06:04 +0200 Subject: [PATCH 04/22] fix(Linux): ACPI fetching --- src/hwprobe/core/linux/common.py | 36 +++++++------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index 1cc5072..1053f92 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -24,11 +24,7 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], ACPIResult]: The way this function works is: 1. It first checks if the device has a direct ACPI path in its sysfs entry. - 2. If not, it checks the parent device (usually a PCI bridge), and attempts to infer the ACPI path from there. - 2.1 The parent device should have a firmware_node directory with "device:XX" directory that can be used to match the child device via "adr" file. - 2.1.1 If the "adr" matches the child device's dev.func, then we can use the parent's ACPI path. - 2.1.2 If the "adr" is 0xFF, it means the child device is denoted as a "generic" device, and this is likely the closest match we can get - 3. If neither direct nor inferred ACPI path is found, return None. + 2. If not, it checks the parent devices recursively until it finds an ACPI path or reaches the root. """ ret_val = (None, ACPIResult.FAILURE) device_path = os.path.join(PCI_ROOT_PATH, device_bdf) @@ -40,35 +36,19 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], ACPIResult]: if acpi_path: return acpi_path, ACPIResult.SUCCESS + device_path = os.path.realpath(device_path) + # Parent directory should be something like RRRR:BB:DD.F try: - parent_node = os.path.dirname(os.path.realpath(device_path)) + while (acpi_path := _read_from_sysfs(device_path, "firmware_node", "path")) is None: + print(device_path) + device_path = os.path.dirname(device_path) + except Exception: return ret_val - if not parent_node: - return ret_val - - # A PCI Bridge should ALWAYS be qualified in the DSDT - if _read_from_sysfs(parent_node, "firmware_node", "path") is None: - return ret_val + print(acpi_path) - for entry in os.listdir(os.path.join(parent_node, "firmware_node")): - if entry.startswith("device"): - if (adr := _read_from_sysfs(parent_node, "firmware_node", entry, "adr")) is None: - continue - - try: - acpi_value = _read_from_sysfs("/sys", "bus", "acpi", "devices", entry, "path") - - if int(adr, 16) == ((int(dev, 16) << 16) | int(func, 16)): - return acpi_value, ACPIResult.SUCCESS - - if int(adr, 16) == 0xFF and acpi_path is None: - acpi_path = acpi_value - except Exception: - continue - return acpi_path, ACPIResult.INFERRED From 6d9675d84b189110ecb80bb12ad45c52a7468f98 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Sat, 15 Aug 2026 10:10:00 +0200 Subject: [PATCH 05/22] PR fixes #1 --- src/hwprobe/core/common/pcie_link.py | 31 +++++ src/hwprobe/core/linux/common.py | 53 ++++---- src/hwprobe/core/linux/display.py | 32 ++--- src/hwprobe/core/linux/graphics.py | 77 ++++++----- src/hwprobe/core/linux/manager.py | 4 +- src/hwprobe/core/windows/graphics.py | 4 +- src/hwprobe/interops/linux/CMakeLists.txt | 3 + src/hwprobe/interops/linux/README.md | 19 ++- .../interops/linux/bindings/libdevice_info.so | Bin 0 -> 33000 bytes src/hwprobe/interops/linux/main.c | 103 +++++++++++---- src/hwprobe/interops/linux/src/gpu_info.cpp | 57 +-------- src/hwprobe/models/gpu_models.py | 27 ++-- src/hwprobe/models/info_models.py | 3 - tests/core/common/test_edid.py | 120 ++++++++++++++++++ tests/core/linux/test_common.py | 2 +- tests/core/linux/test_graphics.py | 18 ++- 16 files changed, 353 insertions(+), 200 deletions(-) create mode 100644 src/hwprobe/core/common/pcie_link.py create mode 100755 src/hwprobe/interops/linux/bindings/libdevice_info.so diff --git a/src/hwprobe/core/common/pcie_link.py b/src/hwprobe/core/common/pcie_link.py new file mode 100644 index 0000000..72ca836 --- /dev/null +++ b/src/hwprobe/core/common/pcie_link.py @@ -0,0 +1,31 @@ +from typing import Optional + +from hwprobe.models.gpu_models import PCIeLinkInfo, PCIeLinkValue + + +def build_pcie_link( + *, + max_gen: int, + current_gen: int, + max_width: int, + current_width: int +) -> Optional[PCIeLinkInfo]: + gen = PCIeLinkValue() + width = PCIeLinkValue() + + if max_gen: + gen.max = max_gen + + if current_gen: + gen.current = current_gen + + if max_width: + width.max = max_width + + if current_width: + width.current = current_width + + if max_gen == 0 and current_gen == 0 and max_width == 0 and current_width == 0: + return None + + return PCIeLinkInfo(gen=gen, width=width) \ No newline at end of file diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index 1053f92..e6977d1 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -1,5 +1,3 @@ -# Source: https://github.com/KernelWanderers/OCSysInfo/blob/main/src/util/pci_root.py - import posixpath import re from typing import Optional @@ -9,12 +7,7 @@ PCI_ROOT_PATH = "/sys/bus/pci/devices/" _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") -class ACPIResult(Enum): - SUCCESS = 0 - INFERRED = 1 - FAILURE = 2 - -def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], ACPIResult]: +def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], bool]: """ Resolve the ACPI path for a given PCI device. @@ -26,30 +19,32 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], ACPIResult]: 1. It first checks if the device has a direct ACPI path in its sysfs entry. 2. If not, it checks the parent devices recursively until it finds an ACPI path or reaches the root. """ - ret_val = (None, ACPIResult.FAILURE) - device_path = os.path.join(PCI_ROOT_PATH, device_bdf) - dev, func = device_bdf.split(":")[-1].split(".") - if not os.path.exists(device_path): + ret_val = None, False + device_path = posixpath.join(PCI_ROOT_PATH, device_bdf) + if not posixpath.exists(device_path): return ret_val acpi_path = _read_from_sysfs(device_path, "firmware_node", "path") if acpi_path: - return acpi_path, ACPIResult.SUCCESS + return acpi_path, True - device_path = os.path.realpath(device_path) + device_path = posixpath.realpath(device_path) # Parent directory should be something like RRRR:BB:DD.F try: while (acpi_path := _read_from_sysfs(device_path, "firmware_node", "path")) is None: - print(device_path) - device_path = os.path.dirname(device_path) + if device_path == posixpath.dirname(device_path): + # We've reached the root of the filesystem without finding an ACPI path + return ret_val + + device_path = posixpath.dirname(device_path) except Exception: return ret_val - print(acpi_path) - - return acpi_path, ACPIResult.INFERRED + # Device has no ACPI path, it is found via PCI enumeration + # Return parent ACPI path instead. + return acpi_path, False # Linux implemented this very annoyingly @@ -61,27 +56,25 @@ def pci_path_linux(device_slot: str): :return: PCI path, e.g. PciRoot(0x0)/Pci(0x2,0x0) """ # Invalid fallback value - def_val = "PciRoot(0x0)/Pci(0x0,0x0)" if not device_slot or not _PCI_BDF_PATTERN.match(device_slot): return None raw_path = f"/sys/bus/pci/devices/{device_slot}/" - path = os.path.realpath(raw_path) + path = posixpath.realpath(raw_path) if not path: return None pci_root = "" pci_segments = [] - for part in path.split(os.sep): + for part in path.split(posixpath.sep): if part.startswith("pci"): try: root_bus = part.split(":")[0].split("pci")[-1] pci_root = f"PciRoot(0x{int(root_bus, 16):x})" - except (ValueError, IndexError) as e: - print(f"Error parsing PCI root bus from {part}: {e}") - return def_val + except (ValueError, IndexError): + return None elif ":" in part and "." in part: # Only the root bridge does not contain a function number try: @@ -91,19 +84,19 @@ def pci_path_linux(device_slot: str): pci_segments.append(f"Pci(0x{int(dev, 16):x},0x{int(func, 16):x})") except (ValueError, IndexError) as e: print(f"Error parsing PCI device/function from {part}: {e}") - return def_val + return None if not pci_root or not pci_segments: - return def_val + return None return f"{pci_root}/{'/'.join(pci_segments)}" def _read_from_sysfs(base: str, *paths) -> Optional[str]: """Read a string from a sysfs file, return None if not found.""" - path = os.path.join(base, *paths) - - if not os.path.exists(path): + path = posixpath.join(base, *paths) + + if not posixpath.exists(path): return None try: diff --git a/src/hwprobe/core/linux/display.py b/src/hwprobe/core/linux/display.py index 7b51cfa..6986a29 100644 --- a/src/hwprobe/core/linux/display.py +++ b/src/hwprobe/core/linux/display.py @@ -1,12 +1,12 @@ import os import posixpath import re -from typing import List, Optional +from typing import Optional from hwprobe.core.common.edid import INTERFACE_ENUM, parse_edid from hwprobe.core.linux.common import pci_path_linux from hwprobe.models.display_models import DisplayInfo, DisplayModuleInfo -from hwprobe.models.gpu_models import GraphicsInfo +from hwprobe.models.gpu_models import GPUInfo from hwprobe.models.status_models import StatusType _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") @@ -24,15 +24,15 @@ "DSI": "DSI", } -def _resolve_parent_gpu_by_bdf(pci_bdf: str, gpu_devices: List[GraphicsInfo]) -> Optional[str]: +def _resolve_parent_gpu_by_bdf(pci_bdf: str, gpu_devices: list[GPUInfo]) -> Optional[str]: """ Given a PCI BDF (::.) of a display device, - find the parent GPU in the list of GraphicsInfo objects. + find the parent GPU in the list of GPUInfo objects. """ + if pci_bdf is None: + return None + for gpu in gpu_devices: - # TODO: This wastes calls, creates additional complexity and may be error-prone. - # A better alternative may be to map direct BDFs to GPUs and pass it here. - # Though, this is a temporary solution until we provide a better design for GPU-Display relationships. if gpu.pci_path == pci_path_linux(pci_bdf): return gpu.name @@ -57,19 +57,12 @@ def _parse_connector_type(device_path: str) -> Optional[str]: def _fetch_individual_monitor_info( device_path: str, - gpu_devices: List[GraphicsInfo] + gpu_devices: list[GPUInfo] ) -> Optional[DisplayModuleInfo]: - edid_path = os.path.join(device_path, "edid") - if not os.path.exists(edid_path): + edid_path = posixpath.join(device_path, "edid") + if not posixpath.exists(edid_path): return None - # For some reason, it's not guaranteed to only have a single "device" directory in the tree/chain - # So, we look at how many is necessary until "device" stops being a directory. - parent_path = device_path - - while os.path.exists(t := os.path.join(parent_path, "device")) and os.path.isdir(t): - parent_path = t - with open(edid_path, "rb") as f: edid_data = f.read() if len(edid_data) == 0: @@ -80,8 +73,9 @@ def _fetch_individual_monitor_info( if connector_type := _parse_connector_type(device_path): monitor_data.interface = connector_type + parent_path = posixpath.realpath(device_path) if parent_path != device_path: - pci_bdf = _extract_pci_bdf_from_sysfs_path(os.path.realpath(parent_path)) + pci_bdf = _extract_pci_bdf_from_sysfs_path(posixpath.realpath(parent_path)) # Resolve parent GPU based on the PCI BDF if (parent_gpu := _resolve_parent_gpu_by_bdf(pci_bdf, gpu_devices)) is not None: @@ -96,7 +90,7 @@ def _fetch_individual_monitor_info( def fetch_display_info( - gpu_devices: List[GraphicsInfo] + gpu_devices: list[GPUInfo] ): display_info = DisplayInfo() pattern = re.compile(r"^card\d+$") diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index 8ed0aa9..a522e69 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -1,7 +1,8 @@ import os +import posixpath from typing import Optional - -from hwprobe.core.linux.common import PCI_ROOT_PATH, ACPIResult, _read_from_sysfs, pci_path_linux, _resolve_acpi_path +from hwprobe.core.common.pcie_link import build_pcie_link +from hwprobe.core.linux.common import PCI_ROOT_PATH, _read_from_sysfs, pci_path_linux, _resolve_acpi_path from hwprobe.models.gpu_models import GPUInfo, GraphicsInfo from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType @@ -14,12 +15,6 @@ except (ImportError, RuntimeError) as e: NATIVE_AVAILABLE = False -# Currently, the info in /sys/class/drm/cardX is being used. -# TODO: Check if lspci and lshw -c display can be used -# Answer: nope, pciutils and lshw are not guaranteed to be installed on all systems. -# https://unix.stackexchange.com/questions/393/how-to-check-how-many-lanes-are-used-by-the-pcie-card -# ^ Solution: /sys/bus/pci/devices/{...}/current_link_width - DISPLAY_CONTROLLER_CLASS = 0x03 # Display Controller class code in PCI def _pcie_gen(raw_speed: str) -> Optional[int]: @@ -60,8 +55,7 @@ def fetch_graphics_info() -> GraphicsInfo: if not _check_gpu_class(device): continue except Exception as e: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not open file for {device}: {e}") + graphics_info.status.make_partial(f"Could not open file for {device}: {e}") continue gpu = GPUInfo() @@ -70,74 +64,77 @@ def fetch_graphics_info() -> GraphicsInfo: if (vendor_id := _read_from_sysfs(gpu_path, "vendor")) is not None: gpu.vendor_id = vendor_id else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read vendor ID for {device}") + graphics_info.status.make_partial(f"Could not read vendor ID for {device}") if (device_id := _read_from_sysfs(gpu_path, "device")) is not None: gpu.device_id = device_id else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read device ID for {device}") + graphics_info.status.make_partial(f"Could not read device ID for {device}") if (cur_width := _read_from_sysfs(gpu_path, "current_link_width")) is not None: if cur_width.isnumeric() and int(cur_width) > 0: - gpu.current_pcie_width = int(cur_width) + cur_width = int(cur_width) else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read current link width for {device}") + graphics_info.status.make_partial(f"Could not read current link width for {device}") if (max_width := _read_from_sysfs(gpu_path, "max_link_width")) is not None: if max_width.isnumeric() and int(max_width) > 0: - gpu.max_pcie_width = int(max_width) + max_width = int(max_width) else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read max link width for {device}") + graphics_info.status.make_partial(f"Could not read max link width for {device}") if (cur_pcie_speed := _read_from_sysfs(gpu_path, "current_link_speed")) is not None: if cur_pcie_speed: - gpu.current_pcie_gen = _pcie_gen(cur_pcie_speed) + cur_pcie_speed = _pcie_gen(cur_pcie_speed) else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read current link speed for {device}") + graphics_info.status.make_partial(f"Could not read current link speed for {device}") if (max_pcie_speed := _read_from_sysfs(gpu_path, "max_link_speed")) is not None: if max_pcie_speed: - gpu.max_pcie_gen = _pcie_gen(max_pcie_speed) + max_pcie_speed = _pcie_gen(max_pcie_speed) else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read max link speed for {device}") + graphics_info.status.make_partial(f"Could not read max link speed for {device}") acpi_path, result = _resolve_acpi_path(device) - if result == ACPIResult.SUCCESS or result == ACPIResult.INFERRED: + if acpi_path is not None: gpu.acpi_path = acpi_path - if result == ACPIResult.INFERRED: - graphics_info.status.messages.append(f"ACPI path for {device} was inferred through parent device (bridge), may be inaccurate") + if not result: + graphics_info.status.messages.append(f"ACPI path for {device} was inferred through parent device, device itself was likely found via PCI enumeration") else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not read ACPI path for {device}") + graphics_info.status.make_partial(f"Could not read ACPI path for {device}") if (pci_path := pci_path_linux(device)) is not None: gpu.pci_path = pci_path else: - graphics_info.status.type = StatusType.PARTIAL - graphics_info.status.messages.append(f"Could not resolve PCI path for {device}") - - if ( - vendor_id is not None and - NATIVE_AVAILABLE is True and - (native := native_gpu.get_gpu_info(device, int(gpu.vendor_id, 16))) is not None - ): + graphics_info.status.make_partial(f"Could not resolve PCI path for {device}") + + if not NATIVE_AVAILABLE: + graphics_info.status.make_partial(f"Native GPU info library not available, cannot fetch GPU name or VRAM for {device}") + elif vendor_id is None: + graphics_info.status.make_partial(f"Vendor ID not available, cannot fetch GPU name or VRAM for {device}") + elif (native := native_gpu.get_gpu_info(device, int(vendor_id, 16))) is None: + graphics_info.status.make_partial(f"Native GPU info library could not fetch GPU name or VRAM for {device} with vendor ID {vendor_id}") + else: gpu.name = native.name if native.vram_total_mb > 0: gpu.vram = Megabyte(capacity=int(native.vram_total_mb)) - + else: + graphics_info.status.make_partial(f"Native GPU info library returned VRAM size of 0 for {device} with vendor ID {vendor_id}") + + gpu.pcie_link = build_pcie_link( + max_gen=max_pcie_speed or 0, + current_gen=cur_pcie_speed or 0, + max_width=max_width or 0, + current_width=cur_width or 0 + ) + graphics_info.modules.append(gpu) diff --git a/src/hwprobe/core/linux/manager.py b/src/hwprobe/core/linux/manager.py index d757f93..92d7618 100644 --- a/src/hwprobe/core/linux/manager.py +++ b/src/hwprobe/core/linux/manager.py @@ -49,6 +49,9 @@ def fetch_graphics_info(self) -> GraphicsInfo: return self.info.graphics def fetch_display_info(self) -> DisplayInfo: + if self.info.graphics.modules and len(self.info.graphics.modules): + self.fetch_graphics_info() + self.info.display = fetch_display_info(self.info.graphics.modules) return self.info.display @@ -58,7 +61,6 @@ def fetch_hardware_info(self) -> HardwareInfo: self.fetch_memory_info() self.fetch_network_info() self.fetch_storage_info() - self.fetch_display_info() return self.info def fetch_network_info(self) -> NetworkInfo: diff --git a/src/hwprobe/core/windows/graphics.py b/src/hwprobe/core/windows/graphics.py index 874c363..3f92380 100644 --- a/src/hwprobe/core/windows/graphics.py +++ b/src/hwprobe/core/windows/graphics.py @@ -36,8 +36,8 @@ def _map_gpu(raw: GPURaw) -> GPUInfo: pcie = fetch_pcie_info(raw.pnp_device_id) if pcie: speed, width = pcie - gpu.pcie_gen = speed - gpu.pcie_width = width + gpu.pcie_link.gen.current = speed + gpu.pcie_link.width.current = width # VRAM: registry fallback wins if present, else DXGI value vram_bytes = raw.vram_bytes or raw.dedicated_video_memory_bytes diff --git a/src/hwprobe/interops/linux/CMakeLists.txt b/src/hwprobe/interops/linux/CMakeLists.txt index 0464ee6..015a2f4 100644 --- a/src/hwprobe/interops/linux/CMakeLists.txt +++ b/src/hwprobe/interops/linux/CMakeLists.txt @@ -55,6 +55,9 @@ if(CMAKE_BUILD_TYPE STREQUAL "Release") ) endif() +add_executable(LinuxDeviceInfo main.c) +target_link_libraries(LinuxDeviceInfo PRIVATE device_info) + set_target_properties( device_info PROPERTIES diff --git a/src/hwprobe/interops/linux/README.md b/src/hwprobe/interops/linux/README.md index ed3bf52..c95360c 100644 --- a/src/hwprobe/interops/linux/README.md +++ b/src/hwprobe/interops/linux/README.md @@ -1,8 +1,7 @@ # LinuxDeviceInfo A tiny Linux utility and shared library that enumerates GPUs using DRM ioctls (vendor-specific) with a Vulkan -fallback (universal), reporting model name, vendor/device IDs, VRAM, PCIe generation/width, PCI slot, and driver -name without relying on external tools like `lspci` or `nvidia-smi`. +fallback (universal), reporting model name and VRAM without relying on external tools like `lspci` or `nvidia-smi`. The native library lives in `src/` and `include/`, and is exposed via a command-line tester (`main.c`). Also powers a thin Python `ctypes` binding in `bindings/gpu_info.py`. @@ -57,18 +56,17 @@ cmake --build build ## CLI Usage ```sh -./build/LinuxDeviceInfo +./build/LinuxDeviceInfo ``` Sample output: ``` -Found 1 GPU(s): - -GPU 0: +Vulkan fallback: VRAM total not detected for GPU 0000:09:00.0 +GPU at 0000:09:00.0: Name: NVIDIA GeForce RTX 5070 Ti - VRAM Total: 16384 MB - VRAM Used: 0 MB + VRAM Total: 16303 MB + VRAM Used: 2721 MB ``` The tool exits with code `0` when enumeration succeeds, or `1` if the underlying DRM/Vulkan query fails. @@ -87,9 +85,8 @@ or programmatically: ```python from gpu_info import get_gpu_info -for idx, gpu in enumerate(get_gpu_info()): - print(f"GPU {idx}:") - print(gpu) +gpu = get_gpu_info("0000:09:00.0", 0x10DE) +print(gpu) ``` On import, the script loads the colocated `libdevice_info.so`; ensure you rebuild the CMake project whenever you make diff --git a/src/hwprobe/interops/linux/bindings/libdevice_info.so b/src/hwprobe/interops/linux/bindings/libdevice_info.so new file mode 100755 index 0000000000000000000000000000000000000000..905143e5b917c1942ff13535b0520369ec918387 GIT binary patch literal 33000 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 #include -#include -#include "gpu_info.h" - -int main(int argc, char *argv[]) { - if (argc < 3) { - fprintf(stderr, "Usage: %s \n e.g. %s 0000:01:00.0 0x1002\n", - argv[0], argv[0]); - return 1; - } - - uint32_t vendor_id = (uint32_t)strtoul(argv[2], NULL, 16); - GPUProperties g; - if (get_gpu_info(argv[1], vendor_id, &g) < 0) { - fprintf(stderr, "Failed to query GPU info for %s\n", argv[1]); - return 1; - } - - printf("GPU at %s:\n", argv[1]); - printf(" Name: %s\n", g.name[0] ? g.name : "(unknown)"); - printf(" VRAM Total: %lu MB\n", (unsigned long)g.vram_total_mb); - printf(" VRAM Used: %lu MB\n", (unsigned long)g.vram_used_mb); +#include +#include +#include + +typedef struct VkPhysDevProps +{ + uint32_t api, driver, vendorID, deviceID, devType; + char name[256]; + uint8_t uuid[16]; + alignas(uint64_t) uint8_t _limits[504]; + uint8_t _sparse[20]; +} VkPhysDevProps; + +_Static_assert( + sizeof(VkPhysDevProps) == sizeof(VkPhysicalDeviceProperties), + "VkPhysDevProps size mismatch" +); + +_Static_assert( + alignof(VkPhysDevProps) == alignof(VkPhysicalDeviceProperties), + "VkPhysDevProps alignment mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, api) == + offsetof(VkPhysicalDeviceProperties, apiVersion), + "api offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, driver) == + offsetof(VkPhysicalDeviceProperties, driverVersion), + "driver offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, vendorID) == + offsetof(VkPhysicalDeviceProperties, vendorID), + "vendorID offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, deviceID) == + offsetof(VkPhysicalDeviceProperties, deviceID), + "deviceID offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, devType) == + offsetof(VkPhysicalDeviceProperties, deviceType), + "deviceType offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, name) == + offsetof(VkPhysicalDeviceProperties, deviceName), + "deviceName offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, uuid) == + offsetof(VkPhysicalDeviceProperties, pipelineCacheUUID), + "UUID offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, _limits) == + offsetof(VkPhysicalDeviceProperties, limits), + "limits offset mismatch" +); + +_Static_assert( + offsetof(VkPhysDevProps, _sparse) == + offsetof(VkPhysicalDeviceProperties, sparseProperties), + "sparseProperties offset mismatch" +); + +int main() +{ return 0; -} +} \ No newline at end of file diff --git a/src/hwprobe/interops/linux/src/gpu_info.cpp b/src/hwprobe/interops/linux/src/gpu_info.cpp index 42fd0f6..39efcb9 100644 --- a/src/hwprobe/interops/linux/src/gpu_info.cpp +++ b/src/hwprobe/interops/linux/src/gpu_info.cpp @@ -1,10 +1,11 @@ #include "../include/gpu_info.h" +#include +#include #include #include #include #include -#include #include #include #include @@ -60,7 +61,7 @@ struct VkPhysDevProps uint32_t api, driver, vendorID, deviceID, devType; char name[256]; uint8_t uuid[16]; - uint8_t _limits[504]; + alignas(uint64_t) uint8_t _limits[504]; uint8_t _sparse[20]; }; @@ -242,57 +243,6 @@ static bool icd_matches_vendor(const char *filename, uint32_t vendor_id) } } -static void restrict_vulkan_icds(uint32_t vendor_id) -{ - static const char *icd_dirs[] = {"/usr/share/vulkan/icd.d", "/etc/vulkan/icd.d"}; - - char matches[2048] = {0}; - size_t matches_len = 0; - - for (size_t d = 0; d < sizeof(icd_dirs) / sizeof(icd_dirs[0]); d++) - { - DIR *dir = opendir(icd_dirs[d]); - if (!dir) - continue; - - struct dirent *entry; - while ((entry = readdir(dir)) != NULL) - { - const char *name = entry->d_name; - size_t len = strlen(name); - if (len < 6 || strcmp(name + len - 5, ".json") != 0) - continue; - if (strstr(name, "i686")) - continue; // skip 32-bit manifests - - if (!icd_matches_vendor(name, vendor_id)) - continue; - - char full[768]; - int n = snprintf(full, sizeof(full), "%s/%s", icd_dirs[d], name); - // snprintf returns the length it would have written even if truncated; - // reject that case so we never memcpy past the end of `full`. - if (n <= 0 || static_cast(n) >= sizeof(full)) - continue; - if (matches_len + static_cast(n) + 2 > sizeof(matches)) - continue; - - if (matches_len > 0) - matches[matches_len++] = ':'; - memcpy(matches + matches_len, full, static_cast(n)); - matches_len += static_cast(n); - } - closedir(dir); - } - - if (matches_len > 0) - { - // VK_DRIVER_FILES is the modern name; VK_ICD_FILENAMES is kept for older loaders. - setenv("VK_DRIVER_FILES", matches, 1); - setenv("VK_ICD_FILENAMES", matches, 1); - } -} - static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) { if (pciAddr == NULL) @@ -502,7 +452,6 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) { PCIAddress pciAddr = parse_bdf_to_pci_addr(bdf); VkGPU vk[MAX_GPU_CARDS]; - restrict_vulkan_icds(vendor_id); int vk_n = vulkan_query(vk, &pciAddr); if (vk_n > 0) diff --git a/src/hwprobe/models/gpu_models.py b/src/hwprobe/models/gpu_models.py index ae7bdfb..5c13220 100644 --- a/src/hwprobe/models/gpu_models.py +++ b/src/hwprobe/models/gpu_models.py @@ -17,6 +17,18 @@ class AppleExtendedGPUInfo(BaseModel): #: GPU Generation gpu_gen: Optional[int] = None + + +class PCIeLinkValue(BaseModel): + """The max/current value distinction for a PCIe Link""" + max: Optional[int] = None + current: Optional[int] = None + + +class PCIeLinkInfo(BaseModel): + """Information about the PCIe Link for this GPU (gen/width)""" + gen: Optional[PCIeLinkValue] = None + width: Optional[PCIeLinkValue] = None class GPUInfo(BaseModel): @@ -47,19 +59,8 @@ class GPUInfo(BaseModel): #: PCI path from the firmware tree, e.g. ``PciRoot(0x0)/Pci(0x1C,0x5)/Pci(0x0,0x0)``. pci_path: Optional[str] = None - #: Number of lanes that the GPU occupies on the PCIe bus. - current_pcie_width: Optional[int] = None - - #: PCIe generation currently reported by the GPU. - current_pcie_gen: Optional[int] = None - - #: Number of lanes that the GPU is rated to occupy on the PCIe bus. - max_pcie_width: Optional[int] = None - - #: PCIe generation that the GPU is rated to support. - #: This may be different from ``current_pcie_gen`` if the GPU is running in - #: a reduced mode, or if the motherboard does not support the full generation. - max_pcie_gen: Optional[int] = None + #: PCIe link information, including max/current generation and width. + pcie_link: Optional[PCIeLinkInfo] = None #: Total VRAM available on the GPU. vram: Optional[StorageSize] = None diff --git a/src/hwprobe/models/info_models.py b/src/hwprobe/models/info_models.py index b8d8dc0..a5b9aef 100644 --- a/src/hwprobe/models/info_models.py +++ b/src/hwprobe/models/info_models.py @@ -18,9 +18,6 @@ class HardwareInfo(BaseModel): storage: Optional[StorageInfo] = None graphics: Optional[GraphicsInfo] = None network: Optional[NetworkInfo] = None - display: Optional[DisplayInfo] = None - audio: Optional[AudioInfo] = None - baseboard: Optional[BaseboardInfo] = None class LinuxHardwareInfo(HardwareInfo): diff --git a/tests/core/common/test_edid.py b/tests/core/common/test_edid.py index a3b99b0..e8891f1 100644 --- a/tests/core/common/test_edid.py +++ b/tests/core/common/test_edid.py @@ -197,6 +197,82 @@ def test_analog_with_timing_gets_resolution(self): assert result.resolution.refresh_rate > 0 +def _build_cta_detailed_timing_descriptor( + width=1920, + height=1080, + h_blank=280, + v_blank=45, + pixel_clock_10khz=14850, +): + """Create a valid 18-byte CTA/EDID detailed timing descriptor.""" + descriptor = bytearray(18) + descriptor[0] = pixel_clock_10khz & 0xFF + descriptor[1] = (pixel_clock_10khz >> 8) & 0xFF + descriptor[2] = width & 0xFF + descriptor[3] = h_blank & 0xFF + descriptor[4] = ((width >> 8) & 0x0F) << 4 | ((h_blank >> 8) & 0x0F) + descriptor[5] = height & 0xFF + descriptor[6] = v_blank & 0xFF + descriptor[7] = ((height >> 8) & 0x0F) << 4 | ((v_blank >> 8) & 0x0F) + return bytes(descriptor) + + +def _build_cta_extension(dtd: bytes) -> bytes: + """Build a CTA-861 extension block with the DTD starting at byte offset 4.""" + ext = bytearray(128) + ext[0] = 0x02 + ext[1] = 0x03 + ext[2] = 0x04 + ext[4 : 4 + len(dtd)] = dtd + return bytes(ext) + + +def _build_displayid_type_i_timing_entry( + width=1920, + height=1080, + h_blank=280, + v_blank=45, + pixel_clock_10khz=14850, +): + """Build a 20-byte DisplayID Type-I timing entry using the actual-1 encoding.""" + entry = bytearray(20) + entry[0:3] = (pixel_clock_10khz - 1).to_bytes(3, byteorder="little") + entry[4:6] = (width - 1).to_bytes(2, byteorder="little") + entry[6:8] = (h_blank - 1).to_bytes(2, byteorder="little") + entry[8:10] = b"\x00\x00" + entry[10:12] = b"\x00\x00" + entry[12:14] = (height - 1).to_bytes(2, byteorder="little") + entry[14:16] = (v_blank - 1).to_bytes(2, byteorder="little") + entry[16:20] = b"\x00\x00\x00\x00" + return bytes(entry) + + +def _build_displayid_extension(payload: bytes, *, section_size: int | None = None, truncated: bool = False) -> bytes: + """Build a DisplayID extension block with a Type-I timing payload. + + If truncated is True, the payload length is longer than the advertised section size + to ensure the parser must stop before overrunning the extension boundary. + """ + ext = bytearray(128) + ext[0] = 0x70 + ext[1] = 0x01 + ext[2] = section_size if section_size is not None else 5 + len(payload) + ext[3] = 0x00 + ext[4] = 0x00 + + if truncated: + ext[5] = 0x03 + ext[6] = 0x01 + ext[7] = 0x20 + else: + ext[5] = 0x03 + ext[6] = 0x01 + ext[7] = len(payload) + ext[8 : 8 + len(payload)] = payload + + return bytes(ext) + + class TestCommonFieldsAcrossVersions: def test_year_parsed(self): edid = _build_edid(year_offset=25) @@ -223,3 +299,47 @@ def test_name_and_serial_both_parsed(self): result = parse_edid(edid) assert result.name == "My Display" assert result.serial_number == "ABC123" + + +class TestEdidExtensionFixtures: + def test_parse_edid_reads_cta_dtd_from_extension(self): + dtd = _build_cta_detailed_timing_descriptor() + cta_ext = _build_cta_extension(dtd) + + edid = bytearray(128 * 2) + edid[0x7E] = 1 + edid[128 : 128 + 128] = cta_ext + + result = parse_edid(bytes(edid)) + + assert result.resolution.width == 1920 + assert result.resolution.height == 1080 + assert result.resolution.refresh_rate == 60.0 + + def test_parse_edid_reads_displayid_type_i_timing(self): + entry = _build_displayid_type_i_timing_entry() + displayid_ext = _build_displayid_extension(entry) + + edid = bytearray(128 * 2) + edid[0x7E] = 1 + edid[128 : 128 + 128] = displayid_ext + + result = parse_edid(bytes(edid)) + + assert result.resolution.width == 1920 + assert result.resolution.height == 1080 + assert result.resolution.refresh_rate == 60.0 + + def test_parse_edid_handles_truncated_displayid_payload(self): + entry = _build_displayid_type_i_timing_entry() + displayid_ext = _build_displayid_extension(entry, section_size=25, truncated=True) + + edid = bytearray(128 * 2) + edid[0x7E] = 1 + edid[128 : 128 + 128] = displayid_ext + + result = parse_edid(bytes(edid)) + + assert result.resolution.width is None + assert result.resolution.height is None + assert result.resolution.refresh_rate is None diff --git a/tests/core/linux/test_common.py b/tests/core/linux/test_common.py index 4e75b06..2c07151 100644 --- a/tests/core/linux/test_common.py +++ b/tests/core/linux/test_common.py @@ -44,7 +44,7 @@ def test_fallback_when_sysfs_has_no_pci(self, monkeypatch): "realpath", lambda _: "/sys/devices/platform/non-pci-device", ) - assert pci_path_linux("0000:03:00.0") == "PciRoot(0x0)/Pci(0x0,0x0)" + assert pci_path_linux("0000:03:00.0") == None @pytest.mark.parametrize("bad_slot", ["", "xyz", ":::"]) def test_invalid_device_slot_returns_none(self, bad_slot, monkeypatch): diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index d7c7504..b299bdc 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -203,6 +203,7 @@ def test_fetch_graphics_info_success_intel(self, monkeypatch): } def custom_open(path, *args, **kwargs): + filename = posixpath.basename(path) filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() @@ -216,7 +217,7 @@ def custom_open(path, *args, **kwargs): info = fetch_graphics_info() - assert info.status.type == StatusType.SUCCESS + assert info.status.type == StatusType.PARTIAL assert len(info.modules) == 1 gpu = info.modules[0] assert gpu.vendor_id == "0x8086" @@ -315,6 +316,7 @@ def mock_run(command, *args, **kwargs): assert gpu.pcie_gen == 4 def test_fetch_graphics_info_skip_non_display(self, monkeypatch): + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:04:00.0"]) @@ -332,10 +334,12 @@ def custom_open(path, *args, **kwargs): assert info.status.type == StatusType.SUCCESS def test_fetch_graphics_info_partial_failure(self, monkeypatch): + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:01:00.0"]) def custom_open(path, *args, **kwargs): + filename = posixpath.basename(path) filename = posixpath.basename(path) if filename == "class": return mock_open(read_data="0x030000")() @@ -343,8 +347,10 @@ def custom_open(path, *args, **kwargs): raise OSError("Permission denied") if filename == "device": return mock_open(read_data="0x1234")() - if filename in {"current_link_width", "current_link_speed", "max_link_width", "max_link_speed"}: + if filename in {"current_link_speed", "max_link_speed"}: return mock_open(read_data="8.0 GT/s")() + if filename in {"current_link_width", "max_link_width"}: + return mock_open(read_data="16")() if filename == "path" and "firmware_node" in path: return mock_open(read_data="\\_SB.PCI0.GFX0")() raise FileNotFoundError(path) @@ -371,8 +377,10 @@ def test_fetch_graphics_info_acpi_path_failure(self, monkeypatch): "max_link_width": "16", "max_link_speed": "8.0 GT/s", } + def custom_open(path, *args, **kwargs): + filename = posixpath.basename(path) filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: raise FileNotFoundError("No ACPI path") @@ -393,6 +401,7 @@ def custom_open(path, *args, **kwargs): assert any("ACPI path" in msg for msg in info.status.messages) def test_fetch_graphics_info_pci_path_failure(self, monkeypatch): + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:00:02.0"]) @@ -532,7 +541,10 @@ def custom_open(path, *args, **kwargs): assert len(info.modules) == 1 gpu = info.modules[0] - assert gpu.current_pcie_gen is None + assert gpu.pcie_link.width.current == 16 + assert gpu.pcie_link.width.max == 16 + assert gpu.pcie_link.gen.max == 3 + assert gpu.pcie_link.gen.current is None assert info.status.type == StatusType.PARTIAL assert any("current link speed" in msg for msg in info.status.messages) From f888a301295f889dbe83b2f9871983d18fcb9489 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Sat, 15 Aug 2026 10:40:15 +0200 Subject: [PATCH 06/22] fix: merge conflicts; update test suite --- src/hwprobe/core/linux/graphics.py | 3 + tests/core/common/test_edid.py | 4 +- tests/core/linux/test_display.py | 12 --- tests/core/linux/test_graphics.py | 152 +++++++++++++++-------------- 4 files changed, 83 insertions(+), 88 deletions(-) diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index a522e69..9d402bc 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -19,6 +19,9 @@ def _pcie_gen(raw_speed: str) -> Optional[int]: # Path example: /sys/bus/pci/devices/0000:03:00.0/max_link_speed + + if not raw_speed: + return None # Mapping Dictionary speed_to_gen = {"2.5 GT/s": 1, "5.0 GT/s": 2, "8.0 GT/s": 3, "16.0 GT/s": 4, "32.0 GT/s": 5, "64.0 GT/s": 6} diff --git a/tests/core/common/test_edid.py b/tests/core/common/test_edid.py index e8891f1..2172b45 100644 --- a/tests/core/common/test_edid.py +++ b/tests/core/common/test_edid.py @@ -1,4 +1,4 @@ - +from typing import Optional from hwprobe.core.common.edid import parse_edid @@ -247,7 +247,7 @@ def _build_displayid_type_i_timing_entry( return bytes(entry) -def _build_displayid_extension(payload: bytes, *, section_size: int | None = None, truncated: bool = False) -> bytes: +def _build_displayid_extension(payload: bytes, *, section_size: Optional[int] = None, truncated: bool = False) -> bytes: """Build a DisplayID extension block with a Type-I timing payload. If truncated is True, the payload length is longer than the advertised section size diff --git a/tests/core/linux/test_display.py b/tests/core/linux/test_display.py index 3c3d3c6..2a1d761 100644 --- a/tests/core/linux/test_display.py +++ b/tests/core/linux/test_display.py @@ -89,18 +89,6 @@ def fake_open(path, *args, **kwargs): class TestFetchDisplayInfo: def test_collects_monitors_from_drm(self, monkeypatch): monkeypatch.setattr(posixpath, "isdir", lambda p: p == "/sys/class/drm") - monkeypatch.setattr( - os, - "listdir", - lambda path: { - "/sys/class/drm": ["card0", "renderD128", "version"], - "/sys/class/drm/card0": ["card0-eDP-1", "card0-HDMI-A-1", "device"], - }.get(path, []), - ) - monkeypatch.setattr( - "hwprobe.core.linux.display._fetch_individual_monitor_info", - lambda path: DisplayModuleInfo(name=posixpath.basename(path)), - ) info = fetch_display_info([]) diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index b299bdc..fa8d195 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -1,8 +1,10 @@ import builtins import os -import subprocess +from pathlib import PosixPath +import posixpath from unittest.mock import mock_open +from hwprobe.core.linux.common import _read_from_sysfs from hwprobe.core.linux.graphics import _check_gpu_class, _pcie_gen, fetch_graphics_info from hwprobe.models.status_models import StatusType @@ -14,7 +16,7 @@ def test_pcie_gen_success_gen4(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -23,14 +25,14 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen == 4 def test_pcie_gen_success_gen3(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -39,14 +41,14 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen == 3 def test_pcie_gen_success_gen2(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -55,14 +57,14 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen == 2 def test_pcie_gen_success_gen1(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -71,14 +73,14 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen == 1 def test_pcie_gen_success_gen5(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -87,14 +89,14 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen == 5 def test_pcie_gen_with_suffix(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -103,14 +105,14 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen == 3 def test_pcie_gen_unknown_speed(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): if file == path: @@ -119,28 +121,30 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen is None def test_pcie_gen_file_not_found(self, monkeypatch): device = "0000:01:00.0" - monkeypatch.setattr(os.path, "exists", lambda x: False) + path = f"/sys/bus/pci/devices/{device}/current_link_speed" + + monkeypatch.setattr(posixpath, "exists", lambda x: False) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen is None def test_pcie_gen_read_exception(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - monkeypatch.setattr(os.path, "exists", lambda x: x == path) + monkeypatch.setattr(posixpath, "exists", lambda x: x == path) def mock_open_func(file, *args, **kwargs): raise OSError("Read error") monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(device) + gen = _pcie_gen(_read_from_sysfs(path)) assert gen is None @@ -223,12 +227,14 @@ def custom_open(path, *args, **kwargs): assert gpu.vendor_id == "0x8086" assert gpu.device_id == "0x5917" assert gpu.acpi_path == "\\_SB.PCI0.GFX0" - assert gpu.manufacturer == "Intel Corporation" - assert gpu.name == "UHD Graphics 620" - assert gpu.pcie_gen == 3 + assert gpu.pcie_link is not None + assert gpu.pcie_link.gen.current == 3 + assert gpu.pcie_link.width.current == 16 + # Uncomment this once PCI-IDs parser is implemented + # assert gpu.manufacturer == "Intel Corporation" - def test_fetch_graphics_info_nvidia(self, monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: True) + def test_fetch_graphics_info_native_nvidia(self, monkeypatch): + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:01:00.0"]) file_contents = { @@ -237,11 +243,13 @@ def test_fetch_graphics_info_nvidia(self, monkeypatch): "device": "0x1c03", "current_link_width": "16", "current_link_speed": "8.0 GT/s", + "max_link_width": "16", + "max_link_speed": "8.0 GT/s", "firmware_node/path": "\\_SB.PCI0.PEG0.PEGP", } def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) + filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -250,27 +258,27 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x1,0x0)") - - def mock_run(command, *args, **kwargs): - if command[0] == "nvidia-smi": - return subprocess.CompletedProcess(command, 0, stdout="GeForce GTX 1060, 16, 3, 6144\n") - if command[0] == "lspci": - output = "Vendor:\tNVIDIA\nDevice:\tGeForce GTX 1060\n" - return subprocess.CompletedProcess(command, 0, stdout=output) - return subprocess.CompletedProcess(command, 1) - - monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) + monkeypatch.setattr( + "hwprobe.core.linux.graphics.native_gpu.get_gpu_info", + lambda *args, **kwargs: type( + "Native", + (), + {"name": "GeForce GTX 1060", "vram_total_mb": 6144, "vram_used_mb": 0}, + )(), + ) info = fetch_graphics_info() assert len(info.modules) == 1 gpu = info.modules[0] assert gpu.vendor_id == "0x10de" + assert gpu.name == "GeForce GTX 1060" assert gpu.vram is not None assert gpu.vram.capacity == 6144 - def test_fetch_graphics_info_amd(self, monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: True) + def test_fetch_graphics_info_native_amd(self, monkeypatch): + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:03:00.0"]) file_contents = { @@ -279,51 +287,49 @@ def test_fetch_graphics_info_amd(self, monkeypatch): "device": "0x731f", "current_link_width": "16", "current_link_speed": "16.0 GT/s", + "max_link_width": "16", + "max_link_speed": "16.0 GT/s", "firmware_node/path": "\\_SB.PCI0.PEG0.PEGP", } def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) + filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: return mock_open(read_data=file_contents[filename])() - if filename == "mem_info_vram_total": - return mock_open(read_data=str(8 * 1024 * 1024 * 1024))() raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x3,0x0)") + monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) monkeypatch.setattr( - "glob.glob", lambda x: ["/sys/bus/pci/devices/0000:03:00.0/drm/card0/device/mem_info_vram_total"] + "hwprobe.core.linux.graphics.native_gpu.get_gpu_info", + lambda *args, **kwargs: type( + "Native", + (), + {"name": "Radeon RX 5700 XT", "vram_total_mb": 8192, "vram_used_mb": 0}, + )(), ) - def mock_run(command, *args, **kwargs): - if command[0] == "lspci": - output = "Vendor:\tAMD\nDevice:\tRadeon RX 5700 XT\n" - return subprocess.CompletedProcess(command, 0, stdout=output) - return subprocess.CompletedProcess(command, 1) - - monkeypatch.setattr(subprocess, "run", mock_run) - info = fetch_graphics_info() assert len(info.modules) == 1 gpu = info.modules[0] assert gpu.vendor_id == "0x1002" + assert gpu.name == "Radeon RX 5700 XT" assert gpu.vram is not None assert gpu.vram.capacity == 8192 - assert gpu.pcie_gen == 4 + assert gpu.pcie_link is not None + assert gpu.pcie_link.gen.current == 4 def test_fetch_graphics_info_skip_non_display(self, monkeypatch): - monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:04:00.0"]) def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) - if filename in file_contents: - return mock_open(read_data=file_contents[filename])() + if "class" in path: + return mock_open(read_data="0x020000")() raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) @@ -416,7 +422,7 @@ def test_fetch_graphics_info_pci_path_failure(self, monkeypatch): } def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) + filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -435,7 +441,7 @@ def custom_open(path, *args, **kwargs): assert any("PCI path" in msg for msg in info.status.messages) def test_fetch_graphics_info_nvidia_failure(self, monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: True) + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:01:00.0"]) file_contents = { @@ -449,7 +455,7 @@ def test_fetch_graphics_info_nvidia_failure(self, monkeypatch): } def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) + filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data=file_contents["firmware_node/path"])() if filename in file_contents: @@ -477,8 +483,8 @@ def custom_open(path, *args, **kwargs): assert gpu.vram is not None assert gpu.vram.capacity == 6144 - def test_fetch_graphics_info_lspci_failure(self, monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: True) + def test_fetch_graphics_info_native_failure(self, monkeypatch): + monkeypatch.setattr(posixpath, "exists", lambda x: True) monkeypatch.setattr(os, "listdir", lambda x: ["0000:00:02.0"]) file_contents = { @@ -487,26 +493,22 @@ def test_fetch_graphics_info_lspci_failure(self, monkeypatch): "device": "0x5917", "current_link_width": "16", "max_link_width": "16", + "current_link_speed": "8.0 GT/s", "max_link_speed": "8.0 GT/s", } def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) + filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: - return mock_open(read_data=file_contents["firmware_node/path"])() + return mock_open(read_data="\\_SB.PCI0.GFX0")() if filename in file_contents: return mock_open(read_data=file_contents[filename])() raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) monkeypatch.setattr("hwprobe.core.linux.graphics.pci_path_linux", lambda x: "PciRoot(0x0)/Pci(0x2,0x0)") - - def mock_run(command, *args, **kwargs): - if command[0] == "lspci": - raise FileNotFoundError("lspci not found") - return subprocess.CompletedProcess(command, 0, stdout="") - - monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) + monkeypatch.setattr("hwprobe.core.linux.graphics.native_gpu.get_gpu_info", lambda *args, **kwargs: None) info = fetch_graphics_info() @@ -514,10 +516,10 @@ def mock_run(command, *args, **kwargs): gpu = info.modules[0] assert gpu.vendor_id == "0x8086" assert info.status.type == StatusType.PARTIAL - assert any("LSPCI" in msg for msg in info.status.messages) + assert any("Native GPU info library could not fetch GPU name or VRAM" in msg for msg in info.status.messages) def test_fetch_graphics_info_pcie_gen_failure(self, monkeypatch): - monkeypatch.setattr(os.path, "exists", lambda x: "/current_link_speed" not in x) + monkeypatch.setattr(posixpath, "exists", lambda x: "/current_link_speed" not in str(x)) monkeypatch.setattr(os, "listdir", lambda x: ["0000:00:02.0"]) file_contents = { @@ -529,9 +531,11 @@ def test_fetch_graphics_info_pcie_gen_failure(self, monkeypatch): } def custom_open(path, *args, **kwargs): - filename = os.path.basename(path) + filename = posixpath.basename(path) if filename == "path" and "firmware_node" in path: return mock_open(read_data="\\_SB.PCI0.GFX0")() + if filename in file_contents: + return mock_open(read_data=file_contents[filename])() raise FileNotFoundError(path) monkeypatch.setattr(builtins, "open", custom_open) @@ -541,10 +545,10 @@ def custom_open(path, *args, **kwargs): assert len(info.modules) == 1 gpu = info.modules[0] - assert gpu.pcie_link.width.current == 16 - assert gpu.pcie_link.width.max == 16 - assert gpu.pcie_link.gen.max == 3 + assert gpu.pcie_link is not None + assert gpu.pcie_link.width.current in (0, "0") assert gpu.pcie_link.gen.current is None + assert gpu.pcie_link.gen.max in (None, 0) assert info.status.type == StatusType.PARTIAL assert any("current link speed" in msg for msg in info.status.messages) From 9dd2c8ff16d9c64b865f11161adb0823cfd9f4bb Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Sat, 15 Aug 2026 10:54:35 +0200 Subject: [PATCH 07/22] fix: windows tests failing due to breaking changes --- src/hwprobe/core/common/pcie_link.py | 8 ++++---- src/hwprobe/core/linux/common.py | 5 +++++ src/hwprobe/core/windows/graphics.py | 4 ++-- tests/core/windows/test_graphics.py | 7 +++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/hwprobe/core/common/pcie_link.py b/src/hwprobe/core/common/pcie_link.py index 72ca836..0e5851d 100644 --- a/src/hwprobe/core/common/pcie_link.py +++ b/src/hwprobe/core/common/pcie_link.py @@ -5,10 +5,10 @@ def build_pcie_link( *, - max_gen: int, - current_gen: int, - max_width: int, - current_width: int + max_gen: Optional[int] = None, + current_gen: Optional[int] = None, + max_width: Optional[int] = None, + current_width: Optional[int] = None, ) -> Optional[PCIeLinkInfo]: gen = PCIeLinkValue() width = PCIeLinkValue() diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index e6977d1..9322294 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -69,6 +69,11 @@ def pci_path_linux(device_slot: str): pci_segments = [] for part in path.split(posixpath.sep): + """ + The way Linux represents PCI devices in sysfs is according to their BDF (Bus:Device.Function) notation. + The root bridge is represented as "pci:", and each subsequent device is represented as "::
.". + """ + if part.startswith("pci"): try: root_bus = part.split(":")[0].split("pci")[-1] diff --git a/src/hwprobe/core/windows/graphics.py b/src/hwprobe/core/windows/graphics.py index 3f92380..1bf21af 100644 --- a/src/hwprobe/core/windows/graphics.py +++ b/src/hwprobe/core/windows/graphics.py @@ -1,3 +1,4 @@ +from hwprobe.core.common.pcie_link import build_pcie_link 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 @@ -36,8 +37,7 @@ def _map_gpu(raw: GPURaw) -> GPUInfo: pcie = fetch_pcie_info(raw.pnp_device_id) if pcie: speed, width = pcie - gpu.pcie_link.gen.current = speed - gpu.pcie_link.width.current = width + gpu.pcie_link = build_pcie_link(current_gen=speed, current_width=width) # VRAM: registry fallback wins if present, else DXGI value vram_bytes = raw.vram_bytes or raw.dedicated_video_memory_bytes diff --git a/tests/core/windows/test_graphics.py b/tests/core/windows/test_graphics.py index 7b9b5e7..cf82db6 100644 --- a/tests/core/windows/test_graphics.py +++ b/tests/core/windows/test_graphics.py @@ -172,8 +172,8 @@ def test_vram_registry_fallback_wins(self): def test_pcie_fields_populated(self): info = _run([_gpu()], pcie=(4, 16)) gpu = info.modules[0] - assert gpu.pcie_gen == 4 - assert gpu.pcie_width == 16 + assert gpu.pcie_link.gen.current == 4 + assert gpu.pcie_link.width.current == 16 def test_acpi_and_pci_paths_populated(self): info = _run([_gpu()]) @@ -223,8 +223,7 @@ def test_zero_vram_results_in_none(self): def test_none_pcie_returns_none(self): info = _run([_gpu()], pcie=None) - assert info.modules[0].pcie_gen is None - assert info.modules[0].pcie_width is None + assert info.modules[0].pcie_link is None def test_no_pnp_device_id_skips_location_lookup(self): info = _run([_gpu(pnp_device_id=None)]) From 2db101ccd5abc6d76ec2758b44ea0228ad02b03f Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Sat, 15 Aug 2026 11:18:44 +0200 Subject: [PATCH 08/22] fix: revert PCIe Link changes, will be done in separate PR --- src/hwprobe/core/common/pcie_link.py | 31 ---------------------------- src/hwprobe/core/linux/graphics.py | 25 ++++------------------ src/hwprobe/core/windows/graphics.py | 4 ++-- src/hwprobe/models/gpu_models.py | 19 +++++------------ tests/core/linux/test_graphics.py | 14 +++++-------- tests/core/windows/test_graphics.py | 7 ++++--- 6 files changed, 20 insertions(+), 80 deletions(-) delete mode 100644 src/hwprobe/core/common/pcie_link.py diff --git a/src/hwprobe/core/common/pcie_link.py b/src/hwprobe/core/common/pcie_link.py deleted file mode 100644 index 0e5851d..0000000 --- a/src/hwprobe/core/common/pcie_link.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Optional - -from hwprobe.models.gpu_models import PCIeLinkInfo, PCIeLinkValue - - -def build_pcie_link( - *, - max_gen: Optional[int] = None, - current_gen: Optional[int] = None, - max_width: Optional[int] = None, - current_width: Optional[int] = None, -) -> Optional[PCIeLinkInfo]: - gen = PCIeLinkValue() - width = PCIeLinkValue() - - if max_gen: - gen.max = max_gen - - if current_gen: - gen.current = current_gen - - if max_width: - width.max = max_width - - if current_width: - width.current = current_width - - if max_gen == 0 and current_gen == 0 and max_width == 0 and current_width == 0: - return None - - return PCIeLinkInfo(gen=gen, width=width) \ No newline at end of file diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index 9d402bc..b4bd8db 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -1,7 +1,6 @@ import os import posixpath from typing import Optional -from hwprobe.core.common.pcie_link import build_pcie_link from hwprobe.core.linux.common import PCI_ROOT_PATH, _read_from_sysfs, pci_path_linux, _resolve_acpi_path from hwprobe.models.gpu_models import GPUInfo, GraphicsInfo from hwprobe.models.size_models import Megabyte @@ -82,13 +81,6 @@ def fetch_graphics_info() -> GraphicsInfo: else: graphics_info.status.make_partial(f"Could not read current link width for {device}") - if (max_width := _read_from_sysfs(gpu_path, "max_link_width")) is not None: - if max_width.isnumeric() and int(max_width) > 0: - max_width = int(max_width) - else: - graphics_info.status.make_partial(f"Could not read max link width for {device}") - - if (cur_pcie_speed := _read_from_sysfs(gpu_path, "current_link_speed")) is not None: if cur_pcie_speed: cur_pcie_speed = _pcie_gen(cur_pcie_speed) @@ -96,13 +88,6 @@ def fetch_graphics_info() -> GraphicsInfo: graphics_info.status.make_partial(f"Could not read current link speed for {device}") - if (max_pcie_speed := _read_from_sysfs(gpu_path, "max_link_speed")) is not None: - if max_pcie_speed: - max_pcie_speed = _pcie_gen(max_pcie_speed) - else: - graphics_info.status.make_partial(f"Could not read max link speed for {device}") - - acpi_path, result = _resolve_acpi_path(device) if acpi_path is not None: gpu.acpi_path = acpi_path @@ -131,13 +116,11 @@ def fetch_graphics_info() -> GraphicsInfo: else: graphics_info.status.make_partial(f"Native GPU info library returned VRAM size of 0 for {device} with vendor ID {vendor_id}") - gpu.pcie_link = build_pcie_link( - max_gen=max_pcie_speed or 0, - current_gen=cur_pcie_speed or 0, - max_width=max_width or 0, - current_width=cur_width or 0 - ) + if isinstance(cur_width, int): + gpu.pcie_width = cur_width + if cur_pcie_speed: + gpu.pcie_gen = cur_pcie_speed graphics_info.modules.append(gpu) diff --git a/src/hwprobe/core/windows/graphics.py b/src/hwprobe/core/windows/graphics.py index 1bf21af..874c363 100644 --- a/src/hwprobe/core/windows/graphics.py +++ b/src/hwprobe/core/windows/graphics.py @@ -1,4 +1,3 @@ -from hwprobe.core.common.pcie_link import build_pcie_link 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 @@ -37,7 +36,8 @@ def _map_gpu(raw: GPURaw) -> GPUInfo: pcie = fetch_pcie_info(raw.pnp_device_id) if pcie: speed, width = pcie - gpu.pcie_link = build_pcie_link(current_gen=speed, current_width=width) + 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 diff --git a/src/hwprobe/models/gpu_models.py b/src/hwprobe/models/gpu_models.py index 5c13220..c12a66a 100644 --- a/src/hwprobe/models/gpu_models.py +++ b/src/hwprobe/models/gpu_models.py @@ -17,18 +17,6 @@ class AppleExtendedGPUInfo(BaseModel): #: GPU Generation gpu_gen: Optional[int] = None - - -class PCIeLinkValue(BaseModel): - """The max/current value distinction for a PCIe Link""" - max: Optional[int] = None - current: Optional[int] = None - - -class PCIeLinkInfo(BaseModel): - """Information about the PCIe Link for this GPU (gen/width)""" - gen: Optional[PCIeLinkValue] = None - width: Optional[PCIeLinkValue] = None class GPUInfo(BaseModel): @@ -59,8 +47,11 @@ class GPUInfo(BaseModel): #: PCI path from the firmware tree, e.g. ``PciRoot(0x0)/Pci(0x1C,0x5)/Pci(0x0,0x0)``. pci_path: Optional[str] = None - #: PCIe link information, including max/current generation and width. - pcie_link: Optional[PCIeLinkInfo] = None + #: Number of lanes that the GPU occupies on the PCIe bus. + pcie_width: Optional[int] = None + + #: PCIe generation supported by the GPU. + pcie_gen: Optional[int] = None #: Total VRAM available on the GPU. vram: Optional[StorageSize] = None diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index fa8d195..7861209 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -227,9 +227,8 @@ def custom_open(path, *args, **kwargs): assert gpu.vendor_id == "0x8086" assert gpu.device_id == "0x5917" assert gpu.acpi_path == "\\_SB.PCI0.GFX0" - assert gpu.pcie_link is not None - assert gpu.pcie_link.gen.current == 3 - assert gpu.pcie_link.width.current == 16 + assert gpu.pcie_gen == 3 + assert gpu.pcie_width == 16 # Uncomment this once PCI-IDs parser is implemented # assert gpu.manufacturer == "Intel Corporation" @@ -320,8 +319,7 @@ def custom_open(path, *args, **kwargs): assert gpu.name == "Radeon RX 5700 XT" assert gpu.vram is not None assert gpu.vram.capacity == 8192 - assert gpu.pcie_link is not None - assert gpu.pcie_link.gen.current == 4 + assert gpu.pcie_gen == 4 def test_fetch_graphics_info_skip_non_display(self, monkeypatch): monkeypatch.setattr(posixpath, "exists", lambda x: True) @@ -545,10 +543,8 @@ def custom_open(path, *args, **kwargs): assert len(info.modules) == 1 gpu = info.modules[0] - assert gpu.pcie_link is not None - assert gpu.pcie_link.width.current in (0, "0") - assert gpu.pcie_link.gen.current is None - assert gpu.pcie_link.gen.max in (None, 0) + assert gpu.pcie_width is None + assert gpu.pcie_gen is None assert info.status.type == StatusType.PARTIAL assert any("current link speed" in msg for msg in info.status.messages) diff --git a/tests/core/windows/test_graphics.py b/tests/core/windows/test_graphics.py index cf82db6..7b9b5e7 100644 --- a/tests/core/windows/test_graphics.py +++ b/tests/core/windows/test_graphics.py @@ -172,8 +172,8 @@ def test_vram_registry_fallback_wins(self): def test_pcie_fields_populated(self): info = _run([_gpu()], pcie=(4, 16)) gpu = info.modules[0] - assert gpu.pcie_link.gen.current == 4 - assert gpu.pcie_link.width.current == 16 + assert gpu.pcie_gen == 4 + assert gpu.pcie_width == 16 def test_acpi_and_pci_paths_populated(self): info = _run([_gpu()]) @@ -223,7 +223,8 @@ def test_zero_vram_results_in_none(self): def test_none_pcie_returns_none(self): info = _run([_gpu()], pcie=None) - assert info.modules[0].pcie_link is None + assert info.modules[0].pcie_gen is None + assert info.modules[0].pcie_width is None def test_no_pnp_device_id_skips_location_lookup(self): info = _run([_gpu(pnp_device_id=None)]) From 713e074421463b60018b5f19830d8b1e46debfa3 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Sat, 15 Aug 2026 11:46:21 +0200 Subject: [PATCH 09/22] fix(linux): unit tests --- tests/core/linux/test_display.py | 13 +++++++ tests/core/linux/test_graphics.py | 56 +++++++++++++++++-------------- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/tests/core/linux/test_display.py b/tests/core/linux/test_display.py index 2a1d761..09dacf1 100644 --- a/tests/core/linux/test_display.py +++ b/tests/core/linux/test_display.py @@ -89,6 +89,19 @@ def fake_open(path, *args, **kwargs): class TestFetchDisplayInfo: def test_collects_monitors_from_drm(self, monkeypatch): monkeypatch.setattr(posixpath, "isdir", lambda p: p == "/sys/class/drm") + monkeypatch.setattr( + os, + "listdir", + lambda path: { + "/sys/class/drm": ["card0", "card1"], + "/sys/class/drm/card0": ["card0-eDP-1"], + "/sys/class/drm/card1": ["card1-HDMI-A-1"], + }.get(path, []), + ) + monkeypatch.setattr( + "hwprobe.core.linux.display._fetch_individual_monitor_info", + lambda path, gpu_devices: DisplayModuleInfo(name=posixpath.basename(path)), + ) info = fetch_display_info([]) diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index 7861209..73941b9 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -1,6 +1,6 @@ import builtins import os -from pathlib import PosixPath +import pytest import posixpath from unittest.mock import mock_open @@ -8,6 +8,23 @@ from hwprobe.core.linux.graphics import _check_gpu_class, _pcie_gen, fetch_graphics_info from hwprobe.models.status_models import StatusType +@pytest.fixture +def mock_pci_device(tmp_path, monkeypatch): + + pci_root = tmp_path / "sys" / "bus" / "pci" / "devices" + pci_root.mkdir(parents=True) + + monkeypatch.setattr( + "hwprobe.core.linux.graphics.PCI_ROOT_PATH", + pci_root, + ) + + def _create(bdf: str): + device_path = pci_root / bdf + device_path.mkdir(parents=True, exist_ok=True) + return device_path + + return _create class TestPcieGen: """Tests for _pcie_gen.""" @@ -147,36 +164,25 @@ def mock_open_func(file, *args, **kwargs): gen = _pcie_gen(_read_from_sysfs(path)) assert gen is None - +@pytest.mark.parametrize("bdf", ["0000:01:00.0"]) class TestCheckGpuClass: """Tests for _check_gpu_class.""" - def test_check_gpu_class_display_controller(self, monkeypatch): - def mock_open_func(file, *args, **kwargs): - if "class" in file: - return mock_open(read_data="0x030000")() - raise FileNotFoundError(file) - - monkeypatch.setattr(builtins, "open", mock_open_func) - assert _check_gpu_class("0000:01:00.0") is True - - def test_check_gpu_class_vga_controller(self, monkeypatch): - def mock_open_func(file, *args, **kwargs): - if "class" in file: - return mock_open(read_data="0x030200")() - raise FileNotFoundError(file) + def test_check_gpu_class_display_controller(self, mock_pci_device, bdf, monkeypatch): + class_path = mock_pci_device(bdf) / "class" + class_path.write_text("0x030000") + assert _check_gpu_class(bdf) is True - monkeypatch.setattr(builtins, "open", mock_open_func) - assert _check_gpu_class("0000:01:00.0") is True - def test_check_gpu_class_network_controller(self, monkeypatch): - def mock_open_func(file, *args, **kwargs): - if "class" in file: - return mock_open(read_data="0x020000")() - raise FileNotFoundError(file) + def test_check_gpu_class_vga_controller(self, mock_pci_device, bdf, monkeypatch): + class_path = mock_pci_device(bdf) / "class" + class_path.write_text("0x030200") + assert _check_gpu_class(bdf) is True - monkeypatch.setattr(builtins, "open", mock_open_func) - assert _check_gpu_class("0000:01:00.0") is False + def test_check_gpu_class_network_controller(self, mock_pci_device, bdf, monkeypatch): + class_path = mock_pci_device(bdf) / "class" + class_path.write_text("0x020000") + assert _check_gpu_class(bdf) is False class TestFetchGraphicsInfo: From b692ded3a1d26bbb23de259106eeda8a1cc6a667 Mon Sep 17 00:00:00 2001 From: Mahasvan Date: Sun, 16 Aug 2026 13:41:00 +0530 Subject: [PATCH 10/22] test: fix Linux graphics tests --- tests/core/linux/test_graphics.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index 73941b9..8a90060 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -168,21 +168,20 @@ def mock_open_func(file, *args, **kwargs): class TestCheckGpuClass: """Tests for _check_gpu_class.""" - def test_check_gpu_class_display_controller(self, mock_pci_device, bdf, monkeypatch): - class_path = mock_pci_device(bdf) / "class" - class_path.write_text("0x030000") - assert _check_gpu_class(bdf) is True - - - def test_check_gpu_class_vga_controller(self, mock_pci_device, bdf, monkeypatch): - class_path = mock_pci_device(bdf) / "class" - class_path.write_text("0x030200") - assert _check_gpu_class(bdf) is True - - def test_check_gpu_class_network_controller(self, mock_pci_device, bdf, monkeypatch): - class_path = mock_pci_device(bdf) / "class" - class_path.write_text("0x020000") - assert _check_gpu_class(bdf) is False + @pytest.mark.parametrize( + ("device_class", "expected"), + [ + ("0x030000", True), + ("0x030200", True), + ("0x020000", False), + ], + ) + def test_check_gpu_class(self, bdf, monkeypatch, device_class, expected): + monkeypatch.setattr( + "hwprobe.core.linux.graphics._read_from_sysfs", + lambda *args: device_class, + ) + assert _check_gpu_class(bdf) is expected class TestFetchGraphicsInfo: From cf0a74cfbd63989bdb2e8728ea2274d9760a105a Mon Sep 17 00:00:00 2001 From: Mahasvan Date: Sun, 16 Aug 2026 13:46:08 +0530 Subject: [PATCH 11/22] test: remove dead code --- tests/core/linux/test_graphics.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index 8a90060..71f27f3 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -8,24 +8,6 @@ from hwprobe.core.linux.graphics import _check_gpu_class, _pcie_gen, fetch_graphics_info from hwprobe.models.status_models import StatusType -@pytest.fixture -def mock_pci_device(tmp_path, monkeypatch): - - pci_root = tmp_path / "sys" / "bus" / "pci" / "devices" - pci_root.mkdir(parents=True) - - monkeypatch.setattr( - "hwprobe.core.linux.graphics.PCI_ROOT_PATH", - pci_root, - ) - - def _create(bdf: str): - device_path = pci_root / bdf - device_path.mkdir(parents=True, exist_ok=True) - return device_path - - return _create - class TestPcieGen: """Tests for _pcie_gen.""" From 6acf16836a807c4a59c91e99f0bbd43fa8a4cdd8 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Mon, 17 Aug 2026 17:06:17 +0200 Subject: [PATCH 12/22] fix(linux): a few `ty` related errors/warnings --- tests/core/linux/test_cpu.py | 3 ++ tests/core/linux/test_graphics.py | 49 ++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/tests/core/linux/test_cpu.py b/tests/core/linux/test_cpu.py index 21093c7..aed394f 100644 --- a/tests/core/linux/test_cpu.py +++ b/tests/core/linux/test_cpu.py @@ -145,6 +145,8 @@ class TestX86Flags: def test_x86_flags_sse_variants(self): cpu_lines = "flags\t\t: sse sse2 sse3 ssse3 sse4_1 sse4_2\n" flags = _x86_flags(cpu_lines) + + assert flags is not None assert "SSE" in flags assert "SSE2" in flags assert "SSE3" in flags @@ -155,6 +157,7 @@ def test_x86_flags_sse_variants(self): def test_x86_flags_with_lm(self): cpu_lines = "flags\t\t: sse lm\n" flags = _x86_flags(cpu_lines) + assert flags is not None assert "LM" in flags def test_x86_flags_missing(self): diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index 71f27f3..d9c8222 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -24,7 +24,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen == 4 def test_pcie_gen_success_gen3(self, monkeypatch): @@ -40,7 +43,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen == 3 def test_pcie_gen_success_gen2(self, monkeypatch): @@ -56,7 +62,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen == 2 def test_pcie_gen_success_gen1(self, monkeypatch): @@ -72,7 +81,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen == 1 def test_pcie_gen_success_gen5(self, monkeypatch): @@ -88,7 +100,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen == 5 def test_pcie_gen_with_suffix(self, monkeypatch): @@ -104,7 +119,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen == 3 def test_pcie_gen_unknown_speed(self, monkeypatch): @@ -120,16 +138,22 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is not None + + gen = _pcie_gen(speed) assert gen is None def test_pcie_gen_file_not_found(self, monkeypatch): device = "0000:01:00.0" path = f"/sys/bus/pci/devices/{device}/current_link_speed" - + monkeypatch.setattr(posixpath, "exists", lambda x: False) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is None + + gen = _pcie_gen(speed) assert gen is None def test_pcie_gen_read_exception(self, monkeypatch): @@ -143,7 +167,10 @@ def mock_open_func(file, *args, **kwargs): monkeypatch.setattr(builtins, "open", mock_open_func) - gen = _pcie_gen(_read_from_sysfs(path)) + speed = _read_from_sysfs(path) + assert speed is None + + gen = _pcie_gen(speed) assert gen is None @pytest.mark.parametrize("bdf", ["0000:01:00.0"]) @@ -368,7 +395,7 @@ def test_fetch_graphics_info_acpi_path_failure(self, monkeypatch): "max_link_width": "16", "max_link_speed": "8.0 GT/s", } - + def custom_open(path, *args, **kwargs): filename = posixpath.basename(path) From e7eab2d5eec2b4cd5d90cd11bcca0f000715941d Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Mon, 17 Aug 2026 17:06:31 +0200 Subject: [PATCH 13/22] update(linux): add unit test for PCI bus device --- tests/core/linux/test_common.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/core/linux/test_common.py b/tests/core/linux/test_common.py index 2c07151..9013d50 100644 --- a/tests/core/linux/test_common.py +++ b/tests/core/linux/test_common.py @@ -4,7 +4,6 @@ from hwprobe.core.linux.common import pci_path_linux - class TestPciPathLinux: def test_single_device(self, monkeypatch): monkeypatch.setattr( @@ -38,6 +37,14 @@ def test_non_zero_domain(self, monkeypatch): ) assert pci_path_linux("0001:00:00.0") == "PciRoot(0x1)/Pci(0x0,0x0)" + def test_bus_device(self, monkeypatch): + monkeypatch.setattr( + posixpath, + "realpath", + lambda _: "/sys/devices/platform/bus@0/14100000.pcie/pci0001:00/0001:00:00.0" + ) + assert pci_path_linux("0001:00:00.0") == "PciRoot(0x1)/Pci(0x0,0x0)" + def test_fallback_when_sysfs_has_no_pci(self, monkeypatch): monkeypatch.setattr( posixpath, From c54a075828e9a29ffa48b60fbdc3ac79d269f2b7 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Mon, 17 Aug 2026 17:07:15 +0200 Subject: [PATCH 14/22] fix(linux): `_pcie_gen` now properly annotates IN parameter as `Optional[str]` since `_read_from_sysfs` can return None --- src/hwprobe/core/linux/graphics.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index b4bd8db..a5737fc 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -16,9 +16,9 @@ DISPLAY_CONTROLLER_CLASS = 0x03 # Display Controller class code in PCI -def _pcie_gen(raw_speed: str) -> Optional[int]: +def _pcie_gen(raw_speed: Optional[str]) -> Optional[int]: # Path example: /sys/bus/pci/devices/0000:03:00.0/max_link_speed - + if not raw_speed: return None @@ -91,7 +91,7 @@ def fetch_graphics_info() -> GraphicsInfo: acpi_path, result = _resolve_acpi_path(device) if acpi_path is not None: gpu.acpi_path = acpi_path - + if not result: graphics_info.status.messages.append(f"ACPI path for {device} was inferred through parent device, device itself was likely found via PCI enumeration") else: @@ -102,7 +102,7 @@ def fetch_graphics_info() -> GraphicsInfo: gpu.pci_path = pci_path else: graphics_info.status.make_partial(f"Could not resolve PCI path for {device}") - + if not NATIVE_AVAILABLE: graphics_info.status.make_partial(f"Native GPU info library not available, cannot fetch GPU name or VRAM for {device}") elif vendor_id is None: From 8a241c82309dbc2710ba90bbd1cbbb2b2f205720 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Mon, 17 Aug 2026 17:07:39 +0200 Subject: [PATCH 15/22] refactor(linux): `pci_path_linux` now uses regex matching to grab PCI root/function segments, added comment to elaborate --- src/hwprobe/core/linux/common.py | 93 +++++++++++++++++++------------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index 9322294..9d5eae8 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -1,11 +1,10 @@ import posixpath import re from typing import Optional -from pathlib import Path -from enum import Enum PCI_ROOT_PATH = "/sys/bus/pci/devices/" _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") +_PCI_BDF_PATH_PATTERN = re.compile(r"(pci([0-9a-fA-F]{4}):([0-9a-fA-F]{2}))|(([0-9a-fA-F]{4}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})\.([0-9a-fA-F]{1}))") def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], bool]: """ @@ -14,7 +13,7 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], bool]: :param device_bdf: The BDF identifier (Bus:Device.Function) of the device :return: The resolved ACPI path if found, else None. - + The way this function works is: 1. It first checks if the device has a direct ACPI path in its sysfs entry. 2. If not, it checks the parent devices recursively until it finds an ACPI path or reaches the root. @@ -27,21 +26,21 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], bool]: acpi_path = _read_from_sysfs(device_path, "firmware_node", "path") if acpi_path: return acpi_path, True - + device_path = posixpath.realpath(device_path) - + # Parent directory should be something like RRRR:BB:DD.F try: while (acpi_path := _read_from_sysfs(device_path, "firmware_node", "path")) is None: if device_path == posixpath.dirname(device_path): # We've reached the root of the filesystem without finding an ACPI path return ret_val - + device_path = posixpath.dirname(device_path) - + except Exception: return ret_val - + # Device has no ACPI path, it is found via PCI enumeration # Return parent ACPI path instead. return acpi_path, False @@ -50,52 +49,72 @@ def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], bool]: # Linux implemented this very annoyingly # - https://tldp.org/LDP/tlk/dd/pci.html # - https://wiki.osdev.org/PCI -def pci_path_linux(device_slot: str): +def pci_path_linux(device_slot: str) -> Optional[str]: """ :param device_slot: format: ::. - :return: PCI path, e.g. PciRoot(0x0)/Pci(0x2,0x0) + :return: UEFI-style PCI path, e.g. PciRoot(0x0)/Pci(0x2,0x0) """ # Invalid fallback value if not device_slot or not _PCI_BDF_PATTERN.match(device_slot): return None - + raw_path = f"/sys/bus/pci/devices/{device_slot}/" path = posixpath.realpath(raw_path) if not path: return None - + pci_root = "" pci_segments = [] - - for part in path.split(posixpath.sep): - """ - The way Linux represents PCI devices in sysfs is according to their BDF (Bus:Device.Function) notation. - The root bridge is represented as "pci:", and each subsequent device is represented as "::
.". - """ - - if part.startswith("pci"): - try: - root_bus = part.split(":")[0].split("pci")[-1] - pci_root = f"PciRoot(0x{int(root_bus, 16):x})" - except (ValueError, IndexError): - return None - - elif ":" in part and "." in part: # Only the root bridge does not contain a function number - try: - bdf_segments = part.split(":")[-1] - dev, func = bdf_segments.split(".") - - pci_segments.append(f"Pci(0x{int(dev, 16):x},0x{int(func, 16):x})") - except (ValueError, IndexError) as e: - print(f"Error parsing PCI device/function from {part}: {e}") - return None + + r""" + On Linux, PCI devices are described directly in their sysfs path structure. + I.e: /sys/devices////... + + We are trying to extract the PCI root value, and the PCI function segments from this path. + This is done by finding all matches of the PCI BDF PATH PATTERN in the path, + and using the match groups to determine if it's a root or segment path. + + The base regex contains two distinct capture groups: + pci([0-9a-fA-F]{4}):([0-9a-fA-F]{2})) + ([0-9a-fA-F]{4}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})\.([0-9a-fA-F]{1})) + + This will then allow us to determine if a match is a root or segment path + by checking match[0], if it's an empty string: it's a segment path, otherwise it's a root path. + + Given a path: '/sys/devices/pci0000:00/0000:00:03.1/0000:09:00.0' + + This would make the PCI hierarchy: PciRoot(0x0)/Pci(0x3,0x1)/Pci(0x0,0x0) + + Then, when matching, we'd get a result like so: + matches = [ + ('pci0000:00', '0000', '00', '', '', '', '', ''), + ('', '', '', '0000:00:03.1', '0000', '00', '03', '1'), + ('', '', '', '0000:09:00.0', '0000', '09', '00', '0')P + ] + + Structure of a single match: + + PCI Root PCI function (segment) + _ _ _ _ _|_ _ _ _ _ _ _ _ _ _ _ _ | _ _ _ _ _ _ _ + | | | | | | | | + ( 'pciXXXX:YY', 'XXXX', 'YY', 'XXXX:YY:ZZ.F', 'XXXX', 'YY', 'ZZ', 'F' ) + """ + matches = _PCI_BDF_PATH_PATTERN.findall(path) + for match in matches: + if not match: + continue + + if match[0]: + pci_root = f"PciRoot(0x{int(match[1], 16):x})" + elif match[4]: + pci_segments.append(f"Pci(0x{int(match[6], 16):x},0x{int(match[7], 16):x})") if not pci_root or not pci_segments: return None return f"{pci_root}/{'/'.join(pci_segments)}" - + def _read_from_sysfs(base: str, *paths) -> Optional[str]: """Read a string from a sysfs file, return None if not found.""" @@ -103,7 +122,7 @@ def _read_from_sysfs(base: str, *paths) -> Optional[str]: if not posixpath.exists(path): return None - + try: with open(path) as f: return f.read().strip() From 90429c33976add7f2f96d087995279980c1e114d Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Mon, 17 Aug 2026 18:57:55 +0200 Subject: [PATCH 16/22] fix(linux): simplify `_PCI_BDF_PATH_PATTERN` regex, and ensure it's case-insensitive --- src/hwprobe/core/linux/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hwprobe/core/linux/common.py b/src/hwprobe/core/linux/common.py index 9d5eae8..a7d8bf3 100644 --- a/src/hwprobe/core/linux/common.py +++ b/src/hwprobe/core/linux/common.py @@ -4,7 +4,7 @@ PCI_ROOT_PATH = "/sys/bus/pci/devices/" _PCI_BDF_PATTERN = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$") -_PCI_BDF_PATH_PATTERN = re.compile(r"(pci([0-9a-fA-F]{4}):([0-9a-fA-F]{2}))|(([0-9a-fA-F]{4}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})\.([0-9a-fA-F]{1}))") +_PCI_BDF_PATH_PATTERN = re.compile(r"(pci([a-f\d]{4}):([a-f\d]{2}))|(([a-f\d]{4}):([a-f\d]{2}):([a-f\d]{2})\.([a-f\d]{1}))", re.IGNORECASE) def _resolve_acpi_path(device_bdf: str) -> tuple[Optional[str], bool]: """ From e7879cff10f69efcd2abe774c1e120e0274af762 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Tue, 18 Aug 2026 06:37:30 +0200 Subject: [PATCH 17/22] fix(linux): PR fixes #2 --- MANIFEST.in | 5 +- pyproject.toml | 10 +- src/hwprobe/core/linux/display.py | 11 ++- src/hwprobe/core/linux/manager.py | 7 +- src/hwprobe/interops/linux/README.md | 3 - src/hwprobe/interops/linux/main.c | 103 +++++--------------- src/hwprobe/interops/linux/src/gpu_info.cpp | 26 +---- tests/core/linux/test_display.py | 2 +- 8 files changed, 47 insertions(+), 120 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 024d041..2ec4679 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include LICENSE include README.md -recursive-include src/hwprobe/interops/win/bindings *.dll -recursive-include src/hwprobe/interops/mac/bindings *.dylib +recursive-include src/hwprobe/interops/win/bindings *.dll +recursive-include src/hwprobe/interops/mac/bindings *.dylib +recursive-include src/hwprobe/interops/linux/bindings *.so diff --git a/pyproject.toml b/pyproject.toml index a0b72fe..7a4669b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,13 +31,19 @@ dependencies = [ where = ["src"] [tool.setuptools.package-data] -"hwprobe.interops.win.bindings" = ["*.dll"] -"hwprobe.interops.mac.bindings" = ["*.dylib"] +"hwprobe.interops.win.bindings" = ["*.dll"] +"hwprobe.interops.mac.bindings" = ["*.dylib"] +"hwprobe.interops.linux.bindings" = ["*.so"] [project.urls] Homepage = "https://github.com/Mahasvan/HWProbe" Issues = "https://github.com/Mahasvan/HWProbe/issues" +[dependency-groups] +dev = [ + "ruff>=0.16.3", +] + [tool.ruff] target-version = "py39" line-length = 120 diff --git a/src/hwprobe/core/linux/display.py b/src/hwprobe/core/linux/display.py index 6986a29..6acdd06 100644 --- a/src/hwprobe/core/linux/display.py +++ b/src/hwprobe/core/linux/display.py @@ -26,16 +26,17 @@ def _resolve_parent_gpu_by_bdf(pci_bdf: str, gpu_devices: list[GPUInfo]) -> Optional[str]: """ - Given a PCI BDF (::.) of a display device, + Given a PCI BDF (::.) of a display device, find the parent GPU in the list of GPUInfo objects. """ if pci_bdf is None: return None - + for gpu in gpu_devices: - if gpu.pci_path == pci_path_linux(pci_bdf): + path = pci_path_linux(pci_bdf) + if path and gpu.pci_path == path: return gpu.name - + return None @@ -78,7 +79,7 @@ def _fetch_individual_monitor_info( pci_bdf = _extract_pci_bdf_from_sysfs_path(posixpath.realpath(parent_path)) # Resolve parent GPU based on the PCI BDF - if (parent_gpu := _resolve_parent_gpu_by_bdf(pci_bdf, gpu_devices)) is not None: + if pci_bdf is not None and (parent_gpu := _resolve_parent_gpu_by_bdf(pci_bdf, gpu_devices)) is not None: monitor_data.gpu_name = parent_gpu acpi_file = posixpath.join(device_path, "firmware_node", "path") diff --git a/src/hwprobe/core/linux/manager.py b/src/hwprobe/core/linux/manager.py index 92d7618..942dd58 100644 --- a/src/hwprobe/core/linux/manager.py +++ b/src/hwprobe/core/linux/manager.py @@ -49,11 +49,10 @@ def fetch_graphics_info(self) -> GraphicsInfo: return self.info.graphics def fetch_display_info(self) -> DisplayInfo: - if self.info.graphics.modules and len(self.info.graphics.modules): + if not self.info.graphics or not len(self.info.graphics.modules): self.fetch_graphics_info() - - self.info.display = fetch_display_info(self.info.graphics.modules) - return self.info.display + + return fetch_display_info(self.info.graphics.modules) def fetch_hardware_info(self) -> HardwareInfo: self.fetch_cpu_info() diff --git a/src/hwprobe/interops/linux/README.md b/src/hwprobe/interops/linux/README.md index c95360c..a006194 100644 --- a/src/hwprobe/interops/linux/README.md +++ b/src/hwprobe/interops/linux/README.md @@ -92,9 +92,6 @@ print(gpu) On import, the script loads the colocated `libdevice_info.so`; ensure you rebuild the CMake project whenever you make changes to the native code. -Or use the high-level API (automatic fallback to sysfs + `lspci`/`nvidia-smi`/`rocm-smi` when the native library -isn't available): - ```python from hwprobe.core.linux.graphics import fetch_graphics_info diff --git a/src/hwprobe/interops/linux/main.c b/src/hwprobe/interops/linux/main.c index 78856ee..86eb168 100644 --- a/src/hwprobe/interops/linux/main.c +++ b/src/hwprobe/interops/linux/main.c @@ -1,82 +1,25 @@ +#include #include -#include -#include -#include - -typedef struct VkPhysDevProps -{ - uint32_t api, driver, vendorID, deviceID, devType; - char name[256]; - uint8_t uuid[16]; - alignas(uint64_t) uint8_t _limits[504]; - uint8_t _sparse[20]; -} VkPhysDevProps; - -_Static_assert( - sizeof(VkPhysDevProps) == sizeof(VkPhysicalDeviceProperties), - "VkPhysDevProps size mismatch" -); - -_Static_assert( - alignof(VkPhysDevProps) == alignof(VkPhysicalDeviceProperties), - "VkPhysDevProps alignment mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, api) == - offsetof(VkPhysicalDeviceProperties, apiVersion), - "api offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, driver) == - offsetof(VkPhysicalDeviceProperties, driverVersion), - "driver offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, vendorID) == - offsetof(VkPhysicalDeviceProperties, vendorID), - "vendorID offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, deviceID) == - offsetof(VkPhysicalDeviceProperties, deviceID), - "deviceID offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, devType) == - offsetof(VkPhysicalDeviceProperties, deviceType), - "deviceType offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, name) == - offsetof(VkPhysicalDeviceProperties, deviceName), - "deviceName offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, uuid) == - offsetof(VkPhysicalDeviceProperties, pipelineCacheUUID), - "UUID offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, _limits) == - offsetof(VkPhysicalDeviceProperties, limits), - "limits offset mismatch" -); - -_Static_assert( - offsetof(VkPhysDevProps, _sparse) == - offsetof(VkPhysicalDeviceProperties, sparseProperties), - "sparseProperties offset mismatch" -); - -int main() -{ +#include +#include "gpu_info.h" + +int main(int argc, char *argv[]) { + if (argc < 3) { + fprintf(stderr, "Usage: %s \n e.g. %s 0000:01:00.0 0x1002\n", + argv[0], argv[0]); + return 1; + } + + uint32_t vendor_id = (uint32_t)strtoul(argv[2], NULL, 16); + GPUProperties g; + if (get_gpu_info(argv[1], vendor_id, &g) < 0) { + fprintf(stderr, "Failed to query GPU info for %s\n", argv[1]); + return 1; + } + + printf("GPU at %s:\n", argv[1]); + printf(" Name: %s\n", g.name[0] ? g.name : "(unknown)"); + printf(" VRAM Total: %lu MB\n", (unsigned long)g.vram_total_mb); + printf(" VRAM Used: %lu MB\n", (unsigned long)g.vram_used_mb); return 0; -} \ No newline at end of file +} diff --git a/src/hwprobe/interops/linux/src/gpu_info.cpp b/src/hwprobe/interops/linux/src/gpu_info.cpp index 39efcb9..10a898b 100644 --- a/src/hwprobe/interops/linux/src/gpu_info.cpp +++ b/src/hwprobe/interops/linux/src/gpu_info.cpp @@ -223,26 +223,6 @@ static void vram_nouveau(int fd, GPUProperties *g) g->vram_total_mb = to_mb(p.value); } -// By default the Vulkan loader dlopen()s + initializes every installed ICD -// manifest (a dozen-plus with a typical Mesa install) before it can tell us -// which ones actually have hardware. Since we already know each GPU's vendor -// from sysfs, point the loader at only the matching driver(s) so it skips -// the rest — this is the dominant cost of the fallback path. -static bool icd_matches_vendor(const char *filename, uint32_t vendor_id) -{ - switch (vendor_id) - { - case 0x10DE: - return strstr(filename, "nvidia") || strstr(filename, "nouveau"); - case 0x1002: - return strstr(filename, "radeon") != NULL; - case 0x8086: - return strstr(filename, "intel") != NULL; - default: - return false; - } -} - static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) { if (pciAddr == NULL) @@ -393,7 +373,7 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) char drm_dir_path[160]; snprintf(drm_dir_path, sizeof(drm_dir_path), "/sys/bus/pci/devices/%s/drm", bdf); - char card_name[32] = {0}; + char card_name[256] = {0}; DIR *drm_dir = opendir(drm_dir_path); if (drm_dir) @@ -412,7 +392,7 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) closedir(drm_dir); } - GPUProperties g = {0}; + GPUProperties g = {}; if (card_name[0]) { @@ -428,7 +408,7 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) { // TODO: Figure out if there is a vendor-agnostic way to query VRAM usage // without having to fall back to Vulkan. - // + // // For now, we use vendor-specific ioctls. switch (vendor_id) { diff --git a/tests/core/linux/test_display.py b/tests/core/linux/test_display.py index 09dacf1..ba4adc6 100644 --- a/tests/core/linux/test_display.py +++ b/tests/core/linux/test_display.py @@ -119,7 +119,7 @@ def test_skips_monitors_returning_none(self, monkeypatch): ) monkeypatch.setattr( "hwprobe.core.linux.display._fetch_individual_monitor_info", - lambda path: None, + lambda path, gpu_devices: None, ) info = fetch_display_info([]) From a0405e3cfeb8464c6102afef756b23db37f46700 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 19 Aug 2026 10:12:57 +0200 Subject: [PATCH 18/22] refactor(linux): update interops API, link against vulkan directly; build with vulkan headers fix(linux): PR fixes #3 --- .clang-format | 17 + src/hwprobe/interops/linux/CMakeLists.txt | 33 +- src/hwprobe/interops/linux/README.md | 34 +- src/hwprobe/interops/linux/include/gpu_info.h | 14 +- src/hwprobe/interops/linux/src/gpu_info.cpp | 676 ++++++++---------- 5 files changed, 363 insertions(+), 411 deletions(-) create mode 100644 .clang-format diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..3be2907 --- /dev/null +++ b/.clang-format @@ -0,0 +1,17 @@ +Language: Cpp +BreakBeforeBraces: Stroustrup +PointerAlignment: Right +IndentWidth: 2 +AccessModifierOffset: 0 +ColumnLimit: 80 +NamespaceIndentation: All +AlignTrailingComments: true +AllowAllParametersOfDeclarationOnNextLine: true +AlwaysBreakTemplateDeclarations: true +AlignAfterOpenBracket: AlwaysBreak +UseTab: Never +IncludeBlocks: Preserve +AlignConsecutiveDeclarations: true +AlignConsecutiveAssignments: true +SpacesInParentheses: false +SpaceBeforeParens: ControlStatements diff --git a/src/hwprobe/interops/linux/CMakeLists.txt b/src/hwprobe/interops/linux/CMakeLists.txt index 015a2f4..832a193 100644 --- a/src/hwprobe/interops/linux/CMakeLists.txt +++ b/src/hwprobe/interops/linux/CMakeLists.txt @@ -7,12 +7,14 @@ set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif() find_package(PkgConfig REQUIRED) +find_package(Vulkan REQUIRED) pkg_check_modules(LIBDRM REQUIRED libdrm) # --------------------------------------------------------------------- @@ -33,17 +35,34 @@ target_include_directories( ${LIBDRM_INCLUDE_DIRS} ) -target_link_libraries( - device_info - PRIVATE - ${LIBDRM_LIBRARIES} - dl -) +# Link against libdrm and the Vulkan loader. We no longer need libdl for dlopen/dlsym. +if(TARGET Vulkan::Vulkan) + target_link_libraries( + device_info + PRIVATE + ${LIBDRM_LIBRARIES} + Vulkan::Vulkan + ) +else() + target_link_libraries( + device_info + PRIVATE + ${LIBDRM_LIBRARIES} + ${Vulkan_LIBRARY} + ) + target_include_directories( + device_info + PRIVATE + ${Vulkan_INCLUDE_DIRS} + ) +endif() target_compile_options( device_info PRIVATE ${LIBDRM_CFLAGS_OTHER} + -Wall + -Werror ) if(CMAKE_BUILD_TYPE STREQUAL "Release") @@ -63,4 +82,4 @@ set_target_properties( PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/bindings OUTPUT_NAME device_info -) \ No newline at end of file +) diff --git a/src/hwprobe/interops/linux/README.md b/src/hwprobe/interops/linux/README.md index a006194..7b19712 100644 --- a/src/hwprobe/interops/linux/README.md +++ b/src/hwprobe/interops/linux/README.md @@ -14,12 +14,12 @@ If you are someone with more know-how, and find lapses in this code, we'd be mor ## Vendor Coverage -| Vendor | VRAM Source | Notes | -|--------|-------------|-------| -| **AMD** | `AMDGPU_INFO_VRAM_GTT` ioctl | Direct kernel interface | -| **Intel** | `DRM_I915_QUERY_MEMORY_REGIONS` ioctl | Intel Arc + integrated | -| **NVIDIA** | Nouveau DRM ioctl + Vulkan fallback | Vulkan preferred for proprietary driver | -| **Any** | Vulkan `VkPhysicalDeviceMemoryProperties` | Universal fallback via `libvulkan.so.1` | +| Vendor | VRAM Source | Notes | +| ---------- | ----------------------------------------- | --------------------------------------- | +| **AMD** | `AMDGPU_INFO_VRAM_GTT` ioctl | Direct kernel interface | +| **Intel** | `DRM_I915_QUERY_MEMORY_REGIONS` ioctl | Intel Arc + integrated | +| **NVIDIA** | Nouveau DRM ioctl + Vulkan fallback | Vulkan preferred for proprietary driver | +| **Any** | Vulkan `VkPhysicalDeviceMemoryProperties` | Universal fallback via `libvulkan.so.1` | ## Requirements @@ -28,17 +28,17 @@ If you are someone with more know-how, and find lapses in this code, we'd be mor - CMake 3.21+ - `libdrm` development headers (linked at build time) - Python 3.7+ (for the `gpu_info.py` binding) - Assuming you want to compile this to use with HWProbe. -- `libvulkan.so.1` at runtime (optional; `dlopen`'d for the universal fallback path, no SDK headers needed to build) +- `libvulkan.so.1` at runtime and other Vulkan-related packages ```bash # Debian/Ubuntu -sudo apt install build-essential cmake libdrm-dev +sudo apt install build-essential cmake pkg-config libdrm-dev libvulkan-dev vulkan-headers # Fedora/RHEL -sudo dnf install gcc-c++ cmake libdrm-devel +sudo dnf install gcc-c++ cmake pkgconf-pkg-config libdrm-devel vulkan-loader-devel vulkan-headers # Arch -sudo pacman -S base-devel cmake libdrm +sudo pacman -S base-devel cmake pkgconf libdrm vulkan-headers vulkan-icd-loader ``` ## Build @@ -75,13 +75,6 @@ The tool exits with code `0` when enumeration succeeds, or `1` if the underlying After building the project once (so that `bindings/libdevice_info.so` exists), you can inspect GPUs from Python: -```sh -cd bindings -python3 gpu_info.py -``` - -or programmatically: - ```python from gpu_info import get_gpu_info @@ -103,13 +96,12 @@ for gpu in info.modules: ## Why C++ Instead of Pure Python? 1. **No external dependencies** — `lspci`, `nvidia-smi`, `rocm-smi` may not be installed -2. **Vendor-neutral VRAM** — DRM ioctls work for all vendors without proprietary tools +2. **Efficient VRAM detection** — DRM ioctls work for all vendors without proprietary tools 3. **Faster** — Direct kernel interface, no subprocess overhead -4. **Unified Vulkan fallback** — When DRM doesn't provide VRAM, Vulkan fills the gap +4. **Unified Vulkan fallback** ## Limitations -- **ACPI path**: Optional, requires `firmware_node` in sysfs (not all systems) - **Intel Arc VRAM**: Requires kernel 5.16+ for `DRM_I915_QUERY_MEMORY_REGIONS` - **Nouveau VRAM**: Requires open Nouveau driver (proprietary NVIDIA driver uses Vulkan path) @@ -119,5 +111,3 @@ for gpu in info.modules: - **`get_gpu_info` returns -1**: verify that `/sys/class/drm` is populated and that DRM/Vulkan drivers are installed. - **VRAM shows 0 MB**: the Vulkan fallback requires `libvulkan.so.1` to be installed; without it, VRAM is only reported for vendors with a supported DRM ioctl (AMD, Intel, open-source Nouveau). -- **PCIe gen/width shows 0**: the sysfs link-status attributes may not be exposed by all drivers. This is - driver-dependent and not a bug in the library. diff --git a/src/hwprobe/interops/linux/include/gpu_info.h b/src/hwprobe/interops/linux/include/gpu_info.h index 7f6e4e0..852988d 100644 --- a/src/hwprobe/interops/linux/include/gpu_info.h +++ b/src/hwprobe/interops/linux/include/gpu_info.h @@ -11,12 +11,14 @@ extern "C" #define INTEL_VENDOR_ID 0x8086 #define NVIDIA_VENDOR_ID 0x10DE -typedef struct -{ - uint16_t domain; //!< PCI domain number - uint8_t bus; //!< PCI bus number - uint8_t device; //!< PCI device number - uint8_t function; //!< PCI function number +#define MAX_GPU_CARDS (16u) +#define BYTES_PER_MB (1024 * 1024) + +typedef struct { + uint16_t domain : 16u; //!< PCI domain number + uint8_t bus : 8u; //!< PCI bus number + uint8_t device : 5u; //!< PCI device number + uint8_t function : 3u; //!< PCI function number } PCIAddress; typedef struct diff --git a/src/hwprobe/interops/linux/src/gpu_info.cpp b/src/hwprobe/interops/linux/src/gpu_info.cpp index 10a898b..453560a 100644 --- a/src/hwprobe/interops/linux/src/gpu_info.cpp +++ b/src/hwprobe/interops/linux/src/gpu_info.cpp @@ -1,153 +1,61 @@ #include "../include/gpu_info.h" -#include -#include #include #include #include #include -#include #include -#include +#include #include #include + #include #include #include +#include +#include +#include +#include -// -// Vulkan types — dlopen'd at runtime, no SDK headers required -// +typedef struct VkGPU { + uint32_t vendor_id; + uint32_t device_id; + uint64_t vram_mb; + uint64_t used_mb; + char slot[64]; + char name[256]; +} VkGPU; -typedef void *VkInstance; -typedef void *VkPhysicalDevice; +static uint64_t to_mb(uint64_t bytes) { return bytes / BYTES_PER_MB; } -enum -{ - VK_STYPE_APP_INFO = 0, - VK_STYPE_INST_CREATE = 1, - VK_STYPE_PROPS2 = 1000059001, - VK_STYPE_PCI_BUS_EXT = 1000212000, - VK_STYPE_MEM_PROPS2 = 1000059006, - VK_STYPE_MEM_BUDGET_EXT = 1000237000, - VK_HEAP_DEVICE_LOCAL = 0x1, -}; - -struct VkAppInfo -{ - uint32_t sType; - const void *pNext; - const char *appName; - uint32_t appVer; - const char *engName; - uint32_t engVer; - uint32_t apiVer; -}; - -struct VkInstCreateInfo -{ - uint32_t sType; - const void *pNext; - uint32_t flags; - const VkAppInfo *pAppInfo; - uint32_t layerCnt; - const char *const *layers; - uint32_t extCnt; - const char *const *exts; -}; - -struct VkPhysDevProps -{ - uint32_t api, driver, vendorID, deviceID, devType; - char name[256]; - uint8_t uuid[16]; - alignas(uint64_t) uint8_t _limits[504]; - uint8_t _sparse[20]; -}; - -struct VkPhysDevProps2 -{ - uint32_t sType; - void *pNext; - VkPhysDevProps props; -}; - -struct VkPCIBusInfo -{ - uint32_t sType; - void *pNext; - uint32_t dom, bus, dev, func; -}; - -struct VkMemHeap -{ - uint64_t size; - uint32_t flags; -}; -struct VkMemType +static PCIAddress parse_bdf_to_pci_addr(const char *bdf) { - uint32_t flags; - uint32_t heapIdx; -}; + PCIAddress pciAddr = {0}; + if (!bdf || strlen(bdf) == 0) + return pciAddr; -struct VkMemProps -{ - uint32_t typeCnt; - VkMemType types[32]; - uint32_t heapCnt; - VkMemHeap heaps[16]; -}; + char *endptr = nullptr; -struct VkMemProps2 -{ - uint32_t sType; - void *pNext; - VkMemProps memProps; -}; + uint16_t domain = strtoll(bdf, &endptr, 16u); + if (*endptr != ':') + return pciAddr; -struct VkMemBudget -{ - uint32_t sType; - void *pNext; - uint64_t budget[16]; - uint64_t usage[16]; -}; + uint8_t bus = strtoll(endptr + 1, &endptr, 16u); + if (*endptr != ':') + return pciAddr; -struct VkGPU -{ - char name[256]; - char slot[32]; - uint64_t vram_mb; - uint64_t used_mb; - uint32_t vendor_id; - uint32_t device_id; -}; - -typedef int32_t (*PFN_CreateInst)(const VkInstCreateInfo *, const void *, VkInstance *); -typedef void (*PFN_DestroyInst)(VkInstance, const void *); -typedef int32_t (*PFN_EnumDevs)(VkInstance, uint32_t *, VkPhysicalDevice *); -typedef void (*PFN_GetProps2)(VkPhysicalDevice, VkPhysDevProps2 *); -typedef void (*PFN_GetMem2)(VkPhysicalDevice, VkMemProps2 *); - -enum -{ - MAX_GPU_CARDS = 16, - BYTES_PER_MB = 1024 * 1024, -}; + uint8_t device = strtoll(endptr + 1, &endptr, 16u); + if (*endptr != '.') + return pciAddr; -static uint64_t to_mb(uint64_t bytes) -{ - return bytes / BYTES_PER_MB; -} + uint8_t function = strtoll(endptr + 1, &endptr, 16u); -static PCIAddress parse_bdf_to_pci_addr(const char *bdf) -{ - PCIAddress pciAddr = {0}; - if (!bdf) - return pciAddr; + pciAddr.domain = domain; + pciAddr.bus = bus; + pciAddr.device = device; + pciAddr.function = function; - sscanf(bdf, "%hx:%hhx:%hhx.%hhd", &pciAddr.domain, &pciAddr.bus, &pciAddr.device, &pciAddr.function); - return pciAddr; + return pciAddr; } // @@ -156,303 +64,319 @@ static PCIAddress parse_bdf_to_pci_addr(const char *bdf) static void vram_amdgpu(int fd, GPUProperties *g) { - drm_amdgpu_info req = {0}; - drm_amdgpu_info_vram_gtt vram = {0}; + if (g == nullptr) + return; - req.return_pointer = reinterpret_cast(&vram); - req.return_size = sizeof(vram); - req.query = AMDGPU_INFO_VRAM_GTT; + drm_amdgpu_info req = {0}; + drm_amdgpu_memory_info mem; - if (ioctl(fd, DRM_IOCTL_AMDGPU_INFO, &req) == 0) - g->vram_total_mb = to_mb(vram.vram_size); + req.return_pointer = reinterpret_cast(&mem); + req.return_size = sizeof(mem); + req.query = AMDGPU_INFO_MEMORY; - drm_amdgpu_info ureq = {0}; - struct - { - uint64_t vram, vis, gtt; - } usage = {0}; + if (ioctl(fd, DRM_IOCTL_AMDGPU_INFO, &req) == 0) { + g->vram_total_mb = to_mb(mem.vram.total_heap_size); + g->vram_used_mb = to_mb(mem.vram.heap_usage); + } +} - ureq.return_pointer = reinterpret_cast(&usage); - ureq.return_size = sizeof(usage); - ureq.query = AMDGPU_INFO_VRAM_USAGE; +static void vram_radeon(int fd, GPUProperties *g) +{ + if (g == nullptr) + return; - if (ioctl(fd, DRM_IOCTL_AMDGPU_INFO, &ureq) == 0) - g->vram_used_mb = to_mb(usage.vram); + drm_radeon_gem_info gem_info = {0}; + + if (ioctl(fd, DRM_IOCTL_RADEON_GEM_INFO, &gem_info) == 0) { + g->vram_total_mb = to_mb(gem_info.vram_size); + } + + drm_radeon_info info = {0}; + info.request = RADEON_INFO_VRAM_USAGE; + info.value = (uint64_t)(uintptr_t)(&g->vram_used_mb); + if (ioctl(fd, DRM_IOCTL_RADEON_INFO, &info) < 0) { + g->vram_used_mb = 0; + } } static void vram_i915(int fd, GPUProperties *g) { - drm_i915_query_item item = {0}; - item.query_id = DRM_I915_QUERY_MEMORY_REGIONS; - - drm_i915_query q = {0}; - q.num_items = 1; - q.items_ptr = reinterpret_cast(&item); - - if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) != 0 || item.length <= 0) - return; - - uint8_t *buf = static_cast(calloc(1, item.length)); - if (!buf) - return; - - item.data_ptr = reinterpret_cast(buf); - - if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) == 0) - { - drm_i915_query_memory_regions *r = reinterpret_cast(buf); - for (uint32_t i = 0; i < r->num_regions; i++) - { - if (r->regions[i].region.memory_class == I915_MEMORY_CLASS_DEVICE) - { - g->vram_total_mb = to_mb(r->regions[i].probed_size); - break; - } - } + if (g == nullptr) + return; + + drm_i915_query_item item = {0}; + item.query_id = DRM_I915_QUERY_MEMORY_REGIONS; + + drm_i915_query q = {0}; + q.num_items = 1; + q.items_ptr = reinterpret_cast(&item); + + if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) != 0 || item.length <= 0) + return; + + uint8_t *buf = static_cast(calloc(1, item.length)); + if (!buf) + return; + + item.data_ptr = reinterpret_cast(buf); + + if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) == 0) { + drm_i915_query_memory_regions *r = + reinterpret_cast(buf); + for (uint32_t i = 0; i < r->num_regions; i++) { + if (r->regions[i].region.memory_class == I915_MEMORY_CLASS_DEVICE) { + g->vram_total_mb = to_mb(r->regions[i].probed_size); + break; + } } + } + + free(buf); +} + +static void vram_xe(int fd, GPUProperties *g) +{ + if (g == nullptr) + return; - free(buf); + uint64_t total_vram = 0u; + uint64_t used_vram = 0u; + drm_xe_query_mem_regions regions = {0}; + + if (ioctl(fd, DRM_IOCTL_XE_DEVICE_QUERY, ®ions) == 0) { + for (uint32_t i = 0; i < regions.num_mem_regions; i++) { + total_vram += regions.mem_regions[i].total_size; + used_vram += regions.mem_regions[i].used; + } + } + g->vram_total_mb = to_mb(total_vram); + g->vram_used_mb = to_mb(used_vram); } static void vram_nouveau(int fd, GPUProperties *g) { - drm_nouveau_getparam p = {0}; - p.param = NOUVEAU_GETPARAM_FB_SIZE; + if (g == nullptr) + return; + + drm_nouveau_getparam p = {0}; + p.param = NOUVEAU_GETPARAM_FB_SIZE; - if (ioctl(fd, DRM_IOCTL_NOUVEAU_GETPARAM, &p) == 0 && p.value > 0) - g->vram_total_mb = to_mb(p.value); + if (ioctl(fd, DRM_IOCTL_NOUVEAU_GETPARAM, &p) == 0 && p.value > 0) + g->vram_total_mb = to_mb(p.value); } static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) { - if (pciAddr == NULL) - return -1; - - void *lib = dlopen("libvulkan.so.1", RTLD_LAZY); - if (!lib) - return -1; - - // Load the Vulkan entry points we need. - // If any are missing, the ICD is too old to support the features we need. - PFN_CreateInst createInstance = reinterpret_cast(dlsym(lib, "vkCreateInstance")); - PFN_DestroyInst destroyInstance = reinterpret_cast(dlsym(lib, "vkDestroyInstance")); - PFN_EnumDevs enumPhysDev = reinterpret_cast(dlsym(lib, "vkEnumeratePhysicalDevices")); - PFN_GetProps2 getPhysDevProps = reinterpret_cast(dlsym(lib, "vkGetPhysicalDeviceProperties2")); - PFN_GetMem2 getPhysDevMemProps = reinterpret_cast(dlsym(lib, "vkGetPhysicalDeviceMemoryProperties2")); - - if (!createInstance || !destroyInstance || !enumPhysDev || !getPhysDevProps || !getPhysDevMemProps) - { - dlclose(lib); - return -1; - } + if (pciAddr == NULL) + return -1; + + // Request the get_physical_device_properties2 extension to read PCI bus + // info. + const char *instExts[] = { + VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME}; + + VkApplicationInfo appInfo = {}; + appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + appInfo.pApplicationName = "HWProbe"; + appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0); +#ifdef VK_API_VERSION_1_2 + appInfo.apiVersion = VK_API_VERSION_1_2; +#else + appInfo.apiVersion = VK_MAKE_VERSION(1, 2, 0); +#endif + + VkInstanceCreateInfo instCreate = {}; + instCreate.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + instCreate.pApplicationInfo = &appInfo; + instCreate.enabledExtensionCount = 1; + instCreate.ppEnabledExtensionNames = instExts; + + VkInstance inst = VK_NULL_HANDLE; + VkResult r = vkCreateInstance(&instCreate, NULL, &inst); + if (r != VK_SUCCESS || inst == VK_NULL_HANDLE) + return -1; + + uint32_t numberOfDevices = 0; + r = vkEnumeratePhysicalDevices(inst, &numberOfDevices, NULL); + if (r != VK_SUCCESS) { + vkDestroyInstance(inst, NULL); + return -1; + } + + if (numberOfDevices == 0) { + vkDestroyInstance(inst, NULL); + return 0; + } - VkAppInfo vkAppInfo = {VK_STYPE_APP_INFO, NULL, "HWProbe", 1, NULL, 0, (1u << 22) | (1u << 12)}; - VkInstCreateInfo vkInstCreate = {VK_STYPE_INST_CREATE, NULL, 0, &vkAppInfo, 0, NULL, 0, NULL}; + if (numberOfDevices > MAX_GPU_CARDS) + numberOfDevices = MAX_GPU_CARDS; - VkInstance inst = NULL; - if (createInstance(&vkInstCreate, NULL, &inst) != 0 || !inst) - { - dlclose(lib); - return -1; - } + VkPhysicalDevice devs[MAX_GPU_CARDS]; + r = vkEnumeratePhysicalDevices(inst, &numberOfDevices, devs); + if (r != VK_SUCCESS) { + vkDestroyInstance(inst, NULL); + return -1; + } - // Unfortunately, Vulkan doesn't provide a way to directly correlate - // a PCI BDF to a VkPhysicalDevice. We have to enumerate all devices and - // check their PCI bus info until we find a match (or exhaust the device list). - // - // The only "useful" identifiers Vulkan provides are internal [L|U]UID representations, - // which doesn't help us with our situation: they are used to identify devices across - // multiple Graphics API stacks. - // - // What we can do is return early if a VkPhysicalDevice matches the PCI BDF we are looking for. - // - // Sources: - // - https://docs.vulkan.org/spec/latest/chapters/devsandqueues.html - // - https://docs.vulkan.org/refpages/latest/refpages/source/vkGetWinrtDisplayNV.html - uint32_t numberOfDevices = 0; - if (enumPhysDev(inst, &numberOfDevices, NULL) != 0) - { - destroyInstance(inst, NULL); - dlclose(lib); - return -1; - } + uint32_t cnt = 0; - if (numberOfDevices == 0) - { - destroyInstance(inst, NULL); - dlclose(lib); - return 0; - } + for (uint32_t i = 0; i < numberOfDevices && cnt < MAX_GPU_CARDS; i++) { + VkGPU *g = &out[cnt++]; - if (numberOfDevices > MAX_GPU_CARDS) - numberOfDevices = MAX_GPU_CARDS; + VkPhysicalDevicePCIBusInfoPropertiesEXT pci = {}; + pci.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PCI_BUS_INFO_PROPERTIES_EXT; - VkPhysicalDevice devs[MAX_GPU_CARDS]; - if (enumPhysDev(inst, &numberOfDevices, devs) != 0) - { - destroyInstance(inst, NULL); - dlclose(lib); - return -1; - } + VkPhysicalDeviceProperties2 props2 = {}; + props2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + props2.pNext = &pci; + vkGetPhysicalDeviceProperties2(devs[i], &props2); - int cnt = 0; - - for (uint32_t i = 0; i < numberOfDevices && cnt < MAX_GPU_CARDS; i++) - { - VkPCIBusInfo pci = {VK_STYPE_PCI_BUS_EXT, NULL, 0, 0, 0, 0}; - VkPhysDevProps2 p = {0}; - p.sType = VK_STYPE_PROPS2; - p.pNext = &pci; - getPhysDevProps(devs[i], &p); - - VkMemBudget bgt = {0}; - bgt.sType = VK_STYPE_MEM_BUDGET_EXT; - bgt.pNext = NULL; - - VkMemProps2 m = {0}; - m.sType = VK_STYPE_MEM_PROPS2; - m.pNext = &bgt; - getPhysDevMemProps(devs[i], &m); - - uint64_t total = 0, used = 0; - for (uint32_t h = 0; h < m.memProps.heapCnt; h++) - { - if (m.memProps.heaps[h].flags & VK_HEAP_DEVICE_LOCAL) - { - total += m.memProps.heaps[h].size; - // total - budget ≈ system-wide VRAM usage - if (bgt.budget[h] > 0 && bgt.budget[h] < m.memProps.heaps[h].size) - used += m.memProps.heaps[h].size - bgt.budget[h]; - } - } + snprintf( + g->slot, sizeof(g->slot), "%04x:%02x:%02x.%x", pci.pciDomain, + pci.pciBus, pci.pciDevice, pci.pciFunction); - VkGPU *g = &out[cnt++]; + if (pciAddr->domain != pci.pciDomain || pciAddr->bus != pci.pciBus || + pciAddr->device != pci.pciDevice || + pciAddr->function != pci.pciFunction) { + // Skip devices that don't match the PCI address. + continue; + } - g->vendor_id = p.props.vendorID; - g->device_id = p.props.deviceID; - g->vram_mb = to_mb(total); - g->used_mb = to_mb(used); + VkPhysicalDeviceMemoryBudgetPropertiesEXT budget = {}; + budget.sType = + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT; + + VkPhysicalDeviceMemoryProperties2 memProps2 = {}; + memProps2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_PROPERTIES_2; + memProps2.pNext = &budget; + vkGetPhysicalDeviceMemoryProperties2(devs[i], &memProps2); + + uint64_t total = 0, used = 0; + uint32_t heapCnt = memProps2.memoryProperties.memoryHeapCount; + for (uint32_t h = 0; h < heapCnt; h++) { + if (memProps2.memoryProperties.memoryHeaps[h].flags & + VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) { + total += memProps2.memoryProperties.memoryHeaps[h].size; + // total - budget ≈ system-wide VRAM usage + if (budget.heapBudget[h] > 0 && + budget.heapBudget[h] < + memProps2.memoryProperties.memoryHeaps[h].size) + used += memProps2.memoryProperties.memoryHeaps[h].size - + budget.heapBudget[h]; + } + } - snprintf(g->slot, sizeof(g->slot), "%04x:%02x:%02x.%x", pci.dom, pci.bus, pci.dev, pci.func); - snprintf(g->name, sizeof(g->name), "%s", p.props.name); + g->vendor_id = props2.properties.vendorID; + g->device_id = props2.properties.deviceID; + g->vram_mb = to_mb(total); + g->used_mb = to_mb(used); - if ( - pciAddr->domain == pci.dom && - pciAddr->bus == pci.bus && - pciAddr->device == pci.dev && - pciAddr->function == pci.func) - { - // Found the device we were looking for; stop enumerating. - break; - } - } + snprintf(g->name, sizeof(g->name), "%s", props2.properties.deviceName); + } - destroyInstance(inst, NULL); - dlclose(lib); - return cnt; + vkDestroyInstance(inst, NULL); + return cnt; } /** * Get GPU information for a specific PCI device. * - * @note This function also returns the current VRAM usage, even though it currently - * has no proper application in HWProbe. It shall stay here for future reference - * if we decide to query similar information on other platforms. + * @note This function also returns the current VRAM usage, even though it + * currently has no proper application in HWProbe. It shall stay here for future + * reference if we decide to query similar information on other platforms. * * @param bdf The PCI bus:device.function string (e.g., "0000:01:00.0"). - * @param vendor_id The PCI vendor ID of the GPU (e.g., 0x1002 for AMD, 0x8086 for Intel, 0x10DE for NVIDIA). + * @param vendor_id The PCI vendor ID of the GPU (e.g., 0x1002 for AMD, 0x8086 + * for Intel, 0x10DE for NVIDIA). * @param out Pointer to a GPUProperties struct to receive the GPU information. * @return 0 on success, -1 on failure. */ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) { - if (!bdf || !out) - return -1; - - // Find the DRM card node under /sys/bus/pci/devices//drm/cardN. - char drm_dir_path[160]; - snprintf(drm_dir_path, sizeof(drm_dir_path), "/sys/bus/pci/devices/%s/drm", bdf); - - char card_name[256] = {0}; - DIR *drm_dir = opendir(drm_dir_path); - - if (drm_dir) - { - struct dirent *entry; - - while ((entry = readdir(drm_dir)) != NULL) - { - if (strncmp(entry->d_name, "card", 4) == 0 && - entry->d_name[4] >= '0' && entry->d_name[4] <= '9') - { - snprintf(card_name, sizeof(card_name), "%s", entry->d_name); - break; - } - } - closedir(drm_dir); + if (!bdf || !out) + return -1; + + // Find the DRM card node under /sys/bus/pci/devices//drm/cardN. + char drm_dir_path[160]; + snprintf( + drm_dir_path, sizeof(drm_dir_path), "/sys/bus/pci/devices/%s/drm", bdf); + + char card_name[256] = {0}; + DIR *drm_dir = opendir(drm_dir_path); + + if (drm_dir) { + struct dirent *entry; + + while ((entry = readdir(drm_dir)) != NULL) { + if (strncmp(entry->d_name, "card", 4) == 0 && entry->d_name[4] >= '0' && + entry->d_name[4] <= '9') { + snprintf(card_name, sizeof(card_name), "%s", entry->d_name); + break; + } } + closedir(drm_dir); + } - GPUProperties g = {}; - - if (card_name[0]) - { - char devpath[64]; - - snprintf(devpath, sizeof(devpath), "/dev/dri/%s", card_name); - - int fd = open(devpath, O_RDWR | O_CLOEXEC); - if (fd < 0) - fd = open(devpath, O_RDONLY | O_CLOEXEC); - - if (fd >= 0) - { - // TODO: Figure out if there is a vendor-agnostic way to query VRAM usage - // without having to fall back to Vulkan. - // - // For now, we use vendor-specific ioctls. - switch (vendor_id) - { - case AMD_VENDOR_ID: - vram_amdgpu(fd, &g); - break; - case INTEL_VENDOR_ID: - vram_i915(fd, &g); - break; - case NVIDIA_VENDOR_ID: - vram_nouveau(fd, &g); - break; - } - - close(fd); - } - } + GPUProperties g = {}; + + if (card_name[0]) { + char devpath[64]; - // Vulkan fallback if DRM didn't give us complete info. - if (!g.vram_total_mb || !g.name[0]) - { - PCIAddress pciAddr = parse_bdf_to_pci_addr(bdf); - VkGPU vk[MAX_GPU_CARDS]; - int vk_n = vulkan_query(vk, &pciAddr); - - if (vk_n > 0) - { - for (int v = 0; v < vk_n; v++) - { - if (strcmp(bdf, vk[v].slot) != 0) - continue; - - if (!g.vram_total_mb) - g.vram_total_mb = vk[v].vram_mb; - if (!g.vram_used_mb) - g.vram_used_mb = vk[v].used_mb; - if (vk[v].name[0]) - snprintf(g.name, sizeof(g.name), "%s", vk[v].name); - - break; - } + snprintf(devpath, sizeof(devpath), "/dev/dri/%s", card_name); + + int fd = open(devpath, O_RDWR | O_CLOEXEC); + if (fd < 0) + fd = open(devpath, O_RDONLY | O_CLOEXEC); + + if (fd >= 0) { + drmVersionPtr drm_version = drmGetVersion(fd); + + // TODO: Test all the DRM IOCTL methods; support from users + if (drm_version) { + if (strcmp(drm_version->name, "amdgpu") == 0) { + vram_amdgpu(fd, &g); + } + else if (strcmp(drm_version->name, "radeon") == 0) { + vram_radeon(fd, &g); + } + else if (strcmp(drm_version->name, "i915") == 0) { + vram_i915(fd, &g); } + else if (strcmp(drm_version->name, "xe") == 0) { + vram_xe(fd, &g); + } + else if (strcmp(drm_version->name, "nouveau") == 0) { + vram_nouveau(fd, &g); + } + } + close(fd); } + } + + // TODO: Remove 'g.name[0]' check when pci-ids parser is implemented! + // Vulkan fallback if DRM didn't give us complete info. + if (!g.vram_total_mb || !g.name[0]) { + PCIAddress pciAddr = parse_bdf_to_pci_addr(bdf); + VkGPU vk[MAX_GPU_CARDS]; + int vk_n = vulkan_query(vk, &pciAddr); + + if (vk_n > 0) { + for (int v = 0; v < vk_n; v++) { + if (strcmp(bdf, vk[v].slot) != 0) + continue; + + if (!g.vram_total_mb) + g.vram_total_mb = vk[v].vram_mb; + if (!g.vram_used_mb) + g.vram_used_mb = vk[v].used_mb; + if (vk[v].name[0]) + snprintf(g.name, sizeof(g.name), "%s", vk[v].name); + + break; + } + } + } - *out = g; - return 0; + *out = g; + return 0; } From e1aafd834a52f6c21167e12d46586ad48e7e5f54 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 19 Aug 2026 10:22:04 +0200 Subject: [PATCH 19/22] fix(linux): run `clang-format` on rest of interops files --- src/hwprobe/interops/linux/include/gpu_info.h | 22 +++++----- src/hwprobe/interops/linux/main.c | 40 ++++++++++--------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/hwprobe/interops/linux/include/gpu_info.h b/src/hwprobe/interops/linux/include/gpu_info.h index 852988d..c86e28f 100644 --- a/src/hwprobe/interops/linux/include/gpu_info.h +++ b/src/hwprobe/interops/linux/include/gpu_info.h @@ -3,8 +3,7 @@ #include #ifdef __cplusplus -extern "C" -{ +extern "C" { #endif #define AMD_VENDOR_ID 0x1002 @@ -15,17 +14,18 @@ extern "C" #define BYTES_PER_MB (1024 * 1024) typedef struct { - uint16_t domain : 16u; //!< PCI domain number - uint8_t bus : 8u; //!< PCI bus number - uint8_t device : 5u; //!< PCI device number - uint8_t function : 3u; //!< PCI function number + uint16_t domain : 16u; //!< PCI domain number + uint8_t bus : 8u; //!< PCI bus number + uint8_t device : 5u; //!< PCI device number + uint8_t function : 3u; //!< PCI function number } PCIAddress; -typedef struct -{ - char name[256]; //!< GPU name or description, if available. - uint64_t vram_total_mb; //!< Total VRAM capacity in MB: 0 if unavailable, greater than 0 otherwise. - uint64_t vram_used_mb; //!< Total VRAM used by all processes: 0 if available, greater than 0 otherwise. +typedef struct { + char name[256]; //!< GPU name or description, if available. + uint64_t vram_total_mb; //!< Total VRAM capacity in MB: 0 if unavailable, + //!< greater than 0 otherwise. + uint64_t vram_used_mb; //!< Total VRAM used by all processes: 0 if available, + //!< greater than 0 otherwise. } GPUProperties; int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out); diff --git a/src/hwprobe/interops/linux/main.c b/src/hwprobe/interops/linux/main.c index 86eb168..5d7b626 100644 --- a/src/hwprobe/interops/linux/main.c +++ b/src/hwprobe/interops/linux/main.c @@ -1,25 +1,27 @@ -#include +#include "gpu_info.h" #include +#include #include -#include "gpu_info.h" -int main(int argc, char *argv[]) { - if (argc < 3) { - fprintf(stderr, "Usage: %s \n e.g. %s 0000:01:00.0 0x1002\n", - argv[0], argv[0]); - return 1; - } +int main(int argc, char *argv[]) +{ + if (argc < 3) { + fprintf( + stderr, "Usage: %s \n e.g. %s 0000:01:00.0 0x1002\n", + argv[0], argv[0]); + return 1; + } - uint32_t vendor_id = (uint32_t)strtoul(argv[2], NULL, 16); - GPUProperties g; - if (get_gpu_info(argv[1], vendor_id, &g) < 0) { - fprintf(stderr, "Failed to query GPU info for %s\n", argv[1]); - return 1; - } + uint32_t vendor_id = (uint32_t)strtoul(argv[2], NULL, 16); + GPUProperties g; + if (get_gpu_info(argv[1], vendor_id, &g) < 0) { + fprintf(stderr, "Failed to query GPU info for %s\n", argv[1]); + return 1; + } - printf("GPU at %s:\n", argv[1]); - printf(" Name: %s\n", g.name[0] ? g.name : "(unknown)"); - printf(" VRAM Total: %lu MB\n", (unsigned long)g.vram_total_mb); - printf(" VRAM Used: %lu MB\n", (unsigned long)g.vram_used_mb); - return 0; + printf("GPU at %s:\n", argv[1]); + printf(" Name: %s\n", g.name[0] ? g.name : "(unknown)"); + printf(" VRAM Total: %lu MB\n", (unsigned long)g.vram_total_mb); + printf(" VRAM Used: %lu MB\n", (unsigned long)g.vram_used_mb); + return 0; } From 5a254d60e16fbd8aacce9cf24cbbdc4efc7dfbf4 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 19 Aug 2026 10:54:03 +0200 Subject: [PATCH 20/22] fix(linux): put braces after if-statements for one-instruction lines --- src/hwprobe/interops/linux/src/gpu_info.cpp | 64 ++++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/src/hwprobe/interops/linux/src/gpu_info.cpp b/src/hwprobe/interops/linux/src/gpu_info.cpp index 453560a..54192db 100644 --- a/src/hwprobe/interops/linux/src/gpu_info.cpp +++ b/src/hwprobe/interops/linux/src/gpu_info.cpp @@ -31,22 +31,26 @@ static uint64_t to_mb(uint64_t bytes) { return bytes / BYTES_PER_MB; } static PCIAddress parse_bdf_to_pci_addr(const char *bdf) { PCIAddress pciAddr = {0}; - if (!bdf || strlen(bdf) == 0) + if (!bdf || strlen(bdf) == 0) { return pciAddr; + } char *endptr = nullptr; uint16_t domain = strtoll(bdf, &endptr, 16u); - if (*endptr != ':') + if (*endptr != ':') { return pciAddr; + } uint8_t bus = strtoll(endptr + 1, &endptr, 16u); - if (*endptr != ':') + if (*endptr != ':') { return pciAddr; + } uint8_t device = strtoll(endptr + 1, &endptr, 16u); - if (*endptr != '.') + if (*endptr != '.') { return pciAddr; + } uint8_t function = strtoll(endptr + 1, &endptr, 16u); @@ -64,8 +68,9 @@ static PCIAddress parse_bdf_to_pci_addr(const char *bdf) static void vram_amdgpu(int fd, GPUProperties *g) { - if (g == nullptr) + if (g == nullptr) { return; + } drm_amdgpu_info req = {0}; drm_amdgpu_memory_info mem; @@ -82,8 +87,9 @@ static void vram_amdgpu(int fd, GPUProperties *g) static void vram_radeon(int fd, GPUProperties *g) { - if (g == nullptr) + if (g == nullptr) { return; + } drm_radeon_gem_info gem_info = {0}; @@ -101,8 +107,9 @@ static void vram_radeon(int fd, GPUProperties *g) static void vram_i915(int fd, GPUProperties *g) { - if (g == nullptr) + if (g == nullptr) { return; + } drm_i915_query_item item = {0}; item.query_id = DRM_I915_QUERY_MEMORY_REGIONS; @@ -111,12 +118,14 @@ static void vram_i915(int fd, GPUProperties *g) q.num_items = 1; q.items_ptr = reinterpret_cast(&item); - if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) != 0 || item.length <= 0) + if (ioctl(fd, DRM_IOCTL_I915_QUERY, &q) != 0 || item.length <= 0) { return; + } uint8_t *buf = static_cast(calloc(1, item.length)); - if (!buf) + if (!buf) { return; + } item.data_ptr = reinterpret_cast(buf); @@ -136,8 +145,9 @@ static void vram_i915(int fd, GPUProperties *g) static void vram_xe(int fd, GPUProperties *g) { - if (g == nullptr) + if (g == nullptr) { return; + } uint64_t total_vram = 0u; uint64_t used_vram = 0u; @@ -155,20 +165,23 @@ static void vram_xe(int fd, GPUProperties *g) static void vram_nouveau(int fd, GPUProperties *g) { - if (g == nullptr) + if (g == nullptr) { return; + } drm_nouveau_getparam p = {0}; p.param = NOUVEAU_GETPARAM_FB_SIZE; - if (ioctl(fd, DRM_IOCTL_NOUVEAU_GETPARAM, &p) == 0 && p.value > 0) + if (ioctl(fd, DRM_IOCTL_NOUVEAU_GETPARAM, &p) == 0 && p.value > 0) { g->vram_total_mb = to_mb(p.value); + } } static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) { - if (pciAddr == NULL) + if (pciAddr == NULL) { return -1; + } // Request the get_physical_device_properties2 extension to read PCI bus // info. @@ -193,8 +206,9 @@ static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) VkInstance inst = VK_NULL_HANDLE; VkResult r = vkCreateInstance(&instCreate, NULL, &inst); - if (r != VK_SUCCESS || inst == VK_NULL_HANDLE) + if (r != VK_SUCCESS || inst == VK_NULL_HANDLE) { return -1; + } uint32_t numberOfDevices = 0; r = vkEnumeratePhysicalDevices(inst, &numberOfDevices, NULL); @@ -208,8 +222,9 @@ static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) return 0; } - if (numberOfDevices > MAX_GPU_CARDS) + if (numberOfDevices > MAX_GPU_CARDS) { numberOfDevices = MAX_GPU_CARDS; + } VkPhysicalDevice devs[MAX_GPU_CARDS]; r = vkEnumeratePhysicalDevices(inst, &numberOfDevices, devs); @@ -293,8 +308,9 @@ static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) */ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) { - if (!bdf || !out) + if (!bdf || !out) { return -1; + } // Find the DRM card node under /sys/bus/pci/devices//drm/cardN. char drm_dir_path[160]; @@ -325,8 +341,9 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) snprintf(devpath, sizeof(devpath), "/dev/dri/%s", card_name); int fd = open(devpath, O_RDWR | O_CLOEXEC); - if (fd < 0) + if (fd < 0) { fd = open(devpath, O_RDONLY | O_CLOEXEC); + } if (fd >= 0) { drmVersionPtr drm_version = drmGetVersion(fd); @@ -362,16 +379,19 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) if (vk_n > 0) { for (int v = 0; v < vk_n; v++) { - if (strcmp(bdf, vk[v].slot) != 0) + if (strcmp(bdf, vk[v].slot) != 0) { continue; + } - if (!g.vram_total_mb) + if (!g.vram_total_mb) { g.vram_total_mb = vk[v].vram_mb; - if (!g.vram_used_mb) + } + if (!g.vram_used_mb) { g.vram_used_mb = vk[v].used_mb; - if (vk[v].name[0]) + } + if (vk[v].name[0]) { snprintf(g.name, sizeof(g.name), "%s", vk[v].name); - + } break; } } From 10e60bc1a4a46d26610c4eb5a196267ec74543a2 Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 19 Aug 2026 11:35:22 +0200 Subject: [PATCH 21/22] fix(linux): miscellaneous fixes & patching xe driver ioctl to query memory regions size first, then allocate a buffer capable of holding the information --- src/hwprobe/core/linux/graphics.py | 28 +++++-- src/hwprobe/core/linux/manager.py | 8 +- .../interops/linux/bindings/gpu_info.py | 27 ++++--- .../interops/linux/bindings/libdevice_info.so | Bin 33000 -> 190136 bytes src/hwprobe/interops/linux/include/gpu_info.h | 9 ++- src/hwprobe/interops/linux/src/gpu_info.cpp | 74 ++++++++++++++---- 6 files changed, 110 insertions(+), 36 deletions(-) diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index a5737fc..79361bd 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -1,10 +1,12 @@ import os import posixpath from typing import Optional -from hwprobe.core.linux.common import PCI_ROOT_PATH, _read_from_sysfs, pci_path_linux, _resolve_acpi_path + +from hwprobe.core.linux.common import PCI_ROOT_PATH, _read_from_sysfs, _resolve_acpi_path, pci_path_linux from hwprobe.models.gpu_models import GPUInfo, GraphicsInfo from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType +from src.hwprobe.interops.linux.bindings.gpu_info import GPUInfoQueryStatus # Try to import native C library bindings try: @@ -39,6 +41,10 @@ def _check_gpu_class(device: str) -> bool: We want the devices of base class 0x03, which denotes a Display Controller. """ device_class = _read_from_sysfs(PCI_ROOT_PATH, device, "class") + + if device_class is None: + return False + class_code = int(device_class, base=16) base_class = class_code >> 16 @@ -107,19 +113,27 @@ def fetch_graphics_info() -> GraphicsInfo: graphics_info.status.make_partial(f"Native GPU info library not available, cannot fetch GPU name or VRAM for {device}") elif vendor_id is None: graphics_info.status.make_partial(f"Vendor ID not available, cannot fetch GPU name or VRAM for {device}") - elif (native := native_gpu.get_gpu_info(device, int(vendor_id, 16))) is None: + elif (ret_native := native_gpu.get_gpu_info(device, int(vendor_id, 16))) is None: # pyright: ignore[reportPossiblyUnboundVariable] graphics_info.status.make_partial(f"Native GPU info library could not fetch GPU name or VRAM for {device} with vendor ID {vendor_id}") else: - gpu.name = native.name - if native.vram_total_mb > 0: - gpu.vram = Megabyte(capacity=int(native.vram_total_mb)) + ret, native = ret_native + if ret == GPUInfoQueryStatus.FAILURE: + graphics_info.status.make_partial(f"Native GPU info library could not fetch GPU name or VRAM for {device} with vendor ID {vendor_id}") else: - graphics_info.status.make_partial(f"Native GPU info library returned VRAM size of 0 for {device} with vendor ID {vendor_id}") + if ret & GPUInfoQueryStatus.VULKAN_NAME_FALLBACK: + graphics_info.status.make_partial(f"Native GPU info library used Vulkan fallback to fetch GPU name for {device} with vendor ID {vendor_id}") + gpu.name = native.name + if native.vram_total_mb > 0: + gpu.vram = Megabyte(capacity=int(native.vram_total_mb)) + if ret & GPUInfoQueryStatus.VULKAN_VRAM_FALLBACK: + graphics_info.status.make_partial(f"Native GPU info library used Vulkan fallback to fetch VRAM for {device} with vendor ID {vendor_id}") + else: + graphics_info.status.make_partial(f"Native GPU info library returned VRAM size of 0 for {device} with vendor ID {vendor_id}") if isinstance(cur_width, int): gpu.pcie_width = cur_width - if cur_pcie_speed: + if isinstance(cur_pcie_speed, int): gpu.pcie_gen = cur_pcie_speed graphics_info.modules.append(gpu) diff --git a/src/hwprobe/core/linux/manager.py b/src/hwprobe/core/linux/manager.py index 942dd58..a1eaca4 100644 --- a/src/hwprobe/core/linux/manager.py +++ b/src/hwprobe/core/linux/manager.py @@ -4,7 +4,6 @@ from hwprobe.core.linux.memory import fetch_memory_info from hwprobe.core.linux.network import fetch_network_info from hwprobe.core.linux.storage import fetch_storage_info -from hwprobe.models.display_models import DisplayInfo from hwprobe.models.gpu_models import GraphicsInfo from hwprobe.models.info_models import ( CPUInfo, @@ -52,7 +51,12 @@ def fetch_display_info(self) -> DisplayInfo: if not self.info.graphics or not len(self.info.graphics.modules): self.fetch_graphics_info() - return fetch_display_info(self.info.graphics.modules) + # Both 'ty' and 'pyright' complain for two reasons: + # 1. The attribute 'graphics' is optional and may not be present + # 2. The attribute 'modules' is attached to the 'graphics' attribute, which is optional + # + # However, with all of our checks in this method, we know that 'graphics' is present and 'modules' is not empty. + return fetch_display_info(self.info.graphics.modules) # ty: ignore[unresolved-attribute] # pyright: ignore[reportOptionalMemberAccess] def fetch_hardware_info(self) -> HardwareInfo: self.fetch_cpu_info() diff --git a/src/hwprobe/interops/linux/bindings/gpu_info.py b/src/hwprobe/interops/linux/bindings/gpu_info.py index b4a7f40..5765be9 100644 --- a/src/hwprobe/interops/linux/bindings/gpu_info.py +++ b/src/hwprobe/interops/linux/bindings/gpu_info.py @@ -4,12 +4,19 @@ """ import ctypes -import os from dataclasses import dataclass +from enum import IntEnum from pathlib import Path from typing import Optional +class GPUInfoQueryStatus(IntEnum): + FAILURE = -1 + DRM_SUCCESS = 0 + VULKAN_VRAM_FALLBACK = 1 + VULKAN_NAME_FALLBACK = 2 + + @dataclass class GPUProperties: """Python representation of the C GPUProperties struct""" @@ -42,7 +49,7 @@ def _find_library() -> Optional[ctypes.CDLL]: lib = ctypes.CDLL(str(_LIB_PATH)) # Configure function signature lib.get_gpu_info.argtypes = [ctypes.c_char_p, ctypes.c_uint32, ctypes.POINTER(_CGPUProperties)] - lib.get_gpu_info.restype = ctypes.c_int + lib.get_gpu_info.restype = GPUInfoQueryStatus return lib except (OSError, AttributeError): return None @@ -57,7 +64,7 @@ def is_available() -> bool: return _lib is not None -def get_gpu_info(bdf: str, vendor_id: int) -> GPUProperties: +def get_gpu_info(bdf: str, vendor_id: int) -> tuple[GPUInfoQueryStatus, GPUProperties]: """ Query VRAM and driver name for the GPU at the given PCI BDF address. @@ -77,11 +84,11 @@ def get_gpu_info(bdf: str, vendor_id: int) -> GPUProperties: c_gpu = _CGPUProperties() ret = _lib.get_gpu_info(bdf.encode(), vendor_id, ctypes.byref(c_gpu)) - if ret == -1: - raise RuntimeError(f"get_gpu_info() failed for BDF {bdf!r}") - - return GPUProperties( - name=c_gpu.name.decode("utf-8", errors="replace").strip(), - vram_total_mb=c_gpu.vram_total_mb, - vram_used_mb=c_gpu.vram_used_mb, + return ( + ret, + GPUProperties( + name=c_gpu.name.decode("utf-8", errors="replace").strip(), + vram_total_mb=c_gpu.vram_total_mb, + vram_used_mb=c_gpu.vram_used_mb, + ), ) diff --git a/src/hwprobe/interops/linux/bindings/libdevice_info.so b/src/hwprobe/interops/linux/bindings/libdevice_info.so index 905143e5b917c1942ff13535b0520369ec918387..df6d06fdac03f8f18abc15b6e79592cebacbd6b3 100755 GIT binary patch literal 190136 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%0Ds(W)7oRb4(;s-IAUg|Cz`<5 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 literal 33000 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 #include #include +#include #include #include @@ -26,6 +27,15 @@ typedef struct VkGPU { char name[256]; } VkGPU; +inline GPUInfoQueryStatus operator|(GPUInfoQueryStatus a, GPUInfoQueryStatus b) { + return static_cast(static_cast(a) | static_cast(b)); +} + +inline GPUInfoQueryStatus operator|=(GPUInfoQueryStatus a, GPUInfoQueryStatus b) { + a = a | b; + return a; +} + static uint64_t to_mb(uint64_t bytes) { return bytes / BYTES_PER_MB; } static PCIAddress parse_bdf_to_pci_addr(const char *bdf) @@ -145,22 +155,45 @@ static void vram_i915(int fd, GPUProperties *g) static void vram_xe(int fd, GPUProperties *g) { - if (g == nullptr) { - return; - } + if (g == nullptr || fd < 0) { + return; + } - uint64_t total_vram = 0u; - uint64_t used_vram = 0u; - drm_xe_query_mem_regions regions = {0}; + struct drm_xe_device_query query = {}; + query.query = DRM_XE_DEVICE_QUERY_MEM_REGIONS; - if (ioctl(fd, DRM_IOCTL_XE_DEVICE_QUERY, ®ions) == 0) { - for (uint32_t i = 0; i < regions.num_mem_regions; i++) { - total_vram += regions.mem_regions[i].total_size; - used_vram += regions.mem_regions[i].used; + if (ioctl(fd, DRM_IOCTL_XE_DEVICE_QUERY, &query) != 0 || query.size == 0) { + g->vram_total_mb = 0; + g->vram_used_mb = 0; + return; } - } - g->vram_total_mb = to_mb(total_vram); - g->vram_used_mb = to_mb(used_vram); + + std::vector buffer(query.size); + query.data = reinterpret_cast(buffer.data()); + + if (ioctl(fd, DRM_IOCTL_XE_DEVICE_QUERY, &query) != 0) { + g->vram_total_mb = 0; + g->vram_used_mb = 0; + return; + } + + const auto *regions = reinterpret_cast(buffer.data()); + + uint64_t total_vram = 0; + uint64_t used_vram = 0; + + for (uint32_t i = 0; i < regions->num_mem_regions; ++i) { + const auto &mem = regions->mem_regions[i]; + + // Filter out system memory (SYSMEM) to only aggregate discrete VRAM + if (mem.mem_class == DRM_XE_MEM_REGION_CLASS_VRAM) { + total_vram += mem.total_size; + used_vram += mem.used; + } + } + + g->vram_total_mb = to_mb(total_vram); + g->vram_used_mb = to_mb(used_vram); } static void vram_nouveau(int fd, GPUProperties *g) @@ -306,12 +339,14 @@ static int vulkan_query(VkGPU *out, const PCIAddress *pciAddr) * @param out Pointer to a GPUProperties struct to receive the GPU information. * @return 0 on success, -1 on failure. */ -int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) +GPUInfoQueryStatus get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) { if (!bdf || !out) { - return -1; + return GPUInfoQueryStatus::FAILURE; } + GPUInfoQueryStatus status = GPUInfoQueryStatus::FAILURE; + // Find the DRM card node under /sys/bus/pci/devices//drm/cardN. char drm_dir_path[160]; snprintf( @@ -385,12 +420,19 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) if (!g.vram_total_mb) { g.vram_total_mb = vk[v].vram_mb; + status = GPUInfoQueryStatus::VULKAN_VRAM_FALLBACK; } if (!g.vram_used_mb) { g.vram_used_mb = vk[v].used_mb; + status = GPUInfoQueryStatus::VULKAN_VRAM_FALLBACK; } if (vk[v].name[0]) { snprintf(g.name, sizeof(g.name), "%s", vk[v].name); + if (status == GPUInfoQueryStatus::VULKAN_VRAM_FALLBACK) { + status |= GPUInfoQueryStatus::VULKAN_NAME_FALLBACK; + } else { + status = GPUInfoQueryStatus::VULKAN_NAME_FALLBACK; + } } break; } @@ -398,5 +440,5 @@ int get_gpu_info(const char *bdf, uint32_t vendor_id, GPUProperties *out) } *out = g; - return 0; + return status; } From 034a7d9b8988887c344fdaf5cb000249b02ca50e Mon Sep 17 00:00:00 2001 From: kernel-dev Date: Wed, 19 Aug 2026 11:55:37 +0200 Subject: [PATCH 22/22] fix(linux): UTs --- src/hwprobe/core/linux/graphics.py | 14 +++++----- tests/core/linux/test_graphics.py | 45 ++++++++++++++++++------------ 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/hwprobe/core/linux/graphics.py b/src/hwprobe/core/linux/graphics.py index 79361bd..e472640 100644 --- a/src/hwprobe/core/linux/graphics.py +++ b/src/hwprobe/core/linux/graphics.py @@ -3,17 +3,17 @@ from typing import Optional from hwprobe.core.linux.common import PCI_ROOT_PATH, _read_from_sysfs, _resolve_acpi_path, pci_path_linux +from hwprobe.interops.linux.bindings.gpu_info import GPUInfoQueryStatus from hwprobe.models.gpu_models import GPUInfo, GraphicsInfo from hwprobe.models.size_models import Megabyte from hwprobe.models.status_models import StatusType -from src.hwprobe.interops.linux.bindings.gpu_info import GPUInfoQueryStatus # Try to import native C library bindings try: from hwprobe.interops.linux.bindings import gpu_info as native_gpu NATIVE_AVAILABLE = native_gpu.is_available() -except (ImportError, RuntimeError) as e: +except (ImportError, RuntimeError): NATIVE_AVAILABLE = False DISPLAY_CONTROLLER_CLASS = 0x03 # Display Controller class code in PCI @@ -59,11 +59,7 @@ def fetch_graphics_info() -> GraphicsInfo: return graphics_info for device in os.listdir(PCI_ROOT_PATH): - try: - if not _check_gpu_class(device): - continue - except Exception as e: - graphics_info.status.make_partial(f"Could not open file for {device}: {e}") + if not _check_gpu_class(device): continue gpu = GPUInfo() @@ -138,4 +134,8 @@ def fetch_graphics_info() -> GraphicsInfo: graphics_info.modules.append(gpu) + if not graphics_info.modules: + graphics_info.status.type = StatusType.FAILED + graphics_info.status.messages.append("No GPU modules found") + return graphics_info diff --git a/tests/core/linux/test_graphics.py b/tests/core/linux/test_graphics.py index d9c8222..d7632e4 100644 --- a/tests/core/linux/test_graphics.py +++ b/tests/core/linux/test_graphics.py @@ -6,6 +6,7 @@ from hwprobe.core.linux.common import _read_from_sysfs from hwprobe.core.linux.graphics import _check_gpu_class, _pcie_gen, fetch_graphics_info +from hwprobe.interops.linux.bindings.gpu_info import GPUInfoQueryStatus, GPUProperties from hwprobe.models.status_models import StatusType class TestPcieGen: @@ -274,11 +275,14 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) monkeypatch.setattr( "hwprobe.core.linux.graphics.native_gpu.get_gpu_info", - lambda *args, **kwargs: type( - "Native", - (), - {"name": "GeForce GTX 1060", "vram_total_mb": 6144, "vram_used_mb": 0}, - )(), + lambda *args, **kwargs: ( + GPUInfoQueryStatus.DRM_SUCCESS, + GPUProperties( + name="GeForce GTX 1060", + vram_total_mb=6144, + vram_used_mb=0, + ), + ), ) info = fetch_graphics_info() @@ -318,11 +322,14 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) monkeypatch.setattr( "hwprobe.core.linux.graphics.native_gpu.get_gpu_info", - lambda *args, **kwargs: type( - "Native", - (), - {"name": "Radeon RX 5700 XT", "vram_total_mb": 8192, "vram_used_mb": 0}, - )(), + lambda *args, **kwargs: ( + GPUInfoQueryStatus.DRM_SUCCESS, + GPUProperties( + name="Radeon RX 5700 XT", + vram_total_mb=8192, + vram_used_mb=0, + ), + ) ) info = fetch_graphics_info() @@ -349,7 +356,7 @@ def custom_open(path, *args, **kwargs): info = fetch_graphics_info() assert len(info.modules) == 0 - assert info.status.type == StatusType.SUCCESS + assert info.status.type == StatusType.FAILED def test_fetch_graphics_info_partial_failure(self, monkeypatch): monkeypatch.setattr(posixpath, "exists", lambda x: True) @@ -479,11 +486,14 @@ def custom_open(path, *args, **kwargs): monkeypatch.setattr("hwprobe.core.linux.graphics.NATIVE_AVAILABLE", True) monkeypatch.setattr( "hwprobe.core.linux.graphics.native_gpu.get_gpu_info", - lambda *args, **kwargs: type( - "Native", - (), - {"name": "GeForce GTX 1060", "vram_total_mb": 6144, "vram_used_mb": 0}, - )(), + lambda *args, **kwargs: ( + GPUInfoQueryStatus.DRM_SUCCESS, + GPUProperties( + name="GeForce GTX 1060", + vram_total_mb=6144, + vram_used_mb=0, + ), + ), ) info = fetch_graphics_info() @@ -576,5 +586,4 @@ def custom_open(path, *args, **kwargs): info = fetch_graphics_info() assert len(info.modules) == 0 - assert info.status.type == StatusType.PARTIAL - assert any("Could not open file" in msg for msg in info.status.messages) + assert info.status.type == StatusType.FAILED