# Copyright (c) 2019 Nordic Semiconductor ASA
# Copyright (c) 2019 Linaro Limited
# Copyright 2025 NXP
# SPDX-License-Identifier: BSD-3-Clause

# Tip: You can view just the documentation with 'pydoc3 devicetree.edtlib'

"""
Library for working with devicetrees at a higher level compared to dtlib. Like
dtlib, this library presents a tree of devicetree nodes, but the nodes are
augmented with information from bindings and include some interpretation of
properties. Some of this interpretation is based on conventions established
by the Linux kernel, so the Documentation/devicetree/bindings in the Linux
source code is sometimes good reference material.

Bindings are YAML files that describe devicetree nodes. Devicetree
nodes are usually mapped to bindings via their 'compatible = "..."' property,
but a binding can also come from a 'child-binding:' key in the binding for the
parent devicetree node.

Each devicetree node (dtlib.Node) gets a corresponding edtlib.Node instance,
which has all the information related to the node.

The top-level entry points for the library are the EDT and Binding classes.
See their constructor docstrings for details. There is also a
bindings_from_paths() helper function.
"""

# NOTE: tests/test_edtlib.py is the test suite for this library.

# Implementation notes
# --------------------
#
# A '_' prefix on an identifier in Python is a convention for marking it private.
# Please do not access private things. Instead, think of what API you need, and
# add it.
#
# This module is not meant to have any global state. It should be possible to
# create several EDT objects with independent binding paths and flags. If you
# need to add a configuration parameter or the like, store it in the EDT
# instance, and initialize it e.g. with a constructor argument.
#
# This library is layered on top of dtlib, and is not meant to expose it to
# clients. This keeps the header generation script simple.
#
# General biased advice:
#
# - Consider using @property for APIs that don't need parameters. It makes
#   functions look like attributes, which is less awkward in clients, and makes
#   it easy to switch back and forth between variables and functions.
#
# - Think about the data type of the thing you're exposing. Exposing something
#   as e.g. a list or a dictionary is often nicer and more flexible than adding
#   a function.
#
# - Avoid get_*() prefixes on functions. Name them after the thing they return
#   instead. This often makes the code read more naturally in callers.
#
#   Also, consider using @property instead of get_*().
#
# - Don't expose dtlib stuff directly.
#
# - Add documentation for any new APIs you add.
#
#   The convention here is that docstrings (quoted strings) are used for public
#   APIs, and "doc comments" for internal functions.
#
#   @properties are documented in the class docstring, as if they were
#   variables. See the existing @properties for a template.

import base64
import hashlib
import logging
import os
import re
from collections import defaultdict
from collections.abc import Callable, Iterable
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, NoReturn, Optional, Union

import yaml

try:
    # Use the C LibYAML parser if available, rather than the Python parser.
    # This makes e.g. gen_defines.py more than twice as fast.
    from yaml import CLoader as Loader
except ImportError:
    from yaml import Loader  # type: ignore

from devicetree._private import _slice_helper
from devicetree.dtlib import DT, DTError, Type, to_num, to_nums
from devicetree.dtlib import Node as dtlib_Node
from devicetree.dtlib import Property as dtlib_Property
from devicetree.grutils import Graph


def _compute_hash(path: str) -> str:
    # Calculates the hash associated with the node's full path.
    hasher = hashlib.sha256()
    hasher.update(path.encode())
    return base64.b64encode(hasher.digest(), altchars=b'__').decode().rstrip('=')

#
# Public classes
#


class Binding:
    """
    Represents a parsed binding.

    These attributes are available on Binding objects:

    path:
      The absolute path to the file defining the binding.

    title:
      The free-form title of the binding (optional).

      When the content in the 'description:' is too long, the 'title:' can
      be used as a heading for the extended description. Typically, it serves
      as a description of the hardware model. For example:

      title: Nordic GPIO

      description: |
        Descriptions and example nodes related to GPIO.
        ...

    description:
      The free-form description of the binding.

    compatible:
      The compatible string the binding matches.

      This may be None. For example, it's None when the Binding is inferred
      from node properties. It can also be None for Binding objects created
      using 'child-binding:' with no compatible.

    examples:
      Provides a minimal example node illustrating the binding (optional).
      Like this:

      examples:
        - |
          / {
              model = "This is a sample node";
              ...
          };

    prop2specs:
      A dict mapping property names to PropertySpec objects
      describing those properties' values.

    specifier2cells:
      A dict that maps specifier space names (like "gpio",
      "clock", "pwm", etc.) to lists of cell names.

      For example, if the binding YAML contains 'pin' and 'flags' cell names
      for the 'gpio' specifier space, like this:

          gpio-cells:
          - pin
          - flags

      Then the Binding object will have a 'specifier2cells' attribute mapping
      "gpio" to ["pin", "flags"]. A missing key should be interpreted as zero
      cells.

    raw:
      The binding as an object parsed from YAML.

    bus:
      If nodes with this binding's 'compatible' describe a bus, a string
      describing the bus type (like "i2c") or a list describing supported
      protocols (like ["i3c", "i2c"]). None otherwise.

      Note that this is the raw value from the binding where it can be
      a string or a list. Use "buses" instead unless you need the raw
      value, where "buses" is always a list.

    buses:
      Deprived property from 'bus' where 'buses' is a list of bus(es),
      for example, ["i2c"] or ["i3c", "i2c"]. Or an empty list if there is
      no 'bus:' in this binding.

    on_bus:
      If nodes with this binding's 'compatible' appear on a bus, a string
      describing the bus type (like "i2c"). None otherwise.

    child_binding:
      If this binding describes the properties of child nodes, then
      this is a Binding object for those children; it is None otherwise.
      A Binding object's 'child_binding.child_binding' is not None if there
      are multiple levels of 'child-binding' descriptions in the binding.
    """

    def __init__(self, path: Optional[str], fname2path: dict[str, str],
                 raw: Any = None, require_compatible: bool = True,
                 require_description: bool = True, require_title: bool = False):
        """
        Binding constructor.

        path:
          Path to binding YAML file. May be None.

        fname2path:
          Map from include files to their absolute paths. Must
          not be None, but may be empty.

        raw:
          Optional raw content in the binding.
          This does not have to have any "include:" lines resolved.
          May be left out, in which case 'path' is opened and read.
          This can be used to resolve child bindings, for example.

        require_compatible:
          If True, it is an error if the binding does not contain a
          "compatible:" line. If False, a missing "compatible:" is
          not an error. Either way, "compatible:" must be a string
          if it is present in the binding.

        require_description:
          If True, it is an error if the binding does not contain a
          "description:" line. If False, a missing "description:" is
          not an error. Either way, "description:" must be a string
          if it is present in the binding.

        require_title:
          If True, it is an error if the binding does not contain a
          "title:" line. If False, a missing "title:" is not an error.
          Either way, "title:" must be a string if it is present in
          the binding.
        """
        self.path: Optional[str] = path
        self._fname2path: dict[str, str] = fname2path

        if raw is None:
            if path is None:
                _err("you must provide either a 'path' or a 'raw' argument")
            with open(path, encoding="utf-8") as f:
                raw = yaml.load(f, Loader=_BindingLoader)

        # Merge any included files into self.raw. This also pulls in
        # inherited child binding definitions, so it has to be done
        # before initializing those.
        self.raw: dict = self._merge_includes(raw, self.path)

        # Recursively initialize any child bindings. These don't
        # require a 'compatible', 'description' or 'title' to be well
        # defined, but they must be dicts.
        if "child-binding" in raw:
            if not isinstance(raw["child-binding"], dict):
                _err(f"malformed 'child-binding:' in {self.path}, "
                     "expected a binding (dictionary with keys/values)")
            self.child_binding: Optional[Binding] = Binding(
                path, fname2path,
                raw=raw["child-binding"],
                require_compatible=False,
                require_description=False)
        else:
            self.child_binding = None

        # Make sure this is a well defined object.
        self._check(require_compatible, require_description, require_title)

        # Initialize look up tables.
        self.prop2specs: dict[str, PropertySpec] = {}
        for prop_name in self.raw.get("properties", {}):
            self.prop2specs[prop_name] = PropertySpec(prop_name, self)
        self.specifier2cells: dict[str, list[str]] = {}
        for key, val in self.raw.items():
            if key.endswith("-cells"):
                self.specifier2cells[key[:-len("-cells")]] = val

    def __repr__(self) -> str:
        if self.compatible:
            compat = f" for compatible '{self.compatible}'"
        else:
            compat = ""
        basename = os.path.basename(self.path or "")
        return f"<Binding {basename}" + compat + ">"

    @property
    def title(self) -> Optional[str]:
        "See the class docstring"
        return self.raw.get('title')

    @property
    def description(self) -> Optional[str]:
        "See the class docstring"
        return self.raw.get('description')

    @property
    def compatible(self) -> Optional[str]:
        "See the class docstring"
        return self.raw.get('compatible')

    @property
    def bus(self) -> Union[None, str, list[str]]:
        "See the class docstring"
        return self.raw.get('bus')

    @property
    def examples(self) -> Optional[list[str]]:
        "See the class docstring"
        return self.raw.get('examples')

    @property
    def buses(self) -> list[str]:
        "See the class docstring"
        if self.raw.get('bus') is not None:
            return self._buses
        else:
            return []

    @property
    def on_bus(self) -> Optional[str]:
        "See the class docstring"
        return self.raw.get('on-bus')

    def _merge_includes(self, raw: dict, binding_path: Optional[str]) -> dict:
        # Constructor helper. Merges included files in
        # 'raw["include"]' into 'raw' using 'self._include_paths' as a
        # source of include files, removing the "include" key while
        # doing so.
        #
        # This treats 'binding_path' as the binding file being built up
        # and uses it for error messages.

        if "include" not in raw:
            return raw

        include = raw.pop("include")

        # First, merge the included files together. If more than one included
        # file has a 'required:' for a particular property, OR the values
        # together, so that 'required: true' wins.

        merged: dict[str, Any] = {}

        if isinstance(include, str):
            # Simple scalar string case
            _merge_props(merged, self._load_raw(include), None, binding_path,
                         False)
        elif isinstance(include, list):
            # List of strings and maps. These types may be intermixed.
            for elem in include:
                if isinstance(elem, str):
                    _merge_props(merged, self._load_raw(elem), None,
                                 binding_path, False)
                elif isinstance(elem, dict):
                    name = elem.pop('name', None)
                    allowlist = elem.pop('property-allowlist', None)
                    blocklist = elem.pop('property-blocklist', None)
                    child_filter = elem.pop('child-binding', None)

                    if elem:
                        # We've popped out all the valid keys.
                        _err(f"'include:' in {binding_path} should not have "
                             f"these unexpected contents: {elem}")

                    _check_include_dict(name, allowlist, blocklist,
                                        child_filter, binding_path)

                    contents = self._load_raw(name)

                    _filter_properties(contents, allowlist, blocklist,
                                       child_filter, binding_path)
                    _merge_props(merged, contents, None, binding_path, False)
                else:
                    _err(f"all elements in 'include:' in {binding_path} "
                         "should be either strings or maps with a 'name' key "
                         "and optional 'property-allowlist' or "
                         f"'property-blocklist' keys, but got: {elem}")
        else:
            # Invalid item.
            _err(f"'include:' in {binding_path} "
                 f"should be a string or list, but has type {type(include)}")

        # Next, merge the merged included files into 'raw'. Error out if
        # 'raw' has 'required: false' while the merged included files have
        # 'required: true'.

        _merge_props(raw, merged, None, binding_path, check_required=True)

        return raw

    def _load_raw(self, fname: str) -> dict:
        # Returns the contents of the binding given by 'fname' after merging
        # any bindings it lists in 'include:' into it. 'fname' is just the
        # basename of the file, so we check that there aren't multiple
        # candidates.

        path = self._fname2path.get(fname)

        if not path:
            _err(f"'{fname}' not found")

        with open(path, encoding="utf-8") as f:
            contents = yaml.load(f, Loader=_BindingLoader)
            if not isinstance(contents, dict):
                _err(f'{path}: invalid contents, expected a mapping')

        return self._merge_includes(contents, path)

    def _check(self, require_compatible: bool, require_description: bool,
               require_title: bool):
        # Does sanity checking on the binding.

        raw = self.raw

        if "compatible" in raw:
            compatible = raw["compatible"]
            if not isinstance(compatible, str):
                _err(f"malformed 'compatible: {compatible}' "
                     f"field in {self.path} - "
                     f"should be a string, not {type(compatible).__name__}")
        elif require_compatible:
            _err(f"missing 'compatible' in {self.path}")

        if "title" in raw:
            title = raw["title"]
            if not isinstance(title, str) or not title:
                _err(f"malformed or empty 'title' in {self.path}")
        elif require_title:
            _err(f"missing 'title' in {self.path}")

        if "description" in raw:
            description = raw["description"]
            if not isinstance(description, str) or not description:
                _err(f"malformed or empty 'description' in {self.path}")
        elif require_description:
            _err(f"missing 'description' in {self.path}")

        # Allowed top-level keys. The 'include' key should have been
        # removed by _load_raw() already.
        ok_top = {"title", "description", "compatible", "bus",
                  "on-bus", "properties", "child-binding", "examples"}

        # Descriptive errors for legacy bindings.
        legacy_errors = {
            "#cells": "expected *-cells syntax",
            "child": "use 'bus: <bus>' instead",
            "child-bus": "use 'bus: <bus>' instead",
            "parent": "use 'on-bus: <bus>' instead",
            "parent-bus": "use 'on-bus: <bus>' instead",
            "sub-node": "use 'child-binding' instead",
        }

        for key in raw:
            if key in legacy_errors:
                _err(f"legacy '{key}:' in {self.path}, {legacy_errors[key]}")

            if key not in ok_top and not key.endswith("-cells"):
                _err(f"unknown key '{key}' in {self.path}, "
                     f"expected one of {', '.join(ok_top)}, or *-cells")

        if "bus" in raw:
            bus = raw["bus"]
            if (not isinstance(bus, str) and
               (not isinstance(bus, list) and
                not all(isinstance(elem, str) for elem in bus))):
                _err(f"malformed 'bus:' value in {self.path}, "
                     "expected string or list of strings")

            if isinstance(bus, list):
                self._buses = bus
            else:
                # Convert bus into a list
                self._buses = [bus]

        if ("on-bus" in raw
            and not isinstance(raw["on-bus"], str)):
            _err(f"malformed 'on-bus:' value in {self.path}, "
                 "expected string")

        self._check_properties()

        for key, val in raw.items():
            if (key.endswith("-cells")
                and not isinstance(val, list)
                or not all(isinstance(elem, str) for elem in val)):
                _err(f"malformed '{key}:' in {self.path}, "
                     "expected a list of strings")

    def _check_properties(self) -> None:
        # _check() helper for checking the contents of 'properties:'.

        raw = self.raw

        if "properties" not in raw:
            return

        ok_prop_keys = {"description", "type", "required",
                        "enum", "const", "default", "deprecated",
                        "specifier-space"}

        for prop_name, options in raw["properties"].items():
            for key in options:
                if key not in ok_prop_keys:
                    _err(f"unknown setting '{key}' in "
                         f"'properties: {prop_name}: ...' in {self.path}, "
                         f"expected one of {', '.join(ok_prop_keys)}")

            _check_prop_by_type(prop_name, options, self.path)

            for true_false_opt in ["required", "deprecated"]:
                if true_false_opt in options:
                    option = options[true_false_opt]
                    if not isinstance(option, bool):
                        _err(f"malformed '{true_false_opt}:' setting '{option}' "
                             f"for '{prop_name}' in 'properties' in {self.path}, "
                             "expected true/false")

            if options.get("deprecated") and options.get("required"):
                _err(f"'{prop_name}' in 'properties' in {self.path} should not "
                      "have both 'deprecated' and 'required' set")

            if ("description" in options
                and not isinstance(options["description"], str)):
                _err("missing, malformed, or empty 'description' for "
                     f"'{prop_name}' in 'properties' in {self.path}")

            if "enum" in options and not isinstance(options["enum"], list):
                _err(f"enum in {self.path} for property '{prop_name}' "
                     "is not a list")


