diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 1c33772c4c..e02b450bf0 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -4,6 +4,7 @@ module.exports = { CODEOWNERS_MARKER: '', TOO_BIG_MARKER: '', DEPRECATED_COMPONENT_MARKER: '', + ORG_FORK_MARKER: '', MANAGED_LABELS: [ 'new-component', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index fb9dadc6a0..25c0ba49af 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -281,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) { return { labels, deprecatedInfo }; } +// Strategy: Detect when maintainers cannot modify the PR branch +function detectMaintainerAccess(context) { + const pr = context.payload.pull_request; + + // Only relevant for cross-repo PRs (forks) + if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) { + return null; + } + + if (pr.maintainer_can_modify) { + return null; + } + + const isOrgFork = pr.head.repo.owner.type === 'Organization'; + console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`); + return { isOrgFork, orgName: pr.head.repo.owner.login }; +} + // Strategy: Requirements detection async function detectRequirements(allLabels, prFiles, context) { const labels = new Set(); @@ -329,5 +347,6 @@ module.exports = { detectTests, detectPRTemplateCheckboxes, detectDeprecatedComponents, + detectMaintainerAccess, detectRequirements }; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 42588c0bc8..021e91a9ee 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -12,9 +12,10 @@ const { detectTests, detectPRTemplateCheckboxes, detectDeprecatedComponents, + detectMaintainerAccess, detectRequirements } = require('./detectors'); -const { handleReviews } = require('./reviews'); +const { handleReviews, handleMaintainerAccessComment } = require('./reviews'); const { applyLabels, removeOldLabels } = require('./labels'); // Fetch API data @@ -114,7 +115,8 @@ module.exports = async ({ github, context }) => { codeOwnerLabels, testLabels, checkboxLabels, - deprecatedResult + deprecatedResult, + maintainerAccess ] = await Promise.all([ detectMergeBranch(context), detectComponentPlatforms(changedFiles, apiData), @@ -127,7 +129,8 @@ module.exports = async ({ github, context }) => { detectCodeOwner(github, context, changedFiles), detectTests(changedFiles), detectPRTemplateCheckboxes(context), - detectDeprecatedComponents(github, context, changedFiles) + detectDeprecatedComponents(github, context, changedFiles), + detectMaintainerAccess(context) ]); // Extract deprecated component info @@ -177,8 +180,11 @@ module.exports = async ({ github, context }) => { console.log('Computed labels:', finalLabels.join(', ')); - // Handle reviews - await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + // Handle reviews and org fork comment + await Promise.all([ + handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD), + handleMaintainerAccessComment(github, context, maintainerAccess) + ]); // Apply labels await applyLabels(github, context, finalLabels); diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index 906e2c456a..7ac136515d 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -2,7 +2,8 @@ const { BOT_COMMENT_MARKER, CODEOWNERS_MARKER, TOO_BIG_MARKER, - DEPRECATED_COMPONENT_MARKER + DEPRECATED_COMPONENT_MARKER, + ORG_FORK_MARKER } = require('./constants'); // Generate review messages @@ -136,6 +137,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d } } +// Handle maintainer access warning comment +async function handleMaintainerAccessComment(github, context, maintainerAccess) { + if (!maintainerAccess) { + return; + } + + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + const prAuthor = context.payload.pull_request.user.login; + + // Check if we already posted the warning (iterate pages to exit early) + let existingComment; + for await (const { data: comments } of github.paginate.iterator( + github.rest.issues.listComments, + { owner, repo, issue_number: pr_number } + )) { + existingComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body && comment.body.includes(ORG_FORK_MARKER) + ); + if (existingComment) { + break; + } + } + + if (existingComment) { + console.log('Maintainer access warning comment already exists, skipping'); + return; + } + + let body; + if (maintainerAccess.isOrgFork) { + body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` + + `Hey there @${prAuthor},\n` + + `It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` + + `GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` + + `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` + + `To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` + + `See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`; + } else { + body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` + + `Hey there @${prAuthor},\n` + + `It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` + + `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` + + `Please enable this option in the PR sidebar to allow maintainer collaboration.`; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body + }); + console.log('Created maintainer access warning comment'); +} + module.exports = { - handleReviews + handleReviews, + handleMaintainerAccessComment }; diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index 65dd3196df..ccb47237b6 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -8,7 +8,7 @@ from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.PollingComponent, light.LightOutput + "HBridgeLightOutput", cg.Component, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index c309154852..4e064d5352 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -1,20 +1,17 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -#include "esphome/core/log.h" +#include "esphome/components/output/float_output.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome { namespace hbridge { -// Using PollingComponent as the updates are more consistent and reduces flickering -class HBridgeLightOutput : public PollingComponent, public light::LightOutput { +class HBridgeLightOutput : public Component, public light::LightOutput { public: - HBridgeLightOutput() : PollingComponent(1) {} - - void set_pina_pin(output::FloatOutput *pina_pin) { pina_pin_ = pina_pin; } - void set_pinb_pin(output::FloatOutput *pinb_pin) { pinb_pin_ = pinb_pin; } + void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } + void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); @@ -24,16 +21,16 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput { return traits; } - void setup() override { this->forward_direction_ = false; } + void setup() override { this->disable_loop(); } - void update() override { - // This method runs around 60 times per second - // We cannot do the PWM ourselves so we are reliant on the hardware PWM - if (!this->forward_direction_) { // First LED Direction + void loop() override { + // Only called when both channels are active — alternate H-bridge direction + // each iteration to multiplex cold and warm white. + if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); this->forward_direction_ = true; - } else { // Second LED Direction + } else { this->pina_pin_->set_level(0); this->pinb_pin_->set_level(this->pinb_duty_); this->forward_direction_ = false; @@ -43,15 +40,32 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput { float get_setup_priority() const override { return setup_priority::HARDWARE; } void write_state(light::LightState *state) override { - state->current_values_as_cwww(&this->pina_duty_, &this->pinb_duty_, false); + float new_pina, new_pinb; + state->current_values_as_cwww(&new_pina, &new_pinb, false); + + this->pina_duty_ = new_pina; + this->pinb_duty_ = new_pinb; + + if (new_pina != 0.0f && new_pinb != 0.0f) { + // Both channels active — need loop to alternate H-bridge direction + this->high_freq_.start(); + this->enable_loop(); + } else { + // Zero or one channel active — drive pins directly, no multiplexing needed + this->high_freq_.stop(); + this->disable_loop(); + this->pina_pin_->set_level(new_pina); + this->pinb_pin_->set_level(new_pinb); + } } protected: output::FloatOutput *pina_pin_; output::FloatOutput *pinb_pin_; - float pina_duty_ = 0; - float pinb_duty_ = 0; - bool forward_direction_ = false; + float pina_duty_{0}; + float pinb_duty_{0}; + bool forward_direction_{false}; + HighFrequencyLoopRequester high_freq_; }; } // namespace hbridge diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5a1f011617..b71d57498a 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__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, @@ -24,6 +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, } ) .extend(cv.COMPONENT_SCHEMA) @@ -35,6 +37,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index fbdb6903b8..118a77ce37 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -24,11 +24,22 @@ void MCP23016::setup() { // all pins input this->write_reg_(MCP23016_IODIR1, 0xFFFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&MCP23016::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_context(); } void MCP23016::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } } bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); } @@ -37,6 +48,9 @@ void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { this->update_reg_(pin, true, MCP23016_IODIR1); + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg_(pin, false, MCP23016_IODIR1); } diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 494bc9c197..32149ba3e2 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -35,7 +35,10 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: float get_setup_priority() const override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(MCP23016 *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -51,6 +54,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: uint16_t olat_{0x0000}; // Cache for input values (16-bit combined for both banks) uint16_t input_mask_{0x0000}; + InternalGPIOPin *interrupt_pin_{nullptr}; }; class MCP23016GPIOPin : public GPIOPin { diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index b6e156e7ff..813bb35c48 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__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, @@ -25,7 +26,12 @@ PCA6416AGPIOPin = pca6416a_ns.class_( CONF_PCA6416A = "pca6416a" CONFIG_SCHEMA = ( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent)}) + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) .extend(cv.COMPONENT_SCHEMA) .extend(i2c.i2c_device_schema(0x21)) ) @@ -35,6 +41,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index f393af88ce..dc7463b01b 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -49,11 +49,22 @@ void PCA6416AComponent::setup() { ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA6416AComponent::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enable_loop_soon_any_context(); } void PCA6416AComponent::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } } void PCA6416AComponent::dump_config() { @@ -62,6 +73,7 @@ void PCA6416AComponent::dump_config() { } else { ESP_LOGCONFIG(TAG, "PCA6416A:"); } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -101,6 +113,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { this->update_register_(pin, true, pull_dir); this->update_register_(pin, false, pull_en); } + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { this->update_register_(pin, true, io_dir); if (has_pullup_) { @@ -109,6 +124,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { } else { ESP_LOGW(TAG, "Your PCA6416A does not support pull-up resistors"); } + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { this->update_register_(pin, false, io_dir); } diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 138a51cc20..4d2e6b219e 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -24,7 +24,10 @@ class PCA6416AComponent : public Component, void dump_config() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(PCA6416AComponent *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -43,6 +46,7 @@ class PCA6416AComponent : public Component, esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors bool has_pullup_{false}; + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 8cb5f7c18d..3a53eba61b 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -9,7 +9,7 @@ #include #include // For cyw43_arch_lwip_begin/end (LwIPLock) #elif defined(USE_ETHERNET) -#include // For ethernet_arch_lwip_begin/end (LwIPLock) +#include // For LWIPMutex — LwIPLock delegates to it (see below) #include "esphome/components/ethernet/ethernet_component.h" #endif #include @@ -43,9 +43,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } // main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat // assertion failures). See esphome#10681. // -// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end. -// Both acquire the async_context recursive mutex to prevent IRQ callbacks from -// firing during critical sections. +// WiFi uses cyw43_arch_lwip_begin/end. +// +// For wired Ethernet, taking only the async_context lock is NOT enough. The +// W5500 GPIO IRQ path (LwipIntfDev::_irq) checks arduino-pico's `__inLWIP` +// counter to decide whether to defer packet processing. If we hold the +// async_context lock without bumping `__inLWIP`, an interrupt-driven packet +// arrival re-enters lwIP from IRQ context and corrupts pbufs (the `pbuf_cat` +// assertion crash on wiznet-w5500-evb-pico). We mirror arduino-pico's +// LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump `__inLWIP`, take the +// lock, and on release re-unmask any GPIO IRQs that were deferred while we +// held it. We can't `using LwIPLock = LWIPMutex;` in helpers.h because +// pulling lwip_wrap.h there poisons many TUs with lwIP types. // // When neither WiFi nor Ethernet is configured, this is a no-op since // there's no network stack and no lwip callbacks to race with. @@ -53,8 +62,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); } LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); } #elif defined(USE_ETHERNET) -LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); } -LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); } +LwIPLock::LwIPLock() { + __inLWIP++; + ethernet_arch_lwip_begin(); +} +LwIPLock::~LwIPLock() { + ethernet_arch_lwip_end(); + __inLWIP--; + if (__needsIRQEN && !__inLWIP) { + __needsIRQEN = false; + ethernet_arch_lwip_gpio_unmask(); + } +} #else LwIPLock::LwIPLock() {} LwIPLock::~LwIPLock() {} diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index f42e0fe398..5f571fcea6 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__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, @@ -27,6 +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, } ) .extend(cv.COMPONENT_SCHEMA) @@ -38,6 +40,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 79c5253898..3eb794df44 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -24,9 +24,18 @@ void TCA9555Component::setup() { this->mark_failed(); return; } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&TCA9555Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR TCA9555Component::gpio_intr(TCA9555Component *arg) { arg->enable_loop_soon_any_context(); } void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -36,6 +45,9 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Clear mode mask bit this->mode_mask_ &= ~(1 << pin); @@ -43,7 +55,12 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { // Write GPIO to enable input mode this->write_gpio_modes_(); } -void TCA9555Component::loop() { this->reset_pin_cache_(); } +void TCA9555Component::loop() { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } +} bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 9f7273b1e7..d4d070013c 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -24,7 +24,10 @@ class TCA9555Component : public Component, void loop() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(TCA9555Component *arg); 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; @@ -39,6 +42,8 @@ class TCA9555Component : public Component, bool read_gpio_modes_(); bool write_gpio_modes_(); bool read_gpio_outputs_(); + + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. diff --git a/tests/components/mcp23016/common.yaml b/tests/components/mcp23016/common.yaml index e8e3ad9d08..81f38b3f52 100644 --- a/tests/components/mcp23016/common.yaml +++ b/tests/components/mcp23016/common.yaml @@ -1,6 +1,10 @@ mcp23016: - i2c_id: i2c_bus - id: mcp23016_hub + - i2c_id: i2c_bus + id: mcp23016_hub + - i2c_id: i2c_bus + id: mcp23016_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/mcp23016/test.esp32-idf.yaml b/tests/components/mcp23016/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/mcp23016/test.esp32-idf.yaml +++ b/tests/components/mcp23016/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23016/test.esp8266-ard.yaml b/tests/components/mcp23016/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/mcp23016/test.esp8266-ard.yaml +++ b/tests/components/mcp23016/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23016/test.rp2040-ard.yaml b/tests/components/mcp23016/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/mcp23016/test.rp2040-ard.yaml +++ b/tests/components/mcp23016/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/pca6416a/common.yaml b/tests/components/pca6416a/common.yaml index 9ad6e2fb15..09083c6c15 100644 --- a/tests/components/pca6416a/common.yaml +++ b/tests/components/pca6416a/common.yaml @@ -2,6 +2,10 @@ pca6416a: - id: pca6416a_hub i2c_id: i2c_bus address: 0x21 + - id: pca6416a_hub_int + i2c_id: i2c_bus + address: 0x22 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pca6416a/test.esp32-idf.yaml b/tests/components/pca6416a/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/pca6416a/test.esp32-idf.yaml +++ b/tests/components/pca6416a/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pca6416a/test.esp8266-ard.yaml b/tests/components/pca6416a/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/pca6416a/test.esp8266-ard.yaml +++ b/tests/components/pca6416a/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pca6416a/test.rp2040-ard.yaml b/tests/components/pca6416a/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/pca6416a/test.rp2040-ard.yaml +++ b/tests/components/pca6416a/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml diff --git a/tests/components/tca9555/common.yaml b/tests/components/tca9555/common.yaml index 82b4c959d8..d1a68c575a 100644 --- a/tests/components/tca9555/common.yaml +++ b/tests/components/tca9555/common.yaml @@ -2,6 +2,10 @@ tca9555: - id: tca9555_hub i2c_id: i2c_bus address: 0x21 + - id: tca9555_hub_int + i2c_id: i2c_bus + address: 0x22 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/tca9555/test.esp32-idf.yaml b/tests/components/tca9555/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/tca9555/test.esp32-idf.yaml +++ b/tests/components/tca9555/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/tca9555/test.esp8266-ard.yaml b/tests/components/tca9555/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/tca9555/test.esp8266-ard.yaml +++ b/tests/components/tca9555/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/tca9555/test.rp2040-ard.yaml b/tests/components/tca9555/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/tca9555/test.rp2040-ard.yaml +++ b/tests/components/tca9555/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml