From 9baff7652074031d27ba4c547bfee6250e36151a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 23:29:14 -0500 Subject: [PATCH] [api] Treat homeassistant.event variables as lambdas (#18759) --- esphome/components/api/__init__.py | 45 +++++++++++-- esphome/config_validation.py | 35 ++++++++++- esphome/core/__init__.py | 3 +- .../api/test_homeassistant_variables.py | 63 +++++++++++++++++++ .../api/test_homeassistant_variables.yaml | 32 ++++++++++ tests/components/api/common-base.yaml | 8 +++ tests/components/homeassistant/common.yaml | 4 +- tests/unit_tests/test_config_validation.py | 46 ++++++++++++++ 8 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/api/test_homeassistant_variables.py create mode 100644 tests/component_tests/api/test_homeassistant_variables.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index a10bfd3418..2e891a9663 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,4 +1,5 @@ import logging +import re from typing import Any from esphome import automation @@ -499,6 +500,40 @@ async def to_code(config: ConfigType) -> None: KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)}) +_ID_CALL_PROG = re.compile(r"\bid\s*\(") + + +# Remove before 2027.3.0: untagged strings that look like lambda source keep +# being compiled as lambdas during the deprecation window +def _coerce_implicit_lambda(value: Any) -> Any: + if not isinstance(value, str): + return value + if cv.looks_like_returning_lambda(value): + _LOGGER.warning( + "[api] The 'variables' value '%s' looks like a lambda but is " + "missing the !lambda tag. It is compiled as a lambda for now but " + "will be sent as literal text from 2027.3.0. Add !lambda to keep " + "it evaluated; literal text belongs under 'data:'.", + value, + ) + # cv.templatable runs returning_lambda on the coerced Lambda + return cv.lambda_(value) + if _ID_CALL_PROG.search(value): + # lambda source without a return: issue 5394's mistake class + _LOGGER.warning( + "[api] The 'variables' value '%s' is sent as literal text; wrap " + "it in !lambda 'return ...;' to evaluate it instead.", + value, + ) + return value + + +# Static strings or !lambda values. cv.templatable stays introspectable for +# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA. +VARIABLES_SCHEMA = cv.Schema( + {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))} +) + def _validate_response_config(config: ConfigType) -> ConfigType: # Validate dependencies: @@ -535,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ), cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): cv.Schema( - {cv.string: cv.returning_lambda} - ), + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), @@ -598,6 +631,8 @@ async def homeassistant_service_to_code( cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) + if isinstance(templ, str): + templ = cg.FlashStringLiteral(templ) cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) if on_error := config.get(CONF_ON_ERROR): @@ -652,7 +687,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( cv.Required(CONF_EVENT): validate_homeassistant_event, cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, } ) @@ -698,6 +733,8 @@ async def homeassistant_event_to_code( cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) + if isinstance(templ, str): + templ = cg.FlashStringLiteral(templ) cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) return var diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 09962e8c95..904cbd1919 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1882,13 +1882,46 @@ def lambda_(value): return value +# 'return' at a statement boundary; only consulted when the source has no +# semicolon, so ';' is not a boundary. Migration use only, see +# looks_like_returning_lambda. +LAMBDA_RETURN_STATEMENT_PROG = re.compile(r"(?:^|[:{})\n])\s*return\b") +LAMBDA_RETURN_KEYWORD_PROG = re.compile(r"\breturn\b") +# RESERVED_IDS subset that can begin a return expression; 'this'/'true' would +# promote prose and infix 'and'/'or' cannot start an expression. +_CPP_LEADING_WORD_OPERATORS = "not|new|sizeof|delete" +# Two or more plain words: prose, not C++. A single word is indistinguishable +# from 'return x'. Migration use only, see looks_like_returning_lambda. +LAMBDA_PROSE_TAIL_PROG = re.compile( + rf"(?!(?:{_CPP_LEADING_WORD_OPERATORS})\b)[A-Za-z']+(?:,?\s+[A-Za-z']+)+[.!?]?" +) + + +def looks_like_returning_lambda(value: str) -> bool: + """Check whether a string looks like C++ lambda source: a semicolon means + code, so any return keyword counts; without one, a boundary return whose + tail does not read as prose is a return statement missing its semicolon. + + For migrating deprecated implicit lambdas only; new validators must + require an explicit !lambda tag instead of guessing. + """ + src = Lambda.comment_remover(value) + if ";" in src: + return LAMBDA_RETURN_KEYWORD_PROG.search(src) is not None + for match in LAMBDA_RETURN_STATEMENT_PROG.finditer(src): + tail = src[match.end() :].split("\n", 1)[0].strip() + if not LAMBDA_PROSE_TAIL_PROG.fullmatch(tail): + return True + return False + + def returning_lambda(value): """Coerce this configuration option to a lambda. Additionally, make sure the lambda returns something. """ value = lambda_(value) - if "return" not in value.value: + if LAMBDA_RETURN_KEYWORD_PROG.search(Lambda.comment_remover(value.value)) is None: raise Invalid( "Lambda doesn't contain a 'return' statement, but the lambda " "is expected to return a value. \n" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 2ec2a08e83..77efc91bef 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -339,7 +339,8 @@ class Lambda: self._requires_ids = None # https://stackoverflow.com/a/241506/229052 - def comment_remover(self, text): + @staticmethod + def comment_remover(text): def replacer(match): s = match.group(0) if s.startswith("/"): diff --git a/tests/component_tests/api/test_homeassistant_variables.py b/tests/component_tests/api/test_homeassistant_variables.py new file mode 100644 index 0000000000..48e53d8f4c --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_variables.py @@ -0,0 +1,63 @@ +"""Tests for variables handling in homeassistant.event and homeassistant.action.""" + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml" + + +def test_plain_string_with_return_is_compiled_as_lambda_with_warning( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A plain string with a return statement compiles as a lambda and warns.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(CONFIG) + + assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2 + assert "return millis();" in main_cpp + # The source text must not be sent as a static string value. + assert '"return millis();"' not in main_cpp + assert "missing the !lambda tag" in caplog.text + + +def test_static_string_is_kept_as_static_value( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A static string stays static, PROGMEM wrapped, with no warning.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(CONFIG) + + assert ( + main_cpp.count( + 'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));' + ) + == 2 + ) + assert "static value" not in caplog.text + + +def test_static_id_value_stays_literal_with_hint( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Lambda source without a return stays literal text but warns.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(CONFIG) + + assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp + assert "sent as literal text" in caplog.text + + +def test_explicit_lambda_tag_is_compiled_as_lambda( + generate_main: Callable[[str | Path], str], +) -> None: + """A !lambda value keeps working unchanged.""" + main_cpp = generate_main(CONFIG) + + assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp + assert "return App.get_name();" in main_cpp diff --git a/tests/component_tests/api/test_homeassistant_variables.yaml b/tests/component_tests/api/test_homeassistant_variables.yaml new file mode 100644 index 0000000000..e1ec07cc74 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_variables.yaml @@ -0,0 +1,32 @@ +esphome: + name: test + on_boot: + then: + # Plain strings with a return statement compile as lambdas + - homeassistant.event: + event: esphome.test_event + data_template: + message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}" + variables: + lambda_var: |- + return millis(); + static_var: static value + tagged_var: !lambda return App.get_name(); + hint_var: id(test_sensor).state + - homeassistant.action: + action: notify.notify + data_template: + message: "{{ lambda_var }} {{ static_var }}" + variables: + lambda_var: |- + return millis(); + static_var: static value + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +api: diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index d7470ee4b3..c9eb200471 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -9,6 +9,14 @@ esphome: event: esphome.button_pressed data: message: Button was pressed + - homeassistant.event: + event: esphome.button_pressed_with_variables + data_template: + message: Button {{ button_name }} ({{ button_index }}) was pressed from {{ button_source }} + variables: + button_name: !lambda 'return std::string("test_button");' + button_index: !lambda 'return 1;' + button_source: static_value - homeassistant.action: action: notify.html5 data: diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 71a7ac65c2..1099f7ea85 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -12,7 +12,7 @@ esphome: data_template: message: The humidity is {{ my_variable }}%. variables: - my_variable: "return id(ha_hello_world_temperature).state;" + my_variable: !lambda "return id(ha_hello_world_temperature).state;" - homeassistant.action: action: notify.html5 data: @@ -24,7 +24,7 @@ esphome: data_template: message: The humidity is {{ my_variable }}%. variables: - my_variable: "return id(ha_hello_world_temperature).state;" + my_variable: !lambda "return id(ha_hello_world_temperature).state;" wifi: ssid: MySSID diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 0f927a6513..457b9d017b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2565,6 +2565,52 @@ def test_returning_lambda_no_return() -> None: cv.returning_lambda(Lambda("int x = 5;")) +def test_returning_lambda_return_only_in_comment() -> None: + with pytest.raises(Invalid, match="return statement"): + cv.returning_lambda(Lambda("// return 5;\nint x = 5;")) + + +def test_returning_lambda_missing_semicolon_is_accepted() -> None: + """A forgotten semicolon is left for the C++ compiler to report.""" + assert isinstance(cv.returning_lambda(Lambda("return x")), Lambda) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("return 5;", True), + ("if (x) { return x; } return 0;", True), + ("if (x) return 1; else return 0;", True), + ("switch (x) { case 0: return 1; }", True), + # a semicolon means code: any return keyword counts + ("return not x;", True), + ("return a and b;", True), + ("please return the sensor; then wait", True), + # a forgotten semicolon is still lambda source; the compiler reports it + ("return id(x).state", True), + ("return x", True), + ("return 5", True), + ("return not x", True), + # accepted: a one-word tail is indistinguishable from 'return x' + ("return soon", True), + ("Alert: return home", True), + ("static value", False), + ("no returns here", False), + ("the_return_value", False), + # without a semicolon, prose is not lambda source + ("please return the item", False), + ("return to sender", False), + ("return a and b", False), + # return only inside a comment is not a return statement + ("// return 5;\nint x = 5;", False), + ("/* return 5; */ int x = 5;", False), + ("return 5; // done", True), + ], +) +def test_looks_like_returning_lambda(value: str, expected: bool) -> None: + assert cv.looks_like_returning_lambda(value) is expected + + # --------------------------------------------------------------------------- # dimensions # ---------------------------------------------------------------------------