From ed6ab4565df661d0f3da70980c23457825ea82cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:59 -1000 Subject: [PATCH] [core] Emit filter, action and condition lists one item at a time (#20116) --- esphome/automation.py | 24 ++++--- esphome/components/api/__init__.py | 2 +- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/sensor/__init__.py | 5 +- esphome/components/sensor/filter.h | 3 + esphome/components/text_sensor/__init__.py | 3 +- esphome/core/base_automation.h | 33 ++++++++-- .../automation/test_condition_groups.py | 2 +- .../sensor/test_timeout_throttle.py | 10 +-- .../template/config/per_item_lists.yaml | 62 +++++++++++++++++++ .../template/test_per_item_lists.py | 30 +++++++++ 11 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/template/config/per_item_lists.yaml create mode 100644 tests/component_tests/template/test_per_item_lists.py diff --git a/esphome/automation.py b/esphome/automation.py index fea5769050e..2f90cbfcbbf 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -724,7 +724,7 @@ async def condition_group_to_code( if len(conditions) == 1: return conditions[0] return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions ) @@ -840,11 +840,13 @@ async def if_action_to_code( condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) if CONF_THEN in config: - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) if has_else: - actions = await build_action_list(config[CONF_ELSE], template_arg, args) - cg.add(var.add_else(actions)) + for action in await build_action_list(config[CONF_ELSE], template_arg, args): + cg.add(var.add_else_action(action)) + cg.add(var.finish_else()) return var @@ -867,8 +869,9 @@ async def while_action_to_code( ) -> MockObj: condition = await build_condition(config[CONF_CONDITION], template_arg, args) var = cg.new_Pvariable(action_id, template_arg, condition) - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -897,7 +900,9 @@ async def repeat_action_to_code( cg.TemplateArguments(cg.uint32, *template_arg.args), [(cg.uint32, "iteration"), *args], ) - cg.add(var.add_then(actions)) + for action in actions: + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -1113,7 +1118,8 @@ async def _new_automation( templ = cg.TemplateArguments(*(arg[0] for arg in args)) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + for action in actions: + cg.add(obj.add_action(action)) return obj diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f105c1d7f79..9611228ba1d 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -664,7 +664,7 @@ async def to_code(config: ConfigType) -> None: unregister_id, var, ) - cg.add(auto.add_actions([unregister_action])) + cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) if CORE.is_esp8266 and has_user_actions: diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 60e20aa1935..109a22c7bca 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -607,7 +607,8 @@ async def setup_binary_sensor_core_(var, config): if filters_config := config.get(CONF_FILTERS): cg.add_define("USE_BINARY_SENSOR_FILTER") filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_binary_sensor_automations, var, config) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2b404442a4d..1440e07f763 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -672,7 +672,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), *filters) @FILTER_REGISTRY.register( @@ -1033,7 +1033,8 @@ async def setup_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") filters = await _build_chain_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 510ee94c6a7..01949f45df2 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -545,6 +545,9 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, template class OrFilter : public Filter { public: explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } + // Codegen passes each filter as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Fs) == N) explicit OrFilter(Fs *...filters) : filters_{filters...} {} void initialize(Sensor *parent, Filter *next) override { Filter::initialize(parent, next); diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index e9c70f7a2e3..959ce77c7a9 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -208,7 +208,8 @@ async def setup_text_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_text_sensor_automations, var, config) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 69def222da2..09cde1261d0 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -22,6 +22,9 @@ template class AndCondition : public Condition explicit AndCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit AndCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -40,6 +43,9 @@ template class OrCondition : public Condition { explicit OrCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit OrCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -67,6 +73,9 @@ template class XorCondition : public Condition explicit XorCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit XorCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -311,10 +320,9 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} - // Precondition: add_then/add_else must be called at most once per instance. - // Codegen always batches the full action list into a single call. Calling - // twice would re-append the same inline continuation pointer and form a - // self-loop in the next_ chain. + // Precondition: build each branch either with one add_then/add_else call, or with + // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. + // Appending the inline continuation twice forms a self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -325,6 +333,13 @@ template class IfAction : public Action { this->else_.add_action(&this->else_continuation_.action); } + // Codegen appends branch actions one at a time, then finishes each branch once; a list literal + // would be a constant array in .rodata, which is RAM on ESP8266. + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->then_continuation_); } + void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } + void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void play_complex(const Ts &...x) override { this->num_running_++; if (this->condition_->check(x...)) { @@ -364,11 +379,14 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -428,11 +446,14 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py index effe7b5b0bd..5d9f9b828ed 100644 --- a/tests/component_tests/automation/test_condition_groups.py +++ b/tests/component_tests/automation/test_condition_groups.py @@ -15,7 +15,7 @@ def test_single_condition_groups_are_unwrapped( assert "IfAction(lambdacondition_id);" in main_cpp assert "IfAction(lambdacondition_id_2);" in main_cpp group = re.search( - r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + r"new\((\w+)\) AndCondition<2>\(lambdacondition_id_3, lambdacondition_id_4\);", main_cpp, ) assert group is not None diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py index 8154471e472..ceecd2f1226 100644 --- a/tests/component_tests/sensor/test_timeout_throttle.py +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -15,10 +15,10 @@ def test_timeout_throttle_pair_is_merged( len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) == 1 ) - merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) - assert merged is not None - assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + merged = re.findall(r"merged->add_filter\((\w+)\);", main_cpp) + assert len(merged) == 1 + assert f"new({merged[0]}) sensor::TimeoutThrottleFilter(1000);" in main_cpp # Every other shape keeps its two filters for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): - call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) - assert call is not None and len(call.group(1).split(",")) == 2, sensor_id + calls = re.findall(rf"{sensor_id}->add_filter\(\w+\);", main_cpp) + assert len(calls) == 2, sensor_id diff --git a/tests/component_tests/template/config/per_item_lists.yaml b/tests/component_tests/template/config/per_item_lists.yaml new file mode 100644 index 00000000000..89f1a80ce2d --- /dev/null +++ b/tests/component_tests/template/config/per_item_lists.yaml @@ -0,0 +1,62 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +globals: + - id: counter + type: int + initial_value: "0" + +sensor: + - platform: template + id: temp + lambda: return 1.0; + filters: + - multiply: 2.0 + - offset: 1.0 + - or: + - throttle: 2s + - delta: 1.0 + +binary_sensor: + - platform: template + id: button + lambda: return true; + filters: + - invert: + - delayed_on: 10ms + +text_sensor: + - platform: template + id: label + lambda: return {"x"}; + filters: + - to_upper: + - append: "!" + +interval: + - interval: 10s + then: + - if: + condition: + and: + - lambda: return id(counter) == 0; + - binary_sensor.is_on: button + then: + - lambda: id(counter) += 1; + - logger.log: even + else: + - logger.log: odd + - repeat: + count: 2 + then: + - logger.log: again + - while: + condition: + lambda: return id(counter) < 3; + then: + - lambda: id(counter) += 1; diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py new file mode 100644 index 00000000000..adf1498e37e --- /dev/null +++ b/tests/component_tests/template/test_per_item_lists.py @@ -0,0 +1,30 @@ +"""Filters and action lists are added one item at a time, so no pointer list lands in .rodata.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_filters_and_actions_are_added_per_item( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("per_item_lists.yaml")) + + for list_call in ( + "add_filters(", + "set_filters(", + "add_actions(", + "add_then(", + "add_else(", + ): + assert list_call not in main_cpp + assert len(re.findall(r"temp->add_filter\(", main_cpp)) == 3 + assert len(re.findall(r"button->add_filter\(", main_cpp)) == 2 + assert len(re.findall(r"label->add_filter\(", main_cpp)) == 2 + assert main_cpp.count("->add_then_action(") == 4 + assert main_cpp.count("->add_else_action(") == 1 + assert main_cpp.count("->finish_then();") == 3 + assert main_cpp.count("->finish_else();") == 1 + # and/or conditions and the sensor `or` filter take their items as separate arguments + assert not re.search(r"(Condition|OrFilter)<[^>]*>\(\{", main_cpp)