Core track same state for a period / Allow on platforms (#9273)

* Core track state period / Allow on platforms

* Add tests

* fix lint

* fix tests

* add new tracker to automation state

* update schema

* fix bug

* revert validate string

* Fix bug

* Set arguments to async_check_funct

* add logic into numeric_state

* fix numeric_state

* Add tests

* fix retrigger state

* cleanup

* Add delay function to template binary_sensor

* Fix tests & lint

* add more tests

* fix lint

* Address comments

* fix test & lint
This commit is contained in:
Pascal Vizeli 2017-09-05 02:01:01 +02:00 committed by GitHub
parent 67828cb7a2
commit ed699896cb
8 changed files with 548 additions and 88 deletions

View file

@ -12,16 +12,18 @@ import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.const import ( from homeassistant.const import (
CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID,
CONF_BELOW, CONF_ABOVE) CONF_BELOW, CONF_ABOVE, CONF_FOR)
from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.event import (
async_track_state_change, async_track_same_state)
from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers import condition, config_validation as cv
TRIGGER_SCHEMA = vol.All(vol.Schema({ TRIGGER_SCHEMA = vol.All(vol.Schema({
vol.Required(CONF_PLATFORM): 'numeric_state', vol.Required(CONF_PLATFORM): 'numeric_state',
vol.Required(CONF_ENTITY_ID): cv.entity_ids, vol.Required(CONF_ENTITY_ID): cv.entity_ids,
CONF_BELOW: vol.Coerce(float), vol.Optional(CONF_BELOW): vol.Coerce(float),
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): vol.All(cv.time_period, cv.positive_timedelta),
}), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) }), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE))
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -33,15 +35,18 @@ def async_trigger(hass, config, action):
entity_id = config.get(CONF_ENTITY_ID) entity_id = config.get(CONF_ENTITY_ID)
below = config.get(CONF_BELOW) below = config.get(CONF_BELOW)
above = config.get(CONF_ABOVE) above = config.get(CONF_ABOVE)
time_delta = config.get(CONF_FOR)
value_template = config.get(CONF_VALUE_TEMPLATE) value_template = config.get(CONF_VALUE_TEMPLATE)
async_remove_track_same = None
if value_template is not None: if value_template is not None:
value_template.hass = hass value_template.hass = hass
@callback @callback
def state_automation_listener(entity, from_s, to_s): def check_numeric_state(entity, from_s, to_s):
"""Listen for state changes and calls action.""" """Return True if they should trigger."""
if to_s is None: if to_s is None:
return return False
variables = { variables = {
'trigger': { 'trigger': {
@ -55,17 +60,56 @@ def async_trigger(hass, config, action):
# If new one doesn't match, nothing to do # If new one doesn't match, nothing to do
if not condition.async_numeric_state( if not condition.async_numeric_state(
hass, to_s, below, above, value_template, variables): hass, to_s, below, above, value_template, variables):
return False
return True
@callback
def state_automation_listener(entity, from_s, to_s):
"""Listen for state changes and calls action."""
nonlocal async_remove_track_same
if not check_numeric_state(entity, from_s, to_s):
return return
variables = {
'trigger': {
'platform': 'numeric_state',
'entity_id': entity,
'below': below,
'above': above,
'from_state': from_s,
'to_state': to_s,
}
}
# Only match if old didn't exist or existed but didn't match # Only match if old didn't exist or existed but didn't match
# Written as: skip if old one did exist and matched # Written as: skip if old one did exist and matched
if from_s is not None and condition.async_numeric_state( if from_s is not None and condition.async_numeric_state(
hass, from_s, below, above, value_template, variables): hass, from_s, below, above, value_template, variables):
return return
variables['trigger']['from_state'] = from_s @callback
variables['trigger']['to_state'] = to_s def call_action():
"""Call action with right context."""
hass.async_run_job(action, variables)
hass.async_run_job(action, variables) if not time_delta:
call_action()
return
return async_track_state_change(hass, entity_id, state_automation_listener) async_remove_track_same = async_track_same_state(
hass, True, time_delta, call_action, entity_ids=entity_id,
async_check_func=check_numeric_state)
unsub = async_track_state_change(
hass, entity_id, state_automation_listener)
@callback
def async_remove():
"""Remove state listeners async."""
unsub()
if async_remove_track_same:
async_remove_track_same() # pylint: disable=not-callable
return async_remove

View file

@ -8,28 +8,23 @@ import asyncio
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.util.dt as dt_util from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR
from homeassistant.const import MATCH_ALL, CONF_PLATFORM
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_state_change, async_track_point_in_utc_time) async_track_state_change, async_track_same_state)
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
CONF_ENTITY_ID = 'entity_id' CONF_ENTITY_ID = 'entity_id'
CONF_FROM = 'from' CONF_FROM = 'from'
CONF_TO = 'to' CONF_TO = 'to'
CONF_FOR = 'for'
TRIGGER_SCHEMA = vol.All( TRIGGER_SCHEMA = vol.All(vol.Schema({
vol.Schema({ vol.Required(CONF_PLATFORM): 'state',
vol.Required(CONF_PLATFORM): 'state', vol.Required(CONF_ENTITY_ID): cv.entity_ids,
vol.Required(CONF_ENTITY_ID): cv.entity_ids, # 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,
CONF_FROM: str, vol.Optional(CONF_TO): str,
CONF_TO: str, vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta),
CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), }), cv.key_dependency(CONF_FOR, CONF_TO))
}),
cv.key_dependency(CONF_FOR, CONF_TO),
)
@asyncio.coroutine @asyncio.coroutine
@ -39,28 +34,15 @@ def async_trigger(hass, config, action):
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)
async_remove_state_for_cancel = None
async_remove_state_for_listener = None
match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL)
async_remove_track_same = None
@callback
def clear_listener():
"""Clear all unsub listener."""
nonlocal async_remove_state_for_cancel, async_remove_state_for_listener
# pylint: disable=not-callable
if async_remove_state_for_listener is not None:
async_remove_state_for_listener()
async_remove_state_for_listener = None
if async_remove_state_for_cancel is not None:
async_remove_state_for_cancel()
async_remove_state_for_cancel = None
@callback @callback
def state_automation_listener(entity, from_s, to_s): def state_automation_listener(entity, from_s, to_s):
"""Listen for state changes and calls action.""" """Listen for state changes and calls action."""
nonlocal async_remove_state_for_cancel, async_remove_state_for_listener nonlocal async_remove_track_same
@callback
def call_action(): def call_action():
"""Call action with right context.""" """Call action with right context."""
hass.async_run_job(action, { hass.async_run_job(action, {
@ -78,33 +60,12 @@ def async_trigger(hass, config, action):
from_s.last_changed == to_s.last_changed): from_s.last_changed == to_s.last_changed):
return return
if time_delta is None: if not time_delta:
call_action() call_action()
return return
@callback async_remove_track_same = async_track_same_state(
def state_for_listener(now): hass, to_s.state, time_delta, call_action, entity_ids=entity_id)
"""Fire on state changes after a delay and calls action."""
nonlocal async_remove_state_for_listener
async_remove_state_for_listener = None
clear_listener()
call_action()
@callback
def state_for_cancel_listener(entity, inner_from_s, inner_to_s):
"""Fire on changes and cancel for listener if changed."""
if inner_to_s.state == to_s.state:
return
clear_listener()
# cleanup previous listener
clear_listener()
async_remove_state_for_listener = async_track_point_in_utc_time(
hass, state_for_listener, dt_util.utcnow() + time_delta)
async_remove_state_for_cancel = async_track_state_change(
hass, entity, state_for_cancel_listener)
unsub = async_track_state_change( unsub = async_track_state_change(
hass, entity_id, state_automation_listener, from_state, to_state) hass, entity_id, state_automation_listener, from_state, to_state)
@ -113,6 +74,7 @@ def async_trigger(hass, config, action):
def async_remove(): def async_remove():
"""Remove state listeners async.""" """Remove state listeners async."""
unsub() unsub()
clear_listener() if async_remove_track_same:
async_remove_track_same() # pylint: disable=not-callable
return async_remove return async_remove

View file

@ -19,16 +19,24 @@ from homeassistant.const import (
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.event import (
async_track_state_change, async_track_same_state)
from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.restore_state import async_get_last_state
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_DELAY_ON = 'delay_on'
CONF_DELAY_OFF = 'delay_off'
SENSOR_SCHEMA = vol.Schema({ SENSOR_SCHEMA = vol.Schema({
vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_DELAY_ON):
vol.All(cv.time_period, cv.positive_timedelta),
vol.Optional(CONF_DELAY_OFF):
vol.All(cv.time_period, cv.positive_timedelta),
}) })
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
value_template.extract_entities()) value_template.extract_entities())
friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
device_class = device_config.get(CONF_DEVICE_CLASS) device_class = device_config.get(CONF_DEVICE_CLASS)
delay_on = device_config.get(CONF_DELAY_ON)
delay_off = device_config.get(CONF_DELAY_OFF)
if value_template is not None: if value_template is not None:
value_template.hass = hass value_template.hass = hass
@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
sensors.append( sensors.append(
BinarySensorTemplate( BinarySensorTemplate(
hass, device, friendly_name, device_class, value_template, hass, device, friendly_name, device_class, value_template,
entity_ids) entity_ids, delay_on, delay_off)
) )
if not sensors: if not sensors:
_LOGGER.error("No sensors added") _LOGGER.error("No sensors added")
return False return False
async_add_devices(sensors, True) async_add_devices(sensors)
return True return True
@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice):
"""A virtual binary sensor that triggers from another sensor.""" """A virtual binary sensor that triggers from another sensor."""
def __init__(self, hass, device, friendly_name, device_class, def __init__(self, hass, device, friendly_name, device_class,
value_template, entity_ids): value_template, entity_ids, delay_on, delay_off):
"""Initialize the Template binary sensor.""" """Initialize the Template binary sensor."""
self.hass = hass self.hass = hass
self.entity_id = async_generate_entity_id( self.entity_id = async_generate_entity_id(
@ -78,6 +88,8 @@ class BinarySensorTemplate(BinarySensorDevice):
self._template = value_template self._template = value_template
self._state = None self._state = None
self._entities = entity_ids self._entities = entity_ids
self._delay_on = delay_on
self._delay_off = delay_off
@asyncio.coroutine @asyncio.coroutine
def async_added_to_hass(self): def async_added_to_hass(self):
@ -89,7 +101,7 @@ class BinarySensorTemplate(BinarySensorDevice):
@callback @callback
def template_bsensor_state_listener(entity, old_state, new_state): def template_bsensor_state_listener(entity, old_state, new_state):
"""Handle the target device state changes.""" """Handle the target device state changes."""
self.hass.async_add_job(self.async_update_ha_state(True)) self.async_check_state()
@callback @callback
def template_bsensor_startup(event): def template_bsensor_startup(event):
@ -97,7 +109,7 @@ class BinarySensorTemplate(BinarySensorDevice):
async_track_state_change( async_track_state_change(
self.hass, self._entities, template_bsensor_state_listener) self.hass, self._entities, template_bsensor_state_listener)
self.hass.async_add_job(self.async_update_ha_state(True)) self.hass.async_add_job(self.async_check_state)
self.hass.bus.async_listen_once( self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, template_bsensor_startup) EVENT_HOMEASSISTANT_START, template_bsensor_startup)
@ -122,11 +134,11 @@ class BinarySensorTemplate(BinarySensorDevice):
"""No polling needed.""" """No polling needed."""
return False return False
@asyncio.coroutine @callback
def async_update(self): def _async_render(self, *args):
"""Update the state from the template.""" """Get the state of template."""
try: try:
self._state = self._template.async_render().lower() == 'true' return self._template.async_render().lower() == 'true'
except TemplateError as ex: except TemplateError as ex:
if ex.args and ex.args[0].startswith( if ex.args and ex.args[0].startswith(
"UndefinedError: 'None' has no attribute"): "UndefinedError: 'None' has no attribute"):
@ -135,4 +147,29 @@ class BinarySensorTemplate(BinarySensorDevice):
"the state is unknown", self._name) "the state is unknown", self._name)
return return
_LOGGER.error("Could not render template %s: %s", self._name, ex) _LOGGER.error("Could not render template %s: %s", self._name, ex)
self._state = False
@callback
def async_check_state(self):
"""Update the state from the template."""
state = self._async_render()
# return if the state don't change or is invalid
if state is None or state == self.state:
return
@callback
def set_state():
"""Set state of template binary sensor."""
self._state = state
self.hass.async_add_job(self.async_update_ha_state())
# state without delay
if (state and not self._delay_on) or \
(not state and not self._delay_off):
set_state()
return
period = self._delay_on if state else self._delay_off
async_track_same_state(
self.hass, state, period, set_state, entity_ids=self._entities,
async_check_func=self._async_render)

