From 415213a325df9a6037a9b6bfcde16edf46411407 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Aug 2020 16:21:16 +0200 Subject: [PATCH] Add support for attributes in state/numeric state trigger (#39238) --- .../homeassistant/triggers/numeric_state.py | 6 +- .../homeassistant/triggers/state.py | 46 +++++++---- .../triggers/test_numeric_state.py | 58 ++++++++++++++ .../homeassistant/triggers/test_state.py | 80 +++++++++++++++++++ 4 files changed, 175 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index b2958f4d63a..a1678e0a24c 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import ( CONF_ABOVE, + CONF_ATTRIBUTE, CONF_BELOW, CONF_ENTITY_ID, CONF_FOR, @@ -48,6 +49,7 @@ TRIGGER_SCHEMA = vol.All( vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.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), @@ -70,6 +72,7 @@ async def async_attach_trigger( unsub_track_same = {} entities_triggered = set() period: dict = {} + attribute = config.get(CONF_ATTRIBUTE) if value_template is not None: value_template.hass = hass @@ -86,10 +89,11 @@ async def async_attach_trigger( "entity_id": entity, "below": below, "above": above, + "attribute": attribute, } } return condition.async_numeric_state( - hass, to_s, below, above, value_template, variables + hass, to_s, below, above, value_template, variables, attribute ) @callback diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 9ddd1b3fcb8..7ccba9aade8 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -1,13 +1,13 @@ """Offer state listening automation rules.""" from datetime import timedelta import logging -from typing import Dict +from typing import Dict, Optional import voluptuous as vol from homeassistant import exceptions -from homeassistant.const import CONF_FOR, CONF_PLATFORM, MATCH_ALL -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.event import ( Event, @@ -34,6 +34,7 @@ TRIGGER_SCHEMA = vol.All( vol.Optional(CONF_FROM): vol.Any(str, [str]), vol.Optional(CONF_TO): vol.Any(str, [str]), vol.Optional(CONF_FOR): cv.positive_time_period_template, + vol.Optional(CONF_ATTRIBUTE): cv.match_all, } ), cv.key_dependency(CONF_FOR, CONF_TO), @@ -59,23 +60,33 @@ async def async_attach_trigger( period: Dict[str, timedelta] = {} match_from_state = process_state_match(from_state) match_to_state = process_state_match(to_state) + attribute = config.get(CONF_ATTRIBUTE) @callback def state_automation_listener(event: Event): """Listen for state changes and calls action.""" entity: str = event.data["entity_id"] - if entity not in entity_id: - return + from_s: Optional[State] = event.data.get("old_state") + to_s: Optional[State] = event.data.get("new_state") - from_s = event.data.get("old_state") - to_s = event.data.get("new_state") - old_state = getattr(from_s, "state", None) - new_state = getattr(to_s, "state", None) + if from_s is None: + old_value = None + elif attribute is 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 ( - not match_from_state(old_state) - or not match_to_state(new_state) - or (not match_all and old_state == new_state) + not match_from_state(old_value) + or not match_to_state(new_value) + or (not match_all and old_value == new_value) ): return @@ -91,6 +102,7 @@ async def async_attach_trigger( "from_state": from_s, "to_state": to_s, "for": time_delta if not time_delta else period[entity], + "attribute": attribute, } }, event.context, @@ -119,10 +131,16 @@ async def async_attach_trigger( ) return - def _check_same_state(_, _2, new_st): + def _check_same_state(_, _2, new_st: State): if new_st is None: 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( hass, period[entity], call_action, _check_same_state, entity_ids=entity, diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 01b276c236a..932dde91120 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1239,3 +1239,61 @@ def test_below_above(): numeric_state_trigger.TRIGGER_SCHEMA( {"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 diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 7f256293025..9cce567ca68 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -1007,3 +1007,83 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 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