Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
29c0ecc
feat: Display/GPU detection improvements
kernel-dev Aug 12, 2026
54460ef
fix: move ACPI helper to common & notify user if ACPI path was extrap…
kernel-dev Aug 12, 2026
7ccc94e
update: _resolve_acpi_path docstring
kernel-dev Aug 12, 2026
0cf5368
fix(Linux): ACPI fetching
kernel-dev Aug 14, 2026
6d9675d
PR fixes #1
kernel-dev Aug 15, 2026
f888a30
fix: merge conflicts; update test suite
kernel-dev Aug 15, 2026
9dd2c8f
fix: windows tests failing due to breaking changes
kernel-dev Aug 15, 2026
2db101c
fix: revert PCIe Link changes, will be done in separate PR
kernel-dev Aug 15, 2026
713e074
fix(linux): unit tests
kernel-dev Aug 15, 2026
b692ded
test: fix Linux graphics tests
Mahasvan Aug 16, 2026
cf0a74c
test: remove dead code
Mahasvan Aug 16, 2026
6acf168
fix(linux): a few `ty` related errors/warnings
kernel-dev Aug 17, 2026
e7eab2d
update(linux): add unit test for PCI bus device
kernel-dev Aug 17, 2026
c54a075
fix(linux): `_pcie_gen` now properly annotates IN parameter as
kernel-dev Aug 17, 2026
8a241c8
refactor(linux): `pci_path_linux` now uses regex matching to grab PCI
kernel-dev Aug 17, 2026
90429c3
fix(linux): simplify `_PCI_BDF_PATH_PATTERN` regex, and ensure it's
kernel-dev Aug 17, 2026
e7879cf
fix(linux): PR fixes #2
kernel-dev Aug 18, 2026
a0405e3
refactor(linux): update interops API, link against vulkan directly;
kernel-dev Aug 19, 2026
e1aafd8
fix(linux): run `clang-format` on rest of interops files
kernel-dev Aug 19, 2026
5a254d6
fix(linux): put braces after if-statements for one-instruction lines
kernel-dev Aug 19, 2026
10e60bc
fix(linux): miscellaneous fixes & patching xe driver ioctl to query
kernel-dev Aug 19, 2026
034a7d9
fix(linux): UTs
kernel-dev Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .clang-format
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
253 changes: 192 additions & 61 deletions src/hwprobe/core/common/edid.py
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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.
Expand All @@ -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)
Comment thread
kernel-dev marked this conversation as resolved.

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
Loading
Loading