Fix section and entity variable resolution for template platforms (#149660)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Petro31
2025-09-23 02:12:24 -04:00
committed by GitHub
parent d389141aee
commit 3dd941eff7
5 changed files with 307 additions and 23 deletions

View File

@@ -176,7 +176,15 @@ TEMPLATE_BLUEPRINT_SCHEMA = vol.All(
)
async def _async_resolve_blueprints(
def _merge_section_variables(config: ConfigType, section_variables: ConfigType) -> None:
"""Merges a template entity configuration's variables with the section variables."""
if (variables := config.pop(CONF_VARIABLES, None)) and isinstance(variables, dict):
config[CONF_VARIABLES] = {**section_variables, **variables}
else:
config[CONF_VARIABLES] = section_variables
async def _async_resolve_template_config(
hass: HomeAssistant,
config: ConfigType,
) -> TemplateConfig:
@@ -187,12 +195,11 @@ async def _async_resolve_blueprints(
with suppress(ValueError): # Invalid config
raw_config = dict(config)
config = _backward_compat_schema(config)
if is_blueprint_instance_config(config):
blueprints = async_get_blueprints(hass)
blueprint_inputs = await blueprints.async_inputs_from_config(
_backward_compat_schema(config)
)
blueprint_inputs = await blueprints.async_inputs_from_config(config)
raw_blueprint_inputs = blueprint_inputs.config_with_inputs
config = blueprint_inputs.async_substitute()
@@ -205,14 +212,32 @@ async def _async_resolve_blueprints(
for prop in (CONF_NAME, CONF_UNIQUE_ID):
if prop in config:
config[platform][prop] = config.pop(prop)
# For regular template entities, CONF_VARIABLES should be removed because they just
# house input results for template entities. For Trigger based template entities
# CONF_VARIABLES should not be removed because the variables are always
# executed between the trigger and action.
# State based template entities remove CONF_VARIABLES because they pass
# blueprint inputs to the template entities. Trigger based template entities
# retain CONF_VARIABLES because the variables are always executed between
# the trigger and action.
if CONF_TRIGGERS not in config and CONF_VARIABLES in config:
config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES)
_merge_section_variables(config[platform], config.pop(CONF_VARIABLES))
raw_config = dict(config)
# Trigger based template entities retain CONF_VARIABLES because the variables are
# always executed between the trigger and action.
elif CONF_TRIGGERS not in config and CONF_VARIABLES in config:
# State based template entities have 2 layers of variables. Variables at the section level
# and variables at the entity level should be merged together at the entity level.
section_variables = config.pop(CONF_VARIABLES)
platform_config: list[ConfigType] | ConfigType
platforms = [platform for platform in PLATFORMS if platform in config]
for platform in platforms:
platform_config = config[platform]
if platform in PLATFORMS:
if isinstance(platform_config, dict):
platform_config = [platform_config]
for entity_config in platform_config:
_merge_section_variables(entity_config, section_variables)
template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config))
template_config.raw_blueprint_inputs = raw_blueprint_inputs
template_config.raw_config = raw_config
@@ -225,7 +250,7 @@ async def async_validate_config_section(
) -> TemplateConfig:
"""Validate an entire config section for the template integration."""
validated_config = await _async_resolve_blueprints(hass, config)
validated_config = await _async_resolve_template_config(hass, config)
if CONF_TRIGGERS in validated_config:
validated_config[CONF_TRIGGERS] = await async_validate_trigger_config(

View File

@@ -4,8 +4,9 @@ from __future__ import annotations
from typing import Any
from homeassistant.const import CONF_STATE
from homeassistant.const import CONF_STATE, CONF_VARIABLES
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.template import _SENTINEL
from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -32,6 +33,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
TriggerBaseEntity.__init__(self, hass, config)
AbstractTemplateEntity.__init__(self, hass, config)
self._entity_variables: ScriptVariables | None = config.get(CONF_VARIABLES)
self._rendered_entity_variables: dict | None = None
self._state_render_error = False
async def async_added_to_hass(self) -> None:
@@ -63,9 +66,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
@callback
def _render_script_variables(self) -> dict:
"""Render configured variables."""
if self.coordinator.data is None:
return {}
return self.coordinator.data["run_variables"] or {}
return self._rendered_entity_variables or {}
def _render_templates(self, variables: dict[str, Any]) -> None:
"""Render templates."""
@@ -92,7 +93,18 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
def _process_data(self) -> None:
"""Process new data."""
variables = self._template_variables(self.coordinator.data["run_variables"])
coordinator_variables = self.coordinator.data["run_variables"]
if self._entity_variables:
entity_variables = self._entity_variables.async_simple_render(
coordinator_variables
)
self._rendered_entity_variables = {
**coordinator_variables,
**entity_variables,
}
else:
self._rendered_entity_variables = coordinator_variables
variables = self._template_variables(self._rendered_entity_variables)
if self._render_availability_template(variables):
self._render_templates(variables)

View File

@@ -5,8 +5,12 @@ from __future__ import annotations
import pytest
import voluptuous as vol
from homeassistant.components.template.config import CONFIG_SECTION_SCHEMA
from homeassistant.components.template.config import (
CONFIG_SECTION_SCHEMA,
async_validate_config_section,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.template import Template
@@ -93,3 +97,162 @@ async def test_invalid_default_entity_id(
}
with pytest.raises(vol.Invalid):
CONFIG_SECTION_SCHEMA(config)
@pytest.mark.parametrize(
("config", "expected"),
[
(
{
"variables": {"a": 1},
"button": {
"press": {
"service": "test.automation",
"data_template": {"caller": "{{ this.entity_id }}"},
},
"variables": {"b": 2},
"device_class": "restart",
"unique_id": "test",
"name": "test",
"icon": "mdi:test",
},
},
{"a": 1, "b": 2},
),
(
{
"variables": {"a": 1},
"button": [
{
"press": {
"service": "test.automation",
"data_template": {"caller": "{{ this.entity_id }}"},
},
"variables": {"b": 2},
"device_class": "restart",
"unique_id": "test",
"name": "test",
"icon": "mdi:test",
}
],
},
{"a": 1, "b": 2},
),
(
{
"variables": {"a": 1},
"button": [
{
"press": {
"service": "test.automation",
"data_template": {"caller": "{{ this.entity_id }}"},
},
"variables": {"a": 2, "b": 2},
"device_class": "restart",
"unique_id": "test",
"name": "test",
"icon": "mdi:test",
}
],
},
{"a": 2, "b": 2},
),
(
{
"variables": {"a": 1},
"button": {
"press": {
"service": "test.automation",
"data_template": {"caller": "{{ this.entity_id }}"},
},
"device_class": "restart",
"unique_id": "test",
"name": "test",
"icon": "mdi:test",
},
},
{"a": 1},
),
(
{
"button": {
"press": {
"service": "test.automation",
"data_template": {"caller": "{{ this.entity_id }}"},
},
"variables": {"b": 2},
"device_class": "restart",
"unique_id": "test",
"name": "test",
"icon": "mdi:test",
},
},
{"b": 2},
),
],
)
async def test_combined_state_variables(
hass: HomeAssistant, config: dict, expected: dict
) -> None:
"""Tests combining variables for state based template entities."""
validated = await async_validate_config_section(hass, config)
assert "variables" not in validated
variables: ScriptVariables = validated["button"][0]["variables"]
assert variables.as_dict() == expected
@pytest.mark.parametrize(
("config", "expected_root", "expected_entity"),
[
(
{
"trigger": {"trigger": "event", "event_type": "my_event"},
"variables": {"a": 1},
"binary_sensor": {
"name": "test",
"state": "{{ trigger.event.event_type }}",
"variables": {"b": 2},
},
},
{"a": 1},
{"b": 2},
),
(
{
"triggers": {"trigger": "event", "event_type": "my_event"},
"variables": {"a": 1},
"binary_sensor": {
"name": "test",
"state": "{{ trigger.event.event_type }}",
},
},
{"a": 1},
{},
),
(
{
"trigger": {"trigger": "event", "event_type": "my_event"},
"binary_sensor": {
"name": "test",
"state": "{{ trigger.event.event_type }}",
"variables": {"b": 2},
},
},
{},
{"b": 2},
),
],
)
async def test_combined_trigger_variables(
hass: HomeAssistant,
config: dict,
expected_root: dict,
expected_entity: dict,
) -> None:
"""Tests variable are not combined for trigger based template entities."""
empty = ScriptVariables({})
validated = await async_validate_config_section(hass, config)
root_variables: ScriptVariables = validated.get("variables", empty)
assert root_variables.as_dict() == expected_root
variables: ScriptVariables = validated["binary_sensor"][0].get("variables", empty)
assert variables.as_dict() == expected_entity

View File

@@ -2298,6 +2298,65 @@ async def test_trigger_action(hass: HomeAssistant) -> None:
assert events[0].context.parent_id == context.id
@pytest.mark.parametrize(("count", "domain"), [(1, "template")])
@pytest.mark.parametrize(
"config",
[
{
"template": [
{
"unique_id": "listening-test-event",
"trigger": {"platform": "event", "event_type": "test_event"},
"variables": {"a": "{{ trigger.event.data.a }}"},
"action": [
{
"variables": {"b": "{{ a + 1 }}"},
},
{"event": "test_event2", "event_data": {"hello": "world"}},
],
"sensor": [
{
"name": "Hello Name",
"state": "{{ a + b + c }}",
"variables": {"c": "{{ b + 1 }}"},
"attributes": {
"a": "{{ a }}",
"b": "{{ b }}",
"c": "{{ c }}",
},
}
],
},
],
},
],
)
@pytest.mark.usefixtures("start_ha")
async def test_trigger_action_variables(hass: HomeAssistant) -> None:
"""Test trigger entity with variables in an action works."""
event = "test_event2"
context = Context()
events = async_capture_events(hass, event)
state = hass.states.get("sensor.hello_name")
assert state is not None
assert state.state == STATE_UNKNOWN
context = Context()
hass.bus.async_fire("test_event", {"a": 1}, context=context)
await hass.async_block_till_done()
state = hass.states.get("sensor.hello_name")
assert state.state == str(1 + 2 + 3)
assert state.context is context
assert state.attributes["a"] == 1
assert state.attributes["b"] == 2
assert state.attributes["c"] == 3
assert len(events) == 1
assert events[0].context.parent_id == context.id
@pytest.mark.parametrize(("count", "domain"), [(1, template.DOMAIN)])
@pytest.mark.parametrize(
"config",

View File

@@ -7,6 +7,7 @@ from homeassistant.components.template.coordinator import TriggerUpdateCoordinat
from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import template
from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.trigger_template_entity import CONF_PICTURE
_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}'
@@ -123,18 +124,42 @@ async def test_template_state_syntax_error(
async def test_script_variables_from_coordinator(hass: HomeAssistant) -> None:
"""Test script variables."""
coordinator = TriggerUpdateCoordinator(hass, {})
entity = TestEntity(hass, coordinator, {})
assert entity._render_script_variables() == {}
hass.states.async_set("sensor.test", "1")
coordinator.data = {"run_variables": None}
coordinator = TriggerUpdateCoordinator(
hass,
{
"variables": ScriptVariables(
{"a": template.Template("{{ states('sensor.test') }}", hass), "c": 0}
)
},
)
entity = TestEntity(
hass,
coordinator,
{
"state": template.Template("{{ 'on' }}", hass),
"variables": ScriptVariables(
{"b": template.Template("{{ a + 1 }}", hass), "c": 1}
),
},
)
await coordinator._handle_triggered({})
entity._process_data()
assert entity._render_script_variables() == {"a": 1, "b": 2, "c": 1}
assert entity._render_script_variables() == {}
hass.states.async_set("sensor.test", "2")
coordinator._execute_update({"value": STATE_ON})
await coordinator._handle_triggered({"value": STATE_ON})
entity._process_data()
assert entity._render_script_variables() == {"value": STATE_ON}
assert entity._render_script_variables() == {
"value": STATE_ON,
"a": 2,
"b": 3,
"c": 1,
}
async def test_default_entity_id(hass: HomeAssistant) -> None: