[Concept,19/25] scripts: Add a tool to convert CHIDs to devicetree

Message ID 20250903133639.3235920-20-sjg@u-boot.org
State New
Headers
Series Selection of devicetree using CHIDs |

Commit Message

Simon Glass Sept. 3, 2025, 1:36 p.m. UTC
  From: Simon Glass <sjg@chromium.org>

Add a Python script to convert Hardware ID files (as produced by
'fwupd hwids') into a devicetree format suitable for use within U-Boot.

Provide a simple test as well.

Co-developed-by: Claude <noreply@anthropic.com>
Signed-off-by: Simon Glass <sjg@chromium.org>
---

 scripts/hwids_to_dtsi.py           | 795 +++++++++++++++++++++++++++++
 test/scripts/test_hwids_to_dtsi.py | 306 +++++++++++
 2 files changed, 1101 insertions(+)
 create mode 100755 scripts/hwids_to_dtsi.py
 create mode 100644 test/scripts/test_hwids_to_dtsi.py
  

Patch

diff --git a/scripts/hwids_to_dtsi.py b/scripts/hwids_to_dtsi.py
new file mode 100755
index 00000000000..d1f138687fe
--- /dev/null
+++ b/scripts/hwids_to_dtsi.py
@@ -0,0 +1,795 @@ 
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0+
+"""
+Convert HWIDS txt files to devicetree source (.dtsi) format
+
+This script converts hardware ID files from board/efi/hwids/ into devicetree
+source files. The output includes SMBIOS computer information as properties
+and Hardware IDs as binary GUID arrays.
+"""
+
+import argparse
+import glob
+from io import StringIO
+import os
+import re
+import sys
+import traceback
+import uuid
+from enum import IntEnum
+
+
+DTSI_HEADER = """// SPDX-License-Identifier: GPL-2.0+
+
+// Computer Hardware IDs for multiple boards
+// Generated from source_path
+
+/ {
+\tchid: chid {};
+};
+
+&chid {
+"""
+
+DTSI_FOOTER = """};
+"""
+
+# Constants for magic numbers
+GUID_LENGTH = 36  # Length of GUID string without braces (8-4-4-4-12 format)
+HWIDS_SECTION_HEADER_LINES = 2  # Lines for 'Hardware IDs' header and dashes
+CHID_BINARY_LENGTH = 16  # Length of CHID binary data in bytes
+HEX_BASE = 16  # Base for hexadecimal conversions
+
+# Field types for special handling
+VERSION_FIELDS = {
+    'BiosMajorRelease', 'BiosMinorRelease',
+    'FirmwareMajorRelease', 'FirmwareMinorRelease'
+}
+HEX_ENCLOSURE_FIELD = 'EnclosureKind'
+
+class CHIDField(IntEnum):
+    """CHID field types matching the C enum chid_field_t."""
+    MANUF = 0
+    FAMILY = 1
+    PRODUCT_NAME = 2
+    PRODUCT_SKU = 3
+    BOARD_MANUF = 4
+    BOARD_PRODUCT = 5
+    BIOS_VENDOR = 6
+    BIOS_VERSION = 7
+    BIOS_MAJOR = 8
+    BIOS_MINOR = 9
+    ENCLOSURE_TYPE = 10
+
+
+class CHIDVariant(IntEnum):
+    """CHID variant IDs matching the Microsoft specification."""
+    CHID_00 = 0   # Most specific
+    CHID_01 = 1
+    CHID_02 = 2
+    CHID_03 = 3
+    CHID_04 = 4
+    CHID_05 = 5
+    CHID_06 = 6
+    CHID_07 = 7
+    CHID_08 = 8
+    CHID_09 = 9
+    CHID_10 = 10
+    CHID_11 = 11
+    CHID_12 = 12
+    CHID_13 = 13
+    CHID_14 = 14  # Least specific
+
+
+# Field mapping from HWIDS file field names to CHIDField bits and devicetree
+# properties
+# Format: 'HWIDSFieldName': (CHIDField.BIT or None, 'devicetree-property-name')
+# Note: Firmware fields don't map to CHIDField bits
+FIELD_MAP = {
+    'Manufacturer': (CHIDField.MANUF, 'manufacturer'),
+    'Family': (CHIDField.FAMILY, 'family'),
+    'ProductName': (CHIDField.PRODUCT_NAME, 'product-name'),
+    'ProductSku': (CHIDField.PRODUCT_SKU, 'product-sku'),
+    'BaseboardManufacturer': (CHIDField.BOARD_MANUF, 'baseboard-manufacturer'),
+    'BaseboardProduct': (CHIDField.BOARD_PRODUCT, 'baseboard-product'),
+    'BiosVendor': (CHIDField.BIOS_VENDOR, 'bios-vendor'),
+    'BiosVersion': (CHIDField.BIOS_VERSION, 'bios-version'),
+    'BiosMajorRelease': (CHIDField.BIOS_MAJOR, 'bios-major-release'),
+    'BiosMinorRelease': (CHIDField.BIOS_MINOR, 'bios-minor-release'),
+    'EnclosureKind': (CHIDField.ENCLOSURE_TYPE, 'enclosure-kind'),
+    'FirmwareMajorRelease': (None, 'firmware-major-release'),
+    'FirmwareMinorRelease': (None, 'firmware-minor-release'),
+}
+
+
+# CHID variants table matching the C code variants array
+CHID_VARIANTS = {
+    CHIDVariant.CHID_00: {
+        'name': 'HardwareID-00',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY) |
+                  (1 << CHIDField.PRODUCT_NAME) | (1 << CHIDField.PRODUCT_SKU) |
+                  (1 << CHIDField.BIOS_VENDOR) | (1 << CHIDField.BIOS_VERSION) |
+                  (1 << CHIDField.BIOS_MAJOR) | (1 << CHIDField.BIOS_MINOR)
+    },
+    CHIDVariant.CHID_01: {
+        'name': 'HardwareID-01',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY) |
+                  (1 << CHIDField.PRODUCT_NAME) | (1 << CHIDField.BIOS_VENDOR) |
+                  (1 << CHIDField.BIOS_VERSION) | (1 << CHIDField.BIOS_MAJOR) |
+                  (1 << CHIDField.BIOS_MINOR)
+    },
+    CHIDVariant.CHID_02: {
+        'name': 'HardwareID-02',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.PRODUCT_NAME) |
+                  (1 << CHIDField.BIOS_VENDOR) | (1 << CHIDField.BIOS_VERSION) |
+                  (1 << CHIDField.BIOS_MAJOR) | (1 << CHIDField.BIOS_MINOR)
+    },
+    CHIDVariant.CHID_03: {
+        'name': 'HardwareID-03',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY) |
+                  (1 << CHIDField.PRODUCT_NAME) | (1 << CHIDField.PRODUCT_SKU) |
+                  (1 << CHIDField.BOARD_MANUF) | (1 << CHIDField.BOARD_PRODUCT)
+    },
+    CHIDVariant.CHID_04: {
+        'name': 'HardwareID-04',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY) |
+                  (1 << CHIDField.PRODUCT_NAME) | (1 << CHIDField.PRODUCT_SKU)
+    },
+    CHIDVariant.CHID_05: {
+        'name': 'HardwareID-05',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY) |
+                  (1 << CHIDField.PRODUCT_NAME)
+    },
+    CHIDVariant.CHID_06: {
+        'name': 'HardwareID-06',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.PRODUCT_SKU) |
+                  (1 << CHIDField.BOARD_MANUF) | (1 << CHIDField.BOARD_PRODUCT)
+    },
+    CHIDVariant.CHID_07: {
+        'name': 'HardwareID-07',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.PRODUCT_SKU)
+    },
+    CHIDVariant.CHID_08: {
+        'name': 'HardwareID-08',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.PRODUCT_NAME) |
+                  (1 << CHIDField.BOARD_MANUF) | (1 << CHIDField.BOARD_PRODUCT)
+    },
+    CHIDVariant.CHID_09: {
+        'name': 'HardwareID-09',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.PRODUCT_NAME)
+    },
+    CHIDVariant.CHID_10: {
+        'name': 'HardwareID-10',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY) |
+                  (1 << CHIDField.BOARD_MANUF) | (1 << CHIDField.BOARD_PRODUCT)
+    },
+    CHIDVariant.CHID_11: {
+        'name': 'HardwareID-11',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.FAMILY)
+    },
+    CHIDVariant.CHID_12: {
+        'name': 'HardwareID-12',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.ENCLOSURE_TYPE)
+    },
+    CHIDVariant.CHID_13: {
+        'name': 'HardwareID-13',
+        'fields': (1 << CHIDField.MANUF) | (1 << CHIDField.BOARD_MANUF) |
+                  (1 << CHIDField.BOARD_PRODUCT)
+    },
+    CHIDVariant.CHID_14: {
+        'name': 'HardwareID-14',
+        'fields': (1 << CHIDField.MANUF)
+    }
+}
+
+
+def load_compatible_map(hwids_dir):
+    """Load the compatible string mapping from compatible-map file
+
+    Args:
+        hwids_dir (str): Directory containing the compatible-map file
+
+    Returns:
+        dict: Mapping from filename to compatible string, empty if there is no
+            map
+    """
+    compatible_map = {}
+    map_file = os.path.join(hwids_dir, 'compatible-map')
+
+    if not os.path.exists(map_file):
+        return compatible_map
+
+    with open(map_file, 'r', encoding='utf-8') as f:
+        for line in f:
+            line = line.strip()
+            if line and not line.startswith('#'):
+                parts = line.split(': ', 1)
+                if len(parts) == 2:
+                    compatible_map[parts[0]] = parts[1]
+
+    return compatible_map
+
+
+def parse_variant_description(description):
+    """Parse variant description to determine CHID variant ID and fields mask
+
+    Args:
+        description (str): Description text after '<-' in HWIDS file
+
+    Returns:
+        tuple: (variant_id, fields_mask) where variant_id is int (0-14) or None,
+               and fields_mask is the bitmask or None if not parseable
+
+    Examples:
+        >>> parse_variant_description('Manufacturer + Family + ProductName')
+        (5, 7)  # HardwareID-05 with fields 0x1|0x2|0x4 = 0x7
+
+        >>> parse_variant_description('Manufacturer')
+        (14, 1)  # HardwareID-14 with field 0x1
+
+        >>> parse_variant_description('Invalid + Field')
+        (None, 0)  # Unknown fields, no variant match
+    """
+    # Parse field names and match to variants
+    desc = description.strip()
+
+    # Parse the field list
+    fields_mask = 0
+    field_parts = desc.split(' + ')
+    for part in field_parts:
+        part = part.strip()
+        if part in FIELD_MAP:
+            # Get CHIDField, ignore dt property name
+            chid_field, _ = FIELD_MAP[part]
+            if chid_field is not None:  # Only add to mask if it's a CHIDField
+                fields_mask |= (1 << chid_field)
+
+    # If no fields were parsed, return None for both
+    if not fields_mask:
+        return None, None
+
+    # Match against known variants
+    for variant_id, variant_info in CHID_VARIANTS.items():
+        if variant_info['fields'] == fields_mask:
+            return int(variant_id), fields_mask
+
+    # Fields were parsed but don't match a known variant
+    return None, fields_mask
+
+
+def _parse_hardware_ids_section(content, filepath):
+    """Parse the Hardware IDs section from HWIDS file content
+
+    Args:
+        content (str): Full file content
+        filepath (str): Path to the file for error reporting
+
+    Returns:
+        list: List of (guid_string, variant_id, bitmask) tuples
+    """
+    hardware_ids = []
+    ids_pattern = r'Hardware IDs\n-+\n(.*?)(?:\n\n|$)'
+    ids_section = re.search(ids_pattern, content, re.DOTALL)
+    if not ids_section:
+        raise ValueError(f'{filepath}: Missing "Hardware IDs" section')
+
+    for linenum, line in enumerate(ids_section.group(1).strip().split('\n'), 1):
+        if not line.strip():
+            continue
+
+        # Extract GUID and variant info from line like:
+        # '{810e34c6-cc69-5e36-8675-2f6e354272d3}' <- HardwareID-00
+        guid_match = re.search(rf'\{{([0-9a-f-]{{{GUID_LENGTH}}})\}}', line)
+        if not guid_match:
+            continue
+
+        guid = guid_match.group(1)
+
+        # The '<-' separator is required for valid HWIDS files
+        if '<-' not in line:
+            # Calculate actual line number in file
+            # (need to account for lines before Hardware IDs section)
+            before = content[:content.find('Hardware IDs')].count('\n')
+            # +2 for header and dashes
+            actual_line = before + linenum + HWIDS_SECTION_HEADER_LINES
+            raise ValueError(
+                f"{filepath}:{actual_line}: Missing '<-' separator in "
+                f'Hardware ID line: {line.strip()}')
+
+        description = line.split('<-', 1)[1].strip()
+        variant_id, bitmask = parse_variant_description(description)
+
+        hardware_ids.append((guid, variant_id, bitmask))
+
+    return hardware_ids
+
+
+def parse_hwids_file(filepath):
+    """Parse a HWIDS txt file and return computer info and hardware IDs
+
+    Args:
+        filepath (str): Path to the HWIDS txt file
+
+    Returns:
+        tuple: (computer_info dict, hardware_ids list of tuples)
+                hardware_ids contains (guid_string, variant_id, bitmask) tuples
+    """
+    info = {}
+    hardware_ids = []
+
+    with open(filepath, 'r', encoding='utf-8') as f:
+        content = f.read()
+
+    # Extract computer information section
+    info_section = re.search(r'Computer Information\n-+\n(.*?)\nHardware IDs',
+                             content, re.DOTALL)
+    if not info_section:
+        raise ValueError(f'{filepath}: Missing "Computer Information" section')
+
+    for line in info_section.group(1).strip().split('\n'):
+        if ':' in line:
+            key, value = line.split(':', 1)
+            info[key.strip()] = value.strip()
+
+    # Extract hardware IDs with variant information
+    hardware_ids = _parse_hardware_ids_section(content, filepath)
+
+    return info, hardware_ids
+
+
+def _add_header(out, basename, source_path=None):
+    """Add header section to devicetree source
+
+    Args:
+        out (StringIO): StringIO object to write to
+        basename (str): Base filename for the header comment
+        source_path (str): Path to the source file or directory
+    """
+    out.write('// SPDX-License-Identifier: GPL-2.0+\n')
+    out.write('\n')
+    out.write(f'// Computer Hardware IDs for {basename}\n')
+    if source_path:
+        out.write(f'// Generated from {source_path}\n')
+    else:
+        out.write('// Generated from board/efi/hwids/\n')
+    out.write('\n')
+
+
+def _add_computer_info(out, computer_info, indent=2):
+    """Add computer information properties to devicetree source
+
+    Args:
+        out (StringIO): StringIO object to write to
+        computer_info (dict): Dictionary of computer information fields
+        indent (int): Number of tab indentations (default 2 for &chid
+            structure)
+    """
+    indent = '\t' * indent
+    out.write(f'{indent}// SMBIOS Computer Information\n')
+    for key, value in computer_info.items():
+        # Look up the devicetree property name from FIELD_MAP
+        if key in FIELD_MAP:
+            _, prop_name = FIELD_MAP[key]
+        else:
+            # Fallback for fields not in FIELD_MAP (e.g. FirmwareMajorRelease,
+            # FirmwareMinorRelease)
+            prop_name = key.lower().replace('release', '-release')
+
+        # Handle numeric values vs strings
+        if key in VERSION_FIELDS and value.isdigit():
+            out.write(f'{indent}{prop_name} = <{value}>;\n')
+        elif key == HEX_ENCLOSURE_FIELD:
+            # Value is already a hex string, convert directly
+            hex_val = int(value, HEX_BASE)
+            out.write(f'{indent}{prop_name} = <0x{hex_val:x}>;\n')
+        else:
+            out.write(f'{indent}{prop_name} = "{value}";\n')
+
+
+def _add_hardware_ids(out, hardware_ids, indent=2):
+    """Add hardware IDs as subnodes to devicetree source
+
+    Args:
+        out (StringIO): StringIO object to write to
+        hardware_ids (list): List of (guid_string, variant_id, bitmask) tuples
+        indent (int): Number of tab indentations (default 2 for &chid
+            structure)
+    """
+    indent = '\t' * indent
+    out.write(f'{indent}// Hardware IDs (CHIDs)\n')
+
+    extra_counter = 0
+    for guid, variant_id, bitmask in hardware_ids:
+        # Convert GUID string to binary array for devicetree
+        guid_obj = uuid.UUID(guid)
+        binary_data = guid_obj.bytes  # Raw 16 bytes, no endian conversion
+        hex_bytes = ' '.join(f'{b:02x}' for b in binary_data)
+        hex_array = f'[{hex_bytes}]'
+
+        # Create node name - use variant number if available, otherwise extra-N
+        if variant_id is not None:
+            node_name = f'hardware-id-{variant_id:02d}'
+            variant_info = CHID_VARIANTS.get(variant_id, {})
+            comment = variant_info.get('name', f'Unknown-{variant_id}')
+        else:
+            node_name = f'extra-{extra_counter}'
+            comment = 'unknown variant'
+            extra_counter += 1
+
+        out.write('\n')
+        out.write(f'{indent}{node_name} {{ // {comment}\n')
+
+        # Add variant property if known
+        if variant_id is not None:
+            out.write(f'{indent}\tvariant = <{variant_id}>;\n')
+
+        # Add fields property if bitmask is known
+        if bitmask is not None:
+            out.write(f'{indent}\tfields = <0x{bitmask:x}>;\n')
+
+        # Add CHID bytes
+        out.write(f'{indent}\tchid = {hex_array};\n')
+
+        out.write(f'{indent}}};\n')
+
+
+def generate_dtsi(basename, compatible, computer_info, hardware_ids,
+                   source_path=None):
+    """Generate devicetree source content
+
+    Args:
+        basename (str): Base filename for comments and node name
+        compatible (str): Compatible string for the node
+        computer_info (dict): Dictionary of computer information
+        hardware_ids (list): List of (guid_string, variant_id, bitmask) tuples
+        source_path (str): Path to the source file or directory
+
+    Returns:
+        str: Complete devicetree source content
+
+    Examples:
+        >>> info = {'Manufacturer': 'ACME', 'ProductName': 'Device'}
+        >>> hwids = [('12345678-1234-5678-9abc-123456789abc', 14, 1)]
+        >>> dtsi = generate_dtsi('acme-device', 'acme,device', info, hwids)
+        >>> '// Computer Hardware IDs for acme-device' in dtsi
+        True
+        >>> 'compatible = "acme,device"' in dtsi
+        True
+    """
+    out = StringIO()
+
+    _add_header(out, basename, source_path)
+
+    # Start root node with chid declaration
+    out.write('/ {\n')
+    out.write('\tchid: chid {};\n')
+    out.write('};\n')
+    out.write('\n')
+
+    # Add device content to chid node using reference
+    out.write('&chid {\n')
+    node_name = basename.replace('.', '-')
+    out.write(f'\t{node_name} {{\n')
+    out.write(f'\t\tcompatible = "{compatible}";\n')
+    out.write('\n')
+
+    _add_computer_info(out, computer_info, indent=2)
+    out.write('\n')
+
+    _add_hardware_ids(out, hardware_ids, indent=2)
+
+    out.write('\t};\n')
+    out.write('};\n')
+
+    return out.getvalue()
+
+
+def parse_arguments():
+    """Parse command line arguments
+
+    Returns:
+        argparse.Namespace: Parsed command line arguments
+    """
+    parser = argparse.ArgumentParser(
+        description='Convert HWIDS txt files to devicetree source (.dtsi)'
+    )
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument(
+        'input_file',
+        nargs='?',
+        help='Path to HWIDS txt file (e.g., board/efi/hwids/filename.txt)'
+    )
+    group.add_argument(
+        '-m', '--map-file',
+        help='compatible.hwidmap file (processes all .txt files in same dir)'
+    )
+    parser.add_argument(
+        '-o', '--output',
+        help='Output file (default: basename.dtsi or hwids.dtsi for dir mode)'
+    )
+    parser.add_argument(
+        '-v', '--verbose',
+        action='store_true',
+        help='Show verbose output with conversion details'
+    )
+    parser.add_argument(
+        '-D', '--debug',
+        action='store_true',
+        help='Show debug traceback on errors'
+    )
+
+    return parser.parse_args()
+
+
+def _process_board_file(out, compatible, txt_file, boards_processed, verbose):
+    """Process a single board HWIDS file and add to output
+
+    Args:
+        out (StringIO): StringIO object to write to
+        compatible (str): Compatible string for the board
+        txt_file (str): Full path to the HWIDS txt file
+        boards_processed (int): Number of boards processed so far
+        verbose (bool): Whether to show verbose output
+
+    Returns:
+        bool: True if board was successfully processed, False otherwise
+    """
+    basename = os.path.splitext(os.path.basename(txt_file))[0]
+    if verbose:
+        print(f'Processing {basename}...')
+
+    computer, hardware_ids = parse_hwids_file(txt_file)
+
+    if not hardware_ids:
+        if verbose:
+            print(f'  Warning: No hardware IDs found in {basename}')
+        return False
+
+    # Add blank line between boards
+    if boards_processed > 0:
+        out.write('\n')
+
+    # Generate board node directly (combining all boards)
+    node_name = basename.replace('.', '-')
+    out.write(f'\t{node_name} {{\n')
+    out.write(f'\t\tcompatible = "{compatible}";\n')
+    out.write('\n')
+
+    # Add computer info and hardware IDs
+    _add_computer_info(out, computer, indent=2)
+    out.write('\n')
+
+    _add_hardware_ids(out, hardware_ids, indent=2)
+
+    out.write('\t};\n')
+
+    if verbose:
+        print(f'  Added {len(hardware_ids)} hardware IDs')
+
+    return True
+
+
+def _load_and_validate_map_file(map_file_path, verbose=False):
+    """Load and validate compatible map file
+
+    Args:
+        map_file_path (str): Path to the compatible.hwidmap file
+        verbose (bool): Show verbose output
+
+    Returns:
+        tuple: (hwids_dir, compatible_map)
+    """
+    # Get directory containing the map file
+    hwids_dir = os.path.dirname(map_file_path)
+
+    # Load compatible string mapping from the specified file
+    compatible_map = {}
+    if os.path.exists(map_file_path):
+        with open(map_file_path, 'r', encoding='utf-8') as f:
+            for line in f:
+                line = line.strip()
+                if line and not line.startswith('#'):
+                    parts = line.split(': ', 1)
+                    if len(parts) == 2:
+                        compatible_map[parts[0]] = parts[1]
+
+    if not compatible_map:
+        raise ValueError(f'No valid mappings found in {map_file_path}')
+
+    # Find all .txt files in the same directory
+    txt_files = glob.glob(os.path.join(hwids_dir, '*.txt'))
+    txt_basenames = {os.path.splitext(os.path.basename(f))[0]
+                     for f in txt_files}
+
+    # Check for files in map that don't exist
+    map_files = set(compatible_map.keys())
+    missing_files = map_files - txt_basenames
+    if missing_files:
+        raise ValueError('Files in map but not found in directory: '
+                         f"{', '.join(sorted(missing_files))}")
+
+    # Check for files in directory that aren't in map
+    extra_files = txt_basenames - map_files
+    if extra_files:
+        file_list = ', '.join(sorted(extra_files))
+        raise ValueError(f'Files in directory but not in map: {file_list}')
+
+    if verbose:
+        print(f'Using compatible map: {map_file_path}')
+        print(f'Processing {len(compatible_map)} HWIDs files from map')
+        print()
+
+    return hwids_dir, compatible_map
+
+
+def _finalise_combined_dtsi(out, hwids_dir, processed, skipped, verbose):
+    """Finalize the combined DTSI output with validation and reporting
+
+    Args:
+        out (StringIO): StringIO object containing the main content
+        hwids_dir (str): Directory path for header generation
+        processed (int): Number of successfully processed files
+        skipped (list): List of skipped board names
+        verbose (bool): Whether to show verbose output
+
+    Returns:
+        str: Final DTSI content with header and footer
+    """
+    if not processed:
+        raise ValueError('No valid HWIDS files could be processed')
+
+    if verbose:
+        print(f'\nProcessed {processed} boards successfully')
+
+    # Print warning about skipped boards
+    if skipped:
+        print(f'Warning: Skipped {len(skipped)} unmapped boards: '
+              f"{', '.join(skipped)}")
+
+    header = DTSI_HEADER.replace('source_path', hwids_dir)
+    return ''.join([header, out.getvalue(), DTSI_FOOTER])
+
+
+def process_map_file(map_file_path, verbose=False):
+    """Process HWIDS files specified in the map file and generate combined DTSI
+
+    Args:
+        map_file_path (str): Path to the compatible.hwidmap file
+        verbose (bool): Show verbose output
+
+    Returns:
+        str: Combined DTSI content for all boards
+    """
+    hwids_dir, compatible_map = _load_and_validate_map_file(map_file_path,
+                                                            verbose)
+
+    out = StringIO()
+    processed = 0
+    skipped = []
+    for basename in sorted(compatible_map.keys()):
+        compatible = compatible_map[basename]
+
+        # Skip files with 'none' mapping
+        if compatible == 'none':
+            skipped.append(basename)
+            if verbose:
+                print(f'Skipping {basename} (mapping: none)')
+            continue
+
+        # Process this board file
+        txt_file = os.path.join(hwids_dir, f'{basename}.txt')
+        if _process_board_file(out, compatible, txt_file, processed, verbose):
+            processed += 1
+
+    return _finalise_combined_dtsi(out, hwids_dir, processed, skipped,
+                                   verbose)
+
+
+def handle_map_file_mode(args):
+    """Handle map file mode processing
+
+    Args:
+        args (argparse.Namespace): Parsed command line arguments
+
+    Returns:
+        str: Generated DTSI content
+    """
+    if not os.path.exists(args.map_file):
+        raise FileNotFoundError(f'Map file {args.map_file} not found')
+
+    dtsi_content = process_map_file(args.map_file, args.verbose)
+    outfile = args.output or 'hwids.dtsi'
+
+    if args.verbose:
+        print(f'Generated combined DTSI -> {outfile}')
+        print()
+
+    return dtsi_content
+
+
+def handle_single_file_mode(args):
+    """Handle single file mode processing
+
+    Args:
+        args (argparse.Namespace): Parsed command line arguments
+
+    Returns:
+        str: Generated DTSI content
+    """
+    if not args.input_file:
+        raise ValueError('input_file is required when not using --map-file')
+
+    if not os.path.exists(args.input_file):
+        raise FileNotFoundError(f"File '{args.input_file}' not found")
+
+    # Get the directory and basename
+    hwids_dir = os.path.dirname(args.input_file)
+    basename = os.path.splitext(os.path.basename(args.input_file))[0]
+
+    # Load compatible string mapping
+    compatible_map = load_compatible_map(hwids_dir)
+    compatible = compatible_map.get(basename, f'unknown,{basename}')
+
+    # Parse the input file
+    info, hardware_ids = parse_hwids_file(args.input_file)
+
+    if not hardware_ids and args.verbose:
+        print(f"Warning: No hardware IDs found in '{args.input_file}'")
+
+    # Generate devicetree source
+    content = generate_dtsi(basename, compatible, info,
+                                 hardware_ids, args.input_file)
+
+    outfile = args.output or f'{basename}.dtsi'
+    if args.verbose:
+        print(f'Converting {args.input_file} -> {outfile}')
+        print(f'Compatible: {compatible}')
+        print(f'Computer info fields: {len(info)}')
+        print(f'Hardware IDs: {len(hardware_ids)}')
+        print()
+
+    return content
+
+
+def main():
+    """Main function
+
+    Returns:
+        int: Exit code (0 for success, 1 for error)
+    """
+    args = parse_arguments()
+    try:
+        # Choose processing mode and handle
+        if args.map_file:
+            content = handle_map_file_mode(args)
+            outfile = args.output or 'hwids.dtsi'
+        else:
+            content = handle_single_file_mode(args)
+            basename = os.path.splitext(os.path.basename(args.input_file))[0]
+            outfile = args.output or f'{basename}.dtsi'
+
+        # Write to file if output specified, otherwise print to stdout
+        if args.output:
+            try:
+                with open(outfile, 'w', encoding='utf-8') as f:
+                    f.write(content)
+                if args.verbose:
+                    print(f'Written to {outfile}')
+            except (IOError, OSError) as e:
+                print(f'Error writing to {outfile}: {e}')
+                return 1
+        else:
+            print(content, end='')
+
+        return 0
+
+    except (ValueError, FileNotFoundError, IOError, OSError) as e:
+        if args.debug:
+            traceback.print_exc()
+        else:
+            print(f'Error: {e}')
+        return 1
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/test/scripts/test_hwids_to_dtsi.py b/test/scripts/test_hwids_to_dtsi.py
new file mode 100644
index 00000000000..c88604631a8
--- /dev/null
+++ b/test/scripts/test_hwids_to_dtsi.py
@@ -0,0 +1,306 @@ 
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0+
+"""
+Test for hwids-to-dtsi.py script
+
+Validates that the HWIDS to devicetree conversion script correctly parses
+hardware ID files and generates proper devicetree-source output
+"""
+
+import os
+import sys
+import tempfile
+import unittest
+import uuid
+from io import StringIO
+
+# Add the scripts directory to the path
+script_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'scripts')
+sys.path.insert(0, script_dir)
+
+# pylint: disable=wrong-import-position,import-error
+from hwids_to_dtsi import (
+    load_compatible_map,
+    parse_hwids_file,
+    generate_dtsi,
+    parse_variant_description,
+    VERSION_FIELDS,
+    HEX_ENCLOSURE_FIELD,
+    _finalise_combined_dtsi
+)
+
+
+class TestHwidsToDeviceTree(unittest.TestCase):
+    """Test cases for HWIDS to devicetree conversion"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        self.test_hwids_content = """Computer Information
+--------------------
+BiosVendor: Insyde Corp.
+BiosVersion: V1.24
+BiosMajorRelease: 0
+BiosMinorRelease: 0
+FirmwareMajorRelease: 01
+FirmwareMinorRelease: 15
+Manufacturer: Acer
+Family: Swift 14 AI
+ProductName: Swift SF14-11
+ProductSku:
+EnclosureKind: a
+BaseboardManufacturer: SX1
+BaseboardProduct: Bluetang_SX1
+Hardware IDs
+------------
+{27d2dba8-e6f1-5c19-ba1c-c25a4744c161}   <- Manufacturer + Family + ProductName + ProductSku + BiosVendor + BiosVersion + BiosMajorRelease + BiosMinorRelease
+{676172cd-d185-53ed-aac6-245d0caa02c4}   <- Manufacturer + Family + ProductName + BiosVendor + BiosVersion + BiosMajorRelease + BiosMinorRelease
+{20c2cf2f-231c-5d02-ae9b-c837ab5653ed}   <- Manufacturer + ProductName + BiosVendor + BiosVersion + BiosMajorRelease + BiosMinorRelease
+{f2ea7095-999d-5e5b-8f2a-4b636a1e399f}   <- Manufacturer + Family + ProductName + ProductSku + BaseboardManufacturer + BaseboardProduct
+{331d7526-8b88-5923-bf98-450cf3ea82a4}   <- Manufacturer + Family + ProductName + ProductSku
+{98ad068a-f812-5f13-920c-3ff3d34d263f}   <- Manufacturer + Family + ProductName
+{3f49141c-d8fb-5a6f-8b4a-074a2397874d}   <- Manufacturer + ProductSku + BaseboardManufacturer + BaseboardProduct
+{7c107a7f-2d77-51aa-aef8-8d777e26ffbc}   <- Manufacturer + ProductSku
+{6a12c9bc-bcfa-5448-9f66-4159dbe8c326}   <- Manufacturer + ProductName + BaseboardManufacturer + BaseboardProduct
+{f55122fb-303f-58bc-b342-6ef653956d1d}   <- Manufacturer + ProductName
+{ee8fa049-e5f4-51e4-89d8-89a0140b8f38}   <- Manufacturer + Family + BaseboardManufacturer + BaseboardProduct
+{4cdff732-fd0c-5bac-b33e-9002788ea557}   <- Manufacturer + Family
+{92dcc94d-48f7-5ee8-b9ec-a6393fb7a484}   <- Manufacturer + EnclosureKind
+{32f83b0f-1fad-5be2-88be-5ab020e7a70e}   <- Manufacturer + BaseboardManufacturer + BaseboardProduct
+{1e301734-5d49-5df4-9ed2-aa1c0a9dddda}   <- Manufacturer
+Extra Hardware IDs
+------------------
+{058c0739-1843-5a10-bab7-fae8aaf30add}   <- Manufacturer + Family + ProductName + ProductSku + BiosVendor
+{100917f4-9c0a-5ac3-a297-794222da9bc9}   <- Manufacturer + Family + ProductName + BiosVendor
+{86654360-65f0-5935-bc87-81102c6a022b}   <- Manufacturer + BiosVendor
+"""
+
+        self.test_compatible_map = """# SPDX-License-Identifier: GPL-2.0+
+# compatible map
+test-device: test,example-device
+"""
+
+    def test_parse_hwids_file(self):
+        """Test parsing of HWIDS file content"""
+        with tempfile.NamedTemporaryFile(mode='w', suffix='.txt',
+                                         delete=False) as outf:
+            outf.write(self.test_hwids_content)
+            outf.flush()
+
+            try:
+                info, hardware_ids = parse_hwids_file(outf.name)
+                expected_info = {
+                    'BiosVendor': 'Insyde Corp.',
+                    'BiosVersion': 'V1.24',
+                    'BiosMajorRelease': '0',
+                    'BiosMinorRelease': '0',
+                    'FirmwareMajorRelease': '01',
+                    'FirmwareMinorRelease': '15',
+                    'Manufacturer': 'Acer',
+                    'Family': 'Swift 14 AI',
+                    'ProductName': 'Swift SF14-11',
+                    'ProductSku': '',
+                    'EnclosureKind': 'a',
+                    'BaseboardManufacturer': 'SX1',
+                    'BaseboardProduct': 'Bluetang_SX1'
+                }
+                self.assertEqual(info, expected_info)
+
+                # Check hardware IDs (now tuples with variant info and bitmask)
+                expected_ids = [
+                    # Variant 0: All main fields
+                    ('27d2dba8-e6f1-5c19-ba1c-c25a4744c161', 0, 0x3cf),
+                    # Variant 1: Without SKU
+                    ('676172cd-d185-53ed-aac6-245d0caa02c4', 1, 0x3c7),
+                    # Variant 2: Without family
+                    ('20c2cf2f-231c-5d02-ae9b-c837ab5653ed', 2, 0x3c5),
+                    # Variant 3: With baseboard, no BIOS version
+                    ('f2ea7095-999d-5e5b-8f2a-4b636a1e399f', 3, 0x3f),
+                    # Variant 4: Basic product ID
+                    ('331d7526-8b88-5923-bf98-450cf3ea82a4', 4, 0xf),
+                    # Variant 5: Without SKU
+                    ('98ad068a-f812-5f13-920c-3ff3d34d263f', 5, 0x7),
+                    # Variant 6: SKU with baseboard
+                    ('3f49141c-d8fb-5a6f-8b4a-074a2397874d', 6, 0x39),
+                    # Variant 7: Manufacturer and SKU
+                    ('7c107a7f-2d77-51aa-aef8-8d777e26ffbc', 7, 0x9),
+                    # Variant 8: Product name with baseboard
+                    ('6a12c9bc-bcfa-5448-9f66-4159dbe8c326', 8, 0x35),
+                    # Variant 9: Manufacturer and product name
+                    ('f55122fb-303f-58bc-b342-6ef653956d1d', 9, 0x5),
+                    # Variant 10: Family with baseboard
+                    ('ee8fa049-e5f4-51e4-89d8-89a0140b8f38', 10, 0x33),
+                    # Variant 11: Manufacturer and family
+                    ('4cdff732-fd0c-5bac-b33e-9002788ea557', 11, 0x3),
+                    # Variant 12: Manufacturer and enclosure
+                    ('92dcc94d-48f7-5ee8-b9ec-a6393fb7a484', 12, 0x401),
+                    # Variant 13: Manufacturer with baseboard
+                    ('32f83b0f-1fad-5be2-88be-5ab020e7a70e', 13, 0x31),
+                    # Variant 14: Manufacturer only
+                    ('1e301734-5d49-5df4-9ed2-aa1c0a9dddda', 14, 0x1),
+                    # Extra Hardware IDs (non-standard variants)
+                    # Extra: Manufacturer + Family + ProductName + ProductSku + BiosVendor
+                    ('058c0739-1843-5a10-bab7-fae8aaf30add', None, 0x4f),
+                    # Extra: Manufacturer + Family + ProductName + BiosVendor
+                    ('100917f4-9c0a-5ac3-a297-794222da9bc9', None, 0x47),
+                    # Extra: Manufacturer + BiosVendor
+                    ('86654360-65f0-5935-bc87-81102c6a022b', None, 0x41),
+                ]
+                self.assertEqual(hardware_ids, expected_ids)
+
+            finally:
+                os.unlink(outf.name)
+
+    def test_load_compatible_map(self):
+        """Test loading compatible string mapping"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            map_file = os.path.join(tmpdir, 'compatible-map')
+            with open(map_file, 'w', encoding='utf-8') as f:
+                f.write(self.test_compatible_map)
+
+            compatible_map = load_compatible_map(tmpdir)
+            self.assertEqual(compatible_map['test-device'],
+                             'test,example-device')
+
+    def test_guid_to_binary(self):
+        """Test GUID to binary conversion"""
+        test_guid = '810e34c6-cc69-5e36-8675-2f6e354272d3'
+        guid_obj = uuid.UUID(test_guid)
+        binary_data = guid_obj.bytes
+
+        # Should be 16 bytes
+        self.assertEqual(len(binary_data), 16)
+
+        # Test known conversion (raw bytes in string order)
+        expected = bytearray([
+            0x81, 0x0e, 0x34, 0xc6,  # time_low (raw bytes)
+            0xcc, 0x69,              # time_mid (raw bytes)
+            0x5e, 0x36,              # time_hi (raw bytes)
+            0x86, 0x75,              # clock_seq (raw bytes)
+            0x2f, 0x6e, 0x35, 0x42, 0x72, 0xd3  # node (raw bytes)
+        ])
+        self.assertEqual(binary_data, bytes(expected))
+
+
+    def test_generate_dtsi(self):
+        """Test devicetree source generation"""
+        info = {
+            'Manufacturer': 'LENOVO',
+            'ProductName': '21BXCTO1WW',
+            'BiosMajorRelease': '1',
+            'EnclosureKind': 'a'
+        }
+        hardware_ids = [('810e34c6-cc69-5e36-8675-2f6e354272d3', 0, 0x3cf)]
+
+        content = generate_dtsi('test-device', 'test,example-device',
+                                     info, hardware_ids)
+
+        self.assertIn('// SPDX-License-Identifier: GPL-2.0+', content)
+        self.assertIn('test-device {', content)
+        self.assertIn('compatible = "test,example-device";', content)
+        self.assertIn('manufacturer = "LENOVO";', content)
+        self.assertIn('product-name = "21BXCTO1WW";', content)
+        self.assertIn('bios-major-release = <1>;', content)
+        self.assertIn('enclosure-kind = <0xa>;', content)
+        self.assertIn('// Hardware IDs (CHIDs)', content)
+        self.assertIn('hardware-id-00 {', content)
+        self.assertIn('variant = <0>;', content)
+        self.assertIn('fields = <0x3cf>;', content)
+        self.assertIn(
+            'chid = [81 0e 34 c6 cc 69 5e 36 86 75 2f 6e 35 42 72 d3];',
+            content)
+
+    def test_invalid_guid_format(self):
+        """Test error handling for invalid GUID format"""
+        with self.assertRaises(ValueError):
+            uuid.UUID('invalid-guid-format')
+
+    def test_missing_compatible_map(self):
+        """Test behavior when compatible-map file is missing"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            compatible_map = load_compatible_map(tmpdir)
+            self.assertEqual(compatible_map, {})
+
+    def test_enclosure_kind_conversion(self):
+        """Test enclosure kind hex conversion"""
+        info = {'EnclosureKind': 'a'}
+        hardware_ids = []
+
+        content = generate_dtsi('test', 'test,device', info, hardware_ids)
+        self.assertIn('enclosure-kind = <0xa>;', content)
+
+        # Test numeric enclosure kind ('10' is interpreted as hex 0x10)
+        info = {'EnclosureKind': '10'}
+        content = generate_dtsi('test', 'test,device', info, hardware_ids)
+        self.assertIn('enclosure-kind = <0x10>;', content)
+
+    def test_empty_hardware_ids(self):
+        """Test handling of empty hardware IDs list"""
+        info = {'Manufacturer': 'TEST'}
+        hardware_ids = []
+
+        content = generate_dtsi('test', 'test,device', info, hardware_ids)
+        self.assertIn('// Hardware IDs (CHIDs)', content)
+
+        # Should have no hardware-id-XX or extra-X nodes
+        self.assertNotIn('hardware-id-', content)
+        self.assertNotIn('extra-', content)
+
+    def test_parse_variant_from_field_description(self):
+        """Test parsing variant ID from field descriptions"""
+        # Test variant 0 - most specific
+        desc = ('Manufacturer + Family + ProductName + ProductSku + '
+                'BiosVendor + BiosVersion + BiosMajorRelease + '
+                'BiosMinorRelease')
+        variant_id, fields_mask = parse_variant_description(desc)
+        self.assertEqual(variant_id, 0)
+        self.assertEqual(fields_mask, 0x3cf)
+
+        # Test variant 14 - least specific (manufacturer only)
+        desc = 'Manufacturer'
+        variant_id, fields_mask = parse_variant_description(desc)
+        self.assertEqual(variant_id, 14)
+        self.assertEqual(fields_mask, 0x1)
+
+        # Test variant 5 - manufacturer, family, product name
+        desc = 'Manufacturer + Family + ProductName'
+        variant_id, fields_mask = parse_variant_description(desc)
+        self.assertEqual(variant_id, 5)
+        self.assertEqual(fields_mask, 0x7)
+
+    def test_constants_usage(self):
+        """Test that magic number constants are used correctly"""
+        # Test GUID_LENGTH constant in regex pattern
+        test_guid = '12345678-1234-5678-9abc-123456789abc'
+        content = f'''Computer Information
+--------------------
+Manufacturer: Test
+
+Hardware IDs
+------------
+{{{test_guid}}}   <- Manufacturer
+'''
+        with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
+            f.write(content)
+            f.flush()
+            try:
+                _info, hardware_ids = parse_hwids_file(f.name)
+                # Should successfully parse the GUID
+                self.assertEqual(len(hardware_ids), 1)
+                self.assertEqual(hardware_ids[0][0], test_guid)
+            finally:
+                os.unlink(f.name)
+
+    def test_version_fields_constants(self):
+        """Test that VERSION_FIELDS constant is used correctly"""
+
+        # Test that all expected version fields are in the constant
+        expected_fields = {'BiosMajorRelease', 'BiosMinorRelease',
+                          'FirmwareMajorRelease', 'FirmwareMinorRelease'}
+        self.assertTrue(expected_fields.issubset(VERSION_FIELDS))
+
+        # Test HEX_ENCLOSURE_FIELD constant
+        self.assertEqual(HEX_ENCLOSURE_FIELD, 'EnclosureKind')
+
+
+if __name__ == '__main__':
+    unittest.main()