From aec6868af174c142fe95d95a3316a5abf6d9c5b3 Mon Sep 17 00:00:00 2001
From: Petro31 <35082313+Petro31@users.noreply.github.com>
Date: Thu, 6 Mar 2025 02:00:11 -0500
Subject: [PATCH] Add abstract class to trigger based template entities
 (#139650)

* add abstract class to trigger based template entities

* updates after merge of parent PR

* add comments

* add tests
---
 homeassistant/components/template/config.py   |  8 ++-
 .../components/template/coordinator.py        | 27 +++++++-
 homeassistant/components/template/helpers.py  |  7 ++-
 homeassistant/components/template/number.py   | 18 +++---
 homeassistant/components/template/select.py   | 23 ++++---
 .../components/template/trigger_entity.py     | 16 ++++-
 tests/components/template/test_blueprint.py   | 62 ++++++++++++++++++-
 tests/components/template/test_number.py      |  8 ++-
 tests/components/template/test_select.py      | 26 +++++++-
 .../template/test_trigger_entity.py           | 13 ++++
 .../template/test_event_sensor.yaml           | 27 ++++++++
 11 files changed, 202 insertions(+), 33 deletions(-)
 create mode 100644 tests/components/template/test_trigger_entity.py
 create mode 100644 tests/testing_config/blueprints/template/test_event_sensor.yaml

diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py
index e0c5514def9..9c92ed2b334 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 4d8fe78f2b5..c11e9b6101b 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 b320f2128cd..d74a4a4ed00 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 e3654661158..3ecf1db565a 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 1e7cb781eb0..eb60a3dbfe4 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 5130f332d5b..87c93b6143b 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 dd008a27822..66630ecf739 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 ec96245b4d0..f73a943e752 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 5b4723a3034..59ab45aeb36 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 00000000000..99aa2d65df9
--- /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 00000000000..8b615eb90ba
--- /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 }}"
-- 
GitLab