diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index eeff98cb6e..0138f75d3e 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -5,6 +5,7 @@ #include #include #include +#include "esphome/core/component.h" #include "esphome/core/hal.h" namespace esphome::gpio_expander { @@ -14,6 +15,10 @@ namespace esphome::gpio_expander { /// This means that for reading whole Port (ex. 8 pins) component needs only one /// I2C/SPI read per main loop call. It assumes that one bit in byte identifies one GPIO pin. /// +/// Supports hardware interrupt pins for efficient event-driven operation. +/// When an interrupt pin is configured, the component will only read from hardware +/// when the interrupt fires, reducing I2C/SPI traffic and CPU usage. +/// /// Template parameters: /// T - Type which represents internal bank register. Could be uint8_t or uint16_t. /// Choose based on how your I/O expander reads pins: @@ -50,6 +55,23 @@ class CachedGpioExpander { void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } + /// @brief Setup interrupt pin for hardware interrupt support + /// @param pin Native GPIO pin connected to the expander's interrupt output (e.g., INTA/INTB) + /// @param parent Component that owns this expander (for loop control) + void setup_interrupt_pin(InternalGPIOPin *pin, Component *parent) { + this->interrupt_pin_ = pin; + this->parent_component_ = parent; + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // Attach ISR in FALLING mode (most expanders use active-low interrupts) + this->interrupt_pin_->attach_interrupt(CachedGpioExpander::gpio_intr_, this, gpio::INTERRUPT_FALLING); + // Start with loop disabled - will be enabled by interrupts + if (this->parent_component_ != nullptr) { + this->parent_component_->disable_loop(); + } + } + } + protected: /// @brief Read GPIO bank from hardware into internal state /// @param pin Pin number (used to determine which bank to read) @@ -68,8 +90,53 @@ class CachedGpioExpander { /// @param value Pin state to write (true = HIGH, false = LOW) virtual void digital_write_hw(P pin, bool value) = 0; + /// @brief Read interrupt status from hardware (chip-specific) + /// @return Bitmask of pins that triggered the interrupt, or nullopt if chip doesn't support interrupts + /// @note Each bit represents a pin that changed state and triggered an interrupt. + /// This method should also clear the interrupt condition on the hardware. + virtual optional read_interrupt_status_(P bank) { return nullopt; } + + /// @brief Process hardware interrupts and update cache + /// @note This is called from loop() when interrupt_pending_ is true. + /// It reads the interrupt status, updates only changed pins, and keeps cache valid. + void process_interrupt_() { + this->interrupt_pending_ = false; + + // Read interrupt status for each bank + for (P bank = 0; bank < BANKS; bank++) { + optional status = this->read_interrupt_status_(bank); + if (status.has_value() && status.value() != 0) { + // Mark interrupted pins as valid in cache + // The interrupt status read should have already updated the cache with current values + this->read_cache_valid_[bank] |= status.value(); + } + } + + // Disable loop until next interrupt + if (this->parent_component_ != nullptr) { + this->parent_component_->disable_loop(); + } + } + /// @brief Invalidate cache. This function should be called in component loop(). - void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } + void reset_pin_cache_() { + // If using interrupts, process pending interrupts instead of invalidating cache + if (this->interrupt_pin_ != nullptr && this->interrupt_pending_) { + this->process_interrupt_(); + } else if (this->interrupt_pin_ == nullptr) { + // Polling mode: invalidate all cache + memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); + } + // If using interrupts but no interrupt pending, cache stays valid + } + + /// @brief ISR handler for interrupt pin + static void IRAM_ATTR gpio_intr_(CachedGpioExpander *arg) { + arg->interrupt_pending_ = true; + if (arg->parent_component_ != nullptr) { + arg->parent_component_->enable_loop_soon_any_context(); + } + } static constexpr uint16_t BITS_PER_BYTE = 8; static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; @@ -77,6 +144,9 @@ class CachedGpioExpander { static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); T read_cache_valid_[BANKS]{0}; + InternalGPIOPin *interrupt_pin_{nullptr}; + Component *parent_component_{nullptr}; + volatile bool interrupt_pending_{false}; }; } // namespace esphome::gpio_expander diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 0c34e4971a..4a3de12912 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -16,9 +16,19 @@ void MCP23008::setup() { // Read current output register state this->read_reg(mcp23x08_base::MCP23X08_OLAT, &this->olat_); + // Configure IOCON register for interrupt operation + uint8_t iocon_value = 0x00; if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x08_base::MCP23X08_IOCON, 0x04); + // Enable open-drain interrupt pins, 3.3V-safe + iocon_value |= 0x04; // ODR bit + } + if (iocon_value != 0x00) { + this->write_reg(mcp23x08_base::MCP23X08_IOCON, iocon_value); + } + + // Setup interrupt pin if configured + if (this->interrupt_pin_internal_ != nullptr) { + this->setup_interrupt_pin(this->interrupt_pin_internal_, this); } } diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 1ad2036939..61b4f9966b 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -17,10 +17,24 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + // Configure IOCON register for interrupt operation + uint8_t iocon_value = 0x00; if (this->open_drain_ints_) { - // enable open-drain interrupt pins, 3.3V-safe - this->write_reg(mcp23x17_base::MCP23X17_IOCONA, 0x04); - this->write_reg(mcp23x17_base::MCP23X17_IOCONB, 0x04); + // Enable open-drain interrupt pins, 3.3V-safe + iocon_value |= 0x04; // ODR bit + } + if (this->interrupt_pin_internal_ != nullptr) { + // Mirror interrupts (INTA and INTB are internally connected) + iocon_value |= 0x40; // MIRROR bit + } + if (iocon_value != 0x00) { + this->write_reg(mcp23x17_base::MCP23X17_IOCONA, iocon_value); + this->write_reg(mcp23x17_base::MCP23X17_IOCONB, iocon_value); + } + + // Setup interrupt pin if configured + if (this->interrupt_pin_internal_ != nullptr) { + this->setup_interrupt_pin(this->interrupt_pin_internal_, this); } } diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 1593c376cd..6a3078a174 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -82,5 +82,37 @@ void MCP23X08Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) { } } +optional MCP23X08Base::read_interrupt_status_(uint8_t bank) { + // MCP23X08 only has one bank (bank 0) + if (bank != 0) { + return 0; + } + + // Read interrupt flag register + uint8_t intf = 0; + if (!this->read_reg(mcp23x08_base::MCP23X08_INTF, &intf)) { + ESP_LOGW(TAG, "Failed to read interrupt flags"); + return nullopt; + } + + // If no interrupts, return early + if (intf == 0) { + return 0; + } + + // Read interrupt capture register (pin values at time of interrupt) + uint8_t intcap = 0; + if (!this->read_reg(mcp23x08_base::MCP23X08_INTCAP, &intcap)) { + ESP_LOGW(TAG, "Failed to read interrupt capture"); + return nullopt; + } + + // Update the input_mask_ with captured values + this->input_mask_ = intcap; + + ESP_LOGV(TAG, "Interrupt: flags=0x%02X, captured=0x%02X", intf, intcap); + return intf; +} + } // namespace mcp23x08_base } // namespace esphome diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.h b/esphome/components/mcp23x08_base/mcp23x08_base.h index 6eee8274b1..57ee98b3ab 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.h +++ b/esphome/components/mcp23x08_base/mcp23x08_base.h @@ -31,13 +31,19 @@ class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase<8> { void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_internal_ = pin; } + protected: void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) override; + optional read_interrupt_status_(uint8_t bank) override; uint8_t olat_{0x00}; /// State read in digital_read_hw uint8_t input_mask_{0x00}; + + /// Internal interrupt pin reference (stored before setup) + InternalGPIOPin *interrupt_pin_internal_{nullptr}; }; } // namespace mcp23x08_base diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index b1f1f260b4..9d71431ff1 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -98,5 +98,39 @@ void MCP23X17Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) { } } +optional MCP23X17Base::read_interrupt_status_(uint8_t bank) { + uint8_t intf_reg = bank == 0 ? mcp23x17_base::MCP23X17_INTFA : mcp23x17_base::MCP23X17_INTFB; + uint8_t intcap_reg = bank == 0 ? mcp23x17_base::MCP23X17_INTCAPA : mcp23x17_base::MCP23X17_INTCAPB; + + // Read interrupt flag register + uint8_t intf = 0; + if (!this->read_reg(intf_reg, &intf)) { + ESP_LOGW(TAG, "Failed to read interrupt flags for bank %u", bank); + return nullopt; + } + + // If no interrupts, return early + if (intf == 0) { + return 0; + } + + // Read interrupt capture register (pin values at time of interrupt) + uint8_t intcap = 0; + if (!this->read_reg(intcap_reg, &intcap)) { + ESP_LOGW(TAG, "Failed to read interrupt capture for bank %u", bank); + return nullopt; + } + + // Update the input_mask_ with captured values + if (bank == 0) { + this->input_mask_ = encode_uint16(this->input_mask_ >> 8, intcap); + } else { + this->input_mask_ = encode_uint16(intcap, this->input_mask_ & 0xFF); + } + + ESP_LOGV(TAG, "Interrupt on bank %u: flags=0x%02X, captured=0x%02X", bank, intf, intcap); + return intf; +} + } // namespace mcp23x17_base } // namespace esphome diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.h b/esphome/components/mcp23x17_base/mcp23x17_base.h index bdd66503e2..6b63b6c19d 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.h +++ b/esphome/components/mcp23x17_base/mcp23x17_base.h @@ -43,14 +43,20 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_internal_ = pin; } + protected: void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) override; + optional read_interrupt_status_(uint8_t bank) override; uint8_t olat_a_{0x00}; uint8_t olat_b_{0x00}; /// State read in digital_read_hw uint16_t input_mask_{0x00}; + + /// Internal interrupt pin reference (stored before setup) + InternalGPIOPin *interrupt_pin_internal_{nullptr}; }; } // namespace mcp23x17_base diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index d6e82101ad..62ae9e3ce2 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -5,6 +5,7 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -32,6 +33,7 @@ MCP23XXX_INTERRUPT_MODES = { MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ).extend(cv.COMPONENT_SCHEMA) @@ -43,6 +45,9 @@ async def register_mcp23xxx(config, num_pins): await cg.register_component(var, config) CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + interrupt_pin = await cg.gpio_pin_expression(interrupt_pin_config) + cg.add(var.set_interrupt_pin(interrupt_pin)) return var diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index c64f923823..ee30bd0b2e 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -33,6 +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, } ) .extend(cv.COMPONENT_SCHEMA) @@ -47,6 +49,10 @@ async def to_code(config): cg.add(var.set_reset(config[CONF_RESET])) + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + interrupt_pin = await cg.gpio_pin_expression(interrupt_pin_config) + cg.add(var.set_interrupt_pin(interrupt_pin)) + def validate_mode(value): if not (value[CONF_INPUT] or value[CONF_OUTPUT]): diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 517ca833e6..6b3dc8c0e4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -33,6 +33,11 @@ void PI4IOE5V6408Component::setup() { return; } } + + // Setup interrupt pin if configured + if (this->interrupt_pin_internal_ != nullptr) { + this->setup_interrupt_pin(this->interrupt_pin_internal_, this); + } } void PI4IOE5V6408Component::dump_config() { ESP_LOGCONFIG(TAG, "PI4IOE5V6408:"); @@ -156,6 +161,38 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->input_mask_ & (1 << pin)); } +optional PI4IOE5V6408Component::read_interrupt_status_(uint8_t bank) { + // PI4IOE5V6408 only has one bank (bank 0) + if (bank != 0) { + return 0; + } + + // Read interrupt status register + uint8_t int_status = 0; + if (!this->read_byte(PI4IOE5V6408_REGISTER_INTERRUPT_STATUS, &int_status)) { + ESP_LOGW(TAG, "Failed to read interrupt status"); + return nullopt; + } + + // If no interrupts, return early + if (int_status == 0) { + return 0; + } + + // Read current input state (reading the input register clears the interrupt) + uint8_t input_state = 0; + if (!this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &input_state)) { + ESP_LOGW(TAG, "Failed to read input state"); + return nullopt; + } + + // Update the input_mask_ with current values + this->input_mask_ = input_state; + + ESP_LOGV(TAG, "Interrupt: status=0x%02X, input=0x%02X", int_status, input_state); + return int_status; +} + float PI4IOE5V6408Component::get_setup_priority() const { return setup_priority::IO; } void PI4IOE5V6408GPIOPin::setup() { this->pin_mode(this->flags_); } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 82b3076fab..cabb3cc9ce 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -23,10 +23,13 @@ class PI4IOE5V6408Component : public Component, /// Indicate if the component should reset the state during setup void set_reset(bool reset) { this->reset_ = reset; } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_internal_ = pin; } + protected: bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; + optional read_interrupt_status_(uint8_t bank) override; /// Mask for the pin mode - 1 means output, 0 means input uint8_t mode_mask_{0x00}; @@ -41,6 +44,9 @@ class PI4IOE5V6408Component : public Component, bool reset_{true}; + /// Internal interrupt pin reference (stored before setup) + InternalGPIOPin *interrupt_pin_internal_{nullptr}; + bool read_gpio_modes_(); bool write_gpio_modes_(); bool read_gpio_outputs_(); diff --git a/tests/components/mcp23017/common.yaml b/tests/components/mcp23017/common.yaml index 54a97e911f..2c10731be4 100644 --- a/tests/components/mcp23017/common.yaml +++ b/tests/components/mcp23017/common.yaml @@ -1,6 +1,9 @@ mcp23017: - i2c_id: i2c_bus - id: mcp23017_hub + - i2c_id: i2c_bus + id: mcp23017_hub + - i2c_id: i2c_bus + id: mcp23017_hub_interrupt + interrupt_pin: GPIO5 binary_sensor: - platform: gpio @@ -9,6 +12,14 @@ binary_sensor: mcp23xxx: mcp23017_hub number: 0 mode: INPUT + interrupt: CHANGE + - platform: gpio + id: mcp23017_binary_sensor_interrupt + pin: + mcp23xxx: mcp23017_hub_interrupt + number: 0 + mode: INPUT + interrupt: CHANGE switch: - platform: gpio diff --git a/tests/components/pi4ioe5v6408/common.yaml b/tests/components/pi4ioe5v6408/common.yaml index 2344622081..7ae258d7bb 100644 --- a/tests/components/pi4ioe5v6408/common.yaml +++ b/tests/components/pi4ioe5v6408/common.yaml @@ -1,7 +1,11 @@ pi4ioe5v6408: - i2c_id: i2c_bus - id: pi4ioe1 - address: 0x44 + - i2c_id: i2c_bus + id: pi4ioe1 + address: 0x44 + - i2c_id: i2c_bus + id: pi4ioe2 + address: 0x45 + interrupt_pin: GPIO5 switch: - platform: gpio @@ -16,3 +20,8 @@ binary_sensor: pin: pi4ioe5v6408: pi4ioe1 number: 1 + - platform: gpio + id: sensor2 + pin: + pi4ioe5v6408: pi4ioe2 + number: 1