View file

@ -101,6 +101,7 @@ CONF_EVENT = 'event'
CONF_EXCLUDE = 'exclude' CONF_EXCLUDE = 'exclude'
CONF_FILE_PATH = 'file_path' CONF_FILE_PATH = 'file_path'
CONF_FILENAME = 'filename' CONF_FILENAME = 'filename'
CONF_FOR = 'for'
CONF_FRIENDLY_NAME = 'friendly_name' CONF_FRIENDLY_NAME = 'friendly_name'
CONF_HEADERS = 'headers' CONF_HEADERS = 'headers'
CONF_HOST = 'host' CONF_HOST = 'host'

View file

@ -113,6 +113,62 @@ def async_track_template(hass, template, action, variables=None):
track_template = threaded_listener_factory(async_track_template) track_template = threaded_listener_factory(async_track_template)
@callback
def async_track_same_state(hass, orig_value, period, action,
async_check_func=None, entity_ids=MATCH_ALL):
"""Track the state of entities for a period and run a action.
If async_check_func is None it use the state of orig_value.
Without entity_ids we track all state changes.
"""
async_remove_state_for_cancel = None
async_remove_state_for_listener = None
@callback
def clear_listener():
"""Clear all unsub listener."""
nonlocal async_remove_state_for_cancel, async_remove_state_for_listener
# pylint: disable=not-callable
if async_remove_state_for_listener is not None:
async_remove_state_for_listener()
async_remove_state_for_listener = None
if async_remove_state_for_cancel is not None:
async_remove_state_for_cancel()
async_remove_state_for_cancel = None
@callback
def state_for_listener(now):
"""Fire on state changes after a delay and calls action."""
nonlocal async_remove_state_for_listener
async_remove_state_for_listener = None
clear_listener()
hass.async_run_job(action)
@callback
def state_for_cancel_listener(entity, from_state, to_state):
"""Fire on changes and cancel for listener if changed."""
if async_check_func:
value = async_check_func(entity, from_state, to_state)
else:
value = to_state.state
if orig_value == value:
return
clear_listener()
async_remove_state_for_listener = async_track_point_in_utc_time(
hass, state_for_listener, dt_util.utcnow() + period)
async_remove_state_for_cancel = async_track_state_change(
hass, entity_ids, state_for_cancel_listener)
return clear_listener
track_same_state = threaded_listener_factory(async_track_same_state)
@callback @callback
def async_track_point_in_time(hass, action, point_in_time): def async_track_point_in_time(hass, action, point_in_time):
"""Add a listener that fires once after a specific point in time.""" """Add a listener that fires once after a specific point in time."""

