mirror of
https://github.com/esphome/esphome.git
synced 2026-08-27 08:28:30 +00:00
[api] Treat homeassistant.event variables as lambdas (#18759)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("/"):
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user