From 8b888f31e0bf4d2dedd34fa25d7f1aa593a0c581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Aug 2026 20:32:03 -0500 Subject: [PATCH] [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) --- esphome/components/gpio_expander/__init__.py | 22 +++++++ esphome/components/mcp23016/__init__.py | 4 +- esphome/components/mcp23xxx_base/__init__.py | 22 +------ esphome/components/pca6416a/__init__.py | 4 +- esphome/components/pca9554/__init__.py | 4 +- esphome/components/pcf8574/__init__.py | 4 +- esphome/components/pi4ioe5v6408/__init__.py | 4 +- esphome/components/tca9555/__init__.py | 4 +- script/build_language_schema.py | 10 +++ .../component_tests/gpio_expander/__init__.py | 0 .../gpio_expander/test_init.py | 61 +++++++++++++++++++ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 tests/component_tests/gpio_expander/__init__.py create mode 100644 tests/component_tests/gpio_expander/test_init.py diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..37c5205fe8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -25,7 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..d53499a78f 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 813bb35c48..1df22a8ff5 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 99b812b33b..f49a68bc3f 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -30,7 +30,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index d8a1e20db6..559fe1d76d 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index d5b19dab1c..ee270138e1 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = ( { cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Optional(CONF_RESET, default=True): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 5f571fcea6..1c643fe1c9 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2b64cb0256..91c1de00cd 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -250,6 +250,16 @@ def add_pin_validators(): "modes": ["input"], } + from esphome.components import gpio_expander + + # Wraps pins.internal_gpio_input_pin_schema, so the editor schema must keep + # treating the config var as a pin + pin_validators[repr(gpio_expander.validate_interrupt_pin)] = { + "schema": True, + "internal": True, + "modes": ["input"], + } + def add_module_registries(domain, module): for attr_name in dir(module): diff --git a/tests/component_tests/gpio_expander/__init__.py b/tests/component_tests/gpio_expander/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py new file mode 100644 index 0000000000..806b1775d2 --- /dev/null +++ b/tests/component_tests/gpio_expander/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the shared io expander interrupt_pin validator.""" + +from __future__ import annotations + +import importlib + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gpio_expander import validate_interrupt_pin +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +@pytest.fixture +def stage_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_plain_pin_accepted(stage_esp32: None) -> None: + value = validate_interrupt_pin( + {"number": 16, "mode": {"input": True, "pullup": True}} + ) + assert value["number"] == 16 + + +def test_inverted_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + validate_interrupt_pin({"number": 16, "inverted": True}) + + +def test_allow_other_uses_rejected(stage_esp32: None) -> None: + with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"): + validate_interrupt_pin({"number": 16, "allow_other_uses": True}) + + +# mcp23017 covers the shared mcp23xxx_base schema +@pytest.mark.parametrize( + "component", + [ + "pcf8574", + "pca9554", + "tca9555", + "pca6416a", + "pi4ioe5v6408", + "mcp23016", + "mcp23017", + ], +) +def test_component_schemas_route_through_validator( + stage_esp32: None, component: str +) -> None: + module = importlib.import_module(f"esphome.components.{component}") + with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"): + module.CONFIG_SCHEMA( + {"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}} + )