From 9e48b8815409fbc056184358a021d2e3d2440f45 Mon Sep 17 00:00:00 2001 From: pavoni Date: Fri, 19 Feb 2016 14:49:11 +0000 Subject: [PATCH] Add `for` delay to state automation. --- homeassistant/components/automation/state.py | 64 ++++++++++++++++- tests/components/automation/test_state.py | 73 +++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 1eaa9e5c240..82182304ff9 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -7,14 +7,21 @@ For more details about this automation rule, please refer to the documentation at https://home-assistant.io/components/automation/#state-trigger """ import logging +from datetime import timedelta -from homeassistant.const import MATCH_ALL -from homeassistant.helpers.event import track_state_change +import homeassistant.util.dt as dt_util + +from homeassistant.const import ( + EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) +from homeassistant.components.automation.time import ( + CONF_HOURS, CONF_MINUTES, CONF_SECONDS) +from homeassistant.helpers.event import track_state_change, track_point_in_time CONF_ENTITY_ID = "entity_id" CONF_FROM = "from" CONF_TO = "to" CONF_STATE = "state" +CONF_FOR = "for" def trigger(hass, config, action): @@ -34,9 +41,60 @@ def trigger(hass, config, action): 'Config error. Surround to/from values with quotes.') return False + if CONF_FOR in config: + hours = config[CONF_FOR].get(CONF_HOURS) + minutes = config[CONF_FOR].get(CONF_MINUTES) + seconds = config[CONF_FOR].get(CONF_SECONDS) + + if hours is None and minutes is None and seconds is None: + logging.getLogger(__name__).error( + "Received invalid value for '%s': %s", + config[CONF_FOR], CONF_FOR) + return False + + if config.get(CONF_TO) is None and config.get(CONF_STATE) is None: + logging.getLogger(__name__).error( + "For: requires a to: value'%s': %s", + config[CONF_FOR], CONF_FOR) + return False + def state_automation_listener(entity, from_s, to_s): """ Listens for state changes and calls action. """ - action() + + def state_for_listener(now): + """ Fires on state changes after a delay and calls action. """ + logging.getLogger(__name__).error('Listener fired') + + hass.bus.remove_listener( + EVENT_STATE_CHANGED, for_state_listener) + action() + + def state_for_cancel_listener(entity, inner_from_s, inner_to_s): + """ Fires on state changes and cancels + for listener if state changed. """ + logging.getLogger(__name__).error( + 'state_for_cancel_listener') + if inner_to_s == to_s: + return + logging.getLogger(__name__).error('Listeners removed') + hass.bus.remove_listener(EVENT_TIME_CHANGED, for_time_listener) + hass.bus.remove_listener( + EVENT_STATE_CHANGED, for_state_listener) + + if CONF_FOR in config: + now = dt_util.now() + target_tm = now + timedelta( + hours=(hours or 0.0), + minutes=(minutes or 0.0), + seconds=(seconds or 0.0)) + logging.getLogger(__name__).error('Listeners added') + for_time_listener = track_point_in_time( + hass, state_for_listener, target_tm) + for_state_listener = track_state_change( + hass, entity_id, state_for_cancel_listener, + MATCH_ALL, MATCH_ALL) + else: + action() track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index fcd1fb616e9..8475d284d3e 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -5,11 +5,13 @@ tests.components.automation.test_state Tests state automation. """ import unittest +from datetime import timedelta +import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation import homeassistant.components.automation.state as state -from tests.common import get_test_home_assistant +from tests.common import fire_time_changed, get_test_home_assistant class TestAutomationState(unittest.TestCase): @@ -352,3 +354,72 @@ class TestAutomationState(unittest.TestCase): 'entity_id': 'test.entity', 'from': True, }, lambda x: x)) + + def test_if_fails_setup_bad_for(self): + self.assertFalse(state.trigger( + self.hass, { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world', + 'for': { + 'invalid': 5 + }, + }, lambda x: x)) + + def test_if_fails_setup_for_without_to(self): + self.assertFalse(state.trigger( + self.hass, { + 'platform': 'state', + 'entity_id': 'test.entity', + 'for': { + 'seconds': 5 + }, + }, lambda x: x)) + + def test_if_not_fires_on_entity_change_with_for(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + self.hass.states.set('test.entity', 'not_world') + self.hass.pool.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_with_for(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + + self.hass.states.set('test.entity', 'world') + self.hass.pool.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls))