From eba125b0939eb2c9acfde97d9ac1aca206d3aac8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 May 2022 16:43:44 +0200 Subject: [PATCH] Ensure 'this' variable is always defined for template entities (#70911) --- .../components/template/template_entity.py | 30 ++++- homeassistant/helpers/template.py | 3 +- tests/components/template/test_sensor.py | 114 ++++++++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index d7d6ab46c62..a6d1cba78e1 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -17,8 +17,9 @@ from homeassistant.const import ( CONF_ICON_TEMPLATE, CONF_NAME, EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN, ) -from homeassistant.core import CoreState, Event, callback +from homeassistant.core import CoreState, Event, State, callback from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -251,13 +252,28 @@ class TemplateEntity(Entity): self._entity_picture_template = config.get(CONF_PICTURE) self._friendly_name_template = config.get(CONF_NAME) + class DummyState(State): + """None-state for template entities not yet added to the state machine.""" + + def __init__(self) -> None: + """Initialize a new state.""" + super().__init__("unknown.unknown", STATE_UNKNOWN) + self.entity_id = None # type: ignore[assignment] + + @property + def name(self) -> str: + """Name of this state.""" + return "" + + variables = {"this": DummyState()} + # Try to render the name as it can influence the entity ID self._attr_name = fallback_name if self._friendly_name_template: self._friendly_name_template.hass = hass with contextlib.suppress(TemplateError): self._attr_name = self._friendly_name_template.async_render( - parse_result=False + variables=variables, parse_result=False ) # Templates will not render while the entity is unavailable, try to render the @@ -266,13 +282,15 @@ class TemplateEntity(Entity): self._entity_picture_template.hass = hass with contextlib.suppress(TemplateError): self._attr_entity_picture = self._entity_picture_template.async_render( - parse_result=False + variables=variables, parse_result=False ) if self._icon_template: self._icon_template.hass = hass with contextlib.suppress(TemplateError): - self._attr_icon = self._icon_template.async_render(parse_result=False) + self._attr_icon = self._icon_template.async_render( + variables=variables, parse_result=False + ) @callback def _update_available(self, result): @@ -373,10 +391,10 @@ class TemplateEntity(Entity): template_var_tups: list[TrackTemplate] = [] has_availability_template = False - values = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} + variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} for template, attributes in self._template_attrs.items(): - template_var_tup = TrackTemplate(template, values) + template_var_tup = TrackTemplate(template, variables) is_availability_template = False for attribute in attributes: # pylint: disable-next=protected-access diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index dbc82ce6902..3f084674a1b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -850,7 +850,8 @@ class TemplateStateFromEntityId(TemplateStateBase): @property def _state(self) -> State: # type: ignore[override] # mypy issue 4125 state = self._hass.states.get(self._entity_id) - assert state + if not state: + state = State(self._entity_id, STATE_UNKNOWN) return state def __repr__(self) -> str: diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 297008e77bb..ddf13c2015b 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -653,6 +653,120 @@ async def test_this_variable(hass, start_ha): assert hass.states.get(TEST_NAME).state == "It Works: " + TEST_NAME +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "sensor": { + "state": "{{ this.attributes.get('test', 'no-test!') }}: {{ this.entity_id }}", + "icon": "mdi:{% if this.entity_id in states and 'friendly_name' in this.attributes %} {{this.attributes['friendly_name']}} {% else %}{{this.entity_id}}:{{this.entity_id in states}}{% endif %}", + "name": "{% if this.entity_id in states and 'friendly_name' in this.attributes %} {{this.attributes['friendly_name']}} {% else %}{{this.entity_id}}:{{this.entity_id in states}}{% endif %}", + "picture": "{% if this.entity_id in states and 'entity_picture' in this.attributes %} {{this.attributes['entity_picture']}} {% else %}{{this.entity_id}}:{{this.entity_id in states}}{% endif %}", + "attributes": {"test": "{{ this.entity_id }}"}, + }, + }, + }, + ], +) +async def test_this_variable_early_hass_not_running(hass, config, count, domain): + """Test referencing 'this' variable before the entity is in the state machine. + + Hass is not yet started when the entity is added. + Icon, name and picture templates are rendered once in the constructor. + """ + entity_id = "sensor.none_false" + + hass.state = CoreState.not_running + + # Setup template + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Sensor state not rendered, icon, name and picture + # templates rendered in constructor with entity_id set to None + state = hass.states.get(entity_id) + assert state.state == "unknown" + assert state.attributes == { + "entity_picture": "None:False", + "friendly_name": "None:False", + "icon": "mdi:None:False", + } + + # Signal hass started + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + # Re-render icon, name, pciture + other templates now rendered + state = hass.states.get(entity_id) + assert state.state == "sensor.none_false: sensor.none_false" + assert state.attributes == { + "entity_picture": "sensor.none_false:False", + "friendly_name": "sensor.none_false:False", + "icon": "mdi:sensor.none_false:False", + "test": "sensor.none_false", + } + + +@pytest.mark.parametrize("count,domain", [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "sensor": { + "state": "{{ this.attributes.get('test', 'no-test!') }}: {{ this.entity_id }}", + "icon": "mdi:{% if this.entity_id in states and 'friendly_name' in this.attributes %} {{this.attributes['friendly_name']}} {% else %}{{this.entity_id}}:{{this.entity_id in states}}{% endif %}", + "name": "{% if this.entity_id in states and 'friendly_name' in this.attributes %} {{this.attributes['friendly_name']}} {% else %}{{this.entity_id}}:{{this.entity_id in states}}{% endif %}", + "picture": "{% if this.entity_id in states and 'entity_picture' in this.attributes %} {{this.attributes['entity_picture']}} {% else %}{{this.entity_id}}:{{this.entity_id in states}}{% endif %}", + "attributes": {"test": "{{ this.entity_id }}"}, + }, + }, + }, + ], +) +async def test_this_variable_early_hass_running(hass, config, count, domain): + """Test referencing 'this' variable before the entity is in the state machine. + + Hass is already started when the entity is added. + Icon, name and picture templates are rendered in the constructor, and again + before the entity is added to hass. + """ + + # Start hass + assert hass.state == CoreState.running + await hass.async_start() + await hass.async_block_till_done() + + # Setup template + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + entity_id = "sensor.none_false" + # All templated rendered + state = hass.states.get(entity_id) + assert state.state == "sensor.none_false: sensor.none_false" + assert state.attributes == { + "entity_picture": "sensor.none_false:False", + "friendly_name": "sensor.none_false:False", + "icon": "mdi:sensor.none_false:False", + "test": "sensor.none_false", + } + + @pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) @pytest.mark.parametrize( "config",