Listen for group member state changes when using `expand` in templates (#35398)
* Re-evaluate template on group member state change * Use named groups for entity extraction regex This will avoid unnecessary edits of match indices if the regex is amended in the future * Improve test coveragepull/35463/head
parent
c71b6c8a71
commit
8f285c15d3
|
@ -44,8 +44,8 @@ _ENVIRONMENT = "template.environment"
|
||||||
|
|
||||||
_RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M)
|
_RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M)
|
||||||
_RE_GET_ENTITIES = re.compile(
|
_RE_GET_ENTITIES = re.compile(
|
||||||
r"(?:(?:states\.|(?:is_state|is_state_attr|state_attr|states)"
|
r"(?:(?:states\.|(?P<func>is_state|is_state_attr|state_attr|states|expand)"
|
||||||
r"\((?:[\ \'\"]?))([\w]+\.[\w]+)|([\w]+))",
|
r"\((?:[\ \'\"]?))(?P<entity_id>[\w]+\.[\w]+)|(?P<variable>[\w]+))",
|
||||||
re.I | re.M,
|
re.I | re.M,
|
||||||
)
|
)
|
||||||
_RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{")
|
_RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{")
|
||||||
|
@ -76,7 +76,9 @@ def render_complex(value: Any, variables: TemplateVarsType = None) -> Any:
|
||||||
|
|
||||||
|
|
||||||
def extract_entities(
|
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]]:
|
) -> Union[str, List[str]]:
|
||||||
"""Extract all entities for state_changed listener from template string."""
|
"""Extract all entities for state_changed listener from template string."""
|
||||||
if template is None or _RE_JINJA_DELIMITERS.search(template) is None:
|
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):
|
if _RE_NONE_ENTITIES.search(template):
|
||||||
return MATCH_ALL
|
return MATCH_ALL
|
||||||
|
|
||||||
extraction = _RE_GET_ENTITIES.findall(template)
|
|
||||||
extraction_final = []
|
extraction_final = []
|
||||||
|
|
||||||
for result in extraction:
|
for result in _RE_GET_ENTITIES.finditer(template):
|
||||||
if (
|
if (
|
||||||
result[0] == "trigger.entity_id"
|
result.group("entity_id") == "trigger.entity_id"
|
||||||
and variables
|
and variables
|
||||||
and "trigger" in variables
|
and "trigger" in variables
|
||||||
and "entity_id" in variables["trigger"]
|
and "entity_id" in variables["trigger"]
|
||||||
):
|
):
|
||||||
extraction_final.append(variables["trigger"]["entity_id"])
|
extraction_final.append(variables["trigger"]["entity_id"])
|
||||||
elif result[0]:
|
elif result.group("entity_id"):
|
||||||
extraction_final.append(result[0])
|
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 (
|
if (
|
||||||
variables
|
variables
|
||||||
and result[1] in variables
|
and result.group("variable") in variables
|
||||||
and isinstance(variables[result[1]], str)
|
and isinstance(variables[result.group("variable")], str)
|
||||||
and valid_entity_id(variables[result[1]])
|
and valid_entity_id(variables[result.group("variable")])
|
||||||
):
|
):
|
||||||
extraction_final.append(variables[result[1]])
|
extraction_final.append(variables[result.group("variable")])
|
||||||
|
|
||||||
if extraction_final:
|
if extraction_final:
|
||||||
return list(set(extraction_final))
|
return list(set(extraction_final))
|
||||||
|
@ -197,7 +202,7 @@ class Template:
|
||||||
self, variables: Optional[Dict[str, Any]] = None
|
self, variables: Optional[Dict[str, Any]] = None
|
||||||
) -> Union[str, List[str]]:
|
) -> Union[str, List[str]]:
|
||||||
"""Extract all entities for state_changed listener."""
|
"""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:
|
def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str:
|
||||||
"""Render given template."""
|
"""Render given template."""
|
||||||
|
|
|
@ -1552,19 +1552,19 @@ def test_closest_function_no_location_states(hass):
|
||||||
|
|
||||||
def test_extract_entities_none_exclude_stuff(hass):
|
def test_extract_entities_none_exclude_stuff(hass):
|
||||||
"""Test extract entities function with none or exclude stuff."""
|
"""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 (
|
assert (
|
||||||
template.extract_entities(
|
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
|
== MATCH_ALL
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
template.extract_entities('{{ distance("123", states.test_object_2) }}')
|
template.extract_entities(hass, '{{ distance("123", states.test_object_2) }}')
|
||||||
== MATCH_ALL
|
== MATCH_ALL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1572,7 +1572,9 @@ def test_extract_entities_none_exclude_stuff(hass):
|
||||||
def test_extract_entities_no_match_entities(hass):
|
def test_extract_entities_no_match_entities(hass):
|
||||||
"""Test extract entities function with none entities stuff."""
|
"""Test extract entities function with none entities stuff."""
|
||||||
assert (
|
assert (
|
||||||
template.extract_entities("{{ value_json.tst | timestamp_custom('%Y' True) }}")
|
template.extract_entities(
|
||||||
|
hass, "{{ value_json.tst | timestamp_custom('%Y' True) }}"
|
||||||
|
)
|
||||||
== MATCH_ALL
|
== 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."""
|
"""Test extract entities function with entities stuff."""
|
||||||
assert (
|
assert (
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"""
|
"""
|
||||||
{% if is_state('device_tracker.phone_1', 'home') %}
|
{% if is_state('device_tracker.phone_1', 'home') %}
|
||||||
Ha, Hercules is home!
|
Ha, Hercules is home!
|
||||||
{% else %}
|
{% else %}
|
||||||
Hercules is at {{ states('device_tracker.phone_1') }}.
|
Hercules is at {{ states('device_tracker.phone_1') }}.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
== ["device_tracker.phone_1"]
|
== ["device_tracker.phone_1"]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"""
|
"""
|
||||||
{{ as_timestamp(states.binary_sensor.garage_door.last_changed) }}
|
{{ as_timestamp(states.binary_sensor.garage_door.last_changed) }}
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
== ["binary_sensor.garage_door"]
|
== ["binary_sensor.garage_door"]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"""
|
"""
|
||||||
{{ states("binary_sensor.garage_door") }}
|
{{ states("binary_sensor.garage_door") }}
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
== ["binary_sensor.garage_door"]
|
== ["binary_sensor.garage_door"]
|
||||||
)
|
)
|
||||||
|
@ -1708,33 +1713,36 @@ Hercules is at {{ states('device_tracker.phone_1') }}.
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"""
|
"""
|
||||||
{{ is_state_attr('device_tracker.phone_2', 'battery', 40) }}
|
{{ is_state_attr('device_tracker.phone_2', 'battery', 40) }}
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
== ["device_tracker.phone_2"]
|
== ["device_tracker.phone_2"]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert sorted(["device_tracker.phone_1", "device_tracker.phone_2"]) == sorted(
|
assert sorted(["device_tracker.phone_1", "device_tracker.phone_2"]) == sorted(
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"""
|
"""
|
||||||
{% if is_state('device_tracker.phone_1', 'home') %}
|
{% if is_state('device_tracker.phone_1', 'home') %}
|
||||||
Ha, Hercules is home!
|
Ha, Hercules is home!
|
||||||
{% elif states.device_tracker.phone_2.attributes.battery < 40 %}
|
{% elif states.device_tracker.phone_2.attributes.battery < 40 %}
|
||||||
Hercules you power goes done!.
|
Hercules you power goes done!.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert sorted(["sensor.pick_humidity", "sensor.pick_temperature"]) == sorted(
|
assert sorted(["sensor.pick_humidity", "sensor.pick_temperature"]) == sorted(
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"""
|
"""
|
||||||
{{
|
{{
|
||||||
states.sensor.pick_temperature.state ~ „°C (“ ~
|
states.sensor.pick_temperature.state ~ „°C (“ ~
|
||||||
states.sensor.pick_humidity.state ~ „ %“
|
states.sensor.pick_humidity.state ~ „ %“
|
||||||
}}
|
}}
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1742,35 +1750,55 @@ states.sensor.pick_humidity.state ~ „ %“
|
||||||
["sensor.luftfeuchtigkeit_mean", "input_number.luftfeuchtigkeit"]
|
["sensor.luftfeuchtigkeit_mean", "input_number.luftfeuchtigkeit"]
|
||||||
) == sorted(
|
) == sorted(
|
||||||
template.extract_entities(
|
template.extract_entities(
|
||||||
|
hass,
|
||||||
"{% if (states('sensor.luftfeuchtigkeit_mean') | int)"
|
"{% if (states('sensor.luftfeuchtigkeit_mean') | int)"
|
||||||
" > (states('input_number.luftfeuchtigkeit') | int +1.5)"
|
" > (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):
|
def test_extract_entities_with_variables(hass):
|
||||||
"""Test extract entities function with variables and entities stuff."""
|
"""Test extract entities function with variables and entities stuff."""
|
||||||
hass.states.async_set("input_boolean.switch", "on")
|
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') }}", {}
|
hass, "{{ is_state('input_boolean.switch', 'off') }}", {}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert {"input_boolean.switch"} == extract_entities(
|
assert ["input_boolean.switch"] == template.extract_entities(
|
||||||
hass,
|
hass,
|
||||||
"{{ is_state(trigger.entity_id, 'off') }}",
|
"{{ is_state(trigger.entity_id, 'off') }}",
|
||||||
{"trigger": {"entity_id": "input_boolean.switch"}},
|
{"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"}
|
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"}
|
hass, "{{ is_state(data, 'off') }}", {"data": "input_boolean.switch"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert {"input_boolean.switch"} == extract_entities(
|
assert ["input_boolean.switch"] == template.extract_entities(
|
||||||
hass,
|
hass,
|
||||||
"{{ is_state(trigger.entity_id, 'off') }}",
|
"{{ is_state(trigger.entity_id, 'off') }}",
|
||||||
{"trigger": {"entity_id": "input_boolean.switch"}},
|
{"trigger": {"entity_id": "input_boolean.switch"}},
|
||||||
|
|
Loading…
Reference in New Issue