From 6dec759261d5db98800ca1c015367a7109caad76 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:18:40 +1100 Subject: [PATCH] [core] Select I/O expander pin providers by I2C address (#18939) Co-authored-by: Claude Sonnet 5 --- esphome/components/max6956/__init__.py | 2 +- esphome/components/mcp23016/__init__.py | 2 +- esphome/components/mcp23xxx_base/__init__.py | 2 +- esphome/components/mipi_dsi/models/m5stack.py | 6 + esphome/components/pca6416a/__init__.py | 2 +- esphome/components/pca9554/__init__.py | 2 +- esphome/components/pcf8574/__init__.py | 2 +- esphome/components/pi4ioe5v6408/__init__.py | 2 +- esphome/components/sx1509/__init__.py | 2 +- esphome/components/tca9555/__init__.py | 2 +- esphome/components/xl9535/__init__.py | 2 +- esphome/config.py | 36 ++++++ esphome/core/__init__.py | 9 +- esphome/pins.py | 67 +++++++++-- .../mipi_dsi/test-tab5.esp32-p4-idf.yaml | 23 ++++ tests/components/xl9535/common.yaml | 12 ++ tests/unit_tests/core/test_config.py | 79 +++++++++++++ .../expander_pin_ambiguous_no_address.yaml | 24 ++++ .../core/config/expander_pin_by_address.yaml | 25 ++++ .../expander_pin_by_address_ambiguous.yaml | 31 +++++ .../expander_pin_by_address_no_match.yaml | 23 ++++ .../expander_pin_reuse_across_syntax.yaml | 30 +++++ tests/unit_tests/test_pins.py | 110 ++++++++++++++++++ 23 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml create mode 100644 tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml create mode 100644 tests/unit_tests/test_pins.py diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 557f497e1a..05abaf886f 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -79,7 +79,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_MAX6956): cv.use_id(MAX6956), + cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956), } ) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5f4b7276d8..aa606507f0 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -60,7 +60,7 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_MCP23016): cv.use_id(MCP23016), + cv.Required(CONF_MCP23016): pins.use_id_or_address(MCP23016), } ) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 755d86e4ea..a626835518 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -74,7 +74,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_MCP23XXX): cv.use_id(MCP23XXXBase), + cv.Required(CONF_MCP23XXX): pins.use_id_or_address(MCP23XXXBase), cv.Optional(CONF_INTERRUPT, default="NO_INTERRUPT"): cv.enum( MCP23XXX_INTERRUPT_MODES, upper=True ), diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 5b07229ec7..4e847ef6fd 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -14,6 +14,8 @@ DsiDriverChip( pclk_frequency="60MHz", lane_bit_rate="730Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 (0xB7, 0x03), # Pad control - 2 lane @@ -67,6 +69,8 @@ TAB5_ST7123 = DsiDriverChip( pclk_frequency="80MHz", lane_bit_rate="960Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0x01,), (0x60, 0x71, 0x23, 0xa2), @@ -118,6 +122,8 @@ DsiDriverChip( pclk_frequency="70MHz", lane_bit_rate="965Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0x01,), (0x60, 0x71, 0x21, 0xA2), diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 47b9118d9a..9756e9431a 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -60,7 +60,7 @@ def validate_mode(value: ConfigType) -> ConfigType: PCA6416A_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), - cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent), + cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 5272df2b55..68e6d163e8 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -66,7 +66,7 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_PCA9554): cv.use_id(PCA9554Component), + cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), } ) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 9f8c7fabd2..4ee1c2af56 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -63,7 +63,7 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_PCF8574): cv.use_id(PCF8574Component), + cv.Required(CONF_PCF8574): pins.use_id_or_address(PCF8574Component), } ) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 11a5dd62bb..45d84b88ef 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -74,7 +74,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_PI4IOE5V6408): cv.use_id(PI4IOE5V6408Component), + cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component), } ) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index e4df7b443c..942911c7fe 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -133,7 +133,7 @@ CONF_SX1509 = "sx1509" SX1509_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SX1509GPIOPin), - cv.Required(CONF_SX1509): cv.use_id(SX1509Component), + cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 3033cc65e0..6ae1afc883 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -62,7 +62,7 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_TCA9555): cv.use_id(TCA9555Component), + cv.Required(CONF_TCA9555): pins.use_id_or_address(TCA9555Component), } ) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 5686b74173..2656b60c10 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -54,7 +54,7 @@ def validate_pin(pin: int) -> int: XL9535_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(XL9535GPIOPin), - cv.Required(CONF_XL9535): cv.use_id(XL9535Component), + cv.Required(CONF_XL9535): pins.use_id_or_address(XL9535Component), cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/config.py b/esphome/config.py index bc69661d8f..28a688f15a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1129,6 +1129,42 @@ class IDPassValidationStep(ConfigValidationStep): if inherits: matches.append(v[0]) + if id.match_config: + # Disambiguate among same-type candidates by comparing their own + # declared config against the requested key/value pairs, e.g. an + # I2C address, instead of requiring a single unambiguous candidate. + criteria = ", ".join(f"{k}={v}" for k, v in id.match_config.items()) + filtered = [ + m + for m in matches + if isinstance( + candidate_conf := result.get_config_for_path( + result.get_path_for_id(m)[:-1] + ), + dict, + ) + and all( + candidate_conf.get(k) == v + for k, v in id.match_config.items() + ) + ] + if len(filtered) == 1: + id.id = filtered[0].id + elif len(filtered) == 0: + result.add_str_error( + f"Couldn't find a '{id.type}' matching {criteria}. " + "Are you missing a hub declaration, or is the address wrong?", + path, + ) + else: + ids = ", ".join(f"'{m.id}'" for m in filtered) + result.add_str_error( + f"Multiple '{id.type}' instances match {criteria}: {ids}. " + "You must assign an explicit ID to the one you want to use.", + path, + ) + continue + if len(matches) == 0: result.add_str_error( f"Couldn't find any component that can be used for '{id.type}'. Are you missing a hub declaration?", diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 30c0372066..1668263e33 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -385,7 +385,9 @@ class Lambda: class ID: - def __init__(self, id, is_declaration=False, type=None, is_manual=None): + def __init__( + self, id, is_declaration=False, type=None, is_manual=None, match_config=None + ): self.id = id if is_manual is None: self.is_manual = id is not None @@ -393,6 +395,10 @@ class ID: self.is_manual = is_manual self.is_declaration = is_declaration self.type: MockObjClass | None = type + # When set, an unnamed (id=None) searching ID is disambiguated among same-type + # candidates by matching these key/value pairs against each candidate's own + # declared config, instead of requiring exactly one candidate to exist. + self.match_config: dict | None = match_config def resolve(self, registered_ids): from esphome.config_validation import RESERVED_IDS @@ -431,6 +437,7 @@ class ID: is_declaration=self.is_declaration, type=self.type, is_manual=self.is_manual, + match_config=self.match_config, ) diff --git a/esphome/pins.py b/esphome/pins.py index 34a406c273..d42cc489a1 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from functools import reduce from logging import Logger import operator @@ -6,6 +6,7 @@ from typing import Any import esphome.config_validation as cv from esphome.const import ( + CONF_ADDRESS, CONF_ALLOW_OTHER_USES, CONF_IGNORE_STRAPPING_WARNING, CONF_INPUT, @@ -17,8 +18,9 @@ from esphome.const import ( CONF_PULLDOWN, CONF_PULLUP, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.cpp_generator import MockObjClass +from esphome.schema_extractors import schema_extractor class PinRegistry(dict): @@ -69,11 +71,15 @@ class PinRegistry(dict): # evaluate here so a validation failure skips the rest result = self[key][1](conf) if CONF_NUMBER in result: - # key maps to the pin schema - if key != CORE.target_platform: - pin_key = (key, conf[key], result[CONF_NUMBER]) - else: - pin_key = (key, key, result[CONF_NUMBER]) + # key maps to the pin schema. The bucket used here doesn't need to + # identify the provider precisely -- final_validate() regroups pin + # usage by the *resolved* provider id once IDPassValidationStep has + # run, so this only has to be hashable. A hub selected by + # match_config (e.g. address) is given as a dict, which isn't. + ref = conf[key] if key != CORE.target_platform else key + if not isinstance(ref, Hashable): + ref = "match_config" + pin_key = (key, ref, result[CONF_NUMBER]) if pin_key not in self.pins_used: self.pins_used[pin_key] = [] # client_id identifies the instance of the providing component @@ -107,7 +113,18 @@ class PinRegistry(dict): Run the final validation for all pins, and check for reuse :param fconf: The full config """ - for (key, _, _), pin_list in self.pins_used.items(): + # Regroup by (schema key, resolved provider id, pin number) rather than + # the validate()-time bucket, so the same physical pin reached via an + # explicit id and via {address: ...} (or an omitted id) is recognized as + # one reuse group regardless of which syntax was used to reference the hub. + grouped: dict[tuple, list] = {} + for (key, _, number), pin_list in self.pins_used.items(): + for entry in pin_list: + client_id = entry[1] + provider_id = client_id.id if isinstance(client_id, ID) else client_id + grouped.setdefault((key, provider_id, number), []).append(entry) + + for (key, _, _), pin_list in grouped.items(): count = len(pin_list) # number of places same pin used. final_val_fun = self[key][2] # final validation function for pin_path, client_id, pin_config in pin_list: @@ -131,6 +148,40 @@ class PinRegistry(dict): PIN_SCHEMA_REGISTRY = PinRegistry() +def use_id_or_address(hub_type, address_key=CONF_ADDRESS): + """Build a validator for a pin-provider hub reference. + + Accepts everything `cv.use_id(hub_type)` accepts (an explicit id, or omitted to + auto-select the sole instance of `hub_type`), plus a mapping like + `{address: 0x21}` to instead select the instance of `hub_type` whose own + declared config has a matching `address_key`. Use this for pin schemas of + I2C-addressed hubs (I/O expanders) so a board with two instances of the + same hub type can pick one without assigning it an explicit id. + """ + id_validator = cv.use_id(hub_type) + + @schema_extractor("use_id") + def validator(value): + if isinstance(value, dict): + address = cv.Schema({cv.Required(address_key): cv.i2c_address})(value)[ + address_key + ] + return ID( + None, + is_declaration=False, + type=hub_type, + # The user explicitly gave selection criteria (unlike the plain + # omitted-id auto-pick), so treat the resolved id as manual: it + # must survive strip_default_ids() and appear in `esphome config` + # output, not be silently dropped as if it were a default. + is_manual=True, + match_config={address_key: address}, + ) + return id_validator(value) + + return validator + + def _set_mode(value, default_mode): if CONF_MODE not in value: return {**value, CONF_MODE: default_mode} diff --git a/tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml b/tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml new file mode 100644 index 0000000000..2f4b02eea8 --- /dev/null +++ b/tests/components/mipi_dsi/test-tab5.esp32-p4-idf.yaml @@ -0,0 +1,23 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-p4-idf.yaml + +esp_ldo: + - id: ldo_id + channel: 3 + voltage: 2.5V + +psram: + +pi4ioe5v6408: + # A second, decoy hub at a different address, so this test only passes if + # the model's reset_pin default actually resolves pi4ioe1 by its address + # (0x43) -- with only one hub declared, the single-instance auto-pick + # fallback would resolve it too, even if address matching regressed. + - id: pi4ioe1 + address: 0x43 + - id: pi4ioe2 + address: 0x44 + +display: + - platform: mipi_dsi + model: M5STACK-TAB5 diff --git a/tests/components/xl9535/common.yaml b/tests/components/xl9535/common.yaml index 81e96131ab..21ce8b5512 100644 --- a/tests/components/xl9535/common.yaml +++ b/tests/components/xl9535/common.yaml @@ -2,6 +2,9 @@ xl9535: - id: xl9535_hub i2c_id: i2c_bus address: 0x20 + - id: xl9535_hub_2 + i2c_id: i2c_bus + address: 0x21 binary_sensor: - platform: gpio @@ -20,3 +23,12 @@ binary_sensor: mode: input: true inverted: false + - platform: gpio + name: XL9535 Pin 1 By Address + pin: + xl9535: + address: 0x21 + number: 1 + mode: + input: true + inverted: false diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 1a36fdc967..883f9d8b3d 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -447,6 +447,85 @@ def test_device_duplicate_id( assert "ID duplicate_device redefined!" in captured.out +def test_expander_pin_selected_by_address(yaml_file: Callable[[str], str]) -> None: + """A pin provider hub with an omitted id can be selected by its address.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_by_address.yaml", FIXTURES_DIR + ) + assert result is not None + + resolved = result["binary_sensor"][0]["pin"]["xl9535"] + assert resolved.id == "xl9535_b" + # Explicit selection criteria must survive strip_default_ids(), unlike a + # plain omitted-id auto-pick. + assert resolved.is_manual is True + + +def test_expander_pin_selected_by_address_no_match( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Selecting an address that matches no hub of that type fails clearly.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_by_address_no_match.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Couldn't find a 'xl9535::XL9535Component' matching address=0x21. " + "Are you missing a hub declaration, or is the address wrong?" in captured.out + ) + + +def test_expander_pin_selected_by_address_ambiguous( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Two hubs sharing the same address (e.g. on different buses) still need an id.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_by_address_ambiguous.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Multiple 'xl9535::XL9535Component' instances match address=0x20: " + "'xl9535_a', 'xl9535_b'. You must assign an explicit ID to the one you " + "want to use." in captured.out + ) + + +def test_expander_pin_ambiguous_without_match_config( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Omitting both id and address with multiple hubs of the same type still + falls back to the original "too many candidates" error.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_ambiguous_no_address.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert ( + "Too many candidates found for 'xl9535' type 'xl9535::XL9535Component' " + "Some are 'xl9535_a', 'xl9535_b'" in captured.out + ) + + +def test_expander_pin_reuse_detected_across_reference_syntax( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """The same physical pin, reached once by explicit id and once by address, + must still be flagged as reused -- reuse detection keys on the resolved + provider id, not on which syntax was used to reference it.""" + result = load_config_from_fixture( + yaml_file, "expander_pin_reuse_across_syntax.yaml", FIXTURES_DIR + ) + assert result is None + + captured = capsys.readouterr() + assert "Pin 5 is used in multiple places" in captured.out + + def test_substitution_with_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml new file mode 100644 index 0000000000..7b22ad3bea --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_ambiguous_no_address.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + - id: xl9535_b + address: 0x21 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml new file mode 100644 index 0000000000..e4cdff9e29 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_by_address.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + - id: xl9535_b + address: 0x21 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + address: 0x21 + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml new file mode 100644 index 0000000000..b951417c2b --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_ambiguous.yaml @@ -0,0 +1,31 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + - id: bus_a + sda: GPIO21 + scl: GPIO22 + - id: bus_b + sda: GPIO18 + scl: GPIO19 + +xl9535: + - id: xl9535_a + i2c_id: bus_a + address: 0x20 + - id: xl9535_b + i2c_id: bus_b + address: 0x20 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + address: 0x20 + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml new file mode 100644 index 0000000000..126f88f31a --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_by_address_no_match.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + +binary_sensor: + - platform: gpio + name: "Expander Pin" + pin: + xl9535: + address: 0x21 + number: 1 + mode: + input: true diff --git a/tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml b/tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml new file mode 100644 index 0000000000..6a6a90fd34 --- /dev/null +++ b/tests/unit_tests/fixtures/core/config/expander_pin_reuse_across_syntax.yaml @@ -0,0 +1,30 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +xl9535: + - id: xl9535_a + address: 0x20 + +binary_sensor: + - platform: gpio + name: "Expander Pin By Id" + pin: + xl9535: xl9535_a + number: 5 + mode: + input: true + - platform: gpio + name: "Expander Pin By Address" + pin: + xl9535: + address: 0x20 + number: 5 + mode: + input: true diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py new file mode 100644 index 0000000000..18dc95a5c6 --- /dev/null +++ b/tests/unit_tests/test_pins.py @@ -0,0 +1,110 @@ +"""Unit tests for esphome/pins.py.""" + +import pytest + +from esphome import config_validation as cv, pins +import esphome.codegen as cg +from esphome.const import CONF_ADDRESS +from esphome.core import ID +from esphome.schema_extractors import SCHEMA_EXTRACT + +SomeHub = cg.esphome_ns.class_("SomeHub") + + +def test_use_id_or_address_with_explicit_id() -> None: + """An explicit id string still resolves exactly like cv.use_id.""" + validator = pins.use_id_or_address(SomeHub) + result = validator("my_hub") + + assert isinstance(result, ID) + assert result.id == "my_hub" + assert result.type is SomeHub + assert result.is_declaration is False + assert result.match_config is None + + +def test_use_id_or_address_with_omitted_id() -> None: + """Omitting the id (None) still falls back to the single-instance auto-pick.""" + validator = pins.use_id_or_address(SomeHub) + result = validator(None) + + assert isinstance(result, ID) + assert result.id is None + assert result.type is SomeHub + assert result.match_config is None + + +def test_use_id_or_address_with_address_mapping() -> None: + """A {address: ...} mapping produces an unnamed ID with match_config set.""" + validator = pins.use_id_or_address(SomeHub) + result = validator({CONF_ADDRESS: 0x21}) + + assert isinstance(result, ID) + assert result.id is None + assert result.type is SomeHub + assert result.is_declaration is False + assert result.match_config == {CONF_ADDRESS: 0x21} + + +def test_use_id_or_address_with_string_address() -> None: + """A hex string address is normalized the same way cv.i2c_address does.""" + validator = pins.use_id_or_address(SomeHub) + result = validator({CONF_ADDRESS: "0x21"}) + + assert result.match_config == {CONF_ADDRESS: 0x21} + + +def test_use_id_or_address_mapping_requires_address_key() -> None: + """A mapping without the address key is rejected, not silently ignored.""" + validator = pins.use_id_or_address(SomeHub) + + with pytest.raises(cv.Invalid): + validator({}) + + +def test_use_id_or_address_mapping_rejects_invalid_address() -> None: + """An out-of-range address is still validated by cv.i2c_address.""" + validator = pins.use_id_or_address(SomeHub) + + with pytest.raises(cv.Invalid): + validator({CONF_ADDRESS: 0x1FF}) + + +def test_use_id_or_address_custom_address_key() -> None: + """A custom address_key is honored both in the mapping and match_config.""" + validator = pins.use_id_or_address(SomeHub, address_key="i2c_address") + result = validator({"i2c_address": 0x10}) + + assert result.match_config == {"i2c_address": 0x10} + + +def test_use_id_or_address_marks_address_match_as_manual() -> None: + """The resolved id must survive strip_default_ids(), unlike a plain + omitted-id auto-pick, since the user explicitly gave selection criteria.""" + validator = pins.use_id_or_address(SomeHub) + result = validator({CONF_ADDRESS: 0x21}) + + assert result.is_manual is True + + +def test_use_id_or_address_schema_extract_passthrough() -> None: + """Called with the SCHEMA_EXTRACT sentinel (as build_language_schema.py + does), the wrapper must fall through to cv.use_id's own handling and + return the hub type, not try to treat it as an address mapping.""" + validator = pins.use_id_or_address(SomeHub) + + assert validator(SCHEMA_EXTRACT) is SomeHub + + +def test_use_id_or_address_registers_as_use_id_schema(monkeypatch) -> None: + """The wrapper must be discoverable by build_language_schema.py as a + `use_id` schema (like a bare cv.use_id), or the generated language schema + silently drops hub-id completion for every pin schema using it.""" + from esphome import schema_extractors + + monkeypatch.setattr(schema_extractors, "EnableSchemaExtraction", True) + monkeypatch.setattr(schema_extractors, "hidden_schemas", {}) + + validator = pins.use_id_or_address(SomeHub) + + assert schema_extractors.hidden_schemas.get(repr(validator)) == "use_id"