class PropertySpec:
    """
    Represents a "property specification", i.e. the description of a
    property provided by a binding file, like its type and description.

    These attributes are available on PropertySpec objects:

    binding:
      The Binding object which defined this property.

    name:
      The property's name.

    path:
      The file where this property was defined. In case a binding includes
      other bindings, this is the including binding file.
      Generally this means that this will be the binding file specifying
      the devicetree node of which this is a property.

    type:
      The type of the property as a string, as given in the binding.

    description:
      The free-form description of the property as a string, or None.

    enum:
      A list of values the property may take as given in the binding, or None.

    enum_tokenizable:
      True if enum is not None and all the values in it are tokenizable;
      False otherwise.

      A property must have string or string-array type and an "enum:" in its
      binding to be tokenizable. Additionally, the "enum:" values must be
      unique after converting all non-alphanumeric characters to underscores
      (so "foo bar" and "foo_bar" in the same "enum:" would not be
      tokenizable).

    enum_upper_tokenizable:
      Like 'enum_tokenizable', with the additional restriction that the
      "enum:" values must be unique after uppercasing and converting
      non-alphanumeric characters to underscores.

    const:
      The property's constant value as given in the binding, or None.

    default:
      The property's default value as given in the binding, or None.

    deprecated:
      True if the property is deprecated; False otherwise.

    required:
      True if the property is marked required; False otherwise.

    specifier_space:
      The specifier space for the property as given in the binding, or None.
    """

    def __init__(self, name: str, binding: Binding):
        self.binding: Binding = binding
        self.name: str = name
        self._raw: dict[str, Any] = self.binding.raw["properties"][name]

    def __repr__(self) -> str:
        return f"<PropertySpec {self.name} type '{self.type}'>"

    @property
    def path(self) -> Optional[str]:
        "See the class docstring"
        return self.binding.path

    @property
    def type(self) -> str:
        "See the class docstring"
        return self._raw["type"]

    @property
    def description(self) -> Optional[str]:
        "See the class docstring"
        return self._raw.get("description")

    @property
    def enum(self) -> Optional[list]:
        "See the class docstring"
        return self._raw.get("enum")

    @property
    def enum_tokenizable(self) -> bool:
        "See the class docstring"
        if not hasattr(self, '_enum_tokenizable'):
            if self.type not in {'string', 'string-array'} or self.enum is None:
                self._enum_tokenizable = False
            else:
                # Saving _as_tokens here lets us reuse it in
                # enum_upper_tokenizable.
                self._as_tokens = [re.sub(_NOT_ALPHANUM_OR_UNDERSCORE,
                                          '_', value)
                                   for value in self.enum]
                self._enum_tokenizable = (len(self._as_tokens) ==
                                          len(set(self._as_tokens)))

        return self._enum_tokenizable

    @property
    def enum_upper_tokenizable(self) -> bool:
        "See the class docstring"
        if not hasattr(self, '_enum_upper_tokenizable'):
            if not self.enum_tokenizable:
                self._enum_upper_tokenizable = False
            else:
                self._enum_upper_tokenizable = (
                    len(self._as_tokens) == len(
                        set(x.upper() for x in self._as_tokens)
                    ))
        return self._enum_upper_tokenizable

    @property
    def const(self) -> Union[None, int, list[int], str, list[str]]:
        "See the class docstring"
        return self._raw.get("const")

    @property
    def default(self) -> Union[None, int, list[int], str, list[str]]:
        "See the class docstring"
        return self._raw.get("default")

    @property
    def required(self) -> bool:
        "See the class docstring"
        return self._raw.get("required", False)

    @property
    def deprecated(self) -> bool:
        "See the class docstring"
        return self._raw.get("deprecated", False)

    @property
    def specifier_space(self) -> Optional[str]:
        "See the class docstring"
        return self._raw.get("specifier-space")

PropertyValType = Union[int, str,
                        list[int], list[str],
                        'Node', list['Node'],
                        list[Optional['ControllerAndData']],
                        bytes, None]


@dataclass
class Property:
    """
    Represents a property on a Node, as set in its DT node and with
    additional info from the 'properties:' section of the binding.

    Only properties mentioned in 'properties:' get created. Properties of type
    'compound' currently do not get Property instances, as it's not clear
    what to generate for them.

    These attributes are available on Property objects. Several are
    just convenience accessors for attributes on the PropertySpec object
    accessible via the 'spec' attribute.

    These attributes are available on Property objects:

    spec:
      The PropertySpec object which specifies this property.

    val:
      The value of the property, with the format determined by spec.type,
      which comes from the 'type:' string in the binding.

        - For 'type: int/array/string/string-array', 'val' is what you'd expect
          (a Python integer or string, or a list of them)

        - For 'type: uint8-array', 'val' is a bytes object

        - For 'type: phandle' and 'type: path', 'val' is the pointed-to Node
          instance

        - For 'type: phandles', 'val' is a list of the pointed-to Node
          instances

        - For 'type: phandle-array', 'val' is a list of ControllerAndData
          instances. See the documentation for that class.

    node:
      The Node instance the property is on

    name:
      Convenience for spec.name.

    description:
      Convenience for spec.description with leading and trailing whitespace
      (including newlines) removed. May be None.

    type:
      Convenience for spec.type.

    val_as_tokens:
      The value of the property as a list of tokens, i.e. with non-alphanumeric
      characters replaced with underscores. This is only safe to access
      if 'spec.enum_tokenizable' returns True.

    enum_indices:
      A list of indices of 'val' in 'spec.enum' (which comes from the 'enum:'
      list in the binding), or None if spec.enum is None.
    """

    spec: PropertySpec
    val: PropertyValType
    node: 'Node'

    @property
    def name(self) -> str:
        "See the class docstring"
        return self.spec.name

    @property
    def description(self) -> Optional[str]:
        "See the class docstring"
        return self.spec.description.strip() if self.spec.description else None

    @property
    def type(self) -> str:
        "See the class docstring"
        return self.spec.type

    @property
    def val_as_tokens(self) -> list[str]:
        "See the class docstring"
        ret = []
        for subval in self.val if isinstance(self.val, list) else [self.val]:
            assert isinstance(subval, str)
            ret.append(str_as_token(subval))
        return ret

    @property
    def enum_indices(self) -> Optional[list[int]]:
        "See the class docstring"
        enum = self.spec.enum
        val = self.val if isinstance(self.val, list) else [self.val]
        return [enum.index(subval) for subval in val] if enum else None


@dataclass
class Register:
    """
    Represents a register on a node.

    These attributes are available on Register objects:

    node:
      The Node instance this register is from

    name:
      The name of the register as given in the 'reg-names' property, or None if
      there is no 'reg-names' property

    addr:
      The starting address of the register, in the parent address space, or None
      if #address-cells is zero. Any 'ranges' properties are taken into account.

    size:
      The length of the register in bytes
    """

    node: 'Node'
    name: Optional[str]
    addr: Optional[int]
    size: Optional[int]


@dataclass
class Range:
    """
    Represents a translation range on a node as described by the 'ranges' property.

    These attributes are available on Range objects:

    node:
      The Node instance this range is from

    child_bus_cells:
      The number of cells used to describe a child bus address.

    child_bus_addr:
      A physical address within the child bus address space, or None if the
      child's #address-cells equals 0.

    parent_bus_cells:
      The number of cells used to describe a parent bus address.

    parent_bus_addr:
      A physical address within the parent bus address space, or None if the
      parent's #address-cells equals 0.

    length_cells:
      The number of cells used to describe the size of range in
      the child's address space.

    length:
      The size of the range in the child address space, or None if the
      child's #size-cells equals 0.
    """
    node: 'Node'
    child_bus_cells: int
    child_bus_addr: Optional[int]
    parent_bus_cells: int
    parent_bus_addr: Optional[int]
    length_cells: int
    length: Optional[int]


@dataclass
class ControllerAndData:
    """
    Represents an entry in an 'interrupts' or 'type: phandle-array' property
    value, e.g. <&ctrl-1 4 0> in

        cs-gpios = <&ctrl-1 4 0 &ctrl-2 3 4>;

    These attributes are available on ControllerAndData objects:

    node:
      The Node instance the property appears on

    controller:
      The Node instance for the controller (e.g. the controller the interrupt
      gets sent to for interrupts)

    data:
      A dictionary that maps names from the *-cells key in the binding for the
      controller to data values, e.g. {"pin": 4, "flags": 0} for the example
      above.

      'interrupts = <1 2>' might give {"irq": 1, "level": 2}.

    name:
      The name of the entry as given in
      'interrupt-names'/'gpio-names'/'pwm-names'/etc., or None if there is no
      *-names property

    basename:
      Basename for the controller when supporting named cells. AKA, the specifier space.
    """
    node: 'Node'
    controller: 'Node'
    data: dict
    name: Optional[str]
    basename: Optional[str]


@dataclass
class PinCtrl:
    """
    Represents a pin control configuration for a set of pins on a device,
    e.g. pinctrl-0 or pinctrl-1.

    These attributes are available on PinCtrl objects:

    node:
      The Node instance the pinctrl-* property is on

    name:
      The name of the configuration, as given in pinctrl-names, or None if
      there is no pinctrl-names property

    name_as_token:
      Like 'name', but with non-alphanumeric characters converted to underscores.

    conf_nodes:
      A list of Node instances for the pin configuration nodes, e.g.
      the nodes pointed at by &state_1 and &state_2 in

          pinctrl-0 = <&state_1 &state_2>;
    """

    node: 'Node'
    name: Optional[str]
    conf_nodes: list['Node']

    @property
    def name_as_token(self):
        "See the class docstring"
        return str_as_token(self.name) if self.name is not None else None


