diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 51ed3bf0155..bcbc9584588 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -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( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 66c57eb2aab..e75d62352b5 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -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) diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py index 77d4c4bc3c2..88d6a2554f5 100644 --- a/tests/components/template/test_config.py +++ b/tests/components/template/test_config.py @@ -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 diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 9aba8511192..0a940d111c5 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -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", diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 7077cbc6f29..22201ab5ca9 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -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: