diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e0c5514def9d8a1e3d212cb9a4f05dff5ca701ea..9c92ed2b334cddc2518a1f36c0c40ea3844014c7 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -122,9 +122,15 @@ async def _async_resolve_blueprints( raise vol.Invalid("more than one platform defined per blueprint") if len(platforms) == 1: platform = platforms.pop() - for prop in (CONF_NAME, CONF_UNIQUE_ID, CONF_VARIABLES): + 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. + if CONF_TRIGGER not in config and CONF_VARIABLES in config: + config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES) raw_config = dict(config) template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config)) diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 4d8fe78f2b51ac748eb12dda4a1ca5c87460bbbb..c11e9b6101bc5c5e11aaa4b48aab0e5f889cdd62 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -2,12 +2,14 @@ from collections.abc import Callable, Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.blueprint import CONF_USE_BLUEPRINT +from homeassistant.const import CONF_PATH, CONF_VARIABLES, EVENT_HOMEASSISTANT_START from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.trace import trace_get from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,7 +24,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): REMOVE_TRIGGER = object() - def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Instantiate trigger data.""" super().__init__( hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator" @@ -32,6 +34,18 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None self._script: Script | None = None + self._run_variables: ScriptVariables | None = None + self._blueprint_inputs: dict | None = None + if config is not None: + self._run_variables = config.get(CONF_VARIABLES) + self._blueprint_inputs = getattr(config, "raw_blueprint_inputs", None) + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + if self._blueprint_inputs is None: + return None + return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) @property def unique_id(self) -> str | None: @@ -104,6 +118,10 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered_with_script( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + # Render run variables after the trigger, before checking conditions. + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return # Create a context referring to the trigger context. @@ -119,6 +137,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return self._execute_update(run_variables, context) diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index b320f2128cdf643446b38f51b3edea33f69074cf..d74a4a4ed00c64e3fd789c893d3998775e337d3d 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.singleton import singleton from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA -from .template_entity import TemplateEntity +from .entity import AbstractTemplateEntity DATA_BLUEPRINTS = "template_blueprints" @@ -23,7 +23,7 @@ def templates_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[s entity_id for platform in async_get_platforms(hass, DOMAIN) for entity_id, template_entity in platform.entities.items() - if isinstance(template_entity, TemplateEntity) + if isinstance(template_entity, AbstractTemplateEntity) and template_entity.referenced_blueprint == blueprint_path ] @@ -33,7 +33,8 @@ def blueprint_in_template(hass: HomeAssistant, entity_id: str) -> str | None: """Return the blueprint the template entity is based on or None.""" for platform in async_get_platforms(hass, DOMAIN): if isinstance( - (template_entity := platform.entities.get(entity_id)), TemplateEntity + (template_entity := platform.entities.get(entity_id)), + AbstractTemplateEntity, ): return template_entity.referenced_blueprint return None diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index e3654661158af55a703a93299d718cdda3ef4ed5..3ecf1db565a43bc9605dd6677009099047776f14 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -236,12 +235,8 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_set_value = Script( - hass, - config[CONF_SET_VALUE], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -276,6 +271,9 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): if self._config[CONF_OPTIMISTIC]: self._attr_native_value = value self.async_write_ha_state() - await self._command_set_value.async_run( - {ATTR_VALUE: value}, context=self._context - ) + if set_value := self._action_scripts.get(CONF_SET_VALUE): + await self.async_run_script( + set_value, + run_variables={ATTR_VALUE: value}, + context=self._context, + ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 1e7cb781eb012c84df9eecb50661ffc95aaef19b..eb60a3dbfe42a4da97699bc3183b97d88826beb8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -28,7 +28,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -198,12 +197,13 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_select_option = Script( - hass, - config[CONF_SELECT_OPTION], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + if select_option := config.get(CONF_SELECT_OPTION): + self.add_script( + CONF_SELECT_OPTION, + select_option, + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, + ) @property def current_option(self) -> str | None: @@ -220,6 +220,9 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): if self._config[CONF_OPTIMISTIC]: self._attr_current_option = option self.async_write_ha_state() - await self._command_select_option.async_run( - {ATTR_OPTION: option}, context=self._context - ) + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): + await self.async_run_script( + select_option, + run_variables={ATTR_OPTION: option}, + context=self._context, + ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 5130f332d5bb540b126e0e1a9d1bc97331ff2814..87c93b6143b81dbd7e22ec3dda27701ab2e83fcb 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -8,10 +8,13 @@ from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator +from .entity import AbstractTemplateEntity class TriggerEntity( # pylint: disable=hass-enforce-class-module - TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator] + TriggerBaseEntity, + CoordinatorEntity[TriggerUpdateCoordinator], + AbstractTemplateEntity, ): """Template entity based on trigger data.""" @@ -24,6 +27,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) + AbstractTemplateEntity.__init__(self, hass) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -38,6 +42,16 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module else: self._unique_id = unique_id + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + return self.coordinator.referenced_blueprint + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + return self.coordinator.data["run_variables"] + @callback def _process_data(self) -> None: """Process new data.""" diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index dd008a278220643bea999333c072889abc68db99..66630ecf7397257ff8f04995ff45878edd2c65ce 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,10 +16,10 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import async_mock_service @@ -212,6 +212,61 @@ async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> No assert not_inverted.state == "on" +async def test_trigger_event_sensor( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test event sensor blueprint.""" + blueprint = "test_event_sensor.yaml" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + }, + ] + }, + ) + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) @@ -262,7 +317,8 @@ async def test_invalid_blueprint( ) assert "more than one platform defined per blueprint" in caplog.text - assert await template.async_get_blueprints(hass).async_get_blueprints() == {} + blueprints = await template.async_get_blueprints(hass).async_get_blueprints() + assert "invalid.yaml" not in blueprints async def test_no_blueprint(hass: HomeAssistant) -> None: diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index ec96245b4d0e137921f80c7c8be8e3e0c22b2c0a..f73a943e7527a21423aafedbe80f0192c681da16 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -330,7 +330,10 @@ async def test_trigger_number(hass: HomeAssistant) -> None: "max": "{{ trigger.event.data.max_beers }}", "step": "{{ trigger.event.data.step }}", "unit_of_measurement": "beer", - "set_value": {"event": "test_number_event"}, + "set_value": { + "event": "test_number_event", + "event_data": {"entity_id": "{{ this.entity_id }}"}, + }, "optimistic": True, }, ], @@ -379,6 +382,9 @@ async def test_trigger_number(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "number.hello_name" def _verify( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5b4723a30346ad3978dc7fec537b781978f8c022..59ab45aeb36d14008ae404c99fcc6ba7a90ef277 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -264,6 +264,7 @@ async def test_templates_with_entities( async def test_trigger_select(hass: HomeAssistant) -> None: """Test trigger based template select.""" events = async_capture_events(hass, "test_number_event") + action_events = async_capture_events(hass, "action_event") assert await setup.async_setup_component( hass, "template", @@ -274,13 +275,23 @@ async def test_trigger_select(hass: HomeAssistant) -> None: { "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"beer": "{{ trigger.event.data.beer }}"}, + "action": [ + {"event": "action_event", "event_data": {"beer": "{{ beer }}"}} + ], "select": [ { "name": "Hello Name", "unique_id": "hello_name-id", "state": "{{ trigger.event.data.beer }}", "options": "{{ trigger.event.data.beers }}", - "select_option": {"event": "test_number_event"}, + "select_option": { + "event": "test_number_event", + "event_data": { + "entity_id": "{{ this.entity_id }}", + "beer": "{{ beer }}", + }, + }, "optimistic": True, }, ], @@ -308,6 +319,12 @@ async def test_trigger_select(hass: HomeAssistant) -> None: assert state.state == "duff" assert state.attributes["options"] == ["duff", "alamo"] + assert len(action_events) == 1 + assert action_events[0].event_type == "action_event" + beer = action_events[0].data.get("beer") + assert beer is not None + assert beer == "duff" + await hass.services.async_call( SELECT_DOMAIN, SELECT_SERVICE_SELECT_OPTION, @@ -316,6 +333,13 @@ async def test_trigger_select(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "select.hello_name" + + beer = events[0].data.get("beer") + assert beer is not None + assert beer == "duff" def _verify( diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py new file mode 100644 index 0000000000000000000000000000000000000000..99aa2d65df95a7ea1adf97899305c11cd4dbc7d5 --- /dev/null +++ b/tests/components/template/test_trigger_entity.py @@ -0,0 +1,13 @@ +"""Test trigger template entity.""" + +from homeassistant.components.template import trigger_entity +from homeassistant.components.template.coordinator import TriggerUpdateCoordinator +from homeassistant.core import HomeAssistant + + +async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: + """Test template entity requires hass to be set before accepting templates.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = trigger_entity.TriggerEntity(hass, coordinator, {}) + + assert entity.referenced_blueprint is None diff --git a/tests/testing_config/blueprints/template/test_event_sensor.yaml b/tests/testing_config/blueprints/template/test_event_sensor.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8b615eb90ba904f02e1d88eed2941f8cb28dbd2e --- /dev/null +++ b/tests/testing_config/blueprints/template/test_event_sensor.yaml @@ -0,0 +1,27 @@ +blueprint: + name: Create Sensor from Event + description: Creates a timestamp sensor from an event + domain: template + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml + input: + event_type: + name: Name of the event_type + description: The event_type for the event trigger + selector: + text: + event_data: + name: The data for the event + description: The event_data for the event trigger + selector: + object: +trigger: + - trigger: event + event_type: !input event_type + event_data: !input event_data +variables: + event_data: "{{ trigger.event.data }}" +sensor: + state: "{{ now() }}" + device_class: timestamp + attributes: + data: "{{ event_data }}"