class Node:
    """
    Represents a devicetree node, augmented with information from bindings, and
    with some interpretation of devicetree properties. There's a one-to-one
    correspondence between devicetree nodes and Nodes.

    These attributes are available on Node objects:

    edt:
      The EDT instance this node is from

    name:
      The name of the node

    unit_addr:
      An integer with the ...@<unit-address> portion of the node name,
      translated through any 'ranges' properties on parent nodes, or None if
      the node name has no unit-address portion. PCI devices use a different
      node name format ...@<dev>,<func> or ...@<dev> (e.g. "pcie@1,0"), in
      this case None is returned.

    title:
      The title string from the binding for the node, or None if the node
      has no binding.

    description:
      The description string from the binding for the node, or None if the node
      has no binding. Leading and trailing whitespace (including newlines) is
      removed.

    path:
      The devicetree path of the node

    label:
      The text from the 'label' property on the node, or None if the node has
      no 'label'

    labels:
      A list of all of the devicetree labels for the node, in the same order
      as the labels appear, but with duplicates removed.

      This corresponds to the actual devicetree source labels, unlike the
      "label" attribute, which is the value of a devicetree property named
      "label".

    parent:
      The Node instance for the devicetree parent of the Node, or None if the
      node is the root node

    children:
      A dictionary with the Node instances for the devicetree children of the
      node, indexed by name

    dep_ordinal:
      A non-negative integer value such that the value for a Node is
      less than the value for all Nodes that depend on it.

      The ordinal is defined for all Nodes, and is unique among nodes in its
      EDT 'nodes' list.

    hash:
      A hashed value of the devicetree path of the node. This is defined for
      all Nodes, and is checked for uniqueness among nodes in its EDT 'nodes'
      list.

    required_by:
      A list with the nodes that directly depend on the node

    depends_on:
      A list with the nodes that the node directly depends on

    status:
      The node's status property value, as a string, or "okay" if the node
      has no status property set.

    read_only:
      True if the node has a 'read-only' property, and False otherwise

    matching_compat:
      The 'compatible' string for the binding that matched the node, or None if
      the node has no binding

    binding_path:
      The path to the binding file for the node, or None if the node has no
      binding

    compats:
      A list of 'compatible' strings for the node, in the same order that
      they're listed in the .dts file

    ranges:
      A list of Range objects extracted from the node's ranges property.
      The list is empty if the node does not have a range property.

    regs:
      A list of Register objects for the node's registers

    props:
      A dict that maps property names to Property objects.
      Property objects are created for the devicetree properties
      defined by the node's binding and that have a default value
      or for which a value is set in the DTS.

    aliases:
      A list of aliases for the node. This is fetched from the /aliases node.

    interrupts:
      A list of ControllerAndData objects for the interrupts generated by the
      node. The list is empty if the node does not generate interrupts.

    pinctrls:
      A list of PinCtrl objects for the pinctrl-<index> properties on the
      node, sorted by index. The list is empty if the node does not have any
      pinctrl-<index> properties.

    buses:
      If the node is a bus node (has a 'bus:' key in its binding), then this
      attribute holds the list of supported bus types, e.g. ["i2c"], ["spi"]
      or ["i3c", "i2c"] if multiple protocols are supported via the same bus.
      If the node is not a bus node, then this attribute is an empty list.

    on_buses:
      The bus the node appears on, e.g. ["i2c"], ["spi"] or ["i3c", "i2c"] if
      multiple protocols are supported via the same bus. The bus is determined
      by searching upwards for a parent node whose binding has a 'bus:' key,
      returning the value of the first 'bus:' key found. If none of the node's
      parents has a 'bus:' key, this attribute is an empty list.

    bus_node:
      Like on_bus, but contains the Node for the bus controller, or None if the
      node is not on a bus.

    flash_controller:
      The flash controller for the node. Only meaningful for nodes representing
      flash partitions.

    spi_cs_gpio:
      The device's SPI GPIO chip select as a ControllerAndData instance, if it
      exists, and None otherwise. See
      Documentation/devicetree/bindings/spi/spi-controller.yaml in the Linux kernel.

    gpio_hogs:
      A list of ControllerAndData objects for the GPIOs hogged by the node. The
      list is empty if the node does not hog any GPIOs. Only relevant for GPIO hog
      nodes.

    is_pci_device:
      True if the node is a PCI device.
    """

    def __init__(
        self,
        dt_node: dtlib_Node,
        edt: "EDT",
        support_fixed_partitions_on_any_bus: bool = True,
    ):
        '''
        For internal use only; not meant to be used outside edtlib itself.
        '''

        compats = (
            dt_node.props["compatible"].to_strings()
            if "compatible" in dt_node.props
            else []
        )

        # Private, don't touch outside the class:
        self._node: dtlib_Node = dt_node
        self._binding: Optional[Binding] = None

        # Public, some of which are initialized properly later:
        self.edt: EDT = edt
        self.dep_ordinal: int = -1
        self.compats: list[str] = compats
        self.ranges: list[Range] = []
        self.regs: list[Register] = []
        self.props: dict[str, Property] = {}
        self.interrupts: list[ControllerAndData] = []
        self.pinctrls: list[PinCtrl] = []
        self.bus_node = self._bus_node(support_fixed_partitions_on_any_bus)
        self.hash: str = _compute_hash(dt_node.path)

        self._init_binding()
        self._init_regs()
        self._init_ranges()

    @property
    def name(self) -> str:
        "See the class docstring"
        return self._node.name

    @property
    def filename(self) -> str:
        "See the class docstring"
        return self._node.filename

    @property
    def lineno(self) -> int:
        "See the class docstring"
        return self._node.lineno

    @property
    def unit_addr(self) -> Optional[int]:
        "See the class docstring"

        # TODO: Return a plain string here later, like dtlib.Node.unit_addr?

        # PCI devices use a different node name format (e.g. "pcie@1,0")
        if "@" not in self.name or self.is_pci_device:
            return None

        try:
            addr = int(self.name.split("@", 1)[1], 16)
        except ValueError:
            _err(f"{self!r} has non-hex unit address")

        return _translate(addr, self._node)

    @property
    def title(self) -> Optional[str]:
        "See the class docstring."
        if self._binding:
            return self._binding.title
        return None

    @property
    def description(self) -> Optional[str]:
        "See the class docstring."
        if self._binding:
            return self._binding.description
        return None

    @property
    def path(self) ->  str:
        "See the class docstring"
        return self._node.path

    @property
    def label(self) -> Optional[str]:
        "See the class docstring"
        if "label" in self._node.props:
            return self._node.props["label"].to_string()
        return None

    @property
    def labels(self) -> list[str]:
        "See the class docstring"
        return self._node.labels

    @property
    def parent(self) -> Optional['Node']:
        "See the class docstring"
        return self.edt._node2enode.get(self._node.parent) # type: ignore

    @property
    def children(self) -> dict[str, 'Node']:
        "See the class docstring"
        # Could be initialized statically too to preserve identity, but not
        # sure if needed. Parent nodes being initialized before their children
        # would need to be kept in mind.
        return {name: self.edt._node2enode[node]
                for name, node in self._node.nodes.items()}

    def child_index(self, node) -> int:
        """Get the index of *node* in self.children.
        Raises KeyError if the argument is not a child of this node.
        """
        if not hasattr(self, '_child2index'):
            # Defer initialization of this lookup table until this
            # method is callable to handle parents needing to be
            # initialized before their chidlren. By the time we
            # return from __init__, 'self.children' is callable.
            self._child2index: dict[str, int] = {}
            for index, child_path in enumerate(child.path for child in
                                               self.children.values()):
                self._child2index[child_path] = index

        return self._child2index[node.path]

    @property
    def required_by(self) -> list['Node']:
        "See the class docstring"
        return self.edt._graph.required_by(self)

    @property
    def depends_on(self) -> list['Node']:
        "See the class docstring"
        return self.edt._graph.depends_on(self)

    @property
    def status(self) -> str:
        "See the class docstring"
        status = self._node.props.get("status")

        if status is None:
            as_string = "okay"
        else:
            as_string = status.to_string()

        return as_string

    @property
    def read_only(self) -> bool:
        "See the class docstring"
        return "read-only" in self._node.props

    @property
    def matching_compat(self) -> Optional[str]:
        "See the class docstring"
        if self._binding:
            return self._binding.compatible
        return None

    @property
    def binding_path(self) -> Optional[str]:
        "See the class docstring"
        if self._binding:
            return self._binding.path
        return None

    @property
    def aliases(self) -> list[str]:
        "See the class docstring"
        return [alias for alias, node in self._node.dt.alias2node.items()
                if node is self._node]

    @property
    def buses(self) -> list[str]:
        "See the class docstring"
        if self._binding:
            return self._binding.buses
        return []

    @property
    def on_buses(self) -> list[str]:
        "See the class docstring"
        bus_node = self.bus_node
        return bus_node.buses if bus_node else []

    @property
    def flash_controller(self) -> 'Node':
        "See the class docstring"

        # The node path might be something like
        # /flash-controller@4001E000/flash@0/partitions/partition@fc000. We go
        # up two levels to get the flash and check its compat. The flash
        # controller might be the flash itself (for cases like NOR flashes).
        # For the case of 'soc-nv-flash', we assume the controller is the
        # parent of the flash node.

        if not self.parent or not self.parent.parent:
            _err(f"flash partition {self!r} lacks parent or grandparent node")

        controller = self.parent.parent
        if controller.matching_compat == "soc-nv-flash":
            if controller.parent is None:
                _err(f"flash controller '{controller.path}' cannot be the root node")
            return controller.parent
        return controller

    @property
    def spi_cs_gpio(self) -> Optional[ControllerAndData]:
        "See the class docstring"

        if not ("spi" in self.on_buses
                and self.bus_node
                and "cs-gpios" in self.bus_node.props):
            return None

        if not self.regs:
            _err(f"{self!r} needs a 'reg' property, to look up the "
                 "chip select index for SPI")

        parent_cs_lst = self.bus_node.props["cs-gpios"].val
        if TYPE_CHECKING:
            assert isinstance(parent_cs_lst, list)

        # cs-gpios is indexed by the unit address
        cs_index = self.regs[0].addr
        if TYPE_CHECKING:
            assert isinstance(cs_index, int)

        if cs_index >= len(parent_cs_lst):
            _err(f"index from 'regs' in {self!r} ({cs_index}) "
                 "is >= number of cs-gpios in "
                 f"{self.bus_node!r} ({len(parent_cs_lst)})")

        ret = parent_cs_lst[cs_index]
        if TYPE_CHECKING:
            assert isinstance(ret, ControllerAndData)
        return ret

    @property
    def gpio_hogs(self) -> list[ControllerAndData]:
        "See the class docstring"

        if "gpio-hog" not in self.props:
            return []

        if not self.parent or "gpio-controller" not in self.parent.props:
            _err(f"GPIO hog {self!r} lacks parent GPIO controller node")

        if "#gpio-cells" not in self.parent._node.props:
            _err(f"GPIO hog {self!r} parent node lacks #gpio-cells")

        n_cells = self.parent._node.props["#gpio-cells"].to_num()
        res = []

        for item in _slice(self._node, "gpios", 4*n_cells,
                           f"4*(<#gpio-cells> (= {n_cells})"):
            controller = self.parent
            res.append(ControllerAndData(
                node=self, controller=controller,
                data=self._named_cells(controller, item, "gpio"),
                name=None, basename="gpio"))

        return res

    @property
    def has_child_binding(self) -> bool:
        """
        True if the node's binding contains a child-binding definition, False
        otherwise
        """
        return bool(self._binding and self._binding.child_binding)

    @property
    def is_pci_device(self) -> bool:
        "See the class docstring"
        return 'pcie' in self.on_buses

    def __repr__(self) -> str:
        if self.binding_path:
            binding = "binding " + self.binding_path
        else:
            binding = "no binding"
        return f"<Node {self.path} in '{self.edt.dts_path}', {binding}>"

    def _init_binding(self) -> None:
        # Initializes Node._binding. It holds data from the node's binding file,
        # in the format returned by PyYAML (plain Python lists, dicts, etc.), or
        # None if the node has no binding.

        # This relies on the parent of the node having already been
        # initialized, which is guaranteed by going through the nodes in
        # node_iter() order.

        if self.path in self.edt._infer_binding_for_paths:
            self._binding_from_properties()
            return

        if self.compats:
            on_buses = self.on_buses

            for compat in self.compats:
                # When matching, respect the order of the 'compatible' entries,
                # and for each one first try to match against an explicitly
                # specified bus (if any) and then against any bus. This is so
                # that matching against bindings which do not specify a bus
                # works the same way in Zephyr as it does elsewhere.
                binding = None

                # Collect all available bindings for this compatible for warning purposes
                available_bindings = [
                    (binding_bus, candidate_binding.path)
                    for (binding_compat, binding_bus), candidate_binding
                    in self.edt._compat2binding.items()
                    if binding_compat == compat
                ]

                for bus in on_buses:
                    if (compat, bus) in self.edt._compat2binding:
                        binding = self.edt._compat2binding[compat, bus]
                        break

                if not binding:
                    if (compat, None) in self.edt._compat2binding:
                        binding = self.edt._compat2binding[compat, None]
                    else:
                        # No matching binding found - warn if bindings exist for other buses
                        if (available_bindings and
                            self.edt._warn_bus_mismatch):
                            current_bus = on_buses[0] if on_buses else "none"

                            # Format available bus information for the warning
                            available_bus_info = []
                            for bus, binding_path in available_bindings:  # type: ignore
                                bus_name = bus if bus is not None else "any"
                                # Get relative path for cleaner output
                                rel_path = (os.path.relpath(binding_path)
                                            if binding_path is not None else "unknown")
                                bus_info = f"'{bus_name}' (from {rel_path})"
                                available_bus_info.append(bus_info)

                            _LOG.warning(
                                f"Node '{self.path}' with compatible '{compat}' "
                                f"is on bus '{current_bus}', but available bindings "
                                f"expect: {', '.join(available_bus_info)}. "
                                f"No binding will be applied to this node."
                            )
                        continue

                self._binding = binding
                return
        else:
            # No 'compatible' property. See if the parent binding has
            # a compatible. This can come from one or more levels of
            # nesting with 'child-binding:'.

            binding_from_parent = self._binding_from_parent()
            if binding_from_parent:
                self._binding = binding_from_parent
                return

        # No binding found
        self._binding = None

    def _binding_from_properties(self) -> None:
        # Sets up a Binding object synthesized from the properties in the node.

        if self.compats:
            _err(f"compatible in node with inferred binding: {self.path}")

        # Synthesize a 'raw' binding as if it had been parsed from YAML.
        raw: dict[str, Any] = {
            'description': 'Inferred binding from properties, via edtlib.',
            'properties': {},
        }
        for name, prop in self._node.props.items():
            pp: dict[str, str] = {}
            if prop.type == Type.EMPTY:
                pp["type"] = "boolean"
            elif prop.type == Type.BYTES:
                pp["type"] = "uint8-array"
            elif prop.type == Type.NUM:
                pp["type"] = "int"
            elif prop.type == Type.NUMS:
                pp["type"] = "array"
            elif prop.type == Type.STRING:
                pp["type"] = "string"
            elif prop.type == Type.STRINGS:
                pp["type"] = "string-array"
            elif prop.type == Type.PHANDLE:
                pp["type"] = "phandle"
            elif prop.type == Type.PHANDLES:
                pp["type"] = "phandles"
            elif prop.type == Type.PHANDLES_AND_NUMS:
                pp["type"] = "phandle-array"
            elif prop.type == Type.PATH:
                pp["type"] = "path"
            else:
                _err(f"cannot infer binding from property: {prop} "
                     f"with type {prop.type!r}")
            raw['properties'][name] = pp

        # Set up Node state.
        self.compats = []
        self._binding = Binding(None, {}, raw=raw, require_compatible=False)

    def _binding_from_parent(self) -> Optional[Binding]:
        # Returns the binding from 'child-binding:' in the parent node's
        # binding.

        if not self.parent:
            return None

        pbinding = self.parent._binding
        if not pbinding:
            return None

        if pbinding.child_binding:
            return pbinding.child_binding

        return None

    def _bus_node(self, support_fixed_partitions_on_any_bus: bool = True
                  ) -> Optional['Node']:
        # Returns the value for self.bus_node. Relies on parent nodes being
        # initialized before their children.

        if not self.parent:
            # This is the root node
            return None

        # Treat 'fixed-partitions' as if they are not on any bus.  The reason is
        # that flash nodes might be on a SPI or controller or SoC bus.  Having
        # bus be None means we'll always match the binding for fixed-partitions
        # also this means want processing the fixed-partitions node we wouldn't
        # try to do anything bus specific with it.
        if support_fixed_partitions_on_any_bus and "fixed-partitions" in self.compats:
            return None

        if self.parent.buses:
            # The parent node is a bus node
            return self.parent

        # Same bus node as parent (possibly None)
        return self.parent.bus_node

    def _init_crossrefs(
        self, default_prop_types: bool = False, err_on_deprecated: bool = False
    ) -> None:
        # Initializes all properties that require cross-references to other
        # nodes, like 'phandle' and 'phandles'. This is done after all nodes
        # have been initialized.
        self._init_props(
            default_prop_types=default_prop_types, err_on_deprecated=err_on_deprecated
        )
        self._init_interrupts()
        self._init_pinctrls()

    def _init_props(self, default_prop_types: bool = False,
                    err_on_deprecated: bool = False) -> None:
        # Creates self.props. See the class docstring. Also checks that all
        # properties on the node are declared in its binding.

        self.props = {}

        if self._binding:
            prop2specs = self._binding.prop2specs
        else:
            prop2specs = None

        # Initialize self.props
        if prop2specs:
            for prop_spec in prop2specs.values():
                self._init_prop(prop_spec, err_on_deprecated)
            self._check_undeclared_props()
        elif default_prop_types:
            for name in self._node.props:
                if name not in _DEFAULT_PROP_SPECS:
                    continue
                prop_spec = _DEFAULT_PROP_SPECS[name]
                val = self._prop_val(name, prop_spec, err_on_deprecated)
                self.props[name] = Property(prop_spec, val, self)

    def _init_prop(self, prop_spec: PropertySpec,
                   err_on_deprecated: bool) -> None:
        # _init_props() helper for initializing a single property.
        # 'prop_spec' is a PropertySpec object from the node's binding.

        name = prop_spec.name
        prop_type = prop_spec.type
        if not prop_type:
            _err(f"'{name}' in {self.binding_path} lacks 'type'")

        val = self._prop_val(name, prop_spec, err_on_deprecated)

        if val is None:
            # 'required: false' property that wasn't there, or a property type
            # for which we store no data.
            return

        enum = prop_spec.enum
        for subval in val if isinstance(val, list) else [val]:
            if enum and subval not in enum:
                _err(f"value of property '{name}' on {self.path} in "
                    f"{self.edt.dts_path} ({subval!r}) is not in 'enum' list in "
                    f"{self.binding_path} ({enum!r})")

        const = prop_spec.const
        if const is not None and val != const:
            _err(f"value of property '{name}' on {self.path} in "
                 f"{self.edt.dts_path} ({val!r}) "
                 "is different from the 'const' value specified in "
                 f"{self.binding_path} ({const!r})")

        # Skip properties that start with '#', like '#size-cells', and mapping
        # properties like 'gpio-map'/'interrupt-map'
        if name[0] == "#" or name.endswith("-map"):
            return

        self.props[name] = Property(prop_spec, val, self)

    def _prop_val(
        self,
        name: str,
        prop_spec: PropertySpec,
        err_on_deprecated: bool,
    ) -> PropertyValType:
        # _init_prop() helper for getting the property's value
        #
        # name:
        #   Property name from binding
        #
        # prop_spec:
        #   PropertySpec from binding
        #
        # err_on_deprecated:
        #   If True, a deprecated property is an error instead of warning.

        node = self._node
        prop = node.props.get(name)
        binding_path = prop_spec.binding.path
        prop_type = prop_spec.type
        deprecated = prop_spec.deprecated
        required = prop_spec.required
        default = prop_spec.default
        specifier_space = prop_spec.specifier_space

        if prop and deprecated:
            msg = (
                f"'{name}' is marked as deprecated in 'properties:' "
                f"in {binding_path} for node {node.path}."
            )
            if err_on_deprecated:
                _err(msg)
            else:
                _LOG.warning(msg)

        if not prop:
            if required and self.status == "okay":
                _err(
                    f"'{name}' is marked as required in 'properties:' in "
                    f"{binding_path}, but does not appear in {node!r}"
                )

            if default is not None:
                # YAML doesn't have a native format for byte arrays. We need to
                # convert those from an array like [0x12, 0x34, ...]. The
                # format has already been checked in
                # _check_prop_by_type().
                if prop_type == "uint8-array":
                    return bytes(default) # type: ignore
                return default

            return False if prop_type == "boolean" else None

        if prop_type == "boolean":
            if prop.type != Type.EMPTY:
                _err(f"'{name}' in {node!r} is defined with 'type: boolean' "
                     f"in {binding_path}, but is assigned a value ('{prop}') "
                     f"instead of being empty ('{name};')")
            return True

        if prop_type == "int":
            return prop.to_num()

        if prop_type == "array":
            return prop.to_nums()

        if prop_type == "uint8-array":
            return prop.to_bytes()

        if prop_type == "string":
            return prop.to_string()

        if prop_type == "string-array":
            return prop.to_strings()

        if prop_type == "phandle":
            return self.edt._node2enode[prop.to_node()]

        if prop_type == "phandles":
            return [self.edt._node2enode[node] for node in prop.to_nodes()]

        if prop_type == "phandle-array":
            # This type is a bit high-level for dtlib as it involves
            # information from bindings and *-names properties, so there's no
            # to_phandle_array() in dtlib. Do the type check ourselves.
            if prop.type not in (Type.PHANDLE, Type.PHANDLES, Type.PHANDLES_AND_NUMS):
                _err(f"expected property '{name}' in {node.path} in "
                     f"{node.dt.filename} to be assigned "
                     f"with '{name} = < &foo ... &bar 1 ... &baz 2 3 >' "
                     f"(a mix of phandles and numbers), not '{prop}'")

            return self._standard_phandle_val_list(prop, specifier_space)

        if prop_type == "path":
            return self.edt._node2enode[prop.to_path()]

        # prop_type == "compound". Checking that the 'type:'
        # value is valid is done in _check_prop_by_type().
        #
        # 'compound' is a dummy type for properties that don't fit any of the
        # patterns above, so that we can require all entries in 'properties:'
        # to have a 'type: ...'. No Property object is created for it.
        return None

    def _check_undeclared_props(self) -> None:
        # Checks that all properties are declared in the binding
        wl = {"compatible", "status", "ranges", "phandle",
              "interrupt-parent", "interrupts-extended", "device_type"}

        for prop_name in self._node.props:
            # Allow a few special properties to not be declared in the binding
            if (prop_name.endswith("-controller")
                or prop_name.startswith("#")
                or prop_name in wl):
                continue

            if TYPE_CHECKING:
                assert self._binding

            if prop_name not in self._binding.prop2specs:
                _err(f"'{prop_name}' appears in {self._node.path} in "
                     f"{self.edt.dts_path}, but is not declared in "
                     f"'properties:' in {self.binding_path}")

    def _init_ranges(self) -> None:
        # Initializes self.ranges
        node = self._node

        self.ranges = []

        if "ranges" not in node.props:
            return

        raw_child_address_cells = node.props.get("#address-cells")
        parent_address_cells = _address_cells(node)
        if raw_child_address_cells is None:
            child_address_cells = 2 # Default value per DT spec.
        else:
            child_address_cells = raw_child_address_cells.to_num()
        raw_child_size_cells = node.props.get("#size-cells")
        if raw_child_size_cells is None:
            child_size_cells = 1 # Default value per DT spec.
        else:
            child_size_cells = raw_child_size_cells.to_num()

        # Number of cells for one translation 3-tuple in 'ranges'
        entry_cells = child_address_cells + parent_address_cells + child_size_cells

        if entry_cells == 0:
            if len(node.props["ranges"].value) == 0:
                return
            else:
                _err(f"'ranges' should be empty in {self._node.path} since "
                     f"<#address-cells> = {child_address_cells}, "
                     f"<#address-cells for parent> = {parent_address_cells} and "
                     f"<#size-cells> = {child_size_cells}")

        for raw_range in _slice(node, "ranges", 4*entry_cells,
                                f"4*(<#address-cells> (= {child_address_cells}) + "
                                "<#address-cells for parent> "
                                f"(= {parent_address_cells}) + "
                                f"<#size-cells> (= {child_size_cells}))"):

            child_bus_cells = child_address_cells
            if child_address_cells == 0:
                child_bus_addr = None
            else:
                child_bus_addr = to_num(raw_range[:4*child_address_cells])
            parent_bus_cells = parent_address_cells
            if parent_address_cells == 0:
                parent_bus_addr = None
            else:
                parent_bus_addr = to_num(
                    raw_range[(4*child_address_cells):
                              (4*child_address_cells + 4*parent_address_cells)])
            length_cells = child_size_cells
            if child_size_cells == 0:
                length = None
            else:
                length = to_num(
                    raw_range[(4*child_address_cells + 4*parent_address_cells):])

            self.ranges.append(Range(self, child_bus_cells, child_bus_addr,
                                     parent_bus_cells, parent_bus_addr,
                                     length_cells, length))

    def _init_regs(self) -> None:
        # Initializes self.regs

        node = self._node

        self.regs = []

        if "reg" not in node.props:
            return

        address_cells = _address_cells(node)
        size_cells = _size_cells(node)

        for raw_reg in _slice(node, "reg", 4*(address_cells + size_cells),
                              f"4*(<#address-cells> (= {address_cells}) + "
                              f"<#size-cells> (= {size_cells}))"):
            if address_cells == 0:
                addr = None
            else:
                addr = _translate(to_num(raw_reg[:4*address_cells]), node)
            if size_cells == 0:
                size = None
            else:
                size = to_num(raw_reg[4*address_cells:])
            # Size zero is ok for PCI devices
            if size_cells != 0 and size == 0 and not self.is_pci_device:
                _err(f"zero-sized 'reg' in {self._node!r} seems meaningless "
                     "(maybe you want a size of one or #size-cells = 0 "
                     "instead)")

            # We'll fix up the name when we're done.
            self.regs.append(Register(self, None, addr, size))

        _add_names(node, "reg", self.regs)

    def _init_pinctrls(self) -> None:
        # Initializes self.pinctrls from any pinctrl-<index> properties

        node = self._node

        # pinctrl-<index> properties
        pinctrl_props = [prop for name, prop in node.props.items()
                         if re.match("pinctrl-[0-9]+", name)]
        # Sort by index
        pinctrl_props.sort(key=lambda prop: prop.name)

        # Check indices
        for i, prop in enumerate(pinctrl_props):
            if prop.name != "pinctrl-" + str(i):
                _err(f"missing 'pinctrl-{i}' property on {node!r} "
                     "- indices should be contiguous and start from zero")

        self.pinctrls = []
        for prop in pinctrl_props:
            # We'll fix up the names below.
            self.pinctrls.append(PinCtrl(
                node=self,
                name=None,
                conf_nodes=[self.edt._node2enode[node]
                            for node in prop.to_nodes()]))

        _add_names(node, "pinctrl", self.pinctrls)

    def _init_interrupts(self) -> None:
        # Initializes self.interrupts

        node = self._node

        self.interrupts = []

        for controller_node, data in _interrupts(node):
            # We'll fix up the names below.
            controller = self.edt._node2enode[controller_node]
            self.interrupts.append(ControllerAndData(
                node=self, controller=controller,
                data=self._named_cells(controller, data, "interrupt"),
                name=None, basename=None))

        _add_names(node, "interrupt", self.interrupts)

    def _standard_phandle_val_list(
            self,
            prop: dtlib_Property,
            specifier_space: Optional[str]
    ) -> list[Optional[ControllerAndData]]:
        # Parses a property like
        #
        #     <prop.name> = <phandle cell phandle cell ...>;
        #
        # where each phandle points to a controller node that has a
        #
        #     #<specifier_space>-cells = <size>;
        #
        # property that gives the number of cells in the value after the
        # controller's phandle in the property.
        #
        # E.g. with a property like
        #
        #     pwms = <&foo 1 2 &bar 3>;
        #
        # If 'specifier_space' is "pwm", then we should have this elsewhere
        # in the tree:
        #
        #     foo: ... {
        #             #pwm-cells = <2>;
        #     };
        #
        #     bar: ... {
        #             #pwm-cells = <1>;
        #     };
        #
        # These values can be given names using the <specifier_space>-names:
        # list in the binding for the phandle nodes.
        #
        # Also parses any
        #
        #     <specifier_space>-names = "...", "...", ...
        #
        # Returns a list of Optional[ControllerAndData] instances.
        #
        # An index is None if the underlying phandle-array element is
        # unspecified.

        if not specifier_space:
            if prop.name.endswith("gpios"):
                # There's some slight special-casing for *-gpios properties in that
                # e.g. foo-gpios still maps to #gpio-cells rather than
                # #foo-gpio-cells
                specifier_space = "gpio"
            else:
                # Strip -s. We've already checked that property names end in -s
                # if there is no specifier space in _check_prop_by_type().
                specifier_space = prop.name[:-1]

        res: list[Optional[ControllerAndData]] = []

        for item in _phandle_val_list(prop, specifier_space):
            if item is None:
                res.append(None)
                continue

            controller_node, data = item
            mapped_controller, mapped_data = (
                _map_phandle_array_entry(prop.node, controller_node,
                                         data, specifier_space))

            controller = self.edt._node2enode[mapped_controller]
            # We'll fix up the names below.
            res.append(ControllerAndData(
                node=self, controller=controller,
                data=self._named_cells(controller, mapped_data,
                                       specifier_space),
                name=None, basename=specifier_space))

        _add_names(self._node, specifier_space, res)

        return res

    def _named_cells(
            self,
            controller: 'Node',
            data: bytes,
            basename: str
    ) -> dict[str, int]:
        # Returns a dictionary that maps <basename>-cells names given in the
        # binding for 'controller' to cell values. 'data' is the raw data, as a
        # byte array.

        if not controller._binding:
            _err(f"{basename} controller {controller._node!r} "
                 f"for {self._node!r} lacks binding")

        if basename in controller._binding.specifier2cells:
            cell_names: list[str] = controller._binding.specifier2cells[basename]
        else:
            # Treat no *-cells in the binding the same as an empty *-cells, so
            # that bindings don't have to have e.g. an empty 'clock-cells:' for
            # '#clock-cells = <0>'.
            cell_names = []

        data_list = to_nums(data)
        if len(data_list) != len(cell_names):
            _err(f"unexpected '{basename}-cells:' length in binding for "
                 f"{controller._node!r} - {len(cell_names)} "
                 f"instead of {len(data_list)}")

        return dict(zip(cell_names, data_list, strict=False))


