[core][cover] Add register_apply_condition for conditions that only test the parent (#19540)

This commit is contained in:
J. Nick Koston
2026-09-24 11:00:59 +12:00
committed by GitHub
parent f4300d9cee
commit 807556171b
9 changed files with 264 additions and 91 deletions
+13
View File
@@ -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:**
+118 -42
View File
@@ -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<Ts...>``.
"""
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(
+9 -18
View File
@@ -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)
-13
View File
@@ -6,19 +6,6 @@
namespace esphome::cover {
template<bool OPEN, typename... Ts> class CoverPositionCondition final : public Condition<Ts...> {
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<typename... Ts> using CoverIsOpenCondition = CoverPositionCondition<true, Ts...>;
template<typename... Ts> using CoverIsClosedCondition = CoverPositionCondition<false, Ts...>;
template<bool OPEN> class CoverPositionTrigger final : public Trigger<> {
public:
CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) {
+2 -3
View File
@@ -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()",
)
-6
View File
@@ -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<typename... Ts> class IsPlayingCondition final : public Condition<Ts...>, public Parented<Rtttl> {
public:
bool check(const Ts &...x) override { return this->parent_->is_playing(); }
};
} // namespace esphome::rtttl
+12
View File
@@ -267,6 +267,18 @@ template<typename... Ts> class ApplyAction final : public Action<Ts...> {
ApplyFn apply_;
};
/// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in.
template<typename... Ts> class ApplyCondition final : public Condition<Ts...> {
public:
using CheckFn = bool (*)(const std::remove_cvref_t<Ts> &...);
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).
+9 -3
View File
@@ -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<std::string> 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
+101 -6
View File
@@ -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) == "<int32_t>"
text = str(check)
assert text.startswith("[](const std::remove_cvref_t<int32_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