View file

@ -1,11 +1,16 @@
"""The tests for numeric state automation.""" """The tests for numeric state automation."""
from datetime import timedelta
import unittest import unittest
from unittest.mock import patch
import homeassistant.components.automation as automation
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.setup import setup_component from homeassistant.setup import setup_component
import homeassistant.components.automation as automation import homeassistant.util.dt as dt_util
from tests.common import get_test_home_assistant, mock_component from tests.common import (
get_test_home_assistant, mock_component, fire_time_changed,
assert_setup_component)
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -576,3 +581,126 @@ class TestAutomationNumericState(unittest.TestCase):
self.hass.block_till_done() self.hass.block_till_done()
self.assertEqual(2, len(self.calls)) self.assertEqual(2, len(self.calls))
def test_if_fails_setup_bad_for(self):
"""Test for setup failure for bad for."""
with assert_setup_component(0):
assert setup_component(self.hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
'above': 8,
'below': 12,
'for': {
'invalid': 5
},
},
'action': {
'service': 'homeassistant.turn_on',
}
}})
def test_if_fails_setup_for_without_above_below(self):
"""Test for setup failures for missing above or below."""
with assert_setup_component(0):
assert setup_component(self.hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
'for': {
'seconds': 5
},
},
'action': {
'service': 'homeassistant.turn_on',
}
}})
def test_if_not_fires_on_entity_change_with_for(self):
"""Test for not firing on entity change with for."""
assert setup_component(self.hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
'above': 8,
'below': 12,
'for': {
'seconds': 5
},
},
'action': {
'service': 'test.automation'
}
}
})
self.hass.states.set('test.entity', 9)
self.hass.block_till_done()
self.hass.states.set('test.entity', 15)
self.hass.block_till_done()
fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10))
self.hass.block_till_done()
self.assertEqual(0, len(self.calls))
def test_if_fires_on_entity_change_with_for_attribute_change(self):
"""Test for firing on entity change with for and attribute change."""
assert setup_component(self.hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
'above': 8,
'below': 12,
'for': {
'seconds': 5
},
},
'action': {
'service': 'test.automation'
}
}
})
utcnow = dt_util.utcnow()
with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
mock_utcnow.return_value = utcnow
self.hass.states.set('test.entity', 9)
self.hass.block_till_done()
mock_utcnow.return_value += timedelta(seconds=4)
fire_time_changed(self.hass, mock_utcnow.return_value)
self.hass.states.set('test.entity', 9,
attributes={"mock_attr": "attr_change"})
self.hass.block_till_done()
self.assertEqual(0, len(self.calls))
mock_utcnow.return_value += timedelta(seconds=4)
fire_time_changed(self.hass, mock_utcnow.return_value)
self.hass.block_till_done()
self.assertEqual(1, len(self.calls))
def test_if_fires_on_entity_change_with_for(self):
"""Test for firing on entity change with for."""
assert setup_component(self.hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
'above': 8,
'below': 12,
'for': {
'seconds': 5
},
},
'action': {
'service': 'test.automation'
}
}
})
self.hass.states.set('test.entity', 9)
self.hass.block_till_done()
fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10))
self.hass.block_till_done()
self.assertEqual(1, len(self.calls))