class EDT:
    """
    Represents a devicetree augmented with information from bindings.

    These attributes are available on EDT objects:

    nodes:
      A list of Node objects for the nodes that appear in the devicetree

    compat2nodes:
      A collections.defaultdict that maps each 'compatible' string that appears
      on some Node to a list of Nodes with that compatible.
      The collection is sorted so that enabled nodes appear first in the
      collection.

    compat2okay:
      Like compat2nodes, but just for nodes with status 'okay'.

    compat2notokay:
      Like compat2nodes, but just for nodes with status not 'okay'.

    compat2vendor:
      A collections.defaultdict that maps each 'compatible' string that appears
      on some Node to a vendor name parsed from vendor_prefixes.

    compat2model:
      A collections.defaultdict that maps each 'compatible' string that appears
      on some Node to a model name parsed from that compatible.

    label2node:
      A dict that maps a node label to the node with that label.

    dep_ord2node:
      A dict that maps an ordinal to the node with that dependency ordinal.

    chosen_nodes:
      A dict that maps the properties defined on the devicetree's /chosen
      node to their values. 'chosen' is indexed by property name (a string),
      and values are converted to Node objects. Note that properties of the
      /chosen node which can't be converted to a Node are not included in
      the value.

    dts_path:
      The .dts path passed to __init__()

    dts_source:
      The final DTS source code of the loaded devicetree after merging nodes
      and processing /delete-node/ and /delete-property/, as a string

    bindings_dirs:
      The bindings directory paths passed to __init__()

    scc_order:
      A list of lists of Nodes. All elements of each list
      depend on each other, and the Nodes in any list do not depend
      on any Node in a subsequent list. Each list defines a Strongly
      Connected Component (SCC) of the graph.

      For an acyclic graph each list will be a singleton. Cycles
      will be represented by lists with multiple nodes. Cycles are
      not expected to be present in devicetree graphs.

    The standard library's pickle module can be used to marshal and
    unmarshal EDT objects.
    """

    def __init__(self,
                 dts: Optional[str],
                 bindings_dirs: list[str],
                 workspace_dir: Optional[str] = None,
                 warn_reg_unit_address_mismatch: bool = True,
                 default_prop_types: bool = True,
                 support_fixed_partitions_on_any_bus: bool = True,
                 infer_binding_for_paths: Optional[Iterable[str]] = None,
                 vendor_prefixes: Optional[dict[str, str]] = None,
                 werror: bool = False,
                 warn_bus_mismatch: bool = False):
        """EDT constructor.

        dts:
          Path to devicetree .dts file. Passing None for this value
          is only for internal use; do not do that outside of edtlib.

        bindings_dirs:
          List of paths to directories containing bindings, in YAML format.
          These directories are recursively searched for .yaml files.

        workspace_dir:
          Path to the root of the Zephyr workspace. This is used as a base
          directory for relative paths in the generated devicetree comments.

        warn_reg_unit_address_mismatch (default: True):
          If True, a warning is logged if a node has a 'reg' property where
          the address of the first entry does not match the unit address of the
          node

        default_prop_types (default: True):
          If True, default property types will be used when a node has no
          bindings.

        support_fixed_partitions_on_any_bus (default True):
          If True, set the Node.bus for 'fixed-partitions' compatible nodes
          to None.  This allows 'fixed-partitions' binding to match regardless
          of the bus the 'fixed-partition' is under.

        infer_binding_for_paths (default: None):
          An iterable of devicetree paths identifying nodes for which bindings
          should be inferred from the node content.  (Child nodes are not
          processed.)  Pass none if no nodes should support inferred bindings.

        vendor_prefixes (default: None):
          A dict mapping vendor prefixes in compatible properties to their
          descriptions. If given, compatibles in the form "manufacturer,device"
          for which "manufacturer" is neither a key in the dict nor a specially
          exempt set of legacy cases will cause warnings.

        werror (default: False):
          If True, some edtlib specific warnings become errors. This currently
          errors out if 'dts' has any deprecated properties set, or an unknown
          vendor prefix is used.

        warn_bus_mismatch (default: False):
          If True, a warning is logged if a node's actual bus does not match
            the bus specified in its binding.
        """
        # All instance attributes should be initialized here.
        # This makes it easy to keep track of them, which makes
        # implementing __deepcopy__() easier.
        # If you change this, make sure to update __deepcopy__() too,
        # and update the tests for that method.

        # Public attributes (the rest are properties)
        self.nodes: list[Node] = []
        self.compat2nodes: dict[str, list[Node]] = defaultdict(list)
        self.compat2okay: dict[str, list[Node]] = defaultdict(list)
        self.compat2notokay: dict[str, list[Node]] = defaultdict(list)
        self.compat2vendor: dict[str, str] = defaultdict(str)
        self.compat2model: dict[str, str]  = defaultdict(str)
        self.label2node: dict[str, Node] = {}
        self.dep_ord2node: dict[int, Node] = {}
        self.dts_path: str = dts # type: ignore
        self.bindings_dirs: list[str] = list(bindings_dirs)

        # Saved kwarg values for internal use
        self._warn_reg_unit_address_mismatch: bool = warn_reg_unit_address_mismatch
        self._default_prop_types: bool = default_prop_types
        self._fixed_partitions_no_bus: bool = support_fixed_partitions_on_any_bus
        self._infer_binding_for_paths: set[str] = set(infer_binding_for_paths or [])
        self._vendor_prefixes: dict[str, str] = vendor_prefixes or {}
        self._werror: bool = bool(werror)
        self._warn_bus_mismatch: bool = warn_bus_mismatch

        # Other internal state
        self._compat2binding: dict[tuple[str, Optional[str]], Binding] = {}
        self._graph: Graph = Graph()
        self._binding_paths: list[str] = _binding_paths(self.bindings_dirs)
        self._binding_fname2path: dict[str, str] = {
            os.path.basename(path): path
            for path in self._binding_paths
        }
        self._node2enode: dict[dtlib_Node, Node] = {}

        if dts is not None:
            try:
                self._dt = DT(dts, base_dir=workspace_dir)
            except DTError as e:
                raise EDTError(e) from e
            self._finish_init()

    def _finish_init(self) -> None:
        # This helper exists to make the __deepcopy__() implementation
        # easier to keep in sync with __init__().
        _check_dt(self._dt)

        self._init_compat2binding()
        self._init_nodes()
        self._init_graph()
        self._init_luts()

        self._check()

    def get_node(self, path: str) -> Node:
        """
        Returns the Node at the DT path or alias 'path'. Raises EDTError if the
        path or alias doesn't exist.
        """
        try:
            return self._node2enode[self._dt.get_node(path)]
        except DTError as e:
            _err(e)

    @property
    def chosen_nodes(self) -> dict[str, Node]:
        ret: dict[str, Node] = {}

        try:
            chosen = self._dt.get_node("/chosen")
        except DTError:
            return ret

        for name, prop in chosen.props.items():
            try:
                node = prop.to_path()
            except DTError:
                # DTS value is not phandle or string, or path doesn't exist
                continue

            ret[name] = self._node2enode[node]

        return ret

    def chosen_node(self, name: str) -> Optional[Node]:
        """
        Returns the Node pointed at by the property named 'name' in /chosen, or
        None if the property is missing
        """
        return self.chosen_nodes.get(name)

    @property
    def dts_source(self) -> str:
        return f"{self._dt}"

    def __repr__(self) -> str:
        return (f"<EDT for '{self.dts_path}', binding directories "
                f"'{self.bindings_dirs}'>")

    def __deepcopy__(self, memo) -> 'EDT':
        """
        Implements support for the standard library copy.deepcopy()
        function on EDT instances.
        """

        ret = EDT(
            None,
            self.bindings_dirs,
            warn_reg_unit_address_mismatch=self._warn_reg_unit_address_mismatch,
            default_prop_types=self._default_prop_types,
            support_fixed_partitions_on_any_bus=self._fixed_partitions_no_bus,
            infer_binding_for_paths=set(self._infer_binding_for_paths),
            vendor_prefixes=dict(self._vendor_prefixes),
            werror=self._werror
        )
        ret.dts_path = self.dts_path
        ret._dt = deepcopy(self._dt, memo)
        ret._finish_init()
        return ret

    @property
    def scc_order(self) -> list[list[Node]]:
        try:
            return self._graph.scc_order()
        except Exception as e:
            raise EDTError(e) from None

    def _process_properties_r(self, root_node: Node, props_node: Node) -> None:
        """
        Process props_node properties for dependencies, and add those as
        dependencies of root_node. Then walk through all the props_node
        children and do the same recursively, maintaining the same root_node.

        This ensures that on a node with child nodes, the parent node includes
        the dependencies of all the child nodes as well as its own.
        """
        # A Node depends on any Nodes present in 'phandle',
        # 'phandles', or 'phandle-array' property values.
        for prop in props_node.props.values():
            if prop.type == 'phandle':
                self._graph.add_edge(root_node, prop.val)
            elif prop.type == 'phandles':
                if TYPE_CHECKING:
                    assert isinstance(prop.val, list)
                for phandle_node in prop.val:
                    self._graph.add_edge(root_node, phandle_node)
            elif prop.type == 'phandle-array':
                if TYPE_CHECKING:
                    assert isinstance(prop.val, list)
                for cd in prop.val:
                    if cd is None:
                        continue
                    if TYPE_CHECKING:
                        assert isinstance(cd, ControllerAndData)
                    self._graph.add_edge(root_node, cd.controller)

        # A Node depends on whatever supports the interrupts it
        # generates.
        for intr in props_node.interrupts:
            self._graph.add_edge(root_node, intr.controller)

        # If the binding defines child bindings, link the child properties to
        # the root_node as well.
        if props_node.has_child_binding:
            for child in props_node.children.values():
                if "compatible" in child.props:
                    # Not a child node, normal node on a different binding.
                    continue
                self._process_properties_r(root_node, child)

    def _process_properties(self, node: Node) -> None:
        """
        Add node dependencies based on own as well as child node properties,
        start from the node itself.
        """
        self._process_properties_r(node, node)

    def _init_graph(self) -> None:
        # Constructs a graph of dependencies between Node instances,
        # which is usable for computing a partial order over the dependencies.
        # The algorithm supports detecting dependency loops.
        #
        # Actually computing the SCC order is lazily deferred to the
        # first time the scc_order property is read.

        for node in self.nodes:
            # Always insert root node
            if not node.parent:
                self._graph.add_node(node)

            # A Node always depends on its parent.
            for child in node.children.values():
                self._graph.add_edge(child, node)

            self._process_properties(node)

    def _init_compat2binding(self) -> None:
        # Creates self._compat2binding, a dictionary that maps
        # (<compatible>, <bus>) tuples (both strings) to Binding objects.
        #
        # The Binding objects are created from YAML files discovered
        # in self.bindings_dirs as needed.
        #
        # For example, self._compat2binding["company,dev", "can"]
        # contains the Binding for the 'company,dev' device, when it
        # appears on the CAN bus.
        #
        # For bindings that don't specify a bus, <bus> is None, so that e.g.
        # self._compat2binding["company,notonbus", None] is the Binding.
        #
        # Only bindings for 'compatible' strings that appear in the devicetree
        # are loaded.

        dt_compats = _dt_compats(self._dt)
        # Searches for any 'compatible' string mentioned in the devicetree
        # files, with a regex
        dt_compats_search = re.compile(
            "|".join(re.escape(compat) for compat in dt_compats)
        ).search

        for binding_path in self._binding_paths:
            with open(binding_path, encoding="utf-8") as f:
                contents = f.read()

            # As an optimization, skip parsing files that don't contain any of
            # the .dts 'compatible' strings, which should be reasonably safe
            if not dt_compats_search(contents):
                continue

            # Load the binding and check that it actually matches one of the
            # compatibles. Might get false positives above due to comments and
            # stuff.

            try:
                # Parsed PyYAML output (Python lists/dictionaries/strings/etc.,
                # representing the file)
                raw = yaml.load(contents, Loader=_BindingLoader)
            except yaml.YAMLError as e:
                _err(
                        f"'{binding_path}' appears in binding directories "
                        f"but isn't valid YAML: {e}")

            # Convert the raw data to a Binding object, erroring out
            # if necessary.
            binding = self._binding(raw, binding_path, dt_compats)

            # Register the binding in self._compat2binding, along with
            # any child bindings that have their own compatibles.
            while binding is not None:
                if binding.compatible:
                    self._register_binding(binding)
                binding = binding.child_binding

    def _binding(self,
                 raw: Optional[dict],
                 binding_path: str,
                 dt_compats: set[str]) -> Optional[Binding]:
        # Convert a 'raw' binding from YAML to a Binding object and return it.
        #
        # Error out if the raw data looks like an invalid binding.
        #
        # Return None if the file doesn't contain a binding or the
        # binding's compatible isn't in dt_compats.

        # Get the 'compatible:' string.
        if raw is None or "compatible" not in raw:
            # Empty file, binding fragment, spurious file, etc.
            return None

        compatible = raw["compatible"]

        if compatible not in dt_compats:
            # Not a compatible we care about.
            return None

        # Initialize and return the Binding object.
        return Binding(binding_path, self._binding_fname2path, raw=raw)

    def _register_binding(self, binding: Binding) -> None:
        # Do not allow two different bindings to have the same
        # 'compatible:'/'on-bus:' combo
        if TYPE_CHECKING:
            assert binding.compatible
        old_binding = self._compat2binding.get((binding.compatible,
                                                binding.on_bus))
        if old_binding:
            msg = (f"both {old_binding.path} and {binding.path} have "
                   f"'compatible: {binding.compatible}'")
            if binding.on_bus is not None:
                msg += f" and 'on-bus: {binding.on_bus}'"
            _err(msg)

        # Register the binding.
        self._compat2binding[binding.compatible, binding.on_bus] = binding

    def _init_nodes(self) -> None:
        # Creates a list of edtlib.Node objects from the dtlib.Node objects, in
        # self.nodes

        hash2node: dict[str, Node] = {}

        for dt_node in self._dt.node_iter():
            # Warning: We depend on parent Nodes being created before their
            # children. This is guaranteed by node_iter().
            node = Node(dt_node, self, self._fixed_partitions_no_bus)

            if node.hash in hash2node:
                _err(f"hash collision between '{node.path}' and "
                     f"'{hash2node[node.hash].path}'")
            hash2node[node.hash] = node

            self.nodes.append(node)
            self._node2enode[dt_node] = node

        for node in self.nodes:
            # Initialize properties that may depend on other Node objects having
            # been created, because they (either always or sometimes) reference
            # other nodes. Must be called separately after all nodes have been
            # created.
            node._init_crossrefs(
                default_prop_types=self._default_prop_types,
                err_on_deprecated=self._werror,
            )

        if self._warn_reg_unit_address_mismatch:
            # This warning matches the simple_bus_reg warning in dtc
            for node in self.nodes:
                # Address mismatch is ok for PCI devices
                if (node.regs and node.regs[0].addr != node.unit_addr and
                        not node.is_pci_device):
                    _LOG.warning("unit address and first address in 'reg' "
                                 f"(0x{node.regs[0].addr:x}) don't match for "
                                 f"{node.path}")

    def _init_luts(self) -> None:
        # Initialize node lookup tables (LUTs).

        for node in self.nodes:
            for label in node.labels:
                self.label2node[label] = node

            for compat in node.compats:
                if node.status == "okay":
                    self.compat2okay[compat].append(node)
                else:
                    self.compat2notokay[compat].append(node)

                if compat in self.compat2vendor:
                    continue

                # The regular expression comes from dt-schema.
                compat_re = r'^[a-zA-Z][a-zA-Z0-9,+\-._]+$'
                if not re.match(compat_re, compat):
                    _err(f"node '{node.path}' compatible '{compat}' "
                         'must match this regular expression: '
                         f"'{compat_re}'")

                if ',' in compat and self._vendor_prefixes:
                    vendor, model = compat.split(',', 1)
                    if vendor in self._vendor_prefixes:
                        self.compat2vendor[compat] = self._vendor_prefixes[vendor]
                        self.compat2model[compat] = model

                    # As an exception, the root node can have whatever
                    # compatibles it wants. Other nodes get checked.
                    elif node.path != '/':
                        if self._werror:
                            handler_fn: Any = _err
                        else:
                            handler_fn = _LOG.warning
                        handler_fn(
                            f"node '{node.path}' compatible '{compat}' "
                            f"has unknown vendor prefix '{vendor}'")

        for compat, nodes in self.compat2okay.items():
            self.compat2nodes[compat].extend(nodes)

        for compat, nodes in self.compat2notokay.items():
            self.compat2nodes[compat].extend(nodes)

        for nodeset in self.scc_order:
            node = nodeset[0]
            self.dep_ord2node[node.dep_ordinal] = node

    def _check(self) -> None:
        # Tree-wide checks and warnings.

        for binding in self._compat2binding.values():
            for spec in binding.prop2specs.values():
                if not spec.enum or spec.type != 'string':
                    continue

                if not spec.enum_tokenizable:
                    _LOG.warning(
                        f"compatible '{binding.compatible}' "
                        f"in binding '{binding.path}' has non-tokenizable enum "
                        f"for property '{spec.name}': " +
                        ', '.join(repr(x) for x in spec.enum))
                elif not spec.enum_upper_tokenizable:
                    _LOG.warning(
                        f"compatible '{binding.compatible}' "
                        f"in binding '{binding.path}' has enum for property "
                        f"'{spec.name}' that is only tokenizable "
                        'in lowercase: ' +
                        ', '.join(repr(x) for x in spec.enum))

        # Validate the contents of compatible properties.
        for node in self.nodes:
            if 'compatible' not in node.props:
                continue

            compatibles = node.props['compatible'].val

            # _check() runs after _init_compat2binding() has called
            # _dt_compats(), which already converted every compatible
            # property to a list of strings. So we know 'compatibles'
            # is a list, but add an assert for future-proofing.
            assert isinstance(compatibles, list)

            for compat in compatibles:
                # This is also just for future-proofing.
                assert isinstance(compat, str)


