Improve template reload (#169480)

This commit is contained in:
Erik Montnemery
2026-05-05 10:29:09 -04:00
committed by Paulus Schoutsen
parent 4f78bbccc0
commit 4d575e69a4
5 changed files with 205 additions and 6 deletions
@@ -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]
@@ -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."""
@@ -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,
+72
View File
@@ -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
@@ -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