diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 7cc16eb5b2..8a40e4e732 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -133,6 +133,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pin(pin)) if config[CONF_USE_INTERRUPT]: + cg.add_define("USE_GPIO_BINARY_SENSOR_INTERRUPT") cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) else: cg.add(var.set_use_interrupt(False)) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index ff07d76901..9d044dca2d 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -7,6 +7,7 @@ namespace esphome::gpio { static const char *const TAG = "gpio.binary_sensor"; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT // Interrupt type strings indexed by edge-triggered InterruptType values: // indices 1-3: RISING_EDGE, FALLING_EDGE, ANY_EDGE; other values (e.g. level-triggered) map to UNKNOWN (index 0). PROGMEM_STRING_TABLE(InterruptTypeStrings, "UNKNOWN", "RISING_EDGE", "FALLING_EDGE", "ANY_EDGE"); @@ -19,7 +20,9 @@ static const LogString *gpio_mode_to_string(bool use_interrupt) { return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); } #endif +#endif +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { bool new_state = arg->isr_pin_.digital_read(); if (new_state != arg->state_) { @@ -43,28 +46,36 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) { // Attach interrupt - from this point on, any changes will be caught by the interrupt pin->attach_interrupt(&GPIOBinarySensorStore::gpio_intr, this, this->interrupt_type_); } +#endif // USE_GPIO_BINARY_SENSOR_INTERRUPT void GPIOBinarySensor::setup() { +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT if (this->store_.use_interrupt_) { auto *internal_pin = static_cast(this->pin_); this->store_.setup(internal_pin, this); this->publish_initial_state(this->store_.get_state()); - } else { - this->pin_->setup(); - this->publish_initial_state(this->pin_->digital_read()); + return; } +#endif + this->pin_->setup(); + this->publish_initial_state(this->pin_->digital_read()); } void GPIOBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "GPIO Binary Sensor", this); LOG_PIN(" Pin: ", this->pin_); +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->store_.use_interrupt_))); if (this->store_.use_interrupt_) { ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->store_.interrupt_type_))); } +#else + ESP_LOGCONFIG(TAG, " Mode: polling"); +#endif } void GPIOBinarySensor::loop() { +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT if (this->store_.use_interrupt_) { if (this->store_.is_changed()) { // Clear the flag immediately to minimize the window where we might miss changes @@ -78,9 +89,10 @@ void GPIOBinarySensor::loop() { // No changes, disable the loop until the next interrupt this->disable_loop(); } - } else { - this->publish_state(this->pin_->digital_read()); + return; } +#endif + this->publish_state(this->pin_->digital_read()); } float GPIOBinarySensor::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 100edb4cca..956443fab5 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" @@ -10,6 +11,7 @@ namespace esphome::gpio { // Store class for ISR data and configuration (no vtables, ISR-safe) class GPIOBinarySensorStore { public: +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT void setup(InternalGPIOPin *pin, Component *component); static void gpio_intr(GPIOBinarySensorStore *arg); @@ -29,15 +31,18 @@ class GPIOBinarySensorStore { // Separate method to clear the flag this->changed_ = false; } +#endif protected: friend class GPIOBinarySensor; +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT ISRInternalGPIOPin isr_pin_; Component *component_{nullptr}; // Pointer to the component for enable_loop_soon_any_context() volatile bool state_{false}; volatile bool changed_{false}; - bool use_interrupt_{true}; gpio::InterruptType interrupt_type_{gpio::INTERRUPT_ANY_EDGE}; + bool use_interrupt_{true}; +#endif }; class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { @@ -46,8 +51,14 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon // Interrupts are only detached on reboot when memory is cleared anyway. void set_pin(GPIOPin *pin) { this->pin_ = pin; } +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT void set_use_interrupt(bool use_interrupt) { this->store_.use_interrupt_ = use_interrupt; } void set_interrupt_type(gpio::InterruptType type) { this->store_.interrupt_type_ = type; } +#else + // Polling-only build: codegen still emits set_use_interrupt(false) calls, + // so keep the setter as an inlined no-op instead of storing the flag. + void set_use_interrupt(bool /*use_interrupt*/) {} +#endif // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup pin diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5f34437145..42da0191ed 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN +#define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor.py b/tests/component_tests/gpio/test_gpio_binary_sensor.py index f336a9105e..60494d9cba 100644 --- a/tests/component_tests/gpio/test_gpio_binary_sensor.py +++ b/tests/component_tests/gpio/test_gpio_binary_sensor.py @@ -3,10 +3,15 @@ from __future__ import annotations from collections.abc import Callable +import logging from pathlib import Path import pytest +from esphome.core import CORE + +INTERRUPT_DEFINE = "USE_GPIO_BINARY_SENSOR_INTERRUPT" + def test_gpio_binary_sensor_basic_setup( generate_main: Callable[[str | Path], str], @@ -69,3 +74,62 @@ def test_gpio_binary_sensor_explicit_polling_mode( ) assert "bs_polling->set_use_interrupt(false);" in main_cpp + + +def test_gpio_binary_sensor_interrupt_emits_define( + generate_main: Callable[[str | Path], str], +) -> None: + """ + An interrupt-mode sensor must emit the define that compiles the ISR code, + since the platform ISR pin implementation is only built when needed + """ + generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml") + + assert INTERRUPT_DEFINE in {d.name for d in CORE.defines} + + +def test_gpio_binary_sensor_polling_omits_define( + generate_main: Callable[[str | Path], str], +) -> None: + """ + A polling-only config must not emit the interrupt define, so the ISR code + (and its reference to ISRInternalGPIOPin) is compiled out + """ + generate_main("tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml") + + assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines} + + +def test_gpio_binary_sensor_mixed_modes_emit_define( + generate_main: Callable[[str | Path], str], +) -> None: + """ + With one interrupt and one polling sensor, the define is emitted and the + polling instance still opts out via its setter + """ + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml" + ) + + assert INTERRUPT_DEFINE in {d.name for d in CORE.defines} + assert "bs_polling->set_use_interrupt(false);" in main_cpp + assert "bs_interrupt->set_use_interrupt" not in main_cpp + + +def test_gpio_binary_sensor_expander_pin_omits_define( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """ + An expander pin can't use interrupts: final validation falls back to + polling and the interrupt define must not be emitted. This is the config + that fails to link if the ISR code is compiled without an internal pin + """ + with caplog.at_level(logging.INFO): + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml" + ) + + assert "bs_expander->set_use_interrupt(false);" in main_cpp + assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines} + assert "falling back to polling mode" in caplog.text diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml b/tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml new file mode 100644 index 0000000000..f153ccebfd --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + scl: 16 + sda: 17 + +ch422g: + - id: ch422g_hub + +binary_sensor: + - platform: gpio + name: "Expander Sensor" + id: bs_expander + pin: + ch422g: ch422g_hub + number: 1 + mode: INPUT diff --git a/tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml b/tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml new file mode 100644 index 0000000000..0e8c7ec3fd --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + +binary_sensor: + - platform: gpio + pin: 5 + name: "Interrupt Sensor" + id: bs_interrupt + + - platform: gpio + pin: 4 + name: "Polling Sensor" + id: bs_polling + use_interrupt: false