def bindings_from_paths(yaml_paths: list[str],
                        ignore_errors: bool = False) -> list[Binding]:
    """
    Get a list of Binding objects from the yaml files 'yaml_paths'.

    If 'ignore_errors' is True, YAML files that cause an EDTError when
    loaded are ignored. (No other exception types are silenced.)
    """

    ret = []
    fname2path = {os.path.basename(path): path for path in yaml_paths}
    for path in yaml_paths:
        try:
            ret.append(Binding(path, fname2path))
        except EDTError:
            if ignore_errors:
                continue
            raise

    return ret


class EDTError(Exception):
    "Exception raised for devicetree- and binding-related errors"

#
# Public global functions
#


def load_vendor_prefixes_txt(vendor_prefixes: str) -> dict[str, str]:
    """Load a vendor-prefixes.txt file and return a dict
    representation mapping a vendor prefix to the vendor name.
    """
    vnd2vendor: dict[str, str] = {}
    with open(vendor_prefixes, encoding='utf-8') as f:
        for line in f:
            line = line.strip()

            if not line or line.startswith('#'):
                # Comment or empty line.
                continue

            # Other lines should be in this form:
            #
            # <vnd><TAB><vendor>
            vnd_vendor = line.split('\t', 1)
            assert len(vnd_vendor) == 2, line
            vnd2vendor[vnd_vendor[0]] = vnd_vendor[1]
    return vnd2vendor

