diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index a7180cbcd7..36dd44155a 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -172,10 +172,16 @@ def validate_gpio_pin(pin): exc, ) else: - # Throw an exception if used for a pin that would not have resulted - # in a validation error anyway! + # `ignore_pin_validation_error` only suppresses an error raised by the + # variant's pin_validation above (e.g. SPI flash/PSRAM pins, invalid pin + # numbers). If that didn't raise, the option is a no-op -- warn so the + # user can clean it up, but don't block the build. if ignore_pin_validation_warning: - raise cv.Invalid(f"GPIO{pin[CONF_NUMBER]} is not a reserved pin") + _LOGGER.warning( + "GPIO%d has no validation errors to ignore; " + "remove `ignore_pin_validation_error: true` from this pin.", + pin[CONF_NUMBER], + ) return pin diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index b658ff7056..8dcb7165e3 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -275,6 +275,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) +ThrottleWithPriorityNanFilter = sensor_ns.class_( + "ThrottleWithPriorityNanFilter", Filter +) TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) @@ -656,9 +659,18 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( THROTTLE_WITH_PRIORITY_SCHEMA, ) async def throttle_with_priority_filter_to_code(config, filter_id): - if not isinstance(config[CONF_VALUE], list): - config[CONF_VALUE] = [config[CONF_VALUE]] - template_ = [await cg.templatable(x, [], cg.float_) for x in config[CONF_VALUE]] + values = config[CONF_VALUE] + if not isinstance(values, list): + values = [values] + # Specialize the common "NaN-only" case (the schema default when the user + # omits `value:`) to avoid the TemplatableFn array + NaN lambda the + # generic ValueListFilter path requires. Behavior is identical: NaN sensor + # readings always bypass the throttle. + if values and all(isinstance(v, float) and math.isnan(v) for v in values): + filter_id = filter_id.copy() + filter_id.type = ThrottleWithPriorityNanFilter + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) + template_ = [await cg.templatable(x, [], cg.float_) for x in values] return cg.new_Pvariable( filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ ) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index fbac7d3535..4896757d3f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -269,6 +269,18 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co return {}; } +// ThrottleWithPriorityNanFilter +ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) + : min_time_between_inputs_(min_time_between_inputs) {} +optional ThrottleWithPriorityNanFilter::new_value(float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { + this->last_input_ = now; + return value; + } + return {}; +} + // DeltaFilter DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 0dbbc33ab3..a91d66a8fb 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -399,6 +399,19 @@ template class ThrottleWithPriorityFilter : public ValueListFilter uint32_t min_time_between_inputs_; }; +/// Specialization of ThrottleWithPriorityFilter for the common "prioritize NaN" +/// case: skips the TemplatableFn array + lambda and inlines the check. +class ThrottleWithPriorityNanFilter : public Filter { + public: + explicit ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs); + + optional new_value(float value) override; + + protected: + uint32_t last_input_{0}; + uint32_t min_time_between_inputs_; +}; + // Base class for timeout filters - contains common loop logic class TimeoutFilterBase : public Filter, public Component { public: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd4f9828ce..ac492e2752 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -8,10 +8,16 @@ from typing import Any import pytest -from esphome.components.esp32 import VARIANTS -from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.components.esp32 import VARIANT_ESP32, VARIANTS +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT +from esphome.components.esp32.gpio import validate_gpio_pin import esphome.config_validation as cv -from esphome.const import CONF_ESPHOME, PlatformFramework +from esphome.const import ( + CONF_ESPHOME, + CONF_IGNORE_PIN_VALIDATION_ERROR, + CONF_NUMBER, + PlatformFramework, +) from esphome.core import CORE from tests.component_tests.types import SetCoreConfigCallable @@ -149,6 +155,73 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_ignore_pin_validation_error_on_clean_pin_warns( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that passes validation but sets `ignore_pin_validation_error: true` + should log a warning nudging the user to remove the flag, and not raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "GPIO4 has no validation errors to ignore" in caplog.text + + +def test_ignore_pin_validation_error_on_dirty_pin_suppresses( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A pin that fails validation with `ignore_pin_validation_error: true` should + log the suppression warning and not raise (existing behavior).""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + # GPIO6 is a flash pin on ESP32 -> pin_validation raises cv.Invalid + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: True} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 6 + assert "Ignoring validation error on pin 6" in caplog.text + + +def test_dirty_pin_without_ignore_flag_raises( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pin that fails validation without the ignore flag should still raise.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 6, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with pytest.raises(cv.Invalid, match="flash interface"): + validate_gpio_pin(pin) + + +def test_clean_pin_without_ignore_flag_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean pin without the ignore flag should pass silently.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + + pin = {CONF_NUMBER: 4, CONF_IGNORE_PIN_VALIDATION_ERROR: False} + with caplog.at_level("WARNING"): + result = validate_gpio_pin(pin) + + assert result[CONF_NUMBER] == 4 + assert "has no validation errors to ignore" not in caplog.text + + def test_execute_from_psram_disabled_sdkconfig( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path],