[sensor] Share filter_out and throttle_with_priority value tables in flash (#20078)

This commit is contained in:
J. Nick Koston
2026-10-12 15:48:27 -10:00
committed by GitHub
parent 2001f60954
commit 02287fdf7b
6 changed files with 146 additions and 55 deletions
+23 -9
View File
@@ -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(
+10 -11
View File
@@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn<float> multiplier) : multiplier_(mu
optional<float> MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); }
// ValueListFilter helper (non-template, shared by all ValueListFilter<N> instantiations)
bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn<float> *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<float> *v = this->values_; v->has_value(); v++) {
float fv = v->value();
// Handle NaN comparison
if (std::isnan(fv)) {
@@ -261,13 +261,12 @@ optional<float> ThrottleFilter::new_value(float value) {
return {};
}
// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp)
optional<float> throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn<float> *values,
size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) {
// ThrottleWithPriorityFilter
optional<float> 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 {};
+16 -33
View File
@@ -330,36 +330,27 @@ class MultiplyFilter : public Filter {
TemplatableFn<float> multiplier_;
};
/// Non-template helper for value matching (implementation in filter.cpp)
bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn<float> *values, size_t count);
// Value tables are read in place from flash, which ESP8266 only allows for whole words.
static_assert(sizeof(TemplatableFn<float>) == sizeof(void *), "TemplatableFn<float> 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<size_t N> 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<TemplatableFn<float>> values) {
init_array_from(this->values_, values);
}
explicit ValueListFilter(const TemplatableFn<float> *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<TemplatableFn<float>, 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<float> *values_;
};
/// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`.
template<size_t N> class FilterOutValueFilter : public ValueListFilter<N> {
class FilterOutValueFilter : public ValueListFilter {
public:
explicit FilterOutValueFilter(std::initializer_list<TemplatableFn<float>> values_to_filter_out)
: ValueListFilter<N>(values_to_filter_out) {}
explicit FilterOutValueFilter(const TemplatableFn<float> *values_to_filter_out)
: ValueListFilter(values_to_filter_out) {}
optional<float> 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<float> throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn<float> *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<size_t N> class ThrottleWithPriorityFilter : public ValueListFilter<N> {
class ThrottleWithPriorityFilter : public ValueListFilter {
public:
explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs,
std::initializer_list<TemplatableFn<float>> prioritized_values)
: ValueListFilter<N>(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn<float> *prioritized_values)
: ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
optional<float> 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<float> new_value(float value) override;
protected:
uint32_t last_input_{0};
+3 -2
View File
@@ -19,11 +19,12 @@ namespace esphome {
/// Codegen wraps constants in stateless lambdas so only a function pointer is needed.
template<typename T, typename... X> class TemplatableFn {
public:
TemplatableFn() = default;
constexpr TemplatableFn() = default;
TemplatableFn(std::nullptr_t) = delete;
// Exact return type match — direct function pointer storage
template<typename F> TemplatableFn(F f) requires std::convertible_to<F, T (*)(X...)> : f_(f) {}
// constexpr so codegen can place arrays of these in flash.
template<typename F> constexpr TemplatableFn(F f) requires std::convertible_to<F, T (*)(X...)> : 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
@@ -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<float> (\w+)\[\] PROGMEM", main_cpp
)
# Every table ends with an empty entry so the filters store no count.
assert main_cpp.count("TemplatableFn<float>()}") == 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<float> (\w+)\[\] PROGMEM = (.*?)TemplatableFn<float>\(\)\}",
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]
@@ -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