[gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472)

This commit is contained in:
J. Nick Koston
2026-08-18 16:04:47 +12:00
committed by Jesse Hills
parent 6a247dfe91
commit 8b888f31e0
11 changed files with 107 additions and 32 deletions
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
@@ -25,7 +25,7 @@ CONFIG_SCHEMA = (
cv.Schema( cv.Schema(
{ {
cv.Required(CONF_ID): cv.declare_id(MCP23016), 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) .extend(cv.COMPONENT_SCHEMA)
+2 -20
View File
@@ -1,8 +1,8 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import gpio_expander
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ALLOW_OTHER_USES,
CONF_ID, CONF_ID,
CONF_INPUT, CONF_INPUT,
CONF_INTERRUPT, 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( MCP23XXX_CONFIG_SCHEMA = cv.Schema(
{ {
cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, 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) ).extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
@@ -29,7 +29,7 @@ CONFIG_SCHEMA = (
cv.Schema( cv.Schema(
{ {
cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), 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) .extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
@@ -30,7 +30,7 @@ CONFIG_SCHEMA = (
{ {
cv.Required(CONF_ID): cv.declare_id(PCA9554Component), cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16), 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) .extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
{ {
cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
cv.Optional(CONF_PCF8575, default=False): cv.boolean, 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) .extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
@@ -34,7 +34,7 @@ CONFIG_SCHEMA = (
{ {
cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component), cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component),
cv.Optional(CONF_RESET, default=True): cv.boolean, 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) .extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_ID, CONF_ID,
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
cv.Schema( cv.Schema(
{ {
cv.Required(CONF_ID): cv.declare_id(TCA9555Component), 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) .extend(cv.COMPONENT_SCHEMA)
+10
View File
@@ -250,6 +250,16 @@ def add_pin_validators():
"modes": ["input"], "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): def add_module_registries(domain, module):
for attr_name in dir(module): for attr_name in dir(module):
@@ -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}}
)