View file

@ -1,5 +1,6 @@
"""The tests for the Template Binary sensor platform.""" """The tests for the Template Binary sensor platform."""
import asyncio import asyncio
from datetime import timedelta
import unittest import unittest
from unittest import mock from unittest import mock
@ -10,10 +11,12 @@ from homeassistant.components.binary_sensor import template
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
from homeassistant.helpers import template as template_hlpr from homeassistant.helpers import template as template_hlpr
from homeassistant.util.async import run_callback_threadsafe from homeassistant.util.async import run_callback_threadsafe
import homeassistant.util.dt as dt_util
from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE
from tests.common import ( from tests.common import (
get_test_home_assistant, assert_setup_component, mock_component) get_test_home_assistant, assert_setup_component, mock_component,
async_fire_time_changed)
class TestBinarySensorTemplate(unittest.TestCase): class TestBinarySensorTemplate(unittest.TestCase):
@ -103,19 +106,20 @@ class TestBinarySensorTemplate(unittest.TestCase):
vs = run_callback_threadsafe( vs = run_callback_threadsafe(
self.hass.loop, template.BinarySensorTemplate, self.hass.loop, template.BinarySensorTemplate,
self.hass, 'parent', 'Parent', 'motion', self.hass, 'parent', 'Parent', 'motion',
template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL,
None, None
).result() ).result()
self.assertFalse(vs.should_poll) self.assertFalse(vs.should_poll)
self.assertEqual('motion', vs.device_class) self.assertEqual('motion', vs.device_class)
self.assertEqual('Parent', vs.name) self.assertEqual('Parent', vs.name)
vs.update() run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
self.assertFalse(vs.is_on) self.assertFalse(vs.is_on)
# pylint: disable=protected-access # pylint: disable=protected-access
vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass) vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass)
vs.update() run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
self.assertTrue(vs.is_on) self.assertTrue(vs.is_on)
def test_event(self): def test_event(self):
@ -155,13 +159,14 @@ class TestBinarySensorTemplate(unittest.TestCase):
vs = run_callback_threadsafe( vs = run_callback_threadsafe(
self.hass.loop, template.BinarySensorTemplate, self.hass.loop, template.BinarySensorTemplate,
self.hass, 'parent', 'Parent', 'motion', self.hass, 'parent', 'Parent', 'motion',
template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL,
None, None
).result() ).result()
mock_render.side_effect = TemplateError('foo') mock_render.side_effect = TemplateError('foo')
vs.update() run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
mock_render.side_effect = TemplateError( mock_render.side_effect = TemplateError(
"UndefinedError: 'None' has no attribute") "UndefinedError: 'None' has no attribute")
vs.update() run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
@asyncio.coroutine @asyncio.coroutine
@ -197,3 +202,124 @@ def test_restore_state(hass):
state = hass.states.get('binary_sensor.test') state = hass.states.get('binary_sensor.test')
assert state.state == 'off' assert state.state == 'off'
@asyncio.coroutine
def test_template_delay_on(hass):
"""Test binary sensor template delay on."""
config = {
'binary_sensor': {
'platform': 'template',
'sensors': {
'test': {
'friendly_name': 'virtual thingy',
'value_template':
"{{ states.sensor.test_state.state == 'on' }}",
'device_class': 'motion',
'delay_on': 5
},
},
},
}
yield from setup.async_setup_component(hass, 'binary_sensor', config)
yield from hass.async_start()
hass.states.async_set('sensor.test_state', 'on')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'off'
future = dt_util.utcnow() + timedelta(seconds=5)
async_fire_time_changed(hass, future)
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'on'
# check with time changes
hass.states.async_set('sensor.test_state', 'off')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'off'
hass.states.async_set('sensor.test_state', 'on')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'off'
hass.states.async_set('sensor.test_state', 'off')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'off'
future = dt_util.utcnow() + timedelta(seconds=5)
async_fire_time_changed(hass, future)
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'off'
@asyncio.coroutine
def test_template_delay_off(hass):
"""Test binary sensor template delay off."""
config = {
'binary_sensor': {
'platform': 'template',
'sensors': {
'test': {
'friendly_name': 'virtual thingy',
'value_template':
"{{ states.sensor.test_state.state == 'on' }}",
'device_class': 'motion',
'delay_off': 5
},
},
},
}
hass.states.async_set('sensor.test_state', 'on')
yield from setup.async_setup_component(hass, 'binary_sensor', config)
yield from hass.async_start()
hass.states.async_set('sensor.test_state', 'off')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'on'
future = dt_util.utcnow() + timedelta(seconds=5)
async_fire_time_changed(hass, future)
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'off'
# check with time changes
hass.states.async_set('sensor.test_state', 'on')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'on'
hass.states.async_set('sensor.test_state', 'off')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'on'
hass.states.async_set('sensor.test_state', 'on')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'on'
future = dt_util.utcnow() + timedelta(seconds=5)
async_fire_time_changed(hass, future)
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.test')
assert state.state == 'on'

