Add support for attributes in state/numeric state trigger (#39238)
This commit is contained in:
parent
342e84e550
commit
415213a325
4 changed files with 175 additions and 15 deletions
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue