Add support for attributes in state/numeric state trigger (#39238)

This commit is contained in:
Paulus Schoutsen 2020-08-25 16:21:16 +02:00 committed by GitHub
parent 342e84e550
commit 415213a325
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 175 additions and 15 deletions

View file

@ -6,6 +6,7 @@ import voluptuous as vol
from homeassistant import exceptions from homeassistant import exceptions
from homeassistant.const import ( from homeassistant.const import (
CONF_ABOVE, CONF_ABOVE,
CONF_ATTRIBUTE,
CONF_BELOW, CONF_BELOW,
CONF_ENTITY_ID, CONF_ENTITY_ID,
CONF_FOR, CONF_FOR,
@ -48,6 +49,7 @@ TRIGGER_SCHEMA = vol.All(
vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_ABOVE): vol.Coerce(float),
vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_FOR): cv.positive_time_period_template, vol.Optional(CONF_FOR): cv.positive_time_period_template,
vol.Optional(CONF_ATTRIBUTE): cv.match_all,
} }
), ),
cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE),
@ -70,6 +72,7 @@ async def async_attach_trigger(
unsub_track_same = {} unsub_track_same = {}
entities_triggered = set() entities_triggered = set()
period: dict = {} period: dict = {}
attribute = config.get(CONF_ATTRIBUTE)
if value_template is not None: if value_template is not None:
value_template.hass = hass value_template.hass = hass
@ -86,10 +89,11 @@ async def async_attach_trigger(
"entity_id": entity, "entity_id": entity,
"below": below, "below": below,
"above": above, "above": above,
"attribute": attribute,
} }
} }
return condition.async_numeric_state( return condition.async_numeric_state(
hass, to_s, below, above, value_template, variables hass, to_s, below, above, value_template, variables, attribute
) )
@callback @callback

View file

