diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index f9b6b3b4975..72a97d6eeab 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -1,54 +1,112 @@ """The template component.""" -import logging -from typing import Optional +from __future__ import annotations +import asyncio +import logging +from typing import Callable + +from homeassistant import config as conf_util from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CoreState, callback +from homeassistant.const import EVENT_HOMEASSISTANT_START, SERVICE_RELOAD +from homeassistant.core import CoreState, Event, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( discovery, trigger as trigger_helper, update_coordinator, ) -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.loader import async_get_integration from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass, config): """Set up the template integration.""" if DOMAIN in config: - for conf in config[DOMAIN]: - coordinator = TriggerUpdateCoordinator(hass, conf) - await coordinator.async_setup(config) + await _process_config(hass, config) - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + async def _reload_config(call: Event) -> None: + """Reload top-level + platforms.""" + try: + unprocessed_conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + conf = await conf_util.async_process_component_config( + hass, unprocessed_conf, await async_get_integration(hass, DOMAIN) + ) + + if conf is None: + return + + await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) + + if DOMAIN in conf: + await _process_config(hass, conf) + + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_RELOAD, _reload_config + ) return True +async def _process_config(hass, config): + """Process config.""" + coordinators: list[TriggerUpdateCoordinator] | None = hass.data.get(DOMAIN) + + # Remove old ones + if coordinators: + for coordinator in coordinators: + coordinator.async_remove() + + async def init_coordinator(hass, conf): + coordinator = TriggerUpdateCoordinator(hass, conf) + await coordinator.async_setup(conf) + return coordinator + + hass.data[DOMAIN] = await asyncio.gather( + *[init_coordinator(hass, conf) for conf in config[DOMAIN]] + ) + + class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): """Class to handle incoming data.""" + REMOVE_TRIGGER = object() + def __init__(self, hass, config): """Instantiate trigger data.""" - super().__init__( - hass, logging.getLogger(__name__), name="Trigger Update Coordinator" - ) + super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") self.config = config - self._unsub_trigger = None + self._unsub_start: Callable[[], None] | None = None + self._unsub_trigger: Callable[[], None] | None = None @property - def unique_id(self) -> Optional[str]: + def unique_id(self) -> str | None: """Return unique ID for the entity.""" return self.config.get("unique_id") + @callback + def async_remove(self): + """Signal that the entities need to remove themselves.""" + if self._unsub_start: + self._unsub_start() + if self._unsub_trigger: + self._unsub_trigger() + async def async_setup(self, hass_config): """Set up the trigger and create entities.""" if self.hass.state == CoreState.running: await self._attach_triggers() else: - self.hass.bus.async_listen_once( + self._unsub_start = self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, self._attach_triggers ) @@ -65,6 +123,9 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): async def _attach_triggers(self, start_event=None) -> None: """Attach the triggers.""" + if start_event is not None: + self._unsub_start = None + self._unsub_trigger = await trigger_helper.async_initialize_triggers( self.hass, self.config[CONF_TRIGGER], diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index edef5673f31..5d1a66836f3 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -36,7 +36,7 @@ from .const import ( ) from .sensor import SENSOR_SCHEMA as PLATFORM_SENSOR_SCHEMA -CONVERSION_PLATFORM = { +LEGACY_SENSOR = { CONF_ICON_TEMPLATE: CONF_ICON, CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, @@ -61,7 +61,7 @@ SENSOR_SCHEMA = vol.Schema( } ) -TRIGGER_ENTITY_SCHEMA = vol.Schema( +CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, @@ -71,16 +71,43 @@ TRIGGER_ENTITY_SCHEMA = vol.Schema( ) +def _rewrite_legacy_to_modern_trigger_conf(cfg: dict): + """Rewrite a legacy to a modern trigger-basd conf.""" + logging.getLogger(__name__).warning( + "The entity definition format under template: differs from the platform configuration format. See https://www.home-assistant.io/integrations/template#configuration-for-trigger-based-template-sensors" + ) + sensor = list(cfg[SENSOR_DOMAIN]) if SENSOR_DOMAIN in cfg else [] + + for device_id, entity_cfg in cfg[CONF_SENSORS].items(): + entity_cfg = {**entity_cfg} + + for from_key, to_key in LEGACY_SENSOR.items(): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = template.Template(val) + entity_cfg[to_key] = val + + if CONF_NAME not in entity_cfg: + entity_cfg[CONF_NAME] = template.Template(device_id) + + sensor.append(entity_cfg) + + return {**cfg, "sensor": sensor} + + async def async_validate_config(hass, config): """Validate config.""" if DOMAIN not in config: return config - trigger_entity_configs = [] + config_sections = [] for cfg in cv.ensure_list(config[DOMAIN]): try: - cfg = TRIGGER_ENTITY_SCHEMA(cfg) + cfg = CONFIG_SECTION_SCHEMA(cfg) cfg[CONF_TRIGGER] = await async_validate_trigger_config( hass, cfg[CONF_TRIGGER] ) @@ -88,39 +115,14 @@ async def async_validate_config(hass, config): async_log_exception(err, DOMAIN, cfg, hass) continue - if CONF_SENSORS not in cfg: - trigger_entity_configs.append(cfg) - continue + if CONF_TRIGGER in cfg and CONF_SENSORS in cfg: + cfg = _rewrite_legacy_to_modern_trigger_conf(cfg) - logging.getLogger(__name__).warning( - "The entity definition format under template: differs from the platform configuration format. See https://www.home-assistant.io/integrations/template#configuration-for-trigger-based-template-sensors" - ) - sensor = list(cfg[SENSOR_DOMAIN]) if SENSOR_DOMAIN in cfg else [] - - for device_id, entity_cfg in cfg[CONF_SENSORS].items(): - entity_cfg = {**entity_cfg} - - for from_key, to_key in CONVERSION_PLATFORM.items(): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = template.Template(val) - entity_cfg[to_key] = val - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(device_id) - - sensor.append(entity_cfg) - - cfg = {**cfg, "sensor": sensor} - - trigger_entity_configs.append(cfg) + config_sections.append(cfg) # Create a copy of the configuration with all config for current # component removed and add validated config back in. config = config_without_domain(config, DOMAIN) - config[DOMAIN] = trigger_entity_configs + config[DOMAIN] = config_sections return config diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 107c54c710e..0f8dff4026f 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -27,7 +27,14 @@ async def test_reloadable(hass): "value_template": "{{ states.sensor.test_sensor.state }}" }, }, - } + }, + "template": { + "trigger": {"platform": "event", "event_type": "event_1"}, + "sensor": { + "name": "top level", + "state": "{{ trigger.event.data.source }}", + }, + }, }, ) await hass.async_block_till_done() @@ -35,8 +42,12 @@ async def test_reloadable(hass): await hass.async_start() await hass.async_block_till_done() + hass.bus.async_fire("event_1", {"source": "init"}) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 3 assert hass.states.get("sensor.state").state == "mytest" - assert len(hass.states.async_all()) == 2 + assert hass.states.get("sensor.top_level").state == "init" yaml_path = path.join( _get_fixtures_base_path(), @@ -52,11 +63,16 @@ async def test_reloadable(hass): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 + + hass.bus.async_fire("event_2", {"source": "reload"}) + await hass.async_block_till_done() assert hass.states.get("sensor.state") is None + assert hass.states.get("sensor.top_level") is None assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 + assert hass.states.get("sensor.top_level_2").state == "reload" async def test_reloadable_can_remove(hass): @@ -74,7 +90,14 @@ async def test_reloadable_can_remove(hass): "value_template": "{{ states.sensor.test_sensor.state }}" }, }, - } + }, + "template": { + "trigger": {"platform": "event", "event_type": "event_1"}, + "sensor": { + "name": "top level", + "state": "{{ trigger.event.data.source }}", + }, + }, }, ) await hass.async_block_till_done() @@ -82,8 +105,12 @@ async def test_reloadable_can_remove(hass): await hass.async_start() await hass.async_block_till_done() + hass.bus.async_fire("event_1", {"source": "init"}) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 3 assert hass.states.get("sensor.state").state == "mytest" - assert len(hass.states.async_all()) == 2 + assert hass.states.get("sensor.top_level").state == "init" yaml_path = path.join( _get_fixtures_base_path(), @@ -251,11 +278,12 @@ async def test_reloadable_multiple_platforms(hass): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 + assert hass.states.get("sensor.top_level_2") is not None async def test_reload_sensors_that_reference_other_template_sensors(hass): diff --git a/tests/fixtures/template/sensor_configuration.yaml b/tests/fixtures/template/sensor_configuration.yaml index 48ef4cf4304..8fb2ae9564f 100644 --- a/tests/fixtures/template/sensor_configuration.yaml +++ b/tests/fixtures/template/sensor_configuration.yaml @@ -21,3 +21,10 @@ sensor: == "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity") == "Watch Apple TV" %}on{% else %}off{% endif %}' +template: + trigger: + platform: event + event_type: event_2 + sensor: + name: top level 2 + state: "{{ trigger.event.data.source }}"