View file

@ -17,6 +17,7 @@ from homeassistant.helpers.event import (
track_state_change, track_state_change,
track_time_interval, track_time_interval,
track_template, track_template,
track_same_state,
track_sunrise, track_sunrise,
track_sunset, track_sunset,
) )
@ -24,7 +25,7 @@ from homeassistant.helpers.template import Template
from homeassistant.components import sun from homeassistant.components import sun
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from tests.common import get_test_home_assistant from tests.common import get_test_home_assistant, fire_time_changed
from unittest.mock import patch from unittest.mock import patch
@ -262,6 +263,111 @@ class TestEventHelpers(unittest.TestCase):
self.assertEqual(2, len(wildcard_runs)) self.assertEqual(2, len(wildcard_runs))
self.assertEqual(2, len(wildercard_runs)) self.assertEqual(2, len(wildercard_runs))
def test_track_same_state_simple_trigger(self):
"""Test track_same_change with trigger simple."""
thread_runs = []
callback_runs = []
coroutine_runs = []
period = timedelta(minutes=1)
def thread_run_callback():
thread_runs.append(1)
track_same_state(
self.hass, 'on', period, thread_run_callback,
entity_ids='light.Bowl')
@ha.callback
def callback_run_callback():
callback_runs.append(1)
track_same_state(
self.hass, 'on', period, callback_run_callback,
entity_ids='light.Bowl')
@asyncio.coroutine
def coroutine_run_callback():
coroutine_runs.append(1)
track_same_state(
self.hass, 'on', period, coroutine_run_callback)
# Adding state to state machine
self.hass.states.set("light.Bowl", "on")
self.hass.block_till_done()
self.assertEqual(0, len(thread_runs))
self.assertEqual(0, len(callback_runs))
self.assertEqual(0, len(coroutine_runs))
# change time to track and see if they trigger
future = dt_util.utcnow() + period
fire_time_changed(self.hass, future)
self.hass.block_till_done()
self.assertEqual(1, len(thread_runs))
self.assertEqual(1, len(callback_runs))
self.assertEqual(1, len(coroutine_runs))
def test_track_same_state_simple_no_trigger(self):
"""Test track_same_change with no trigger."""
callback_runs = []
period = timedelta(minutes=1)
@ha.callback
def callback_run_callback():
callback_runs.append(1)
track_same_state(
self.hass, 'on', period, callback_run_callback,
entity_ids='light.Bowl')
# Adding state to state machine
self.hass.states.set("light.Bowl", "on")
self.hass.block_till_done()
self.assertEqual(0, len(callback_runs))
# Change state on state machine
self.hass.states.set("light.Bowl", "off")
self.hass.block_till_done()
self.assertEqual(0, len(callback_runs))
# change time to track and see if they trigger
future = dt_util.utcnow() + period
fire_time_changed(self.hass, future)
self.hass.block_till_done()
self.assertEqual(0, len(callback_runs))
def test_track_same_state_simple_trigger_check_funct(self):
"""Test track_same_change with trigger and check funct."""
callback_runs = []
check_func = []
period = timedelta(minutes=1)
@ha.callback
def callback_run_callback():
callback_runs.append(1)
@ha.callback
def async_check_func(entity, from_s, to_s):
check_func.append((entity, from_s, to_s))
return 'on'
track_same_state(
self.hass, 'on', period, callback_run_callback,
entity_ids='light.Bowl', async_check_func=async_check_func)
# Adding state to state machine
self.hass.states.set("light.Bowl", "on")
self.hass.block_till_done()
self.assertEqual(0, len(callback_runs))
self.assertEqual('on', check_func[-1][2].state)
self.assertEqual('light.bowl', check_func[-1][0])
# change time to track and see if they trigger
future = dt_util.utcnow() + period
fire_time_changed(self.hass, future)
self.hass.block_till_done()
self.assertEqual(1, len(callback_runs))
def test_track_time_interval(self): def test_track_time_interval(self):
"""Test tracking time interval.""" """Test tracking time interval."""
specific_runs = [] specific_runs = []