#
# Private global functions
#


def _dt_compats(dt: DT) -> set[str]:
    # Returns a set() with all 'compatible' strings in the devicetree
    # represented by dt (a dtlib.DT instance)

    return {compat
            for node in dt.node_iter()
            if "compatible" in node.props
            for compat in node.props["compatible"].to_strings()}


def _binding_paths(bindings_dirs: list[str]) -> list[str]:
    # Returns a list with the paths to all bindings (.yaml files) in
    # 'bindings_dirs'

    return [os.path.join(root, filename)
            for bindings_dir in bindings_dirs
            for root, _, filenames in os.walk(bindings_dir)
            for filename in filenames
            if filename.endswith((".yaml", ".yml"))]


def _binding_inc_error(msg):
    # Helper for reporting errors in the !include implementation

    raise yaml.constructor.ConstructorError(None, None, "error: " + msg)


def _check_include_dict(name: Optional[str],
                        allowlist: Optional[list[str]],
                        blocklist: Optional[list[str]],
                        child_filter: Optional[dict],
                        binding_path: Optional[str]) -> None:
    # Check that an 'include:' named 'name' with property-allowlist
    # 'allowlist', property-blocklist 'blocklist', and
    # child-binding filter 'child_filter' has valid structure.

    if name is None:
        _err(f"'include:' element in {binding_path} "
             "should have a 'name' key")

    if allowlist is not None and blocklist is not None:
        _err(f"'include:' of file '{name}' in {binding_path} "
             "should not specify both 'property-allowlist:' "
             "and 'property-blocklist:'")

    while child_filter is not None:
        child_copy = deepcopy(child_filter)
        child_allowlist: Optional[list[str]] = (
            child_copy.pop('property-allowlist', None))
        child_blocklist: Optional[list[str]] = (
            child_copy.pop('property-blocklist', None))
        next_child_filter: Optional[dict] = (
            child_copy.pop('child-binding', None))

        if child_copy:
            # We've popped out all the valid keys.
            _err(f"'include:' of file '{name}' in {binding_path} "
                 "should not have these unexpected contents in a "
                 f"'child-binding': {child_copy}")

        if child_allowlist is not None and child_blocklist is not None:
            _err(f"'include:' of file '{name}' in {binding_path} "
                 "should not specify both 'property-allowlist:' and "
                 "'property-blocklist:' in a 'child-binding:'")

        child_filter = next_child_filter


def _filter_properties(raw: dict,
                       allowlist: Optional[list[str]],
                       blocklist: Optional[list[str]],
                       child_filter: Optional[dict],
                       binding_path: Optional[str]) -> None:
    # Destructively modifies 'raw["properties"]' and
    # 'raw["child-binding"]', if they exist, according to
    # 'allowlist', 'blocklist', and 'child_filter'.

    props = raw.get('properties')
    _filter_properties_helper(props, allowlist, blocklist, binding_path)

    child_binding = raw.get('child-binding')
    while child_filter is not None and child_binding is not None:
        _filter_properties_helper(child_binding.get('properties'),
                                  child_filter.get('property-allowlist'),
                                  child_filter.get('property-blocklist'),
                                  binding_path)
        child_filter = child_filter.get('child-binding')
        child_binding = child_binding.get('child-binding')


