From 4d575e69a4608b73dd75abb54cf1ef732b267011 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 10:16:22 +0200 Subject: [PATCH] Improve template reload (#169480) --- homeassistant/components/template/__init__.py | 2 +- .../components/template/coordinator.py | 10 +- homeassistant/components/template/entity.py | 5 + tests/components/template/test_entity.py | 72 +++++++++++ .../template/test_trigger_entity.py | 122 +++++++++++++++++- 5 files changed, 205 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index c1a136a29ef0..da070a0eaf66 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -206,7 +206,7 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: # Remove old ones if coordinators: for coordinator in coordinators: - coordinator.async_remove() + await coordinator.async_shutdown() async def init_coordinator( hass: HomeAssistant, conf_section: dict[str, Any] diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index a2823233336a..4c90870dac81 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -59,13 +59,17 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): """Return unique ID for the entity.""" return self.config.get("unique_id") - @callback - def async_remove(self) -> None: - """Signal that the entities need to remove themselves.""" + async def async_shutdown(self) -> None: + """Shut down the coordinator and clean up resources.""" + await super().async_shutdown() if self._unsub_start: self._unsub_start() + self._unsub_start = None if self._unsub_trigger: self._unsub_trigger() + self._unsub_trigger = None + if self._script is not None: + await self._script.async_stop() async def async_setup(self, hass_config: ConfigType) -> None: """Set up the trigger and create entities.""" diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index f7b5c3ff989c..9cd86cc25fc8 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -168,6 +168,11 @@ class AbstractTemplateEntity(Entity): domain, ) + async def async_will_remove_from_hass(self) -> None: + """Stop scripts when removing from Home Assistant.""" + for action_script in self._action_scripts.values(): + await action_script.async_stop() + async def async_run_script( self, script: Script, diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 8e98d8c94a7b..5e0a82357428 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -1,9 +1,14 @@ """Test abstract template entity.""" +import asyncio +from unittest.mock import patch + import pytest from homeassistant.components.template import entity as abstract_entity +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: @@ -11,3 +16,70 @@ async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: with pytest.raises(TypeError): _ = abstract_entity.AbstractTemplateEntity(hass, {}) + + +@pytest.mark.parametrize( + "config", + [ + # State-based template light + { + "template": { + "light": { + "name": "test_light", + "state": "{{ true }}", + "turn_on": [ + {"delay": {"seconds": 120}}, + ], + "turn_off": {"event": "turn_off"}, + }, + } + }, + # Trigger-based template light + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "light": { + "name": "test_light", + "state": "{{ true }}", + "turn_on": [ + {"delay": {"seconds": 120}}, + ], + "turn_off": {"event": "turn_off"}, + }, + } + }, + ], + ids=["state_based", "trigger_based"], +) +async def test_reload_stops_entity_action_scripts( + hass: HomeAssistant, config: dict +) -> None: + """Test that reloading stops template entity action scripts.""" + assert await async_setup_component(hass, "template", config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + entity = hass.data["light"].get_entity("light.test_light") + assert entity is not None + + # Call turn_on — script will start and hang on delay + hass.async_create_task( + hass.services.async_call("light", "turn_on", {"entity_id": "light.test_light"}) + ) + await asyncio.sleep(0) + + turn_on_script = entity._action_scripts["turn_on"] + assert turn_on_script.is_running + + # Reload with empty config removes the entity and stops scripts + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={"template": []}, + ): + await hass.services.async_call("template", SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + assert not turn_on_script.is_running + assert hass.data["light"].get_entity("light.test_light") is None diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index b67b4803dfd8..ece23f573e51 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -1,10 +1,21 @@ """Test trigger template entity.""" +import asyncio +from unittest.mock import patch + import pytest -from homeassistant.components.template import DOMAIN, trigger_entity +from homeassistant.components.template import DATA_COORDINATORS, DOMAIN, trigger_entity from homeassistant.components.template.coordinator import TriggerUpdateCoordinator -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON +from homeassistant.const import ( + CONF_ICON, + CONF_NAME, + CONF_STATE, + EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import template from homeassistant.helpers.trigger_template_entity import CONF_PICTURE @@ -230,3 +241,110 @@ async def test_multiple_template_validators(hass: HomeAssistant) -> None: assert state.state == "opening" assert state.attributes["current_position"] == 50 assert state.attributes["current_tilt_position"] == 49 + + +async def test_shutdown_stops_script_and_keeps_triggers_subscribed( + hass: HomeAssistant, +) -> None: + """Test that HA shutdown stops coordinator scripts without unsubscribing triggers.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "action_event"}, + {"delay": {"seconds": 120}}, + ], + "sensor": { + "name": "test", + "state": "{{ trigger.event.data.value }}", + }, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Verify trigger is active + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 1 + + # Fire the trigger to start the action script, then yield without + # waiting for the script to finish + hass.bus.async_fire("test_event", {"value": "hello"}) + await asyncio.sleep(0) + + # Script should be running (stuck on delay) + coordinators = hass.data[DATA_COORDINATORS] + assert len(coordinators) == 1 + assert coordinators[0]._script.is_running + + # Fire shutdown + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + # Script should be stopped - this is handled by the script helper + assert not coordinators[0]._script.is_running + + # Triggers are not unsubscribed on shutdown + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 1 + + +async def test_reload_stops_script_and_unsubscribes_triggers( + hass: HomeAssistant, +) -> None: + """Test that reloading stops coordinator scripts and unsubscribes old triggers.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "action_event"}, + {"delay": {"seconds": 120}}, + ], + "sensor": { + "name": "test", + "state": "{{ trigger.event.data.value }}", + }, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Verify trigger is active + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 1 + + # Fire the trigger to start the action script + hass.bus.async_fire("test_event", {"value": "hello"}) + await asyncio.sleep(0) + + # Script should be running + coordinators = hass.data[DATA_COORDINATORS] + assert len(coordinators) == 1 + coordinator = coordinators[0] + assert coordinator._script.is_running + + # Reload with empty config + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={"template": []}, + ): + await hass.services.async_call("template", SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + # Script should be stopped + assert not coordinator._script.is_running + + # Old trigger should be unsubscribed + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 0