diff --git a/AGENTS.md b/AGENTS.md index cf78daa5a37..113cfe1b2a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -470,6 +470,19 @@ file does, and it is the authority when they disagree. The most useful starting Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. + **Conditions that only test their parent need no C++ class either.** Register them with + `register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares + against config values. + ```python + automation.register_apply_condition("my_component.is_active", schema, "is_active()") + automation.register_apply_condition( + "my_component.state_is", + schema, + automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)), + ) + ``` + `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. + * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. * **Configuration Validation:** diff --git a/esphome/automation.py b/esphome/automation.py index 373f924e4db..850ac554af1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -214,6 +214,7 @@ validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) +ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) def flash_string(config: ConfigType, value: str) -> str: @@ -255,6 +256,13 @@ class ApplyCall: f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" ) + @property + def members(self) -> list[tuple[Any, Any, Any]]: + """Each arg as ``(conf_key, type_, const_fn or None)``.""" + return [ + (arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in self.args + ] + @dataclass(frozen=True) class ApplyField: @@ -322,6 +330,58 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] +async def _apply_parent(config: ConfigType) -> str: + # Global-scope qualified so a trigger arg named like the id cannot shadow it. + return f"::{await cg.get_variable(config[CONF_ID])}" + + +def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: + # Must match ApplyAction::ApplyFn and ApplyCondition::CheckFn exactly for the function + # pointer conversion. + return [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) + for t, arg in args + ] + + +async def _render_values( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str, + lambda_args: TemplateArgsType, + compare: bool = False, +) -> list[str]: + """Render the argument text of one statement; every key must be present. + + ``compare``: an inlined lambda expression is parenthesized so it binds as a whole + beside an operator. + """ + if any(value is None for value in values): + keys = [key for key, _, _ in members] + raise EsphomeError(f"{name}: {target!r} needs all of {keys}") + exprs: list[str] = [] + for (_, type_, const_fn), value in zip(members, values, strict=True): + if isinstance(value, Lambda): + if isinstance(type_, str): + type_ = cg.RawExpression(type_.format(parent=parent)) + inner = await cg.process_lambda(value, lambda_args, return_type=type_) + expr = call_lambda(inner) + bare = compare and isinstance(expr, cg.RawExpression) + exprs.append(f"({expr})" if bare else str(expr)) + elif const_fn is not None: + exprs.append(const_fn(config, value)) + else: + exprs.append(str(cg.safe_exp(value))) + return exprs + + +def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> list[Any]: + return [_config_lookup(config, key) for key, _, _ in members] + + def register_apply_action( name: str, schema: cv.Schema, @@ -334,10 +394,14 @@ def register_apply_action( in, lambdas are called inline with the trigger args. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. """ + # An action stores the value, so a std::string constant stays in flash on ESP8266. statements_spec = [ ( c.target, - [(arg[0], arg[1], arg[2] if len(arg) == 3 else None) for arg in c.args], + [ + (key, t, fn or (flash_string if t is cg.std_string else None)) + for key, t, fn in c.members + ], ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -351,37 +415,17 @@ def register_apply_action( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - # Global-scope qualified so a trigger arg named like the id cannot shadow it. - parent = f"::{await cg.get_variable(config[CONF_ID])}" - # Must match ApplyAction::ApplyFn exactly for the function pointer conversion. - lambda_args = [ - (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) - for t, arg in args - ] + parent = await _apply_parent(config) + lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] for target, members in statements_spec: - values = [_config_lookup(config, key) for key, _, _ in members] + values = _apply_values(config, members) if members and all(value is None for value in values): continue - if any(value is None for value in values): - keys = [key for key, _, _ in members] - raise EsphomeError(f"{name}: {target!r} needs all of {keys}") - exprs: list[str] = [] - for (_, type_, const_fn), value in zip(members, values, strict=True): - if isinstance(value, Lambda): - if isinstance(type_, str): - type_ = cg.RawExpression(type_.format(parent=parent)) - inner = await cg.process_lambda( - value, lambda_args, return_type=type_ - ) - exprs.append(str(call_lambda(inner))) - elif const_fn is not None: - exprs.append(const_fn(config, value)) - elif type_ is cg.std_string: - exprs.append(flash_string(config, value)) - else: - exprs.append(str(cg.safe_exp(value))) + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args + ) statements.append(f"{receiver}{target.format(*exprs)};") if call: statements = [ @@ -397,6 +441,51 @@ def register_apply_action( register_action(name, ApplyAction, schema, synchronous=True)(builder) +def register_apply_condition( + name: str, schema: cv.Schema, check: str | ApplyCall +) -> None: + """Register a condition that is one expression on its parent, with no C++ class. + + ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an + ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares + against config values, all of which must be present. Write ``== false`` to negate. + String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + Generates one stateless function for ``ApplyCondition``. + """ + call = check if isinstance(check, ApplyCall) else ApplyCall(check) + members = call.members + for conf_key, _, _ in members: + _check_key_in_schema(name, schema, conf_key) + + async def builder( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + parent = await _apply_parent(config) + lambda_args = _apply_lambda_args(args) + exprs = await _render_values( + name, + call.target, + members, + _apply_values(config, members), + config, + parent, + lambda_args, + compare=True, + ) + check_lambda = LambdaExpression( + [f"return {parent}->{call.target.format(*exprs)};"], + lambda_args, + capture="", + return_type=cg.bool_, + ) + return cg.new_Pvariable(condition_id, template_arg, check_lambda) + + register_condition(name, ApplyCondition, schema)(builder) + + def validate_potentially_and_condition(value): if isinstance(value, list): with cv.remove_prepend_path(["and"]): @@ -643,28 +732,15 @@ async def for_condition_to_code( return var -@register_condition( +register_apply_condition( "component.is_idle", - LambdaCondition, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(cg.Component), } ), + "is_idle()", ) -async def component_is_idle_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - lambda_ = await cg.process_lambda( - Lambda(f"return {comp}->is_idle();"), args, return_type=bool - ) - return new_lambda_pvariable( - condition_id, lambda_, StatelessLambdaCondition, template_arg - ) @register_action( diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index f7e029ad9e3..0f5aa9b4503 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,7 +1,7 @@ import logging from esphome import automation -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server import esphome.config_validation as cv @@ -36,7 +36,7 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, @@ -44,7 +44,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import MockObj, MockObjClass -from esphome.types import ConfigType, SafeExpType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -87,8 +87,6 @@ COVER_OPERATIONS = { validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions -CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) -CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) CoverOpenedTrigger = cover_ns.class_( "CoverOpenedTrigger", automation.Trigger.template() ) @@ -287,19 +285,12 @@ COVER_CONDITION_SCHEMA = cv.maybe_simple_value( ) -async def cover_condition_to_code( - config: ConfigType, condition_id: ID, template_arg: MockObj, args: TemplateArgsType -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) - - -automation.register_condition( - "cover.is_open", CoverIsOpenCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) -automation.register_condition( - "cover.is_closed", CoverIsClosedCondition, COVER_CONDITION_SCHEMA -)(cover_condition_to_code) +automation.register_apply_condition( + "cover.is_open", COVER_CONDITION_SCHEMA, f"position == {COVER_OPEN}" +) +automation.register_apply_condition( + "cover.is_closed", COVER_CONDITION_SCHEMA, f"position == {COVER_CLOSED}" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 9af4a1eee50..2032eba0ec3 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,19 +6,6 @@ namespace esphome::cover { -template class CoverPositionCondition final : public Condition { - public: - CoverPositionCondition(Cover *cover) : cover_(cover) {} - - bool check(const Ts &...x) override { return this->cover_->position == (OPEN ? COVER_OPEN : COVER_CLOSED); } - - protected: - Cover *cover_; -}; - -template using CoverIsOpenCondition = CoverPositionCondition; -template using CoverIsClosedCondition = CoverPositionCondition; - template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4da64a8304d..4f4d17b441e 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -18,7 +18,6 @@ CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") Rtttl = rtttl_ns.class_("Rtttl", cg.Component) -IsPlayingCondition = rtttl_ns.class_("IsPlayingCondition", automation.Condition) MULTI_CONF = True @@ -120,12 +119,12 @@ automation.register_apply_action( ) -automation.register_parented_condition( +automation.register_apply_condition( "rtttl.is_playing", - IsPlayingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(Rtttl), } ), + "is_playing()", ) diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 0bf2fce70f5..7a32b79b177 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -1,6 +1,5 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -116,9 +115,4 @@ class Rtttl final : public Component { #endif }; -template class IsPlayingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_playing(); } -}; - } // namespace esphome::rtttl diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index b0c4b649957..703c7b40e33 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -267,6 +267,18 @@ template class ApplyAction final : public Action { ApplyFn apply_; }; +/// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. +template class ApplyCondition final : public Condition { + public: + using CheckFn = bool (*)(const std::remove_cvref_t &...); + explicit ApplyCondition(CheckFn check) : check_(check) {} + + bool check(const Ts &...x) override { return this->check_(x...); } + + protected: + CheckFn check_; +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 81b13de7bf0..ccef9ded052 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -551,11 +551,17 @@ text: update_interval: 1s lambda: | return std::string{"Hello!"}; + # Exercise a register_apply_condition inside a trigger with a std::string + # argument, so ApplyCondition is compiled. set_action: then: - - logger.log: - format: Template Text set to %s - args: ["x.c_str()"] + - if: + condition: + cover.is_open: template_cover_with_triggers + then: + - logger.log: + format: Template Text set to %s + args: ["x.c_str()"] alarm_control_panel: - platform: template diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index c7c5ae998e9..c4265a78885 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -10,6 +10,7 @@ import pytest from esphome.automation import ( ApplyAction, ApplyCall, + ApplyCondition, ApplyField, CallbackAutomation, TriggerForwarder, @@ -19,6 +20,7 @@ from esphome.automation import ( has_non_synchronous_actions, maybe_simple_id, register_apply_action, + register_apply_condition, register_bare_action, register_bare_condition, register_parented_action, @@ -602,6 +604,20 @@ def test_shared_builders_keep_synchronous_flag( assert actions["my.parented"].synchronous is synchronous +async def _run_entry( + entry: RegistryEntry, + config: dict[str, object], + args: list[tuple[object, str]] | None, + platform: str, +) -> RegistryEntry: + """Run a registered builder with the given config, trigger args and platform.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + args = args or [] + template_arg = cg.TemplateArguments(*(t for t, _ in args)) + await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) + return entry + + async def _run_apply_action( registries: tuple[Registry, Registry], fields: tuple[ApplyField | ApplyCall, ...], @@ -611,14 +627,22 @@ async def _run_apply_action( platform: str = "esp32", ) -> RegistryEntry: """Register an apply action and run its builder with the given config.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} actions, _ = registries register_apply_action("my.apply", None, *fields, call=call) - entry = actions["my.apply"] - args = args or [] - template_arg = cg.TemplateArguments(*(t for t, _ in args)) - await entry.fun({CONF_ID: PARENT_ID, **config}, ID("obj_1"), template_arg, args) - return entry + return await _run_entry(actions["my.apply"], config, args, platform) + + +async def _run_apply_condition( + registries: tuple[Registry, Registry], + check: str | ApplyCall, + config: dict[str, object], + args: list[tuple[object, str]] | None = None, + platform: str = "esp32", +) -> RegistryEntry: + """Register an apply condition and run its builder with the given config.""" + _, conditions = registries + register_apply_condition("my.check", None, check) + return await _run_entry(conditions["my.check"], config, args, platform) def _apply_lambda(mock_cg: MockCodegen) -> str: @@ -774,6 +798,13 @@ def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> Non register_apply_action("my.ok", schema, ApplyField("kp", "set_kp", cg.float_)) with pytest.raises(ValueError, match="'kd' is not in the schema"): register_apply_action("my.bad", schema, ApplyField("kd", "set_kd", cg.float_)) + register_apply_condition( + "my.is", schema, ApplyCall("kp == {}", (("kp", cg.float_),)) + ) + with pytest.raises(ValueError, match="'kd' is not in the schema"): + register_apply_condition( + "my.bad_is", schema, ApplyCall("kd == {}", (("kd", cg.float_),)) + ) either = cv.Any(schema, cv.Schema({cv.Optional("kd"): cv.float_})) register_apply_action("my.any", either, ApplyField("kd", "set_kd", cg.float_)) for wrapped in ( @@ -805,3 +836,67 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( mock_cg ) + + +@pytest.mark.asyncio +async def test_register_apply_condition_predicate( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + entry = await _run_apply_condition( + registries, "is_playing()", {}, args=[(cg.int32, "x")] + ) + assert entry.type_id is ApplyCondition + condition_id, template_arg, check = mock_cg.new_pvariable.call_args.args + assert condition_id == ID("obj_1") + assert str(template_arg) == "" + text = str(check) + assert text.startswith("[](const std::remove_cvref_t & x) -> bool {") + assert f"return ::{PARENT_OBJ}->is_playing();" in text + + +@pytest.mark.asyncio +async def test_apply_condition_compares_config_value( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + check = ApplyCall("state == {}", (("state", cg.bool_),)) + await _run_apply_condition(registries, check, {"state": True}) + assert f"return ::{PARENT_OBJ}->state == true;" in _apply_lambda(mock_cg) + + with pytest.raises(EsphomeError, match="needs all of"): + await _run_apply_condition(registries, check, {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) +async def test_apply_condition_string_constant_is_a_plain_literal( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +) -> None: + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) + assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_lambda(mock_cg) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "expected", "called"), + [ + ("return x;", "->state == (x);", False), + ('return x.empty() ? "e" : x;', '->state == (x.empty() ? "e" : x);', False), + ('if (x.empty()) return "e";\nreturn x;', "}(x);", True), + ], +) +async def test_apply_condition_string_lambda_paths( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + body: str, + expected: str, + called: bool, +) -> None: + """A single return is inlined with no copy; a longer body is a called std::string lambda.""" + check = ApplyCall("state == {}", (("state", cg.std_string),)) + await _run_apply_condition( + registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] + ) + text = _apply_lambda(mock_cg) + assert expected in text + assert ("-> std::string {" in text) is called