From 6354399d55688a77359c78b3d9914d001208af7e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 Apr 2016 12:03:57 +0200 Subject: [PATCH] Initial script condition support (#1910) --- .../components/automation/__init__.py | 47 +-- homeassistant/components/automation/event.py | 16 +- .../components/automation/numeric_state.py | 113 ++----- homeassistant/components/automation/state.py | 66 +--- homeassistant/components/automation/sun.py | 71 +---- .../components/automation/template.py | 29 +- homeassistant/components/automation/time.py | 85 +---- homeassistant/components/automation/zone.py | 51 +-- homeassistant/const.py | 6 + homeassistant/helpers/condition.py | 290 ++++++++++++++++++ homeassistant/helpers/config_validation.py | 109 ++++++- homeassistant/helpers/script.py | 15 +- homeassistant/helpers/template.py | 4 +- tests/components/automation/test_init.py | 11 +- .../automation/test_numeric_state.py | 9 +- tests/components/automation/test_sun.py | 34 +- tests/components/automation/test_time.py | 26 +- tests/helpers/test_condition.py | 68 ++++ tests/helpers/test_script.py | 33 ++ 19 files changed, 656 insertions(+), 427 deletions(-) create mode 100644 homeassistant/helpers/condition.py create mode 100644 tests/helpers/test_condition.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3ba5596fb4d..37806a6fdac 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.bootstrap import prepare_setup_platform from homeassistant.const import CONF_PLATFORM from homeassistant.components import logbook -from homeassistant.helpers import extract_domain_configs, script +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import extract_domain_configs, script, condition from homeassistant.loader import get_platform import homeassistant.helpers.config_validation as cv @@ -73,10 +74,11 @@ _CONDITION_SCHEMA = vol.Any( [ vol.All( vol.Schema({ - vol.Required(CONF_PLATFORM): cv.platform_validator(DOMAIN), + CONF_PLATFORM: str, + CONF_CONDITION: str, }, extra=vol.ALLOW_EXTRA), - _platform_validator(METHOD_IF_ACTION, 'IF_ACTION_SCHEMA'), - ) + cv.has_at_least_one_key(CONF_PLATFORM, CONF_CONDITION), + ), ] ) ) @@ -93,15 +95,17 @@ PLATFORM_SCHEMA = vol.Schema({ def setup(hass, config): """Setup the automation.""" + success = False for config_key in extract_domain_configs(config, DOMAIN): conf = config[config_key] for list_no, config_block in enumerate(conf): name = config_block.get(CONF_ALIAS, "{}, {}".format(config_key, list_no)) - _setup_automation(hass, config_block, name, config) + success = (_setup_automation(hass, config_block, name, config) or + success) - return True + return success def _setup_automation(hass, config_block, name, config): @@ -136,7 +140,6 @@ def _process_if(hass, config, p_config, action): """Process if checks.""" cond_type = p_config.get(CONF_CONDITION_TYPE, DEFAULT_CONDITION_TYPE).lower() - if_configs = p_config.get(CONF_CONDITION) use_trigger = if_configs == CONDITION_USE_TRIGGER_VALUES @@ -145,28 +148,32 @@ def _process_if(hass, config, p_config, action): checks = [] for if_config in if_configs: - platform = _resolve_platform(METHOD_IF_ACTION, hass, config, - if_config.get(CONF_PLATFORM)) - if platform is None: - continue + if CONF_PLATFORM in if_config: + if not use_trigger: + _LOGGER.warning("Please switch your condition configuration " + "to use 'condition' instead of 'platform'.") + if_config = dict(if_config) + if_config[CONF_CONDITION] = if_config.pop(CONF_PLATFORM) - check = platform.if_action(hass, if_config) - - # Invalid conditions are allowed if we base it on trigger - if check is None and not use_trigger: - return None - - checks.append(check) + try: + checks.append(condition.from_config(if_config)) + except HomeAssistantError as ex: + # Invalid conditions are allowed if we base it on trigger + if use_trigger: + _LOGGER.warning('Ignoring invalid condition: %s', ex) + else: + _LOGGER.warning('Invalid condition: %s', ex) + return None if cond_type == CONDITION_TYPE_AND: def if_action(variables=None): """AND all conditions.""" - if all(check(variables) for check in checks): + if all(check(hass, variables) for check in checks): action(variables) else: def if_action(variables=None): """OR all conditions.""" - if any(check(variables) for check in checks): + if any(check(hass, variables) for check in checks): action(variables) return if_action diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 46b5b4ef10d..6b3160996f3 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -6,20 +6,26 @@ at https://home-assistant.io/components/automation/#event-trigger """ import logging +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import config_validation as cv + CONF_EVENT_TYPE = "event_type" CONF_EVENT_DATA = "event_data" _LOGGER = logging.getLogger(__name__) +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'event', + vol.Required(CONF_EVENT_TYPE): cv.string, + vol.Optional(CONF_EVENT_DATA): dict, +}) + def trigger(hass, config, action): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) - - if event_type is None: - _LOGGER.error("Missing configuration key %s", CONF_EVENT_TYPE) - return False - event_data = config.get(CONF_EVENT_DATA) def handle_event(event): diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 6ed2add0b25..8a5c993b683 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -5,54 +5,36 @@ For more details about this automation rule, please refer to the documentation at https://home-assistant.io/components/automation/#numeric-state-trigger """ import logging -from functools import partial -from homeassistant.const import CONF_VALUE_TEMPLATE +import voluptuous as vol + +from homeassistant.const import ( + CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, + CONF_BELOW, CONF_ABOVE) from homeassistant.helpers.event import track_state_change -from homeassistant.helpers import template +from homeassistant.helpers import condition, config_validation as cv -CONF_ENTITY_ID = "entity_id" -CONF_BELOW = "below" -CONF_ABOVE = "above" +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + CONF_BELOW: vol.Coerce(float), + CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) _LOGGER = logging.getLogger(__name__) -def _renderer(hass, value_template, state, variables=None): - """Render the state value.""" - if value_template is None: - return state.state - - variables = dict(variables or {}) - variables['state'] = state - - return template.render(hass, value_template, variables) - - def trigger(hass, config, action): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) - - if entity_id is None: - _LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID) - return False - below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) value_template = config.get(CONF_VALUE_TEMPLATE) - if below is None and above is None: - _LOGGER.error("Missing configuration key." - " One of %s or %s is required", - CONF_BELOW, CONF_ABOVE) - return False - - renderer = partial(_renderer, hass, value_template) - # pylint: disable=unused-argument def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - # Fire action if we go from outside range into range if to_s is None: return @@ -64,63 +46,24 @@ def trigger(hass, config, action): 'above': above, } } - to_s_value = renderer(to_s, variables) - from_s_value = None if from_s is None else renderer(from_s, variables) - if _in_range(above, below, to_s_value) and \ - (from_s is None or not _in_range(above, below, from_s_value)): - variables['trigger']['from_state'] = from_s - variables['trigger']['from_value'] = from_s_value - variables['trigger']['to_state'] = to_s - variables['trigger']['to_value'] = to_s_value - action(variables) + # If new one doesn't match, nothing to do + if not condition.numeric_state( + hass, to_s, below, above, value_template, variables): + return + + # Only match if old didn't exist or existed but didn't match + # Written as: skip if old one did exist and matched + if from_s is not None and condition.numeric_state( + hass, from_s, below, above, value_template, variables): + return + + variables['trigger']['from_state'] = from_s + variables['trigger']['to_state'] = to_s + + action(variables) track_state_change( hass, entity_id, state_automation_listener) return True - - -def if_action(hass, config): - """Wrap action method with state based condition.""" - entity_id = config.get(CONF_ENTITY_ID) - - if entity_id is None: - _LOGGER.error("Missing configuration key %s", CONF_ENTITY_ID) - return None - - below = config.get(CONF_BELOW) - above = config.get(CONF_ABOVE) - value_template = config.get(CONF_VALUE_TEMPLATE) - - if below is None and above is None: - _LOGGER.error("Missing configuration key." - " One of %s or %s is required", - CONF_BELOW, CONF_ABOVE) - return None - - renderer = partial(_renderer, hass, value_template) - - def if_numeric_state(variables): - """Test numeric state condition.""" - state = hass.states.get(entity_id) - return state is not None and _in_range(above, below, renderer(state)) - - return if_numeric_state - - -def _in_range(range_start, range_end, value): - """Check if value is inside the range.""" - try: - value = float(value) - except ValueError: - _LOGGER.warning("Value returned from template is not a number: %s", - value) - return False - - if range_start is not None and range_end is not None: - return float(range_start) <= value < float(range_end) - elif range_end is not None: - return value < float(range_end) - else: - return float(range_start) <= value diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 3183dab0803..82fcfdab341 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -4,15 +4,11 @@ Offer state listening automation rules. For more details about this automation rule, please refer to the documentation at https://home-assistant.io/components/automation/#state-trigger """ -from datetime import timedelta - import voluptuous as vol import homeassistant.util.dt as dt_util from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, CONF_PLATFORM) -from homeassistant.components.automation.time import ( - CONF_HOURS, CONF_MINUTES, CONF_SECONDS) from homeassistant.helpers.event import track_state_change, track_point_in_time import homeassistant.helpers.config_validation as cv @@ -22,46 +18,19 @@ CONF_TO = "to" CONF_STATE = "state" CONF_FOR = "for" -BASE_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_id, - # These are str on purpose. Want to catch YAML conversions - CONF_STATE: str, - CONF_FOR: vol.All(vol.Schema({ - CONF_HOURS: vol.Coerce(int), - CONF_MINUTES: vol.Coerce(int), - CONF_SECONDS: vol.Coerce(int), - }), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS)), -}) - -TRIGGER_SCHEMA = vol.Schema(vol.All( - BASE_SCHEMA.extend({ +TRIGGER_SCHEMA = vol.All( + vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, # These are str on purpose. Want to catch YAML conversions CONF_FROM: str, CONF_TO: str, + CONF_STATE: str, + CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), }), vol.Any(cv.key_dependency(CONF_FOR, CONF_TO), cv.key_dependency(CONF_FOR, CONF_STATE)) -)) - -IF_ACTION_SCHEMA = vol.Schema(vol.All( - BASE_SCHEMA, - cv.key_dependency(CONF_FOR, CONF_STATE) -)) - - -def get_time_config(config): - """Helper function to extract the time specified in the configuration.""" - if CONF_FOR not in config: - return None - - hours = config[CONF_FOR].get(CONF_HOURS) - minutes = config[CONF_FOR].get(CONF_MINUTES) - seconds = config[CONF_FOR].get(CONF_SECONDS) - - return timedelta(hours=(hours or 0.0), - minutes=(minutes or 0.0), - seconds=(seconds or 0.0)) +) def trigger(hass, config, action): @@ -69,7 +38,7 @@ def trigger(hass, config, action): entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL - time_delta = get_time_config(config) + time_delta = config.get(CONF_FOR) def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" @@ -97,7 +66,7 @@ def trigger(hass, config, action): 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 == to_s: + if inner_to_s.state == to_s.state: return hass.bus.remove_listener(EVENT_TIME_CHANGED, attached_state_for_listener) @@ -114,20 +83,3 @@ def trigger(hass, config, action): hass, entity_id, state_automation_listener, from_state, to_state) return True - - -def if_action(hass, config): - """Wrap action method with state based condition.""" - entity_id = config.get(CONF_ENTITY_ID) - state = config.get(CONF_STATE) - time_delta = get_time_config(config) - - def if_state(variables): - """Test if condition.""" - is_state = hass.states.is_state(entity_id, state) - return (time_delta is None and is_state or - time_delta is not None and - dt_util.utcnow() - time_delta > - hass.states.get(entity_id).last_changed) - - return if_state diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 7de43d7f5e3..5a69970ff23 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -10,8 +10,6 @@ import logging import voluptuous as vol from homeassistant.const import CONF_PLATFORM -import homeassistant.util.dt as dt_util -from homeassistant.components import sun from homeassistant.helpers.event import track_sunrise, track_sunset import homeassistant.helpers.config_validation as cv @@ -29,26 +27,13 @@ EVENT_SUNRISE = 'sunrise' _LOGGER = logging.getLogger(__name__) - -_SUN_EVENT = vol.All(vol.Lower, vol.Any(EVENT_SUNRISE, EVENT_SUNSET)) - TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'sun', - vol.Required(CONF_EVENT): _SUN_EVENT, + vol.Required(CONF_EVENT): + vol.All(vol.Lower, vol.Any(EVENT_SUNRISE, EVENT_SUNSET)), vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, }) -IF_ACTION_SCHEMA = vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): 'sun', - CONF_BEFORE: _SUN_EVENT, - CONF_AFTER: _SUN_EVENT, - vol.Required(CONF_BEFORE_OFFSET, default=timedelta(0)): cv.time_period, - vol.Required(CONF_AFTER_OFFSET, default=timedelta(0)): cv.time_period, - }), - cv.has_at_least_one_key(CONF_BEFORE, CONF_AFTER), -) - def trigger(hass, config, action): """Listen for events based on configuration.""" @@ -72,55 +57,3 @@ def trigger(hass, config, action): track_sunset(hass, call_action, offset) return True - - -def if_action(hass, config): - """Wrap action method with sun based condition.""" - before = config.get(CONF_BEFORE) - after = config.get(CONF_AFTER) - before_offset = config.get(CONF_BEFORE_OFFSET) - after_offset = config.get(CONF_AFTER_OFFSET) - - if before is None: - def before_func(): - """Return no point in time.""" - return None - elif before == EVENT_SUNRISE: - def before_func(): - """Return time before sunrise.""" - return sun.next_rising(hass) + before_offset - else: - def before_func(): - """Return time before sunset.""" - return sun.next_setting(hass) + before_offset - - if after is None: - def after_func(): - """Return no point in time.""" - return None - elif after == EVENT_SUNRISE: - def after_func(): - """Return time after sunrise.""" - return sun.next_rising(hass) + after_offset - else: - def after_func(): - """Return time after sunset.""" - return sun.next_setting(hass) + after_offset - - def time_if(variables): - """Validate time based if-condition.""" - now = dt_util.now() - before = before_func() - after = after_func() - - if before is not None and now > now.replace(hour=before.hour, - minute=before.minute): - return False - - if after is not None and now < now.replace(hour=after.hour, - minute=after.minute): - return False - - return True - - return time_if diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 66c20518c7e..1cfbf45a24d 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, MATCH_ALL) -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.helpers import condition from homeassistant.helpers.event import track_state_change import homeassistant.helpers.config_validation as cv @@ -34,7 +33,7 @@ def trigger(hass, config, action): def state_changed_listener(entity_id, from_s, to_s): """Listen for state changes and calls action.""" nonlocal already_triggered - template_result = _check_template(hass, value_template) + template_result = condition.template(hass, value_template) # Check to see if template returns true if template_result and not already_triggered: @@ -52,27 +51,3 @@ def trigger(hass, config, action): track_state_change(hass, MATCH_ALL, state_changed_listener) return True - - -def if_action(hass, config): - """Wrap action method with state based condition.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - - return lambda variables: _check_template(hass, value_template, - variables=variables) - - -def _check_template(hass, value_template, variables=None): - """Check if result of template is true.""" - try: - value = template.render(hass, value_template, variables) - except TemplateError as ex: - if ex.args and ex.args[0].startswith( - "UndefinedError: 'None' has no attribute"): - # Common during HA startup - so just a warning - _LOGGER.warning(ex) - else: - _LOGGER.error(ex) - return False - - return value.lower() == 'true' diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 879b0e113d9..8ddeb2958d4 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -6,38 +6,38 @@ at https://home-assistant.io/components/automation/#time-trigger """ import logging -import homeassistant.util.dt as dt_util +import voluptuous as vol + +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_change CONF_HOURS = "hours" CONF_MINUTES = "minutes" CONF_SECONDS = "seconds" -CONF_BEFORE = "before" CONF_AFTER = "after" -CONF_WEEKDAY = "weekday" - -WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] _LOGGER = logging.getLogger(__name__) +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'time', + CONF_AFTER: cv.time, + CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), + CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), +}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, + CONF_SECONDS, CONF_AFTER)) + def trigger(hass, config, action): """Listen for state changes based on configuration.""" if CONF_AFTER in config: - after = dt_util.parse_time(config[CONF_AFTER]) - if after is None: - _error_time(config[CONF_AFTER], CONF_AFTER) - return False + after = config.get(CONF_AFTER) hours, minutes, seconds = after.hour, after.minute, after.second - elif (CONF_HOURS in config or CONF_MINUTES in config or - CONF_SECONDS in config): + else: hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) - else: - _LOGGER.error('One of %s, %s, %s OR %s needs to be specified', - CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AFTER) - return False def time_automation_listener(now): """Listen for time changes and calls action.""" @@ -52,58 +52,3 @@ def trigger(hass, config, action): hour=hours, minute=minutes, second=seconds) return True - - -def if_action(hass, config): - """Wrap action method with time based condition.""" - before = config.get(CONF_BEFORE) - after = config.get(CONF_AFTER) - weekday = config.get(CONF_WEEKDAY) - - if before is None and after is None and weekday is None: - _LOGGER.error( - "Missing if-condition configuration key %s, %s or %s", - CONF_BEFORE, CONF_AFTER, CONF_WEEKDAY) - return None - - if before is not None: - before = dt_util.parse_time(before) - if before is None: - _error_time(before, CONF_BEFORE) - return None - - if after is not None: - after = dt_util.parse_time(after) - if after is None: - _error_time(after, CONF_AFTER) - return None - - def time_if(variables): - """Validate time based if-condition.""" - now = dt_util.now() - if before is not None and now > now.replace(hour=before.hour, - minute=before.minute): - return False - - if after is not None and now < now.replace(hour=after.hour, - minute=after.minute): - return False - - if weekday is not None: - now_weekday = WEEKDAYS[now.weekday()] - - if isinstance(weekday, str) and weekday != now_weekday or \ - now_weekday not in weekday: - return False - - return True - - return time_if - - -def _error_time(value, key): - """Helper method to print error.""" - _LOGGER.error( - "Received invalid value for '%s': %s", key, value) - if isinstance(value, int): - _LOGGER.error('Make sure you wrap time values in quotes') diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index fd798f45549..bf325c677cd 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -6,11 +6,10 @@ at https://home-assistant.io/components/automation/#zone-trigger """ import voluptuous as vol -from homeassistant.components import zone -from homeassistant.const import ( - ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL, CONF_PLATFORM) +from homeassistant.const import MATCH_ALL, CONF_PLATFORM from homeassistant.helpers.event import track_state_change -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + condition, config_validation as cv, location) CONF_ENTITY_ID = "entity_id" CONF_ZONE = "zone" @@ -27,12 +26,6 @@ TRIGGER_SCHEMA = vol.Schema({ vol.Any(EVENT_ENTER, EVENT_LEAVE), }) -IF_ACTION_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'zone', - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_ZONE): cv.entity_id, -}) - def trigger(hass, config, action): """Listen for state changes based on configuration.""" @@ -42,15 +35,16 @@ def trigger(hass, config, action): def zone_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - if from_s and None in (from_s.attributes.get(ATTR_LATITUDE), - from_s.attributes.get(ATTR_LONGITUDE)) or \ - None in (to_s.attributes.get(ATTR_LATITUDE), - to_s.attributes.get(ATTR_LONGITUDE)): + if from_s and not location.has_location(from_s) or \ + not location.has_location(to_s): return zone_state = hass.states.get(zone_entity_id) - from_match = _in_zone(hass, zone_state, from_s) if from_s else None - to_match = _in_zone(hass, zone_state, to_s) + if from_s: + from_match = condition.zone(hass, zone_state, from_s) + else: + from_match = False + to_match = condition.zone(hass, zone_state, to_s) # pylint: disable=too-many-boolean-expressions if event == EVENT_ENTER and not from_match and to_match or \ @@ -69,28 +63,3 @@ def trigger(hass, config, action): hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL) return True - - -def if_action(hass, config): - """Wrap action method with zone based condition.""" - entity_id = config.get(CONF_ENTITY_ID) - zone_entity_id = config.get(CONF_ZONE) - - def if_in_zone(variables): - """Test if condition.""" - zone_state = hass.states.get(zone_entity_id) - return _in_zone(hass, zone_state, hass.states.get(entity_id)) - - return if_in_zone - - -def _in_zone(hass, zone_state, state): - """Check if state is in zone.""" - if not state or None in (state.attributes.get(ATTR_LATITUDE), - state.attributes.get(ATTR_LONGITUDE)): - return False - - return zone_state and zone.in_zone( - zone_state, state.attributes.get(ATTR_LATITUDE), - state.attributes.get(ATTR_LONGITUDE), - state.attributes.get(ATTR_GPS_ACCURACY, 0)) diff --git a/homeassistant/const.py b/homeassistant/const.py index 46182e62c57..70aa0397ab3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -12,6 +12,8 @@ MATCH_ALL = '*' # If no name is specified DEVICE_DEFAULT_NAME = "Unnamed Device" +WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + # #### CONFIG #### CONF_ALIAS = "alias" CONF_ICON = "icon" @@ -34,9 +36,13 @@ CONF_ACCESS_TOKEN = "access_token" CONF_FILENAME = "filename" CONF_MONITORED_CONDITIONS = 'monitored_conditions' CONF_OPTIMISTIC = 'optimistic' +CONF_ENTITY_ID = "entity_id" CONF_ENTITY_NAMESPACE = "entity_namespace" CONF_SCAN_INTERVAL = "scan_interval" CONF_VALUE_TEMPLATE = "value_template" +CONF_CONDITION = 'condition' +CONF_BELOW = 'below' +CONF_ABOVE = 'above' # #### EVENTS #### EVENT_HOMEASSISTANT_START = "homeassistant_start" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py new file mode 100644 index 00000000000..571012bdbc8 --- /dev/null +++ b/homeassistant/helpers/condition.py @@ -0,0 +1,290 @@ +"""Offer reusable conditions.""" +from datetime import timedelta +import logging +import sys + +from homeassistant.components import ( + zone as zone_cmp, sun as sun_cmp) +from homeassistant.const import ( + ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, + CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_CONDITION, + WEEKDAYS) +from homeassistant.exceptions import TemplateError, HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import render +import homeassistant.util.dt as dt_util + +FROM_CONFIG_FORMAT = '{}_from_config' +CONF_BELOW = 'below' +CONF_ABOVE = 'above' +CONF_STATE = 'state' +CONF_ZONE = 'zone' + +EVENT_SUNRISE = 'sunrise' +EVENT_SUNSET = 'sunset' + +_LOGGER = logging.getLogger(__name__) + + +def from_config(config, config_validation=True): + """Turn a condition configuration into a method.""" + factory = getattr( + sys.modules[__name__], + FROM_CONFIG_FORMAT.format(config.get(CONF_CONDITION)), None) + + if factory is None: + raise HomeAssistantError('Invalid condition "{}" specified {}'.format( + config.get(CONF_CONDITION), config)) + + return factory(config, config_validation) + + +def and_from_config(config, config_validation=True): + """Create multi condition matcher using 'AND'.""" + if config_validation: + config = cv.AND_CONDITION_SCHEMA(config) + checks = [from_config(entry) for entry in config['conditions']] + + def if_and_condition(hass, variables=None): + """Test and condition.""" + return all(check(hass, variables) for check in checks) + + return if_and_condition + + +def or_from_config(config, config_validation=True): + """Create multi condition matcher using 'OR'.""" + if config_validation: + config = cv.OR_CONDITION_SCHEMA(config) + checks = [from_config(entry) for entry in config['conditions']] + + def if_or_condition(hass, variables=None): + """Test and condition.""" + return any(check(hass, variables) for check in checks) + + return if_or_condition + + +# pylint: disable=too-many-arguments +def numeric_state(hass, entity, below=None, above=None, value_template=None, + variables=None): + """Test a numeric state condition.""" + if isinstance(entity, str): + entity = hass.states.get(entity) + + if entity is None: + return False + + if value_template is None: + value = entity.state + else: + variables = dict(variables or {}) + variables['state'] = entity + try: + value = render(hass, value_template, variables) + except TemplateError as ex: + _LOGGER.error(ex) + return False + + try: + value = float(value) + except ValueError: + _LOGGER.warning("Value cannot be processed as a number: %s", value) + return False + + print(below, value, above) + + if below is not None and value > below: + return False + + if above is not None and value < above: + return False + + return True + + +def numeric_state_from_config(config, config_validation=True): + """Wrap action method with state based condition.""" + if config_validation: + config = cv.NUMERIC_STATE_CONDITION_SCHEMA(config) + entity_id = config.get(CONF_ENTITY_ID) + below = config.get(CONF_BELOW) + above = config.get(CONF_ABOVE) + value_template = config.get(CONF_VALUE_TEMPLATE) + + def if_numeric_state(hass, variables=None): + """Test numeric state condition.""" + return numeric_state(hass, entity_id, below, above, value_template, + variables) + + return if_numeric_state + + +def state(hass, entity, req_state, for_period=None): + """Test if state matches requirements.""" + if isinstance(entity, str): + entity = hass.states.get(entity) + + if entity is None: + return False + + is_state = entity.state == req_state + + if for_period is None or not is_state: + return is_state + + return dt_util.utcnow() - for_period > entity.last_changed + + +def state_from_config(config, config_validation=True): + """Wrap action method with state based condition.""" + if config_validation: + config = cv.STATE_CONDITION_SCHEMA(config) + entity_id = config.get(CONF_ENTITY_ID) + req_state = config.get(CONF_STATE) + for_period = config.get('for') + + def if_state(hass, variables=None): + """Test if condition.""" + return state(hass, entity_id, req_state, for_period) + + return if_state + + +def sun(hass, before=None, after=None, before_offset=None, after_offset=None): + """Test if current time matches sun requirements.""" + now = dt_util.now().time() + before_offset = before_offset or timedelta(0) + after_offset = after_offset or timedelta(0) + + if before == EVENT_SUNRISE and now > (sun_cmp.next_rising(hass) + + before_offset).time(): + return False + + elif before == EVENT_SUNSET and now > (sun_cmp.next_setting(hass) + + before_offset).time(): + return False + + if after == EVENT_SUNRISE and now < (sun_cmp.next_rising(hass) + + after_offset).time(): + return False + + elif after == EVENT_SUNSET and now < (sun_cmp.next_setting(hass) + + after_offset).time(): + return False + + return True + + +def sun_from_config(config, config_validation=True): + """Wrap action method with sun based condition.""" + if config_validation: + config = cv.SUN_CONDITION_SCHEMA(config) + before = config.get('before') + after = config.get('after') + before_offset = config.get('before_offset') + after_offset = config.get('after_offset') + + def time_if(hass, variables=None): + """Validate time based if-condition.""" + return sun(hass, before, after, before_offset, after_offset) + + return time_if + + +def template(hass, value_template, variables=None): + """Test if template condition matches.""" + try: + value = render(hass, value_template, variables) + except TemplateError as ex: + _LOGGER.error('Error duriong template condition: %s', ex) + return False + + return value.lower() == 'true' + + +def template_from_config(config, config_validation=True): + """Wrap action method with state based condition.""" + if config_validation: + config = cv.TEMPLATE_CONDITION_SCHEMA(config) + value_template = config.get(CONF_VALUE_TEMPLATE) + + def template_if(hass, variables=None): + """Validate template based if-condition.""" + return template(hass, value_template, variables) + + return template_if + + +def time(before=None, after=None, weekday=None): + """Test if local time condition matches.""" + now = dt_util.now() + now_time = now.time() + + if before is not None and now_time > before: + return False + + if after is not None and now_time < after: + return False + + if weekday is not None: + now_weekday = WEEKDAYS[now.weekday()] + + if isinstance(weekday, str) and weekday != now_weekday or \ + now_weekday not in weekday: + return False + + return True + + +def time_from_config(config, config_validation=True): + """Wrap action method with time based condition.""" + if config_validation: + config = cv.TIME_CONDITION_SCHEMA(config) + before = config.get('before') + after = config.get('after') + weekday = config.get('weekday') + + def time_if(hass, variables=None): + """Validate time based if-condition.""" + return time(before, after, weekday) + + return time_if + + +def zone(hass, zone_ent, entity): + """Test if zone-condition matches.""" + if isinstance(zone_ent, str): + zone_ent = hass.states.get(zone_ent) + + if zone_ent is None: + return False + + if isinstance(entity, str): + entity = hass.states.get(entity) + + if entity is None: + return False + + latitude = entity.attributes.get(ATTR_LATITUDE) + longitude = entity.attributes.get(ATTR_LONGITUDE) + + if latitude is None or longitude is None: + return False + + return zone_cmp.in_zone(zone_ent, latitude, longitude, + entity.attributes.get(ATTR_GPS_ACCURACY, 0)) + + +def zone_from_config(config, config_validation=True): + """Wrap action method with zone based condition.""" + if config_validation: + config = cv.ZONE_CONDITION_SCHEMA(config) + entity_id = config.get(CONF_ENTITY_ID) + zone_entity_id = config.get('zone') + + def if_in_zone(hass, variables=None): + """Test if condition.""" + return zone(hass, zone_entity_id, entity_id) + + return if_in_zone diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3a8e6179c86..ca07c08db48 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -7,13 +7,16 @@ import voluptuous as vol from homeassistant.loader import get_platform from homeassistant.const import ( CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_ALIAS) + CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, + CONF_CONDITION, CONF_BELOW, CONF_ABOVE) from homeassistant.helpers.entity import valid_entity_id import homeassistant.util.dt as dt_util from homeassistant.util import slugify # pylint: disable=invalid-name +TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" + # Home Assistant types byte = vol.All(vol.Coerce(int), vol.Range(min=0, max=255)) small_float = vol.All(vol.Coerce(float), vol.Range(min=0, max=1)) @@ -105,9 +108,10 @@ time_period_dict = vol.All( def time_period_str(value): """Validate and transform time offset.""" - if not isinstance(value, str): - raise vol.Invalid( - 'offset {} should be format HH:MM or HH:MM:SS'.format(value)) + if isinstance(value, int): + raise vol.Invalid('Make sure you wrap time values in quotes') + elif not isinstance(value, str): + raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) negative_offset = False if value.startswith('-'): @@ -119,8 +123,7 @@ def time_period_str(value): try: parsed = [int(x) for x in value.split(':')] except ValueError: - raise vol.Invalid( - 'offset {} should be format HH:MM or HH:MM:SS'.format(value)) + raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) if len(parsed) == 2: hour, minute = parsed @@ -128,8 +131,7 @@ def time_period_str(value): elif len(parsed) == 3: hour, minute, second = parsed else: - raise vol.Invalid( - 'offset {} should be format HH:MM or HH:MM:SS'.format(value)) + raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) offset = timedelta(hours=hour, minutes=minute, seconds=second) @@ -217,6 +219,16 @@ def template(value): raise vol.Invalid('invalid template ({})'.format(ex)) +def time(value): + """Validate time.""" + time_val = dt_util.parse_time(value) + + if time_val is None: + raise vol.Invalid('Invalid time specified: {}'.format(value)) + + return time_val + + def time_zone(value): """Validate timezone.""" if dt_util.get_time_zone(value) is not None: @@ -225,6 +237,8 @@ def time_zone(value): 'Invalid time zone passed in. Valid options can be found here: ' 'http://en.wikipedia.org/wiki/List_of_tz_database_time_zones') +weekdays = vol.All(ensure_list, [vol.In(WEEKDAYS)]) + # Validator helpers @@ -261,9 +275,83 @@ SERVICE_SCHEMA = vol.All(vol.Schema({ vol.Exclusive('service_template', 'service name'): template, vol.Optional('data'): dict, vol.Optional('data_template'): {match_all: template}, - vol.Optional('entity_id'): entity_ids, + vol.Optional(CONF_ENTITY_ID): entity_ids, }), has_at_least_one_key('service', 'service_template')) +NUMERIC_STATE_CONDITION_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_CONDITION): 'numeric_state', + vol.Required(CONF_ENTITY_ID): entity_id, + CONF_BELOW: vol.Coerce(float), + CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_VALUE_TEMPLATE): template, +}), has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) + +STATE_CONDITION_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_CONDITION): 'state', + vol.Required(CONF_ENTITY_ID): entity_id, + vol.Required('state'): str, + vol.Optional('for'): vol.All(time_period, positive_timedelta), + # To support use_trigger_value in automation + # Deprecated 2016/04/25 + vol.Optional('from'): str, +}), key_dependency('for', 'state')) + +SUN_CONDITION_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_CONDITION): 'sun', + vol.Optional('before'): vol.Any('sunset', 'sunrise'), + vol.Optional('before_offset'): time_period, + vol.Optional('after'): vol.All(vol.Lower, vol.Any('sunset', 'sunrise')), + vol.Optional('after_offset'): time_period, +}), has_at_least_one_key('before', 'after')) + +TEMPLATE_CONDITION_SCHEMA = vol.Schema({ + vol.Required(CONF_CONDITION): 'template', + vol.Required(CONF_VALUE_TEMPLATE): template, +}) + +TIME_CONDITION_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_CONDITION): 'time', + 'before': time, + 'after': time, + 'weekday': weekdays, +}), has_at_least_one_key('before', 'after', 'weekday')) + +ZONE_CONDITION_SCHEMA = vol.Schema({ + vol.Required(CONF_CONDITION): 'zone', + vol.Required(CONF_ENTITY_ID): entity_id, + 'zone': entity_id, + # To support use_trigger_value in automation + # Deprecated 2016/04/25 + vol.Optional('event'): vol.Any('enter', 'leave'), +}) + +AND_CONDITION_SCHEMA = vol.Schema({ + vol.Required(CONF_CONDITION): 'and', + vol.Required('conditions'): vol.All( + ensure_list, + # pylint: disable=unnecessary-lambda + [lambda value: CONDITION_SCHEMA(value)], + ) +}) + +OR_CONDITION_SCHEMA = vol.Schema({ + vol.Required(CONF_CONDITION): 'or', + vol.Required('conditions'): vol.All( + ensure_list, + # pylint: disable=unnecessary-lambda + [lambda value: CONDITION_SCHEMA(value)], + ) +}) + +CONDITION_SCHEMA = vol.Any( + NUMERIC_STATE_CONDITION_SCHEMA, + STATE_CONDITION_SCHEMA, + SUN_CONDITION_SCHEMA, + TEMPLATE_CONDITION_SCHEMA, + TIME_CONDITION_SCHEMA, + ZONE_CONDITION_SCHEMA, +) + _SCRIPT_DELAY_SCHEMA = vol.Schema({ vol.Optional(CONF_ALIAS): string, vol.Required("delay"): vol.All(time_period, positive_timedelta) @@ -271,5 +359,6 @@ _SCRIPT_DELAY_SCHEMA = vol.Schema({ SCRIPT_SCHEMA = vol.All( ensure_list, - [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA, EVENT_SCHEMA)], + [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA, EVENT_SCHEMA, + CONDITION_SCHEMA)], ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 05e49c6e9ce..2de5d9633ea 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -4,9 +4,9 @@ import threading from itertools import islice import homeassistant.util.dt as date_util -from homeassistant.const import EVENT_TIME_CHANGED +from homeassistant.const import EVENT_TIME_CHANGED, CONF_CONDITION from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.helpers import service +from homeassistant.helpers import service, condition import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -76,6 +76,10 @@ class Script(): self._change_listener() return + elif CONF_CONDITION in action: + if not self._check_condition(action, variables): + break + elif CONF_EVENT in action: self._fire_event(action) @@ -111,6 +115,13 @@ class Script(): self._log("Executing step %s", self.last_action) self.hass.bus.fire(action[CONF_EVENT], action.get(CONF_EVENT_DATA)) + def _check_condition(self, action, variables): + """Test if condition is matching.""" + self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) + check = condition.from_config(action)(self.hass, False) + self._log("Test condition %s: %s", self.last_action, check) + return check + def _remove_listener(self): """Remove point in time listener, if any.""" if self._delay_listener: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 20c85ce64b1..b072f1b2df7 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -33,8 +33,8 @@ def render_with_possible_json_value(hass, template, value, try: return render(hass, template, variables) - except TemplateError: - _LOGGER.exception('Error parsing value') + except TemplateError as ex: + _LOGGER.error('Error parsing value: %s', ex) return value if error_value is _SENTINEL else error_value diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 8e06f524d0e..e90ffe8d765 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -146,12 +146,12 @@ class TestAutomation(unittest.TestCase): ], 'condition': [ { - 'platform': 'state', + 'condition': 'state', 'entity_id': entity_id, 'state': '100' }, { - 'platform': 'numeric_state', + 'condition': 'numeric_state', 'entity_id': entity_id, 'below': 150 } @@ -231,6 +231,7 @@ class TestAutomation(unittest.TestCase): { 'platform': 'state', 'entity_id': entity_id, + 'from': '120', 'state': '100' }, { @@ -248,10 +249,14 @@ class TestAutomation(unittest.TestCase): self.hass.states.set(entity_id, 100) self.hass.pool.block_till_done() - self.assertEqual(2, len(self.calls)) + self.assertEqual(1, len(self.calls)) self.hass.states.set(entity_id, 120) self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + self.hass.states.set(entity_id, 100) + self.hass.pool.block_till_done() self.assertEqual(2, len(self.calls)) self.hass.states.set(entity_id, 151) diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 37df19e38ed..f7d1447632f 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -253,6 +253,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.another_entity', + 'below': 100, }, 'action': { 'service': 'test.automation' @@ -441,13 +442,11 @@ class TestAutomationNumericState(unittest.TestCase): 'data_template': { 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( 'platform', 'entity_id', 'below', 'above', - 'from_state.state', 'from_value', - 'to_state.state', 'to_value')) + 'from_state.state', 'to_state.state')) }, } } }) - # 9 is below 10 self.hass.states.set('test.entity', 'test state 1', {'test_attribute': '1.2'}) self.hass.pool.block_till_done() @@ -456,8 +455,8 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) self.assertEqual( - 'numeric_state - test.entity - 10 - None - test state 1 - 12.0 - ' - 'test state 2 - 9.0', + 'numeric_state - test.entity - 10.0 - None - test state 1 - ' + 'test state 2', self.calls[0].data['some']) def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(self): diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index 1975dc8da44..745e7c060ca 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -40,7 +40,7 @@ class TestAutomationSun(unittest.TestCase): now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 2, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.utcnow', + with patch('homeassistant.util.dt.utcnow', return_value=now): _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { @@ -67,7 +67,7 @@ class TestAutomationSun(unittest.TestCase): now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 14, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.utcnow', + with patch('homeassistant.util.dt.utcnow', return_value=now): _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { @@ -94,7 +94,7 @@ class TestAutomationSun(unittest.TestCase): now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 2, 30, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.utcnow', + with patch('homeassistant.util.dt.utcnow', return_value=now): _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { @@ -128,7 +128,7 @@ class TestAutomationSun(unittest.TestCase): now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 13, 30, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.utcnow', + with patch('homeassistant.util.dt.utcnow', return_value=now): _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { @@ -170,14 +170,14 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 10, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -206,14 +206,14 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 13, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -243,14 +243,14 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 15, 1, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -280,14 +280,14 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 14, 59, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -318,21 +318,21 @@ class TestAutomationSun(unittest.TestCase): }) now = datetime(2015, 9, 16, 9, 59, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 15, 1, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) now = datetime(2015, 9, 16, 12, tzinfo=dt_util.UTC) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -364,7 +364,7 @@ class TestAutomationSun(unittest.TestCase): # Before now = datetime(2015, 9, 16, 17, tzinfo=pytz.timezone('US/Mountain')) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -372,7 +372,7 @@ class TestAutomationSun(unittest.TestCase): # After now = datetime(2015, 9, 16, 18, tzinfo=pytz.timezone('US/Mountain')) - with patch('homeassistant.components.automation.sun.dt_util.now', + with patch('homeassistant.util.dt.now', return_value=now): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 0b19e9389e2..b36ce8c92b5 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -194,7 +194,7 @@ class TestAutomationTime(unittest.TestCase): def test_if_not_working_if_no_values_in_conf_provided(self): """Test for failure if no configuration.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert not _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'time', @@ -211,13 +211,12 @@ class TestAutomationTime(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) - @patch('homeassistant.components.automation.time._LOGGER.error') - def test_if_not_fires_using_wrong_after(self, mock_error): + def test_if_not_fires_using_wrong_after(self): """YAML translates time values to total seconds. This should break the before rule. """ - assert _setup_component(self.hass, automation.DOMAIN, { + assert not _setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'time', @@ -235,7 +234,6 @@ class TestAutomationTime(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) - self.assertEqual(2, mock_error.call_count) def test_if_action_before(self): """Test for if action before.""" @@ -258,14 +256,14 @@ class TestAutomationTime(unittest.TestCase): before_10 = dt_util.now().replace(hour=8) after_10 = dt_util.now().replace(hour=14) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=before_10): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=after_10): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -293,14 +291,14 @@ class TestAutomationTime(unittest.TestCase): before_10 = dt_util.now().replace(hour=8) after_10 = dt_util.now().replace(hour=14) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=before_10): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(0, len(self.calls)) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=after_10): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -329,14 +327,14 @@ class TestAutomationTime(unittest.TestCase): monday = dt_util.now() - timedelta(days=days_past_monday) tuesday = monday + timedelta(days=1) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=monday): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=tuesday): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() @@ -366,21 +364,21 @@ class TestAutomationTime(unittest.TestCase): tuesday = monday + timedelta(days=1) wednesday = tuesday + timedelta(days=1) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=monday): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=tuesday): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() self.assertEqual(2, len(self.calls)) - with patch('homeassistant.components.automation.time.dt_util.now', + with patch('homeassistant.helpers.condition.dt_util.now', return_value=wednesday): self.hass.bus.fire('test_event') self.hass.pool.block_till_done() diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py new file mode 100644 index 00000000000..8eaee797e73 --- /dev/null +++ b/tests/helpers/test_condition.py @@ -0,0 +1,68 @@ +"""Test the condition helper.""" +from homeassistant.helpers import condition + +from tests.common import get_test_home_assistant + + +class TestConditionHelper: + """Test condition helpers.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_and_condition(self): + """Test the 'and' condition.""" + test = condition.from_config({ + 'condition': 'and', + 'conditions': [ + { + 'condition': 'state', + 'entity_id': 'sensor.temperature', + 'state': '100', + }, { + 'condition': 'numeric_state', + 'entity_id': 'sensor.temperature', + 'below': 110, + } + ] + }) + + self.hass.states.set('sensor.temperature', 120) + assert not test(self.hass) + + self.hass.states.set('sensor.temperature', 105) + assert not test(self.hass) + + self.hass.states.set('sensor.temperature', 100) + assert test(self.hass) + + def test_or_condition(self): + """Test the 'or' condition.""" + test = condition.from_config({ + 'condition': 'or', + 'conditions': [ + { + 'condition': 'state', + 'entity_id': 'sensor.temperature', + 'state': '100', + }, { + 'condition': 'numeric_state', + 'entity_id': 'sensor.temperature', + 'below': 110, + } + ] + }) + + self.hass.states.set('sensor.temperature', 120) + assert not test(self.hass) + + self.hass.states.set('sensor.temperature', 105) + assert test(self.hass) + + self.hass.states.set('sensor.temperature', 100) + assert test(self.hass) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 47af833223f..38b9b467d00 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -216,3 +216,36 @@ class TestScriptHelper(unittest.TestCase): assert not script_obj.is_running assert len(calls) == 2 assert calls[-1].data['hello'] == 'universe' + + def test_condition(self): + """Test if we can use conditions in a script.""" + event = 'test_event' + events = [] + + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(event, record_event) + + self.hass.states.set('test.entity', 'hello') + + script_obj = script.Script(self.hass, [ + {'event': event}, + { + 'condition': 'state', + 'entity_id': 'test.entity', + 'state': 'hello', + }, + {'event': event}, + ]) + + script_obj.run() + self.hass.pool.block_till_done() + assert len(events) == 2 + + self.hass.states.set('test.entity', 'goodbye') + + script_obj.run() + self.hass.pool.block_till_done() + assert len(events) == 3