Add template support to state trigger's for option (#24912)

This commit is contained in:
Phil Bruckner 2019-07-08 15:59:58 -05:00 committed by Paulus Schoutsen
parent f9b9883aba
commit 9944e675a5
2 changed files with 218 additions and 18 deletions

View File

@ -1,11 +1,16 @@
"""Offer state listening automation rules.""" """Offer state listening automation rules."""
import logging
import voluptuous as vol import voluptuous as vol
from homeassistant import exceptions
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_state_change, async_track_same_state) async_track_state_change, async_track_same_state)
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
CONF_ENTITY_ID = 'entity_id' CONF_ENTITY_ID = 'entity_id'
CONF_FROM = 'from' CONF_FROM = 'from'
@ -17,7 +22,9 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({
# These are str on purpose. Want to catch YAML conversions # These are str on purpose. Want to catch YAML conversions
vol.Optional(CONF_FROM): str, vol.Optional(CONF_FROM): str,
vol.Optional(CONF_TO): str, vol.Optional(CONF_TO): str,
vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_FOR): vol.Any(
vol.All(cv.time_period, cv.positive_timedelta),
cv.template, cv.template_complex),
}), cv.key_dependency(CONF_FOR, CONF_TO)) }), cv.key_dependency(CONF_FOR, CONF_TO))
@ -27,8 +34,10 @@ async def async_trigger(hass, config, action, automation_info):
from_state = config.get(CONF_FROM, MATCH_ALL) from_state = config.get(CONF_FROM, MATCH_ALL)
to_state = config.get(CONF_TO, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL)
time_delta = config.get(CONF_FOR) time_delta = config.get(CONF_FOR)
template.attach(hass, time_delta)
match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL)
unsub_track_same = {} unsub_track_same = {}
period = {}
@callback @callback
def state_automation_listener(entity, from_s, to_s): def state_automation_listener(entity, from_s, to_s):
@ -42,7 +51,7 @@ async def async_trigger(hass, config, action, automation_info):
'entity_id': entity, 'entity_id': entity,
'from_state': from_s, 'from_state': from_s,
'to_state': to_s, 'to_state': to_s,
'for': time_delta, 'for': time_delta if not time_delta else period[entity]
} }
}, context=to_s.context)) }, context=to_s.context))
@ -55,8 +64,38 @@ async def async_trigger(hass, config, action, automation_info):
call_action() call_action()
return return
variables = {
'trigger': {
'platform': 'state',
'entity_id': entity,
'from_state': from_s,
'to_state': to_s,
}
}
try:
if isinstance(time_delta, template.Template):
period[entity] = vol.All(
cv.time_period,
cv.positive_timedelta)(
time_delta.async_render(variables))
elif isinstance(time_delta, dict):
time_delta_data = {}
time_delta_data.update(
template.render_complex(time_delta, variables))
period[entity] = vol.All(
cv.time_period,
cv.positive_timedelta)(
time_delta_data)
else:
period[entity] = time_delta
except (exceptions.TemplateError, vol.Invalid) as ex:
_LOGGER.error("Error rendering '%s' for template: %s",
automation_info['name'], ex)
return
unsub_track_same[entity] = async_track_same_state( unsub_track_same[entity] = async_track_same_state(
hass, time_delta, call_action, hass, period[entity], call_action,
lambda _, _2, to_state: to_state.state == to_s.state, lambda _, _2, to_state: to_state.state == to_s.state,
entity_ids=entity) entity_ids=entity)

View File

