diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index f60071d633c..f1e2bbf2c09 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -39,8 +39,8 @@ BASE_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( TRIGGER_STATE_SCHEMA = BASE_SCHEMA.extend( { # These are str on purpose. Want to catch YAML conversions - vol.Optional(CONF_FROM): vol.Any(str, [str]), - vol.Optional(CONF_TO): vol.Any(str, [str]), + vol.Optional(CONF_FROM): vol.Any(str, [str], None), + vol.Optional(CONF_TO): vol.Any(str, [str], None), } ) @@ -75,11 +75,15 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) - from_state = config.get(CONF_FROM, MATCH_ALL) - to_state = config.get(CONF_TO, MATCH_ALL) + if (from_state := config.get(CONF_FROM)) is None: + from_state = MATCH_ALL + if (to_state := config.get(CONF_TO)) is None: + to_state = MATCH_ALL time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) - match_all = from_state == MATCH_ALL and to_state == MATCH_ALL + # If neither CONF_FROM or CONF_TO are specified, + # fire on all changes to the state or an attribute + match_all = CONF_FROM not in config and CONF_TO not in config unsub_track_same = {} period: dict[str, timedelta] = {} match_from_state = process_state_match(from_state) diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 8671e40d293..c86bb0cc879 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -106,7 +106,7 @@ async def test_if_fires_on_entity_change_with_from_filter(hass, calls): async def test_if_fires_on_entity_change_with_to_filter(hass, calls): - """Test for firing on entity change with no filter.""" + """Test for firing on entity change with to filter.""" assert await async_setup_component( hass, automation.DOMAIN, @@ -128,6 +128,54 @@ async def test_if_fires_on_entity_change_with_to_filter(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_entity_change_with_from_filter_all(hass, calls): + """Test for firing on entity change with filter.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "from": None, + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("test.entity", "world") + hass.states.async_set("test.entity", "world", {"attribute": 5}) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_fires_on_entity_change_with_to_filter_all(hass, calls): + """Test for firing on entity change with to filter.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "to": None, + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("test.entity", "world") + hass.states.async_set("test.entity", "world", {"attribute": 5}) + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_fires_on_attribute_change_with_to_filter(hass, calls): """Test for not firing on attribute change.""" assert await async_setup_component( @@ -1217,6 +1265,94 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant(hass, call assert len(calls) == 1 +async def test_attribute_if_fires_on_entity_where_attr_stays_constant_filter( + hass, calls +): + """Test for firing if attribute stays the same.""" + hass.states.async_set("test.entity", "bla", {"name": "other_name"}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "attribute": "name", + "to": "best_name", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + # Leave all attributes the same + hass.states.async_set( + "test.entity", "bla", {"name": "best_name", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the untracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "best_name", "other": "new_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the tracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "other_name", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all(hass, calls): + """Test for firing if attribute stays the same.""" + hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "attribute": "name", + "to": None, + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + + # Leave all attributes the same + hass.states.async_set( + "test.entity", "bla", {"name": "name_1", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the untracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "name_1", "other": "new_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + # Change the tracked attribute + hass.states.async_set( + "test.entity", "bla", {"name": "name_2", "other": "old_value"} + ) + await hass.async_block_till_done() + assert len(calls) == 2 + + async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( hass, calls ):