Support modern config for the trigger based template entity (#48635)

This commit is contained in:
Paulus Schoutsen 2021-04-02 16:57:16 -07:00 committed by GitHub
parent cffdbfe13c
commit e882460933
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 173 additions and 76 deletions

View File

@ -2,7 +2,8 @@
import logging import logging
from typing import Optional 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.core import CoreState, callback
from homeassistant.helpers import ( from homeassistant.helpers import (
discovery, discovery,
@ -51,15 +52,16 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
EVENT_HOMEASSISTANT_START, self._attach_triggers EVENT_HOMEASSISTANT_START, self._attach_triggers
) )
self.hass.async_create_task( for platform_domain in (SENSOR_DOMAIN,):
discovery.async_load_platform( self.hass.async_create_task(
self.hass, discovery.async_load_platform(
"sensor", self.hass,
DOMAIN, platform_domain,
{"coordinator": self, "entities": self.config[CONF_SENSORS]}, DOMAIN,
hass_config, {"coordinator": self, "entities": self.config[platform_domain]},
hass_config,
)
) )
)
async def _attach_triggers(self, start_event=None) -> None: async def _attach_triggers(self, start_event=None) -> None:
"""Attach the triggers.""" """Attach the triggers."""

View File

@ -1,23 +1,72 @@
"""Template config validator.""" """Template config validator."""
import logging
import voluptuous as vol 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.config import async_log_exception, config_without_domain
from homeassistant.const import CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.const import (
from homeassistant.helpers import config_validation as cv 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 homeassistant.helpers.trigger import async_validate_trigger_config
from .const import CONF_TRIGGER, DOMAIN from .const import (
from .sensor import SENSOR_SCHEMA 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( TRIGGER_ENTITY_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, 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: except vol.Invalid as err:
async_log_exception(err, DOMAIN, cfg, hass) async_log_exception(err, DOMAIN, cfg, hass)
continue
else: if CONF_SENSORS not in cfg:
trigger_entity_configs.append(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 # Create a copy of the configuration with all config for current
# component removed and add validated config back in. # component removed and add validated config back in.

View File

@ -20,3 +20,7 @@ PLATFORMS = [
"vacuum", "vacuum",
"weather", "weather",
] ]
CONF_AVAILABILITY = "availability"
CONF_ATTRIBUTES = "attributes"
CONF_PICTURE = "picture"

View File

@ -18,6 +18,7 @@ from homeassistant.const import (
CONF_FRIENDLY_NAME_TEMPLATE, CONF_FRIENDLY_NAME_TEMPLATE,
CONF_ICON_TEMPLATE, CONF_ICON_TEMPLATE,
CONF_SENSORS, CONF_SENSORS,
CONF_STATE,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE, 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) friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE)
unit_of_measurement = device_config.get(CONF_UNIT_OF_MEASUREMENT) unit_of_measurement = device_config.get(CONF_UNIT_OF_MEASUREMENT)
device_class = device_config.get(CONF_DEVICE_CLASS) 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) unique_id = device_config.get(CONF_UNIQUE_ID)
sensors.append( 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)) async_add_entities(_async_create_template_tracking_entities(hass, config))
else: else:
async_add_entities( async_add_entities(
TriggerSensorEntity(hass, discovery_info["coordinator"], device_id, config) TriggerSensorEntity(hass, discovery_info["coordinator"], config)
for device_id, config in discovery_info["entities"].items() for config in discovery_info["entities"]
) )
@ -203,9 +204,9 @@ class TriggerSensorEntity(TriggerEntity, SensorEntity):
"""Sensor entity based on trigger data.""" """Sensor entity based on trigger data."""
domain = SENSOR_DOMAIN domain = SENSOR_DOMAIN
extra_template_keys = (CONF_VALUE_TEMPLATE,) extra_template_keys = (CONF_STATE,)
@property @property
def state(self) -> str | None: def state(self) -> str | None:
"""Return state of the sensor.""" """Return state of the sensor."""
return self._rendered.get(CONF_VALUE_TEMPLATE) return self._rendered.get(CONF_STATE)

View File

@ -6,20 +6,16 @@ from typing import Any
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON,
CONF_FRIENDLY_NAME, CONF_NAME,
CONF_FRIENDLY_NAME_TEMPLATE,
CONF_ICON_TEMPLATE,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import template, update_coordinator from homeassistant.helpers import template, update_coordinator
from homeassistant.helpers.entity import async_generate_entity_id
from . import TriggerUpdateCoordinator 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): class TriggerEntity(update_coordinator.CoordinatorEntity):
@ -32,23 +28,13 @@ class TriggerEntity(update_coordinator.CoordinatorEntity):
self, self,
hass: HomeAssistant, hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator, coordinator: TriggerUpdateCoordinator,
device_id: str,
config: dict, config: dict,
): ):
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) 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) 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: if entity_unique_id and coordinator.unique_id:
self._unique_id = f"{coordinator.unique_id}-{entity_unique_id}" self._unique_id = f"{coordinator.unique_id}-{entity_unique_id}"
else: else:
@ -56,32 +42,33 @@ class TriggerEntity(update_coordinator.CoordinatorEntity):
self._config = config self._config = config
self._to_render = [ self._static_rendered = {}
itm self._to_render = []
for itm in (
CONF_VALUE_TEMPLATE, for itm in (
CONF_ICON_TEMPLATE, CONF_NAME,
CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON,
CONF_FRIENDLY_NAME_TEMPLATE, CONF_PICTURE,
CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY,
) ):
if itm in config 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: if self.extra_template_keys is not None:
self._to_render.extend(self.extra_template_keys) 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 @property
def name(self): def name(self):
"""Name of the entity.""" """Name of the entity."""
if ( return self._rendered.get(CONF_NAME)
self._rendered is not None
and (name := self._rendered.get(CONF_FRIENDLY_NAME_TEMPLATE)) is not None
):
return name
return self._name
@property @property
def unique_id(self): def unique_id(self):
@ -101,29 +88,27 @@ class TriggerEntity(update_coordinator.CoordinatorEntity):
@property @property
def icon(self) -> str | None: def icon(self) -> str | None:
"""Return icon.""" """Return icon."""
return self._rendered is not None and self._rendered.get(CONF_ICON_TEMPLATE) return self._rendered.get(CONF_ICON)
@property @property
def entity_picture(self) -> str | None: def entity_picture(self) -> str | None:
"""Return entity picture.""" """Return entity picture."""
return self._rendered is not None and self._rendered.get( return self._rendered.get(CONF_PICTURE)
CONF_ENTITY_PICTURE_TEMPLATE
)
@property @property
def available(self): def available(self):
"""Return availability of the entity.""" """Return availability of the entity."""
return ( return (
self._rendered is not None self._rendered is not self._static_rendered
and and
# Check against False so `None` is ok # 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 @property
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return extra attributes.""" """Return extra attributes."""
return self._rendered.get(CONF_ATTRIBUTE_TEMPLATES) return self._rendered.get(CONF_ATTRIBUTES)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle being added to Home Assistant.""" """Handle being added to Home Assistant."""
@ -136,16 +121,16 @@ class TriggerEntity(update_coordinator.CoordinatorEntity):
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
try: try:
rendered = {} rendered = dict(self._static_rendered)
for key in self._to_render: for key in self._to_render:
rendered[key] = self._config[key].async_render( rendered[key] = self._config[key].async_render(
self.coordinator.data["run_variables"], parse_result=False self.coordinator.data["run_variables"], parse_result=False
) )
if CONF_ATTRIBUTE_TEMPLATES in self._config: if CONF_ATTRIBUTES in self._config:
rendered[CONF_ATTRIBUTE_TEMPLATES] = template.render_complex( rendered[CONF_ATTRIBUTES] = template.render_complex(
self._config[CONF_ATTRIBUTE_TEMPLATES], self._config[CONF_ATTRIBUTES],
self.coordinator.data["run_variables"], self.coordinator.data["run_variables"],
) )
@ -154,7 +139,7 @@ class TriggerEntity(update_coordinator.CoordinatorEntity):
logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error(
"Error rendering %s template for %s: %s", key, self.entity_id, err "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_set_context(self.coordinator.data["context"])
self.async_write_ha_state() self.async_write_ha_state()

View File

@ -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 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.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.template
return self._parse_result(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 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.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.template
return self._parse_result(self.template) return self._parse_result(self.template)

View File

@ -998,14 +998,14 @@ async def test_trigger_entity(hass):
{ {
"template": [ "template": [
{"invalid": "config"}, {"invalid": "config"},
# This one should still be set up # Config after invalid should still be set up
{ {
"unique_id": "listening-test-event", "unique_id": "listening-test-event",
"trigger": {"platform": "event", "event_type": "test_event"}, "trigger": {"platform": "event", "event_type": "test_event"},
"sensors": { "sensors": {
"hello": { "hello": {
"friendly_name": "Hello Name", "friendly_name": "Hello Name",
"unique_id": "just_a_test", "unique_id": "hello_name-id",
"device_class": "battery", "device_class": "battery",
"unit_of_measurement": "%", "unit_of_measurement": "%",
"value_template": "{{ trigger.event.data.beer }}", "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": [], "trigger": [],
@ -1031,7 +1045,7 @@ async def test_trigger_entity(hass):
await hass.async_block_till_done() 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 is not None
assert state.state == STATE_UNKNOWN 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) hass.bus.async_fire("test_event", {"beer": 2}, context=context)
await hass.async_block_till_done() 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.state == "2"
assert state.attributes.get("device_class") == "battery" assert state.attributes.get("device_class") == "battery"
assert state.attributes.get("icon") == "mdi:pirate" assert state.attributes.get("icon") == "mdi:pirate"
@ -1053,10 +1067,24 @@ async def test_trigger_entity(hass):
assert state.context is context assert state.context is context
ent_reg = entity_registry.async_get(hass) ent_reg = entity_registry.async_get(hass)
assert len(ent_reg.entities) == 1 assert len(ent_reg.entities) == 2
assert ( 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): async def test_trigger_entity_render_error(hass):