@ -1,13 +1,13 @@
"""Offer state listening automation rules.""" """Offer state listening automation rules."""
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Dict from typing import Dict, Optional
import voluptuous as vol import voluptuous as vol
from homeassistant import exceptions from homeassistant import exceptions
from homeassistant.const import CONF_FOR, CONF_PLATFORM, MATCH_ALL from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
Event, Event,
@ -34,6 +34,7 @@ TRIGGER_SCHEMA = vol.All(
vol.Optional(CONF_FROM): vol.Any(str, [str]), vol.Optional(CONF_FROM): vol.Any(str, [str]),
vol.Optional(CONF_TO): vol.Any(str, [str]), vol.Optional(CONF_TO): vol.Any(str, [str]),
vol.Optional(CONF_FOR): cv.positive_time_period_template, vol.Optional(CONF_FOR): cv.positive_time_period_template,
vol.Optional(CONF_ATTRIBUTE): cv.match_all,
} }
), ),
cv.key_dependency(CONF_FOR, CONF_TO), cv.key_dependency(CONF_FOR, CONF_TO),
@ -59,23 +60,33 @@ async def async_attach_trigger(
period: Dict[str, timedelta] = {} period: Dict[str, timedelta] = {}
match_from_state = process_state_match(from_state) match_from_state = process_state_match(from_state)
match_to_state = process_state_match(to_state) match_to_state = process_state_match(to_state)
attribute = config.get(CONF_ATTRIBUTE)
@callback @callback
def state_automation_listener(event: Event): def state_automation_listener(event: Event):
"""Listen for state changes and calls action.""" """Listen for state changes and calls action."""
entity: str = event.data["entity_id"] entity: str = event.data["entity_id"]
if entity not in entity_id: from_s: Optional[State] = event.data.get("old_state")
return to_s: Optional[State] = event.data.get("new_state")
from_s = event.data.get("old_state") if from_s is None:
to_s = event.data.get("new_state") old_value = None
old_state = getattr(from_s, "state", None) elif attribute is None:
new_state = getattr(to_s, "state", None) old_value = from_s.state
else:
old_value = from_s.attributes.get(attribute)
if to_s is None:
new_value = None
elif attribute is None:
new_value = to_s.state
else:
new_value = to_s.attributes.get(attribute)
if ( if (
not match_from_state(old_state) not match_from_state(old_value)
or not match_to_state(new_state) or not match_to_state(new_value)
or (not match_all and old_state == new_state) or (not match_all and old_value == new_value)
): ):
return return
@ -91,6 +102,7 @@ async def async_attach_trigger(
"from_state": from_s, "from_state": from_s,
"to_state": to_s, "to_state": to_s,
"for": time_delta if not time_delta else period[entity], "for": time_delta if not time_delta else period[entity],
"attribute": attribute,
} }
}, },
event.context, event.context,
@ -119,10 +131,16 @@ async def async_attach_trigger(
) )
return return
def _check_same_state(_, _2, new_st): def _check_same_state(_, _2, new_st: State):
if new_st is None: if new_st is None:
return False return False
return new_st.state == to_s.state
if attribute is None:
cur_value = new_st.state
else:
cur_value = new_st.attributes.get(attribute)
return cur_value == new_value
unsub_track_same[entity] = async_track_same_state( unsub_track_same[entity] = async_track_same_state(
hass, period[entity], call_action, _check_same_state, entity_ids=entity, hass, period[entity], call_action, _check_same_state, entity_ids=entity,

View file

@ -1239,3 +1239,61 @@ def test_below_above():
numeric_state_trigger.TRIGGER_SCHEMA( numeric_state_trigger.TRIGGER_SCHEMA(
{"platform": "numeric_state", "above": 1200, "below": 1000} {"platform": "numeric_state", "above": 1200, "below": 1000}
) )
async def test_attribute_if_fires_on_entity_change_with_both_filters(hass, calls):
"""Test for firing if both filters are match attribute."""
hass.states.async_set("test.entity", "bla", {"test-measurement": 1})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "numeric_state",
"entity_id": "test.entity",
"above": 3,
"attribute": "test-measurement",
},
"action": {"service": "test.automation"},
}
},
)
await hass.async_block_till_done()
hass.states.async_set("test.entity", "bla", {"test-measurement": 4})
await hass.async_block_till_done()
assert len(calls) == 1
async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop(
hass, calls
):
"""Test for not firing on entity change with for after stop trigger."""
hass.states.async_set("test.entity", "bla", {"test-measurement": 1})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "numeric_state",
"entity_id": "test.entity",
"above": 3,
"attribute": "test-measurement",
"for": 5,
},
"action": {"service": "test.automation"},
}
},
)
await hass.async_block_till_done()
hass.states.async_set("test.entity", "bla", {"test-measurement": 4})
await hass.async_block_till_done()
assert len(calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert len(calls) == 1

View file

@ -1007,3 +1007,83 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls):
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 2 assert len(calls) == 2
assert calls[1].data["some"] == "test.entity_2 - 0:00:10" assert calls[1].data["some"] == "test.entity_2 - 0:00:10"
async def test_attribute_if_fires_on_entity_change_with_both_filters(hass, calls):
"""Test for firing if both filters are match attribute."""
hass.states.async_set("test.entity", "bla", {"name": "hello"})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "state",
"entity_id": "test.entity",
"from": "hello",
"to": "world",
"attribute": "name",
},
"action": {"service": "test.automation"},
}
},
)
await hass.async_block_till_done()
hass.states.async_set("test.entity", "bla", {"name": "world"})
await hass.async_block_till_done()
assert len(calls) == 1
async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop(
hass, calls
):
"""Test for not firing on entity change with for after stop trigger."""
hass.states.async_set("test.entity", "bla", {"name": "hello"})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "state",
"entity_id": "test.entity",
"from": "hello",
"to": "world",
"attribute": "name",
"for": 5,
},
"action": {"service": "test.automation"},
}
},
)
await hass.async_block_till_done()
# Test that the for-check works
hass.states.async_set("test.entity", "bla", {"name": "world"})
await hass.async_block_till_done()
assert len(calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
hass.states.async_set("test.entity", "bla", {"name": "world", "something": "else"})
await hass.async_block_till_done()
assert len(calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert len(calls) == 1
# Now remove state while inside "for"
hass.states.async_set("test.entity", "bla", {"name": "hello"})
hass.states.async_set("test.entity", "bla", {"name": "world"})
await hass.async_block_till_done()
assert len(calls) == 1
hass.states.async_remove("test.entity")
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert len(calls) == 1