@ -288,7 +288,6 @@ async def test_if_fails_setup_if_from_boolean_value(hass, calls):
async def test_if_fails_setup_bad_for(hass, calls): async def test_if_fails_setup_bad_for(hass, calls):
"""Test for setup failure for bad for.""" """Test for setup failure for bad for."""
with assert_setup_component(0, automation.DOMAIN):
assert await async_setup_component(hass, automation.DOMAIN, { assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: { automation.DOMAIN: {
'trigger': { 'trigger': {
@ -304,6 +303,11 @@ async def test_if_fails_setup_bad_for(hass, calls):
} }
}}) }})
with patch.object(automation.state, '_LOGGER') as mock_logger:
hass.states.async_set('test.entity', 'world')
await hass.async_block_till_done()
assert mock_logger.error.called
async def test_if_fails_setup_for_without_to(hass, calls): async def test_if_fails_setup_for_without_to(hass, calls):
"""Test for setup failures for missing to.""" """Test for setup failures for missing to."""
@ -749,3 +753,160 @@ async def test_if_fires_on_entities_change_overlap(hass, calls):
await hass.async_block_till_done() await hass.async_block_till_done()
assert 2 == len(calls) assert 2 == len(calls)
assert 'test.entity_2' == calls[1].data['some'] assert 'test.entity_2' == calls[1].data['some']
async def test_if_fires_on_change_with_for_template_1(hass, calls):
"""Test for firing on change with for template."""
assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
'to': 'world',
'for': {
'seconds': "{{ 5 }}"
},
},
'action': {
'service': 'test.automation'
}
}
})
hass.states.async_set('test.entity', 'world')
await hass.async_block_till_done()
assert 0 == len(calls)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert 1 == len(calls)
async def test_if_fires_on_change_with_for_template_2(hass, calls):
"""Test for firing on change with for template."""
assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
'to': 'world',
'for': "{{ 5 }}",
},
'action': {
'service': 'test.automation'
}
}
})
hass.states.async_set('test.entity', 'world')
await hass.async_block_till_done()
assert 0 == len(calls)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert 1 == len(calls)
async def test_if_fires_on_change_with_for_template_3(hass, calls):
"""Test for firing on change with for template."""
assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
'to': 'world',
'for': "00:00:{{ 5 }}",
},
'action': {
'service': 'test.automation'
}
}
})
hass.states.async_set('test.entity', 'world')
await hass.async_block_till_done()
assert 0 == len(calls)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert 1 == len(calls)
async def test_invalid_for_template_1(hass, calls):
"""Test for invalid for template."""
assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
'to': 'world',
'for': {
'seconds': "{{ five }}"
},
},
'action': {
'service': 'test.automation'
}
}
})
with patch.object(automation.state, '_LOGGER') as mock_logger:
hass.states.async_set('test.entity', 'world')
await hass.async_block_till_done()
assert mock_logger.error.called
async def test_if_fires_on_entities_change_overlap_for_template(hass, calls):
"""Test for firing on entities change with overlap and for template."""
assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': [
'test.entity_1',
'test.entity_2',
],
'to': 'world',
'for': '{{ 5 if trigger.entity_id == "test.entity_1"'
' else 10 }}',
},
'action': {
'service': 'test.automation',
'data_template': {
'some': '{{ trigger.entity_id }} - {{ trigger.for }}',
},
}
}
})
await hass.async_block_till_done()
utcnow = dt_util.utcnow()
with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
mock_utcnow.return_value = utcnow
hass.states.async_set('test.entity_1', 'world')
await hass.async_block_till_done()
mock_utcnow.return_value += timedelta(seconds=1)
async_fire_time_changed(hass, mock_utcnow.return_value)
hass.states.async_set('test.entity_2', 'world')
await hass.async_block_till_done()
mock_utcnow.return_value += timedelta(seconds=1)
async_fire_time_changed(hass, mock_utcnow.return_value)
hass.states.async_set('test.entity_2', 'hello')
await hass.async_block_till_done()
mock_utcnow.return_value += timedelta(seconds=1)
async_fire_time_changed(hass, mock_utcnow.return_value)
hass.states.async_set('test.entity_2', 'world')
await hass.async_block_till_done()
assert 0 == len(calls)
mock_utcnow.return_value += timedelta(seconds=3)
async_fire_time_changed(hass, mock_utcnow.return_value)
await hass.async_block_till_done()
assert 1 == len(calls)
assert 'test.entity_1 - 0:00:05' == calls[0].data['some']
mock_utcnow.return_value += timedelta(seconds=3)
async_fire_time_changed(hass, mock_utcnow.return_value)
await hass.async_block_till_done()
assert 1 == len(calls)
mock_utcnow.return_value += timedelta(seconds=5)
async_fire_time_changed(hass, mock_utcnow.return_value)
await hass.async_block_till_done()
assert 2 == len(calls)
assert 'test.entity_2 - 0:00:10' == calls[1].data['some']