diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 6292cd40fec..3481b5adac6 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -1,11 +1,80 @@ """The template component.""" +import logging +from typing import Optional + +from homeassistant.const import CONF_SENSORS, EVENT_HOMEASSISTANT_START +from homeassistant.core import CoreState, callback +from homeassistant.helpers import ( + discovery, + trigger as trigger_helper, + update_coordinator, +) from homeassistant.helpers.reload import async_setup_reload_service -from .const import DOMAIN, PLATFORMS +from .const import CONF_TRIGGER, DOMAIN, PLATFORMS 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 async_setup_reload_service(hass, DOMAIN, PLATFORMS) return True + + +class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): + """Class to handle incoming data.""" + + def __init__(self, hass, config): + """Instantiate trigger data.""" + super().__init__( + hass, logging.getLogger(__name__), name="Trigger Update Coordinator" + ) + self.config = config + self._unsub_trigger = None + + @property + def unique_id(self) -> Optional[str]: + """Return unique ID for the entity.""" + return self.config.get("unique_id") + + 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( + EVENT_HOMEASSISTANT_START, self._attach_triggers + ) + + self.hass.async_create_task( + discovery.async_load_platform( + self.hass, + "sensor", + DOMAIN, + {"coordinator": self, "entities": self.config[CONF_SENSORS]}, + hass_config, + ) + ) + + async def _attach_triggers(self, start_event=None) -> None: + """Attach the triggers.""" + self._unsub_trigger = await trigger_helper.async_initialize_triggers( + self.hass, + self.config[CONF_TRIGGER], + self._handle_triggered, + DOMAIN, + self.name, + self.logger.log, + start_event is not None, + ) + + @callback + def _handle_triggered(self, run_variables, context=None): + self.async_set_updated_data( + {"run_variables": run_variables, "context": context} + ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py new file mode 100644 index 00000000000..fa0d9a867d1 --- /dev/null +++ b/homeassistant/components/template/config.py @@ -0,0 +1,49 @@ +"""Template config validator.""" + +import voluptuous as vol + +from homeassistant.config import async_log_exception, config_without_domain +from homeassistant.const import CONF_SENSORS, CONF_UNIQUE_ID +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.trigger import async_validate_trigger_config + +from .const import CONF_TRIGGER, DOMAIN +from .sensor import SENSOR_SCHEMA + +CONF_STATE = "state" + + +TRIGGER_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), + } +) + + +async def async_validate_config(hass, config): + """Validate config.""" + if DOMAIN not in config: + return config + + trigger_entity_configs = [] + + for cfg in cv.ensure_list(config[DOMAIN]): + try: + cfg = TRIGGER_ENTITY_SCHEMA(cfg) + cfg[CONF_TRIGGER] = await async_validate_trigger_config( + hass, cfg[CONF_TRIGGER] + ) + except vol.Invalid as err: + async_log_exception(err, DOMAIN, cfg, hass) + + else: + trigger_entity_configs.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 + + return config diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 5d6bf6391df..2f2bc3127d7 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,6 +1,8 @@ """Constants for the Template Platform Components.""" CONF_AVAILABILITY_TEMPLATE = "availability_template" +CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" +CONF_TRIGGER = "trigger" DOMAIN = "template" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 9a63302044a..9714d147e01 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -11,25 +11,24 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS, CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SENSORS, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from .const import CONF_AVAILABILITY_TEMPLATE +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_TRIGGER from .template_entity import TemplateEntity - -CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" +from .trigger_entity import TriggerEntity SENSOR_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -43,8 +42,8 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema( {cv.string: cv.template} ), - vol.Optional(ATTR_FRIENDLY_NAME): cv.string, - vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -52,12 +51,31 @@ SENSOR_SCHEMA = vol.All( ), ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA)} + +def trigger_warning(val): + """Warn if a trigger is defined.""" + if CONF_TRIGGER in val: + raise vol.Invalid( + "You can only add triggers to template entities if they are defined under `template:`. " + "See the template documentation for more information: https://www.home-assistant.io/integrations/template/" + ) + + return val + + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA), + } + ), + trigger_warning, ) -async def _async_create_entities(hass, config): +@callback +def _async_create_template_tracking_entities(hass, config): """Create the template sensors.""" sensors = [] @@ -66,9 +84,9 @@ async def _async_create_entities(hass, config): icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE) - unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) + unit_of_measurement = device_config.get(CONF_UNIT_OF_MEASUREMENT) device_class = device_config.get(CONF_DEVICE_CLASS) attribute_templates = device_config[CONF_ATTRIBUTE_TEMPLATES] unique_id = device_config.get(CONF_UNIQUE_ID) @@ -95,7 +113,13 @@ async def _async_create_entities(hass, config): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the template sensors.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + async_add_entities(_async_create_template_tracking_entities(hass, config)) + else: + async_add_entities( + TriggerSensorEntity(hass, discovery_info["coordinator"], device_id, config) + for device_id, config in discovery_info["entities"].items() + ) class SensorTemplate(TemplateEntity, SensorEntity): @@ -172,3 +196,14 @@ class SensorTemplate(TemplateEntity, SensorEntity): def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return self._unit_of_measurement + + +class TriggerSensorEntity(TriggerEntity, SensorEntity): + """Sensor entity based on trigger data.""" + + extra_template_keys = (CONF_VALUE_TEMPLATE,) + + @property + def state(self) -> str | None: + """Return state of the sensor.""" + return self._rendered.get(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py new file mode 100644 index 00000000000..3874409dc78 --- /dev/null +++ b/homeassistant/components/template/trigger_entity.py @@ -0,0 +1,160 @@ +"""Trigger entity.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_FRIENDLY_NAME_TEMPLATE, + CONF_ICON_TEMPLATE, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import template, update_coordinator +from homeassistant.helpers.entity import async_generate_entity_id + +from . import TriggerUpdateCoordinator +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE + + +class TriggerEntity(update_coordinator.CoordinatorEntity): + """Template entity based on trigger data.""" + + domain = "" + extra_template_keys: tuple | None = None + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + device_id: str, + config: dict, + ): + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_id = async_generate_entity_id( + self.domain + ".{}", device_id, hass=hass + ) + + self._name = config.get(CONF_FRIENDLY_NAME, device_id) + + entity_unique_id = config.get(CONF_UNIQUE_ID) + + if entity_unique_id is None and coordinator.unique_id: + entity_unique_id = device_id + + if entity_unique_id and coordinator.unique_id: + self._unique_id = f"{coordinator.unique_id}-{entity_unique_id}" + else: + self._unique_id = entity_unique_id + + self._config = config + + self._to_render = [ + itm + for itm in ( + CONF_VALUE_TEMPLATE, + CONF_ICON_TEMPLATE, + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME_TEMPLATE, + CONF_AVAILABILITY_TEMPLATE, + ) + if itm in config + ] + + if self.extra_template_keys is not None: + self._to_render.extend(self.extra_template_keys) + + self._rendered = {} + + @property + def name(self): + """Name of the entity.""" + if ( + self._rendered is not None + and (name := self._rendered.get(CONF_FRIENDLY_NAME_TEMPLATE)) is not None + ): + return name + return self._name + + @property + def unique_id(self): + """Return unique ID of the entity.""" + return self._unique_id + + @property + def device_class(self): + """Return device class of the entity.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def unit_of_measurement(self) -> str | None: + """Return unit of measurement.""" + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + + @property + def icon(self) -> str | None: + """Return icon.""" + return self._rendered is not None and self._rendered.get(CONF_ICON_TEMPLATE) + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + return self._rendered is not None and self._rendered.get( + CONF_ENTITY_PICTURE_TEMPLATE + ) + + @property + def available(self): + """Return availability of the entity.""" + return ( + self._rendered is not None + and + # Check against False so `None` is ok + self._rendered.get(CONF_AVAILABILITY_TEMPLATE) is not False + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get(CONF_ATTRIBUTE_TEMPLATES) + + async def async_added_to_hass(self) -> None: + """Handle being added to Home Assistant.""" + template.attach(self.hass, self._config) + await super().async_added_to_hass() + if self.coordinator.data is not None: + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + rendered = {} + + for key in self._to_render: + rendered[key] = self._config[key].async_render( + self.coordinator.data["run_variables"], parse_result=False + ) + + if CONF_ATTRIBUTE_TEMPLATES in self._config: + rendered[CONF_ATTRIBUTE_TEMPLATES] = template.render_complex( + self._config[CONF_ATTRIBUTE_TEMPLATES], + self.coordinator.data["run_variables"], + ) + + self._rendered = rendered + except template.TemplateError as err: + logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( + "Error rendering %s template for %s: %s", key, self.entity_id, err + ) + self._rendered = None + + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 9d014f86a36..11945a3b027 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -15,8 +15,10 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) -from homeassistant.core import CoreState, callback +from homeassistant.core import Context, CoreState, callback +from homeassistant.helpers import entity_registry from homeassistant.helpers.template import Template from homeassistant.setup import ATTR_COMPONENT, async_setup_component import homeassistant.util.dt as dt_util @@ -986,3 +988,126 @@ async def test_duplicate_templates(hass): state = hass.states.get("sensor.test_template_sensor") assert state.attributes["friendly_name"] == "Def" assert state.state == "Def" + + +async def test_trigger_entity(hass): + """Test trigger entity works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + {"invalid": "config"}, + # This one should still be set up + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensors": { + "hello": { + "friendly_name": "Hello Name", + "unique_id": "just_a_test", + "device_class": "battery", + "unit_of_measurement": "%", + "value_template": "{{ trigger.event.data.beer }}", + "entity_picture_template": "{{ '/local/dogs.png' }}", + "icon_template": "{{ 'mdi:pirate' }}", + "attribute_templates": { + "plus_one": "{{ trigger.event.data.beer + 1 }}" + }, + } + }, + }, + ], + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello") + assert state.state == "2" + assert state.attributes.get("device_class") == "battery" + assert state.attributes.get("icon") == "mdi:pirate" + assert state.attributes.get("entity_picture") == "/local/dogs.png" + assert state.attributes.get("plus_one") == 3 + assert state.attributes.get("unit_of_measurement") == "%" + assert state.context is context + + ent_reg = entity_registry.async_get(hass) + assert len(ent_reg.entities) == 1 + assert ( + ent_reg.entities["sensor.hello"].unique_id == "listening-test-event-just_a_test" + ) + + +async def test_trigger_entity_render_error(hass): + """Test trigger entity handles render error.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensors": { + "hello": { + "unique_id": "no-base-id", + "friendly_name": "Hello", + "value_template": "{{ non_existing + 1 }}", + } + }, + }, + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 2}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello") + assert state.state == STATE_UNAVAILABLE + + ent_reg = entity_registry.async_get(hass) + assert len(ent_reg.entities) == 1 + assert ent_reg.entities["sensor.hello"].unique_id == "no-base-id" + + +async def test_trigger_not_allowed_platform_config(hass, caplog): + """Test we throw a helpful warning if a trigger is configured in platform config.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "template", + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.test_state.state }}", + "friendly_name_template": "{{ states.sensor.test_state.state }}", + } + }, + } + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_template_sensor") + assert state is None + assert ( + "You can only add triggers to template entities if they are defined under `template:`." + in caplog.text + )