def _filter_properties_helper(props: Optional[dict],
                              allowlist: Optional[list[str]],
                              blocklist: Optional[list[str]],
                              binding_path: Optional[str]) -> None:
    if props is None or (allowlist is None and blocklist is None):
        return

    _check_prop_filter('property-allowlist', allowlist, binding_path)
    _check_prop_filter('property-blocklist', blocklist, binding_path)

    if allowlist is not None:
        allowset = set(allowlist)
        to_del = [prop for prop in props if prop not in allowset]
    else:
        if TYPE_CHECKING:
            assert blocklist
        blockset = set(blocklist)
        to_del = [prop for prop in props if prop in blockset]

    for prop in to_del:
        del props[prop]


def _check_prop_filter(name: str, value: Optional[list[str]],
                       binding_path: Optional[str]) -> None:
    # Ensure an include: ... property-allowlist or property-blocklist
    # is a list.

    if value is None:
        return

    if not isinstance(value, list):
        _err(f"'{name}' value {value} in {binding_path} should be a list")


def _merge_props(to_dict: dict,
                 from_dict: dict,
                 parent: Optional[str],
                 binding_path: Optional[str],
                 check_required: bool = False):
    # Recursively merges 'from_dict' into 'to_dict', to implement 'include:'.
    #
    # If 'from_dict' and 'to_dict' contain a 'required:' key for the same
    # property, then the values are ORed together.
    #
    # If 'check_required' is True, then an error is raised if 'from_dict' has
    # 'required: true' while 'to_dict' has 'required: false'. This prevents
    # bindings from "downgrading" requirements from bindings they include,
    # which might help keep bindings well-organized.
    #
    # It's an error for most other keys to appear in both 'from_dict' and
    # 'to_dict'. When it's not an error, the value in 'to_dict' takes
    # precedence.
    #
    # 'parent' is the name of the parent key containing 'to_dict' and
    # 'from_dict', and 'binding_path' is the path to the top-level binding.
    # These are used to generate errors for sketchy property overwrites.

    for prop in from_dict:
        if (isinstance(to_dict.get(prop), dict)
            and isinstance(from_dict[prop], dict)):
            _merge_props(to_dict[prop], from_dict[prop], prop, binding_path,
                         check_required)
        elif prop not in to_dict:
            to_dict[prop] = from_dict[prop]
        elif _bad_overwrite(to_dict, from_dict, prop, check_required):
            _err(f"{binding_path} (in '{parent}'): '{prop}' "
                 f"from included file overwritten ('{from_dict[prop]}' "
                 f"replaced with '{to_dict[prop]}')")
        elif prop == "required":
            # Need a separate check here, because this code runs before
            # Binding._check()
            if not (isinstance(from_dict["required"], bool) and
                    isinstance(to_dict["required"], bool)):
                _err(f"malformed 'required:' setting for '{parent}' in "
                     f"'properties' in {binding_path}, expected true/false")

            # 'required: true' takes precedence
            to_dict["required"] = to_dict["required"] or from_dict["required"]


def _bad_overwrite(to_dict: dict, from_dict: dict, prop: str,
                   check_required: bool) -> bool:
    # _merge_props() helper. Returns True in cases where it's bad that
    # to_dict[prop] takes precedence over from_dict[prop].

    if to_dict[prop] == from_dict[prop]:
        return False

    # These are overridden deliberately
    if prop in {"title", "description", "compatible"}:
        return False

    if prop == "required":
        if not check_required:
            return False
        return from_dict[prop] and not to_dict[prop]

    return True


def _binding_include(loader, node):
    # Implements !include, for backwards compatibility. '!include [foo, bar]'
    # just becomes [foo, bar].

    if isinstance(node, yaml.ScalarNode):
        # !include foo.yaml
        return [loader.construct_scalar(node)]

    if isinstance(node, yaml.SequenceNode):
        # !include [foo.yaml, bar.yaml]
        return loader.construct_sequence(node)

    _binding_inc_error("unrecognised node type in !include statement")


def _check_prop_by_type(prop_name: str,
                        options: dict,
                        binding_path: Optional[str]) -> None:
    # Binding._check_properties() helper. Checks 'type:', 'default:',
    # 'const:' and # 'specifier-space:' for the property named 'prop_name'

    prop_type = options.get("type")
    default = options.get("default")
    const = options.get("const")

    if prop_type is None:
        _err(f"missing 'type:' for '{prop_name}' in 'properties' in "
             f"{binding_path}")

    ok_types = {"boolean", "int", "array", "uint8-array", "string",
                "string-array", "phandle", "phandles", "phandle-array",
                "path", "compound"}

    if prop_type not in ok_types:
        _err(f"'{prop_name}' in 'properties:' in {binding_path} "
             f"has unknown type '{prop_type}', expected one of " +
             ", ".join(ok_types))

    if "specifier-space" in options and prop_type != "phandle-array":
        _err(f"'specifier-space' in 'properties: {prop_name}' "
             f"has type '{prop_type}', expected 'phandle-array'")

    if (prop_type == "phandle-array"
        and not prop_name.endswith("s")
        and "specifier-space" not in options):
        _err(f"'{prop_name}' in 'properties:' in {binding_path} "
             f"has type 'phandle-array' and its name does not end in 's', "
             f"but no 'specifier-space' was provided.")

    # If you change const_types, be sure to update the type annotation
    # for PropertySpec.const.
    const_types = {"int", "array", "uint8-array", "string", "string-array"}
    if const and prop_type not in const_types:
        _err(f"const in {binding_path} for property '{prop_name}' "
             f"has type '{prop_type}', expected one of " +
             ", ".join(const_types))

    # Check default

    if default is None:
        return

    if prop_type in {"boolean", "compound", "phandle", "phandles",
                     "phandle-array", "path"}:
        _err("'default:' can't be combined with "
             f"'type: {prop_type}' for '{prop_name}' in "
             f"'properties:' in {binding_path}")

    def ok_default() -> bool:
        # Returns True if 'default' is an okay default for the property's type.
        # If you change this, be sure to update the type annotation for
        # PropertySpec.default.

        if (prop_type == "int" and isinstance(default, int)
            or prop_type == "string" and isinstance(default, str)):
            return True

        # array, uint8-array, or string-array

        if not isinstance(default, list):
            return False

        if (prop_type == "array"
            and all(isinstance(val, int) for val in default)):
            return True

        if (prop_type == "uint8-array"
            and all(isinstance(val, int)
                    and 0 <= val <= 255 for val in default)):
            return True

        # string-array
        return all(isinstance(val, str) for val in default)

    if not ok_default():
        _err(f"'default: {default}' is invalid for '{prop_name}' "
             f"in 'properties:' in {binding_path}, "
             f"which has type {prop_type}")


def _translate(addr: int, node: dtlib_Node) -> int:
    # Recursively translates 'addr' on 'node' to the address space(s) of its
    # parent(s), by looking at 'ranges' properties. Returns the translated
    # address.

    if not node.parent or "ranges" not in node.parent.props:
        # No translation
        return addr

    if not node.parent.props["ranges"].value:
        # DT spec.: "If the property is defined with an <empty> value, it
        # specifies that the parent and child address space is identical, and
        # no address translation is required."
        #
        # Treat this the same as a 'range' that explicitly does a one-to-one
        # mapping, as opposed to there not being any translation.
        return _translate(addr, node.parent)

    # Gives the size of each component in a translation 3-tuple in 'ranges'
    child_address_cells = _address_cells(node)
    parent_address_cells = _address_cells(node.parent)
    child_size_cells = _size_cells(node)

    # Number of cells for one translation 3-tuple in 'ranges'
    entry_cells = child_address_cells + parent_address_cells + child_size_cells

    for raw_range in _slice(node.parent, "ranges", 4*entry_cells,
                            f"4*(<#address-cells> (= {child_address_cells}) + "
                            "<#address-cells for parent> "
                            f"(= {parent_address_cells}) + "
                            f"<#size-cells> (= {child_size_cells}))"):
        child_addr = to_num(raw_range[:4*child_address_cells])
        raw_range = raw_range[4*child_address_cells:]

        parent_addr = to_num(raw_range[:4*parent_address_cells])
        raw_range = raw_range[4*parent_address_cells:]

        child_len = to_num(raw_range)

        if child_addr <= addr < child_addr + child_len:
            # 'addr' is within range of a translation in 'ranges'. Recursively
            # translate it and return the result.
            return _translate(parent_addr + addr - child_addr, node.parent)

    # 'addr' is not within range of any translation in 'ranges'
    return addr


def _add_names(node: dtlib_Node, names_ident: str, objs: Any) -> None:
    # Helper for registering names from <foo>-names properties.
    #
    # node:
    #   Node which has a property that might need named elements.
    #
    # names-ident:
    #   The <foo> part of <foo>-names, e.g. "reg" for "reg-names"
    #
    # objs:
    #   list of objects whose .name field should be set

    full_names_ident = names_ident + "-names"

    if full_names_ident in node.props:
        names = node.props[full_names_ident].to_strings()
        if len(names) != len(objs):
            _err(f"{full_names_ident} property in {node.path} "
                 f"in {node.dt.filename} has {len(names)} strings, "
                 f"expected {len(objs)} strings")

        for obj, name in zip(objs, names, strict=False):
            if obj is None:
                continue
            obj.name = name
    else:
        for obj in objs:
            if obj is not None:
                obj.name = None


def _interrupt_parent(start_node: dtlib_Node) -> dtlib_Node:
    # Returns the node pointed at by the closest 'interrupt-parent', searching
    # the parents of 'node'. As of writing, this behavior isn't specified in
    # the DT spec., but seems to match what some .dts files except.

    node: Optional[dtlib_Node] = start_node

    while node:
        if "interrupt-parent" in node.props:
            iparent = node.props["interrupt-parent"].to_node()
            assert "interrupt-controller" in iparent.props or "interrupt-map" in iparent.props
            return iparent
        node = node.parent
        if node is None:
            _err(f"{start_node!r} no interrupt parent found")
        if ("interrupt-controller" in node.props) or ("interrupt-map" in node.props):
            return node

    _err(f"{start_node!r} has an 'interrupts' property, but neither the node "
         f"nor any of its parents has an 'interrupt-parent' property")


def _interrupts(node: dtlib_Node) -> list[tuple[dtlib_Node, bytes]]:
    # Returns a list of (<controller>, <data>) tuples, with one tuple per
    # interrupt generated by 'node'. <controller> is the destination of the
    # interrupt (possibly after mapping through an 'interrupt-map'), and <data>
    # the data associated with the interrupt (as a 'bytes' object).

    # Takes precedence over 'interrupts' if both are present
    if "interrupts-extended" in node.props:
        prop = node.props["interrupts-extended"]

        ret: list[tuple[dtlib_Node, bytes]] = []
        for entry in _phandle_val_list(prop, "interrupt"):
            if entry is None:
                _err(f"node '{node.path}' interrupts-extended property "
                     "has an empty element")
            iparent, spec = entry
            ret.append(_map_interrupt(node, iparent, spec))
        return ret

    if "interrupts" in node.props:
        # Treat 'interrupts' as a special case of 'interrupts-extended', with
        # the same interrupt parent for all interrupts

        iparent = _interrupt_parent(node)
        interrupt_cells = _interrupt_cells(iparent)

        return [_map_interrupt(node, iparent, raw)
                for raw in _slice(node, "interrupts", 4*interrupt_cells,
                                  "4*<#interrupt-cells>")]

    return []


def _map_interrupt(
        child: dtlib_Node,
        parent: dtlib_Node,
        child_spec: bytes
) -> tuple[dtlib_Node, bytes]:
    # Translates an interrupt headed from 'child' to 'parent' with data
    # 'child_spec' through any 'interrupt-map' properties. Returns a
    # (<controller>, <data>) tuple with the final destination after mapping.

    if "interrupt-controller" in parent.props:
        return (parent, child_spec)

    def own_address_cells(node):
        # Used for parents pointed at by 'interrupt-map'. We can't use
        # _address_cells(), because it's the #address-cells property on 'node'
        # itself that matters.

        address_cells = _address_cells_self(node)
        if address_cells is None:
            _err(f"missing #address-cells on {node!r} "
                 "(while handling interrupt-map)")
        return address_cells

    def spec_len_fn(node):
        # Can't use _address_cells() here, because it's the #address-cells
        # property on 'node' itself that matters
        return own_address_cells(node) + _interrupt_cells(node)

    parent, raw_spec = _map(
        "interrupt", child, parent, _raw_unit_addr(child, parent) + child_spec,
        spec_len_fn, require_controller=True)

    # Strip the parent unit address part, if any
    return (parent, raw_spec[4*own_address_cells(parent):])


def _map_phandle_array_entry(
        child: dtlib_Node,
        parent: dtlib_Node,
        child_spec: bytes,
        basename: str
) -> tuple[dtlib_Node, bytes]:
    # Returns a (<controller>, <data>) tuple with the final destination after
    # mapping through any '<basename>-map' (e.g. gpio-map) properties. See
    # _map_interrupt().

    def spec_len_fn(node):
        prop_name = f"#{basename}-cells"
        if prop_name not in node.props:
            _err(f"expected '{prop_name}' property on {node!r} "
                 f"(referenced by {child!r})")
        return node.props[prop_name].to_num()

    # Do not require <prefix>-controller for anything but interrupts for now
    return _map(basename, child, parent, child_spec, spec_len_fn,
                require_controller=False)


