diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index bc868fa16b8..b5e62ee2fcd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -44,8 +44,8 @@ _ENVIRONMENT = "template.environment" _RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M) _RE_GET_ENTITIES = re.compile( - r"(?:(?:states\.|(?:is_state|is_state_attr|state_attr|states)" - r"\((?:[\ \'\"]?))([\w]+\.[\w]+)|([\w]+))", + r"(?:(?:states\.|(?Pis_state|is_state_attr|state_attr|states|expand)" + r"\((?:[\ \'\"]?))(?P[\w]+\.[\w]+)|(?P[\w]+))", re.I | re.M, ) _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{") @@ -76,7 +76,9 @@ def render_complex(value: Any, variables: TemplateVarsType = None) -> Any: def extract_entities( - template: Optional[str], variables: Optional[Dict[str, Any]] = None + hass: HomeAssistantType, + template: Optional[str], + variables: Optional[Dict[str, Any]] = None, ) -> Union[str, List[str]]: """Extract all entities for state_changed listener from template string.""" if template is None or _RE_JINJA_DELIMITERS.search(template) is None: @@ -85,27 +87,30 @@ def extract_entities( if _RE_NONE_ENTITIES.search(template): return MATCH_ALL - extraction = _RE_GET_ENTITIES.findall(template) extraction_final = [] - for result in extraction: + for result in _RE_GET_ENTITIES.finditer(template): if ( - result[0] == "trigger.entity_id" + result.group("entity_id") == "trigger.entity_id" and variables and "trigger" in variables and "entity_id" in variables["trigger"] ): extraction_final.append(variables["trigger"]["entity_id"]) - elif result[0]: - extraction_final.append(result[0]) + elif result.group("entity_id"): + if result.group("func") == "expand": + for entity in expand(hass, result.group("entity_id")): + extraction_final.append(entity.entity_id) + + extraction_final.append(result.group("entity_id")) if ( variables - and result[1] in variables - and isinstance(variables[result[1]], str) - and valid_entity_id(variables[result[1]]) + and result.group("variable") in variables + and isinstance(variables[result.group("variable")], str) + and valid_entity_id(variables[result.group("variable")]) ): - extraction_final.append(variables[result[1]]) + extraction_final.append(variables[result.group("variable")]) if extraction_final: return list(set(extraction_final)) @@ -197,7 +202,7 @@ class Template: self, variables: Optional[Dict[str, Any]] = None ) -> Union[str, List[str]]: """Extract all entities for state_changed listener.""" - return extract_entities(self.template, variables) + return extract_entities(self.hass, self.template, variables) def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str: """Render given template.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a698c7af6e7..3f3eb7a800c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1552,19 +1552,19 @@ def test_closest_function_no_location_states(hass): def test_extract_entities_none_exclude_stuff(hass): """Test extract entities function with none or exclude stuff.""" - assert template.extract_entities(None) == [] + assert template.extract_entities(hass, None) == [] - assert template.extract_entities("mdi:water") == [] + assert template.extract_entities(hass, "mdi:water") == [] assert ( template.extract_entities( - "{{ closest(states.zone.far_away, states.test_domain).entity_id }}" + hass, "{{ closest(states.zone.far_away, states.test_domain).entity_id }}" ) == MATCH_ALL ) assert ( - template.extract_entities('{{ distance("123", states.test_object_2) }}') + template.extract_entities(hass, '{{ distance("123", states.test_object_2) }}') == MATCH_ALL ) @@ -1572,7 +1572,9 @@ def test_extract_entities_none_exclude_stuff(hass): def test_extract_entities_no_match_entities(hass): """Test extract entities function with none entities stuff.""" assert ( - template.extract_entities("{{ value_json.tst | timestamp_custom('%Y' True) }}") + template.extract_entities( + hass, "{{ value_json.tst | timestamp_custom('%Y' True) }}" + ) == MATCH_ALL ) @@ -1671,35 +1673,38 @@ def test_generate_select(hass): ) -def test_extract_entities_match_entities(hass): +async def test_extract_entities_match_entities(hass): """Test extract entities function with entities stuff.""" assert ( template.extract_entities( + hass, """ {% if is_state('device_tracker.phone_1', 'home') %} Ha, Hercules is home! {% else %} Hercules is at {{ states('device_tracker.phone_1') }}. {% endif %} - """ + """, ) == ["device_tracker.phone_1"] ) assert ( template.extract_entities( + hass, """ {{ as_timestamp(states.binary_sensor.garage_door.last_changed) }} - """ + """, ) == ["binary_sensor.garage_door"] ) assert ( template.extract_entities( + hass, """ {{ states("binary_sensor.garage_door") }} - """ + """, ) == ["binary_sensor.garage_door"] ) @@ -1708,33 +1713,36 @@ Hercules is at {{ states('device_tracker.phone_1') }}. assert ( template.extract_entities( + hass, """ {{ is_state_attr('device_tracker.phone_2', 'battery', 40) }} - """ + """, ) == ["device_tracker.phone_2"] ) assert sorted(["device_tracker.phone_1", "device_tracker.phone_2"]) == sorted( template.extract_entities( + hass, """ {% if is_state('device_tracker.phone_1', 'home') %} Ha, Hercules is home! {% elif states.device_tracker.phone_2.attributes.battery < 40 %} Hercules you power goes done!. {% endif %} - """ + """, ) ) assert sorted(["sensor.pick_humidity", "sensor.pick_temperature"]) == sorted( template.extract_entities( + hass, """ {{ states.sensor.pick_temperature.state ~ „°C (“ ~ states.sensor.pick_humidity.state ~ „ %“ }} - """ + """, ) ) @@ -1742,35 +1750,55 @@ states.sensor.pick_humidity.state ~ „ %“ ["sensor.luftfeuchtigkeit_mean", "input_number.luftfeuchtigkeit"] ) == sorted( template.extract_entities( + hass, "{% if (states('sensor.luftfeuchtigkeit_mean') | int)" " > (states('input_number.luftfeuchtigkeit') | int +1.5)" - " %}true{% endif %}" + " %}true{% endif %}", ) ) + await group.Group.async_create_group(hass, "empty group", []) + + assert ["group.empty_group"] == template.extract_entities( + hass, "{{ expand('group.empty_group') | list | length }}" + ) + + hass.states.async_set("test_domain.object", "exists") + await group.Group.async_create_group(hass, "expand group", ["test_domain.object"]) + + assert sorted(["group.expand_group", "test_domain.object"]) == sorted( + template.extract_entities( + hass, "{{ expand('group.expand_group') | list | length }}" + ) + ) + + assert ["test_domain.entity"] == template.Template( + '{{ is_state("test_domain.entity", "on") }}', hass + ).extract_entities() + def test_extract_entities_with_variables(hass): """Test extract entities function with variables and entities stuff.""" hass.states.async_set("input_boolean.switch", "on") - assert {"input_boolean.switch"} == extract_entities( + assert ["input_boolean.switch"] == template.extract_entities( hass, "{{ is_state('input_boolean.switch', 'off') }}", {} ) - assert {"input_boolean.switch"} == extract_entities( + assert ["input_boolean.switch"] == template.extract_entities( hass, "{{ is_state(trigger.entity_id, 'off') }}", {"trigger": {"entity_id": "input_boolean.switch"}}, ) - assert {"no_state"} == extract_entities( + assert MATCH_ALL == template.extract_entities( hass, "{{ is_state(data, 'off') }}", {"data": "no_state"} ) - assert {"input_boolean.switch"} == extract_entities( + assert ["input_boolean.switch"] == template.extract_entities( hass, "{{ is_state(data, 'off') }}", {"data": "input_boolean.switch"} ) - assert {"input_boolean.switch"} == extract_entities( + assert ["input_boolean.switch"] == template.extract_entities( hass, "{{ is_state(trigger.entity_id, 'off') }}", {"trigger": {"entity_id": "input_boolean.switch"}},