From e882460933f4f8dda3a55fa26ade3ef1125f8c9b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 2 Apr 2021 16:57:16 -0700 Subject: [PATCH] Support modern config for the trigger based template entity (#48635) --- homeassistant/components/template/__init__.py | 20 ++-- homeassistant/components/template/config.py | 91 +++++++++++++++++-- homeassistant/components/template/const.py | 4 + homeassistant/components/template/sensor.py | 11 ++- .../components/template/trigger_entity.py | 79 +++++++--------- homeassistant/helpers/template.py | 4 +- tests/components/template/test_sensor.py | 40 ++++++-- 7 files changed, 173 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 3481b5adac6..f9b6b3b4975 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,7 +2,8 @@ import logging from typing import Optional -from homeassistant.const import CONF_SENSORS, EVENT_HOMEASSISTANT_START +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import CoreState, callback from homeassistant.helpers import ( discovery, @@ -51,15 +52,16 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): 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, + for platform_domain in (SENSOR_DOMAIN,): + self.hass.async_create_task( + discovery.async_load_platform( + self.hass, + platform_domain, + DOMAIN, + {"coordinator": self, "entities": self.config[platform_domain]}, + hass_config, + ) ) - ) async def _attach_triggers(self, start_event=None) -> None: """Attach the triggers.""" diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index fa0d9a867d1..edef5673f31 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -1,23 +1,72 @@ """Template config validator.""" +import logging import voluptuous as vol +from homeassistant.components.sensor import ( + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, +) 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.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_FRIENDLY_NAME_TEMPLATE, + CONF_ICON, + CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_SENSORS, + CONF_STATE, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.trigger import async_validate_trigger_config -from .const import CONF_TRIGGER, DOMAIN -from .sensor import SENSOR_SCHEMA +from .const import ( + CONF_ATTRIBUTE_TEMPLATES, + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_TEMPLATE, + CONF_PICTURE, + CONF_TRIGGER, + DOMAIN, +) +from .sensor import SENSOR_SCHEMA as PLATFORM_SENSOR_SCHEMA -CONF_STATE = "state" +CONVERSION_PLATFORM = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, + CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, + CONF_FRIENDLY_NAME: CONF_NAME, + CONF_VALUE_TEMPLATE: CONF_STATE, +} +SENSOR_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.template, + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + 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), + vol.Optional(SENSOR_DOMAIN): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), + vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys(PLATFORM_SENSOR_SCHEMA), } ) @@ -37,9 +86,37 @@ async def async_validate_config(hass, config): ) except vol.Invalid as err: async_log_exception(err, DOMAIN, cfg, hass) + continue - else: + if CONF_SENSORS not in cfg: trigger_entity_configs.append(cfg) + continue + + 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) # Create a copy of the configuration with all config for current # component removed and add validated config back in. diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 2f2bc3127d7..971d4a864c9 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -20,3 +20,7 @@ PLATFORMS = [ "vacuum", "weather", ] + +CONF_AVAILABILITY = "availability" +CONF_ATTRIBUTES = "attributes" +CONF_PICTURE = "picture" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index a5f5d669b16..4631a775847 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SENSORS, + CONF_STATE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -89,7 +90,7 @@ def _async_create_template_tracking_entities(hass, config): friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE) 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] + attribute_templates = device_config.get(CONF_ATTRIBUTE_TEMPLATES, {}) unique_id = device_config.get(CONF_UNIQUE_ID) sensors.append( @@ -118,8 +119,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 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() + TriggerSensorEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] ) @@ -203,9 +204,9 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity): """Sensor entity based on trigger data.""" domain = SENSOR_DOMAIN - extra_template_keys = (CONF_VALUE_TEMPLATE,) + extra_template_keys = (CONF_STATE,) @property def state(self) -> str | None: """Return state of the sensor.""" - return self._rendered.get(CONF_VALUE_TEMPLATE) + return self._rendered.get(CONF_STATE) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 3874409dc78..418fa976304 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -6,20 +6,16 @@ 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_ICON, + CONF_NAME, 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 +from .const import CONF_ATTRIBUTES, CONF_AVAILABILITY, CONF_PICTURE class TriggerEntity(update_coordinator.CoordinatorEntity): @@ -32,23 +28,13 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): 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: @@ -56,32 +42,33 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): 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 - ] + self._static_rendered = {} + self._to_render = [] + + for itm in ( + CONF_NAME, + CONF_ICON, + CONF_PICTURE, + CONF_AVAILABILITY, + ): + if itm not in config: + continue + + if config[itm].is_static: + self._static_rendered[itm] = config[itm].template + else: + self._to_render.append(itm) if self.extra_template_keys is not None: self._to_render.extend(self.extra_template_keys) - self._rendered = {} + # We make a copy so our initial render is 'unknown' and not 'unavailable' + self._rendered = dict(self._static_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 + return self._rendered.get(CONF_NAME) @property def unique_id(self): @@ -101,29 +88,27 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): @property def icon(self) -> str | None: """Return icon.""" - return self._rendered is not None and self._rendered.get(CONF_ICON_TEMPLATE) + return self._rendered.get(CONF_ICON) @property def entity_picture(self) -> str | None: """Return entity picture.""" - return self._rendered is not None and self._rendered.get( - CONF_ENTITY_PICTURE_TEMPLATE - ) + return self._rendered.get(CONF_PICTURE) @property def available(self): """Return availability of the entity.""" return ( - self._rendered is not None + self._rendered is not self._static_rendered and # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY_TEMPLATE) is not False + self._rendered.get(CONF_AVAILABILITY) is not False ) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return extra attributes.""" - return self._rendered.get(CONF_ATTRIBUTE_TEMPLATES) + return self._rendered.get(CONF_ATTRIBUTES) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -136,16 +121,16 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" try: - rendered = {} + rendered = dict(self._static_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], + if CONF_ATTRIBUTES in self._config: + rendered[CONF_ATTRIBUTES] = template.render_complex( + self._config[CONF_ATTRIBUTES], self.coordinator.data["run_variables"], ) @@ -154,7 +139,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity): 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._rendered = self._static_rendered self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 315efd14516..4989c4172ae 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -336,7 +336,7 @@ class Template: If limited is True, the template is not allowed to access any function or filter depending on hass or the state machine. """ if self.is_static: - if self.hass.config.legacy_templates or not parse_result: + if not parse_result or self.hass.config.legacy_templates: return self.template return self._parse_result(self.template) @@ -360,7 +360,7 @@ class Template: If limited is True, the template is not allowed to access any function or filter depending on hass or the state machine. """ if self.is_static: - if self.hass.config.legacy_templates or not parse_result: + if not parse_result or self.hass.config.legacy_templates: return self.template return self._parse_result(self.template) diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 6aa1e75cc1f..d146f5d88de 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -998,14 +998,14 @@ async def test_trigger_entity(hass): { "template": [ {"invalid": "config"}, - # This one should still be set up + # Config after invalid 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", + "unique_id": "hello_name-id", "device_class": "battery", "unit_of_measurement": "%", "value_template": "{{ trigger.event.data.beer }}", @@ -1016,6 +1016,20 @@ async def test_trigger_entity(hass): }, }, }, + "sensor": [ + { + "name": "via list", + "unique_id": "via_list-id", + "device_class": "battery", + "unit_of_measurement": "%", + "state": "{{ trigger.event.data.beer + 1 }}", + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + "attributes": { + "plus_one": "{{ trigger.event.data.beer + 1 }}" + }, + } + ], }, { "trigger": [], @@ -1031,7 +1045,7 @@ async def test_trigger_entity(hass): await hass.async_block_till_done() - state = hass.states.get("sensor.hello") + state = hass.states.get("sensor.hello_name") assert state is not None assert state.state == STATE_UNKNOWN @@ -1043,7 +1057,7 @@ async def test_trigger_entity(hass): hass.bus.async_fire("test_event", {"beer": 2}, context=context) await hass.async_block_till_done() - state = hass.states.get("sensor.hello") + state = hass.states.get("sensor.hello_name") assert state.state == "2" assert state.attributes.get("device_class") == "battery" assert state.attributes.get("icon") == "mdi:pirate" @@ -1053,10 +1067,24 @@ async def test_trigger_entity(hass): assert state.context is context ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 1 + assert len(ent_reg.entities) == 2 assert ( - ent_reg.entities["sensor.hello"].unique_id == "listening-test-event-just_a_test" + ent_reg.entities["sensor.hello_name"].unique_id + == "listening-test-event-hello_name-id" ) + assert ( + ent_reg.entities["sensor.via_list"].unique_id + == "listening-test-event-via_list-id" + ) + + state = hass.states.get("sensor.via_list") + assert state.state == "3" + 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 async def test_trigger_entity_render_error(hass):