From 02287fdf7b0de1d45a908884867fb8d813d44a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:27 -1000 Subject: [PATCH] [sensor] Share filter_out and throttle_with_priority value tables in flash (#20078) --- esphome/components/sensor/__init__.py | 32 ++++++++---- esphome/components/sensor/filter.cpp | 21 ++++---- esphome/components/sensor/filter.h | 49 ++++++------------ esphome/core/automation.h | 5 +- .../component_tests/sensor/test_value_list.py | 43 ++++++++++++++++ .../sensor/test_value_list.yaml | 51 +++++++++++++++++++ 6 files changed, 146 insertions(+), 55 deletions(-) create mode 100644 tests/component_tests/sensor/test_value_list.py create mode 100644 tests/component_tests/sensor/test_value_list.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8f345e3e668..79c6785be24 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,7 +113,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( SubEntities, @@ -421,16 +421,30 @@ async def multiply_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, template_) +TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_) + + +async def _value_list_table(values: list[Any]) -> MockObj: + """PROGMEM value table, ended by an empty entry so filters store no count.""" + fns = [await cg.templatable(x, [], cg.float_) for x in values] + # Identical lambdas (e.g. a YAML anchor) may keep static state, so only constants share. + return cg.shared_progmem_array( + "sensor_value_list", + TemplatableFloat, + [*fns, TemplatableFloat()], + share=not any(isinstance(v, Lambda) for v in values), + ) + + @FILTER_REGISTRY.register( "filter_out", FilterOutValueFilter, cv.Any(cv.templatable(cv.float_), [cv.templatable(cv.float_)]), ) -async def filter_out_filter_to_code(config, filter_id): +async def filter_out_filter_to_code(config: Any, filter_id: ID) -> MockObj: if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], cg.float_) for x in config] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) + return cg.new_Pvariable(filter_id, await _value_list_table(config)) QUANTILE_SCHEMA = cv.All( @@ -704,7 +718,9 @@ def _is_nan_only(values: Any) -> bool: ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA, ) -async def throttle_with_priority_filter_to_code(config, filter_id): +async def throttle_with_priority_filter_to_code( + config: ConfigType, filter_id: ID +) -> MockObj: values = config[CONF_VALUE] if not isinstance(values, list): values = [values] @@ -716,10 +732,8 @@ async def throttle_with_priority_filter_to_code(config, filter_id): 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_ - ) + table = await _value_list_table(values) + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], table) HEARTBEAT_SCHEMA = cv.Schema( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index ed48ddebdbc..9b027c4e11f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(mu optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { - int8_t accuracy = parent->get_accuracy_decimals(); +// ValueListFilter +bool ValueListFilter::value_matches_any_(float sensor_value) { + int8_t accuracy = this->parent_->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (size_t i = 0; i < count; i++) { - float fv = values[i].value(); + for (const TemplatableFn *v = this->values_; v->has_value(); v++) { + float fv = v->value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -261,13 +261,12 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { +// ThrottleWithPriorityFilter +optional ThrottleWithPriorityFilter::new_value(float value) { const uint32_t now = App.get_loop_component_start_time(); - if (last_input == 0 || now - last_input >= min_time_between_inputs || - value_list_matches_any(parent, value, values, count)) { - last_input = now; + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || + this->value_matches_any_(value)) { + this->last_input_ = now; return value; } return {}; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 9b5ef493081..5ff99881dea 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -330,36 +330,27 @@ class MultiplyFilter : public Filter { TemplatableFn multiplier_; }; -/// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); +// Value tables are read in place from flash, which ESP8266 only allows for whole words. +static_assert(sizeof(TemplatableFn) == sizeof(void *), "TemplatableFn must stay a single pointer"); -/** Base class for filters that compare sensor values against a fixed list of configured values. - * - * Templated on N (the number of values) so the list is stored inline in a std::array, - * avoiding heap allocation and the overhead of FixedVector. - * - * @tparam N Number of values in the filter list, set by code generation to match - * the exact number of values configured in YAML. - */ -template class ValueListFilter : public Filter { +/// Base class for filters that compare sensor values against a fixed list of configured values. +class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { - init_array_from(this->values_, values); - } + explicit ValueListFilter(const TemplatableFn *values) : values_(values) {} /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value) { - return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); - } + bool value_matches_any_(float sensor_value); - std::array, N> values_{}; + // Shared PROGMEM table ended by an empty entry, so no count is stored; entries are function + // pointers, so aligned 32-bit loads from flash are safe on ESP8266. + const TemplatableFn *values_; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -template class FilterOutValueFilter : public ValueListFilter { +class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} + explicit FilterOutValueFilter(const TemplatableFn *values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { if (this->value_matches_any_(value)) @@ -379,21 +370,13 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; -/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); - /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -template class ThrottleWithPriorityFilter : public ValueListFilter { +class ThrottleWithPriorityFilter : public ValueListFilter { public: - explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} + ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn *prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override { - return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, - this->min_time_between_inputs_); - } + optional new_value(float value) override; protected: uint32_t last_input_{0}; diff --git a/esphome/core/automation.h b/esphome/core/automation.h index bbb7f08fb8e..e7cf1c1143a 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -19,11 +19,12 @@ namespace esphome { /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. template class TemplatableFn { public: - TemplatableFn() = default; + constexpr TemplatableFn() = default; TemplatableFn(std::nullptr_t) = delete; // Exact return type match — direct function pointer storage - template TemplatableFn(F f) requires std::convertible_to : f_(f) {} + // constexpr so codegen can place arrays of these in flash. + template constexpr TemplatableFn(F f) requires std::convertible_to : f_(f) {} // Convertible return type (e.g., int -> uint8_t) — casting trampoline. // Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside diff --git a/tests/component_tests/sensor/test_value_list.py b/tests/component_tests/sensor/test_value_list.py new file mode 100644 index 00000000000..168fee24275 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.py @@ -0,0 +1,43 @@ +"""Tests for the sensor value list filter codegen.""" + +from collections.abc import Callable +import re + + +def test_value_list_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Identical value lists share one PROGMEM table; the NaN throttle stays array free.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM", main_cpp + ) + # Every table ends with an empty entry so the filters store no count. + assert main_cpp.count("TemplatableFn()}") == len(tables) + assert ( + len(tables) == 5 + ) # single, shared list, lambda, two anchored stateful lambdas + single, shared, lam = tables[:3] + assert f"sensor::FilterOutValueFilter({single});" in main_cpp + assert main_cpp.count(f"sensor::FilterOutValueFilter({shared});") == 2 + assert f"sensor::FilterOutValueFilter({lam});" in main_cpp + assert f"sensor::ThrottleWithPriorityFilter(1000, {shared});" in main_cpp + assert "sensor::ThrottleWithPriorityNanFilter(1000);" in main_cpp + assert "return 42.0;" in main_cpp + + +def test_value_list_lambda_tables_are_not_shared( + generate_main: Callable[[str], str], +) -> None: + """An anchored lambda may keep static state, so each filter keeps its own table.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM = (.*?)TemplatableFn\(\)\}", + main_cpp, + re.DOTALL, + ) + stateful = [name for name, body in tables if "static float n" in body] + assert len(stateful) == 2 + assert stateful[0] != stateful[1] diff --git a/tests/component_tests/sensor/test_value_list.yaml b/tests/component_tests/sensor/test_value_list.yaml new file mode 100644 index 00000000000..60a044f483c --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.yaml @@ -0,0 +1,51 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: fo_single + lambda: return 1.0; + filters: + - filter_out: 85.0 + - platform: template + id: fo_list_a + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_list_b + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_lambda + lambda: return 1.0; + filters: + - filter_out: !lambda return 42.0; + - platform: template + id: twp + lambda: return 1.0; + filters: + - throttle_with_priority: + timeout: 1s + value: [0.0, 85.0] + - platform: template + id: twp_nan + lambda: return 1.0; + filters: + - throttle_with_priority: 1s + - platform: template + id: fo_stateful_a + lambda: return 1.0; + filters: + - filter_out: &stateful !lambda |- + static float n = 0; + return ++n; + - platform: template + id: fo_stateful_b + lambda: return 1.0; + filters: + - filter_out: *stateful