def _map(
        prefix: str,
        child: dtlib_Node,
        parent: dtlib_Node,
        child_spec: bytes,
        spec_len_fn: Callable[[dtlib_Node], int],
        require_controller: bool
) -> tuple[dtlib_Node, bytes]:
    # Common code for mapping through <prefix>-map properties, e.g.
    # interrupt-map and gpio-map.
    #
    # prefix:
    #   The prefix, e.g. "interrupt" or "gpio"
    #
    # child:
    #   The "sender", e.g. the node with 'interrupts = <...>'
    #
    # parent:
    #   The "receiver", e.g. a node with 'interrupt-map = <...>' or
    #   'interrupt-controller' (no mapping)
    #
    # child_spec:
    #   The data associated with the interrupt/GPIO/etc., as a 'bytes' object,
    #   e.g. <1 2> for 'foo-gpios = <&gpio1 1 2>'.
    #
    # spec_len_fn:
    #   Function called on a parent specified in a *-map property to get the
    #   length of the parent specifier (data after phandle in *-map), in cells
    #
    # require_controller:
    #   If True, the final controller node after mapping is required to have
    #   to have a <prefix>-controller property.

    map_prop = parent.props.get(prefix + "-map")
    if not map_prop:
        if require_controller and prefix + "-controller" not in parent.props:
            _err(f"expected '{prefix}-controller' property on {parent!r} "
                 f"(referenced by {child!r})")

        # No mapping
        return (parent, child_spec)

    masked_child_spec = _mask(prefix, child, parent, child_spec)

    raw = map_prop.value
    while raw:
        if len(raw) < len(child_spec):
            _err(f"bad value for {map_prop!r}, missing/truncated child data")
        child_spec_entry = raw[:len(child_spec)]
        raw = raw[len(child_spec):]

        if len(raw) < 4:
            _err(f"bad value for {map_prop!r}, missing/truncated phandle")
        phandle = to_num(raw[:4])
        raw = raw[4:]

        # Parent specified in *-map
        map_parent = parent.dt.phandle2node.get(phandle)
        if not map_parent:
            _err(f"bad phandle ({phandle}) in {map_prop!r}")

        map_parent_spec_len = 4*spec_len_fn(map_parent)
        if len(raw) < map_parent_spec_len:
            _err(f"bad value for {map_prop!r}, missing/truncated parent data")
        parent_spec = raw[:map_parent_spec_len]
        raw = raw[map_parent_spec_len:]

        # Got one *-map row. Check if it matches the child data.
        if child_spec_entry == masked_child_spec:
            # Handle *-map-pass-thru
            parent_spec = _pass_thru(
                prefix, child, parent, child_spec, parent_spec)

            # Found match. Recursively map and return it.
            return _map(prefix, parent, map_parent, parent_spec, spec_len_fn,
                        require_controller)

    _err(f"child specifier for {child!r} ({child_spec!r}) "
         f"does not appear in {map_prop!r}")


def _mask(
        prefix: str,
        child: dtlib_Node,
        parent: dtlib_Node,
        child_spec: bytes
) -> bytes:
    # Common code for handling <prefix>-mask properties, e.g. interrupt-mask.
    # See _map() for the parameters.

    mask_prop = parent.props.get(prefix + "-map-mask")
    if not mask_prop:
        # No mask
        return child_spec

    mask = mask_prop.value
    if len(mask) != len(child_spec):
        _err(f"{child!r}: expected '{prefix}-mask' in {parent!r} "
             f"to be {len(child_spec)} bytes, is {len(mask)} bytes")

    return _and(child_spec, mask)


def _pass_thru(
        prefix: str,
        child: dtlib_Node,
        parent: dtlib_Node,
        child_spec: bytes,
        parent_spec: bytes
) -> bytes:
    # Common code for handling <prefix>-map-thru properties, e.g.
    # interrupt-pass-thru.
    #
    # parent_spec:
    #   The parent data from the matched entry in the <prefix>-map property
    #
    # See _map() for the other parameters.

    pass_thru_prop = parent.props.get(prefix + "-map-pass-thru")
    if not pass_thru_prop:
        # No pass-thru
        return parent_spec

    pass_thru = pass_thru_prop.value
    if len(pass_thru) != len(child_spec):
        _err(f"{child!r}: expected '{prefix}-map-pass-thru' in {parent!r} "
             f"to be {len(child_spec)} bytes, is {len(pass_thru)} bytes")

    res = _or(_and(child_spec, pass_thru),
              _and(parent_spec, _not(pass_thru)))

    # Truncate to length of parent spec.
    return res[-len(parent_spec):]


def _raw_unit_addr(node: dtlib_Node, parent: dtlib_Node) -> bytes:
    # _map_interrupt() helper. Returns the unit address (derived from 'reg' and
    # #address-cells) as a raw 'bytes'

    iparent: Optional[dtlib_Node] = parent
    iparent_addr_len = _address_cells_self(iparent)
    parent_addr_len = _address_cells(node)

    if iparent_addr_len is None:
        iparent_addr_len =  2  # Default value per DT spec.

    if parent_addr_len is None:
        parent_addr_len =  2  # Default value per DT spec.

    if iparent_addr_len == 0:
        return b''

    if 'reg' not in node.props:
        _err(f"{node!r} lacks 'reg' property "
             "(needed for 'interrupt-map' unit address lookup)")

    iparent_addr_len *= 4
    parent_addr_len *= 4

    prop_len = len(node.props['reg'].value)
    if prop_len < iparent_addr_len or prop_len %4 != 0:
        _err(f"{node!r} has too short or incorrectly defined 'reg' property "
             "(while doing 'interrupt-map' unit address lookup)")

    address = b''
    if parent_addr_len > iparent_addr_len:
        address = node.props['reg'].value[iparent_addr_len - parent_addr_len:parent_addr_len]
    else:
        address = node.props['reg'].value[:iparent_addr_len]

    return address

def _and(b1: bytes, b2: bytes) -> bytes:
    # Returns the bitwise AND of the two 'bytes' objects b1 and b2. Pads
    # with ones on the left if the lengths are not equal.

    # Pad on the left, to equal length
    maxlen = max(len(b1), len(b2))
    return bytes(x & y for x, y in zip(b1.rjust(maxlen, b'\xff'),
                                       b2.rjust(maxlen, b'\xff'), strict=False))


def _or(b1: bytes, b2: bytes) -> bytes:
    # Returns the bitwise OR of the two 'bytes' objects b1 and b2. Pads with
    # zeros on the left if the lengths are not equal.

    # Pad on the left, to equal length
    maxlen = max(len(b1), len(b2))
    return bytes(x | y for x, y in zip(b1.rjust(maxlen, b'\x00'),
                                       b2.rjust(maxlen, b'\x00'), strict=False))


def _not(b: bytes) -> bytes:
    # Returns the bitwise not of the 'bytes' object 'b'

    # ANDing with 0xFF avoids negative numbers
    return bytes(~x & 0xFF for x in b)


def _phandle_val_list(
        prop: dtlib_Property,
        n_cells_name: str
) -> list[Optional[tuple[dtlib_Node, bytes]]]:
    # Parses a '<phandle> <value> <phandle> <value> ...' value. The number of
    # cells that make up each <value> is derived from the node pointed at by
    # the preceding <phandle>.
    #
    # prop:
    #   dtlib.Property with value to parse
    #
    # n_cells_name:
    #   The <name> part of the #<name>-cells property to look for on the nodes
    #   the phandles point to, e.g. "gpio" for #gpio-cells.
    #
    # Each tuple in the return value is a (<node>, <value>) pair, where <node>
    # is the node pointed at by <phandle>. If <phandle> does not refer
    # to a node, the entire list element is None.

    full_n_cells_name = f"#{n_cells_name}-cells"

    res: list[Optional[tuple[dtlib_Node, bytes]]] = []

    raw = prop.value
    while raw:
        if len(raw) < 4:
            # Not enough room for phandle
            _err("bad value for " + repr(prop))
        phandle = to_num(raw[:4])
        raw = raw[4:]

        node = prop.node.dt.phandle2node.get(phandle)
        if not node:
            # Unspecified phandle-array element. This is valid; a 0
            # phandle value followed by no cells is an empty element.
            res.append(None)
            continue

        if full_n_cells_name not in node.props:
            _err(f"{node!r} lacks {full_n_cells_name}")

        n_cells = node.props[full_n_cells_name].to_num()
        if len(raw) < 4*n_cells:
            _err("missing data after phandle in " + repr(prop))

        res.append((node, raw[:4*n_cells]))
        raw = raw[4*n_cells:]

    return res


def _address_cells_self(node: Optional[dtlib_Node]) -> Optional[int]:
    # Returns the #address-cells setting for 'node', giving the number of <u32>
    # cells used to encode the address in the 'reg' property

    if node is not None and "#address-cells" in node.props:
        return node.props["#address-cells"].to_num()
    return None

def _address_cells(node: dtlib_Node) -> int:
    # Returns the #address-cells setting for parent node of 'node', giving the number of <u32>
    # cells used to encode the address in the 'reg' property
    if TYPE_CHECKING:
        assert node.parent

    ret = _address_cells_self(node.parent)
    if ret is None:
        return 2  # Default value per DT spec.
    return int(ret)


def _size_cells(node: dtlib_Node) -> int:
    # Returns the #size-cells setting for 'node', giving the number of <u32>
    # cells used to encode the size in the 'reg' property
    if TYPE_CHECKING:
        assert node.parent

    if "#size-cells" in node.parent.props:
        return node.parent.props["#size-cells"].to_num()
    return 1  # Default value per DT spec.


def _interrupt_cells(node: dtlib_Node) -> int:
    # Returns the #interrupt-cells property value on 'node', erroring out if
    # 'node' has no #interrupt-cells property

    if "#interrupt-cells" not in node.props:
        _err(f"{node!r} lacks #interrupt-cells")
    return node.props["#interrupt-cells"].to_num()


def _slice(node: dtlib_Node,
           prop_name: str,
           size: int,
           size_hint: str) -> list[bytes]:
    return _slice_helper(node, prop_name, size, size_hint, EDTError)


def _check_dt(dt: DT) -> None:
    # Does devicetree sanity checks. dtlib is meant to be general and
    # anything-goes except for very special properties like phandle, but in
    # edtlib we can be pickier.

    # Check that 'status' has one of the values given in the devicetree spec.

    ok_status = {"okay", "disabled", "reserved", "fail", "fail-sss"}

    for node in dt.node_iter():
        if "status" in node.props:
            try:
                status_val = node.props["status"].to_string()
            except DTError as e:
                # The error message gives the path
                _err(str(e))

            if status_val not in ok_status:
                _err(f"unknown 'status' value \"{status_val}\" in {node.path} "
                     f"in {node.dt.filename}, expected one of " +
                     ", ".join(ok_status) +
                     " (see the devicetree specification)")

        ranges_prop = node.props.get("ranges")
        if ranges_prop and ranges_prop.type not in (Type.EMPTY, Type.NUMS):
            _err(f"expected 'ranges = < ... >;' in {node.path} in "
                 f"{node.dt.filename}, not '{ranges_prop}' "
                  "(see the devicetree specification)")


def _err(msg) -> NoReturn:
    raise EDTError(msg)

# Logging object
_LOG = logging.getLogger(__name__)

# Regular expression for non-alphanumeric-or-underscore characters.
_NOT_ALPHANUM_OR_UNDERSCORE = re.compile(r'\W', re.ASCII)


def str_as_token(val: str) -> str:
    """Return a canonical representation of a string as a C token.

    This converts special characters in 'val' to underscores, and
    returns the result."""

    return re.sub(_NOT_ALPHANUM_OR_UNDERSCORE, '_', val)


# Custom PyYAML binding loader class to avoid modifying yaml.Loader directly,
# which could interfere with YAML loading in clients
class _BindingLoader(Loader):
    pass


# Add legacy '!include foo.yaml' handling
_BindingLoader.add_constructor("!include", _binding_include)

#
# "Default" binding for properties which are defined by the spec.
#
# Zephyr: do not change the _DEFAULT_PROP_TYPES keys without
# updating the documentation for the DT_PROP() macro in
# include/devicetree.h.
#

_DEFAULT_PROP_TYPES: dict[str, str] = {
    "compatible": "string-array",
    "status": "string",
    "ranges": "compound",  # NUMS or EMPTY
    "reg": "array",
    "reg-names": "string-array",
    "label": "string",
    "interrupts": "array",
    "interrupts-extended": "compound",
    "interrupt-names": "string-array",
    "interrupt-controller": "boolean",
}

_STATUS_ENUM: list[str] = "ok okay disabled reserved fail fail-sss".split()

def _raw_default_property_for(
        name: str
) -> dict[str, Union[str, bool, list[str]]]:
    ret: dict[str, Union[str, bool, list[str]]] = {
        'type': _DEFAULT_PROP_TYPES[name],
        'required': False,
    }
    if name == 'status':
        ret['enum'] = _STATUS_ENUM
    return ret

_DEFAULT_PROP_BINDING: Binding = Binding(
    None, {},
    raw={
        'properties': {
            name: _raw_default_property_for(name)
            for name in _DEFAULT_PROP_TYPES
        },
    },
    require_compatible=False,
    require_description=False,
)

_DEFAULT_PROP_SPECS: dict[str, PropertySpec] = {
    name: PropertySpec(name, _DEFAULT_PROP_BINDING)
    for name in _DEFAULT_PROP_TYPES
}
