From b2a2cb3fd88b56aec88261fc5a0482d02ba92c08 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Fri, 1 Dec 2017 21:56:35 -0800 Subject: [PATCH 01/97] Update ecobee version to fix stack-trace issue (#10894) --- homeassistant/components/ecobee.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index a7246319e76..b4bb977ee70 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle from homeassistant.util.json import save_json -REQUIREMENTS = ['python-ecobee-api==0.0.12'] +REQUIREMENTS = ['python-ecobee-api==0.0.14'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c8b2b8a6326..fa35333c9f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -809,7 +809,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.12 # homeassistant.components.ecobee -python-ecobee-api==0.0.12 +python-ecobee-api==0.0.14 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.6 From 475b7896e22ebe609f35269517fe6edfcd80201d Mon Sep 17 00:00:00 2001 From: raymccarthy Date: Sat, 2 Dec 2017 15:44:24 +0100 Subject: [PATCH 02/97] Pybotvac multi (#10843) * Update requirements_all.txt * Update neato.py --- homeassistant/components/neato.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index e10878833e4..bd680b5361e 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,8 +17,8 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.3.zip' - '#pybotvac==0.0.3'] +REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.4.zip' + '#pybotvac==0.0.4'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' diff --git a/requirements_all.txt b/requirements_all.txt index fa35333c9f6..42f9d175377 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -352,7 +352,7 @@ https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f8 https://github.com/jabesq/netatmo-api-python/archive/v0.9.2.1.zip#lnetatmo==0.9.2.1 # homeassistant.components.neato -https://github.com/jabesq/pybotvac/archive/v0.0.3.zip#pybotvac==0.0.3 +https://github.com/jabesq/pybotvac/archive/v0.0.4.zip#pybotvac==0.0.4 # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 From 894705240505fd93a838599bc18f449a2dccf65b Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Sat, 2 Dec 2017 13:44:55 -0800 Subject: [PATCH 03/97] Fix issues from review of ecobee weather component (#10903) * Fix issues from review * Don't use STATE_UNKNOWN --- homeassistant/components/weather/ecobee.py | 55 +++++++++++++++------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/weather/ecobee.py b/homeassistant/components/weather/ecobee.py index 8c5354cfdab..379f5c1211b 100644 --- a/homeassistant/components/weather/ecobee.py +++ b/homeassistant/components/weather/ecobee.py @@ -4,11 +4,10 @@ Support for displaying weather info from Ecobee API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/weather.ecobee/ """ -import logging from homeassistant.components import ecobee from homeassistant.components.weather import ( WeatherEntity, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) -from homeassistant.const import (STATE_UNKNOWN, TEMP_FAHRENHEIT) +from homeassistant.const import (TEMP_FAHRENHEIT) DEPENDENCIES = ['ecobee'] @@ -52,8 +51,8 @@ class EcobeeWeather(WeatherEntity): try: forecast = self.weather['forecasts'][index] return forecast[param] - except (ValueError, IndexError): - return STATE_UNKNOWN + except (ValueError, IndexError, KeyError): + raise ValueError @property def name(self): @@ -63,12 +62,18 @@ class EcobeeWeather(WeatherEntity): @property def condition(self): """Return the current condition.""" - return self.get_forecast(0, 'condition') + try: + return self.get_forecast(0, 'condition') + except ValueError: + return None @property def temperature(self): """Return the temperature.""" - return float(self.get_forecast(0, 'temperature')) / 10 + try: + return float(self.get_forecast(0, 'temperature')) / 10 + except ValueError: + return None @property def temperature_unit(self): @@ -78,34 +83,51 @@ class EcobeeWeather(WeatherEntity): @property def pressure(self): """Return the pressure.""" - return int(self.get_forecast(0, 'pressure')) + try: + return int(self.get_forecast(0, 'pressure')) + except ValueError: + return None @property def humidity(self): """Return the humidity.""" - return int(self.get_forecast(0, 'relativeHumidity')) + try: + return int(self.get_forecast(0, 'relativeHumidity')) + except ValueError: + return None @property def visibility(self): """Return the visibility.""" - return int(self.get_forecast(0, 'visibility')) + try: + return int(self.get_forecast(0, 'visibility')) + except ValueError: + return None @property def wind_speed(self): """Return the wind speed.""" - return int(self.get_forecast(0, 'windSpeed')) + try: + return int(self.get_forecast(0, 'windSpeed')) + except ValueError: + return None @property def wind_bearing(self): """Return the wind direction.""" - return int(self.get_forecast(0, 'windBearing')) + try: + return int(self.get_forecast(0, 'windBearing')) + except ValueError: + return None @property def attribution(self): """Return the attribution.""" - station = self.weather['weatherStation'] - time = self.weather['timestamp'] - return "Ecobee weather provided by " + station + " at " + time + if self.weather: + station = self.weather.get('weatherStation', "UNKNOWN") + time = self.weather.get('timestamp', "UNKNOWN") + return "Ecobee weather provided by {} at {}".format(station, time) + return None @property def forecast(self): @@ -134,8 +156,8 @@ class EcobeeWeather(WeatherEntity): int(day['relativeHumidity']) forecasts.append(forecast) return forecasts - except (ValueError, IndexError): - return STATE_UNKNOWN + except (ValueError, IndexError, KeyError): + return None def update(self): """Get the latest state of the sensor.""" @@ -143,4 +165,3 @@ class EcobeeWeather(WeatherEntity): data.update() thermostat = data.ecobee.get_thermostat(self._index) self.weather = thermostat.get('weather', None) - logging.error("Weather Update") From 29f47d58bcb8a6b47796a776636d7b4696b6f088 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Sun, 3 Dec 2017 00:15:57 +0100 Subject: [PATCH 04/97] Bugfix #10902 (#10904) --- homeassistant/components/zwave/node_entity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 04446cff9a1..de8ca0c1ab9 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -137,6 +137,9 @@ class ZWaveNodeEntity(ZWaveBaseEntity): if self.node.can_wake_up(): for value in self.node.get_values(COMMAND_CLASS_WAKE_UP).values(): + if value.index != 0: + continue + self.wakeup_interval = value.data break else: From 0f8e48c26df1ceb03c030b988a1d3d9bc3492f5e Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Sun, 3 Dec 2017 13:52:31 +0100 Subject: [PATCH 05/97] More declarative timeout syntax for manual alarm control panel. (#10738) More declarative timeout syntax for manual alarm control panel --- .../components/alarm_control_panel/demo.py | 31 +- .../components/alarm_control_panel/manual.py | 161 +++-- .../alarm_control_panel/manual_mqtt.py | 161 +++-- homeassistant/const.py | 1 + .../alarm_control_panel/test_manual.py | 523 ++++++++++++++++ .../alarm_control_panel/test_manual_mqtt.py | 566 +++++++++++++++++- 6 files changed, 1329 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index aa90fe1f889..c080a136c08 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -4,30 +4,45 @@ Demo platform that has two fake alarm control panels. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ +import datetime import homeassistant.components.alarm_control_panel.manual as manual from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED, CONF_PENDING_TIME) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, CONF_DELAY_TIME, + CONF_PENDING_TIME, CONF_TRIGGER_TIME) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, { + manual.ManualAlarm(hass, 'Alarm', '1234', None, False, { STATE_ALARM_ARMED_AWAY: { - CONF_PENDING_TIME: 5 + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, STATE_ALARM_ARMED_HOME: { - CONF_PENDING_TIME: 5 + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, STATE_ALARM_ARMED_NIGHT: { - CONF_PENDING_TIME: 5 + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_DISARMED: { + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, STATE_ALARM_ARMED_CUSTOM_BYPASS: { - CONF_PENDING_TIME: 5 + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_PENDING_TIME: datetime.timedelta(seconds=5), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), }, STATE_ALARM_TRIGGERED: { - CONF_PENDING_TIME: 5 + CONF_PENDING_TIME: datetime.timedelta(seconds=5), }, }), ]) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 55f3834c06a..5ff6092493b 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -16,24 +16,40 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, CONF_NAME, CONF_CODE, - CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) + CONF_DELAY_TIME, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + CONF_DISARM_AFTER_TRIGGER) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time +CONF_CODE_TEMPLATE = 'code_template' + DEFAULT_ALARM_NAME = 'HA Alarm' -DEFAULT_PENDING_TIME = 60 -DEFAULT_TRIGGER_TIME = 120 +DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) +DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60) +DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120) DEFAULT_DISARM_AFTER_TRIGGER = False -SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED, - STATE_ALARM_ARMED_CUSTOM_BYPASS] +SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED] +SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_TRIGGERED] + +SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_DISARMED] + +ATTR_PRE_PENDING_STATE = 'pre_pending_state' ATTR_POST_PENDING_STATE = 'post_pending_state' def _state_validator(config): config = copy.deepcopy(config) + for state in SUPPORTED_PRETRIGGER_STATES: + if CONF_DELAY_TIME not in config[state]: + config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + if CONF_TRIGGER_TIME not in config[state]: + config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] for state in SUPPORTED_PENDING_STATES: if CONF_PENDING_TIME not in config[state]: config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] @@ -41,28 +57,44 @@ def _state_validator(config): return config -STATE_SETTING_SCHEMA = vol.Schema({ - vol.Optional(CONF_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)) -}) +def _state_schema(state): + schema = {} + if state in SUPPORTED_PRETRIGGER_STATES: + schema[vol.Optional(CONF_DELAY_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + if state in SUPPORTED_PENDING_STATES: + schema[vol.Optional(CONF_PENDING_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + return vol.Schema(schema) PLATFORM_SCHEMA = vol.Schema(vol.All({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, + vol.Exclusive(CONF_CODE, 'code validation'): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): - vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, - vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, - default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): + _state_schema(STATE_ALARM_ARMED_AWAY), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): + _state_schema(STATE_ALARM_ARMED_HOME), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): + _state_schema(STATE_ALARM_ARMED_NIGHT), + vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, default={}): + _state_schema(STATE_ALARM_ARMED_CUSTOM_BYPASS), + vol.Optional(STATE_ALARM_DISARMED, default={}): + _state_schema(STATE_ALARM_DISARMED), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): + _state_schema(STATE_ALARM_TRIGGERED), }, _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -74,8 +106,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass, config[CONF_NAME], config.get(CONF_CODE), - config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), - config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), + config.get(CONF_CODE_TEMPLATE), config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), config )]) @@ -86,27 +117,37 @@ class ManualAlarm(alarm.AlarmControlPanel): Representation of an alarm status. When armed, will be pending for 'pending_time', after that armed. - When triggered, will be pending for 'trigger_time'. After that will be - triggered for 'trigger_time', after that we return to the previous state - or disarm if `disarm_after_trigger` is true. + When triggered, will be pending for the triggering state's 'delay_time' + plus the triggered state's 'pending_time'. + After that will be triggered for 'trigger_time', after that we return to + the previous state or disarm if `disarm_after_trigger` is true. + A trigger_time of zero disables the alarm_trigger service. """ - def __init__(self, hass, name, code, pending_time, trigger_time, + def __init__(self, hass, name, code, code_template, disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name - self._code = str(code) if code else None - self._trigger_time = datetime.timedelta(seconds=trigger_time) + if code_template: + self._code = code_template + self._code.hass = hass + else: + self._code = code or None self._disarm_after_trigger = disarm_after_trigger - self._pre_trigger_state = self._state + self._previous_state = self._state self._state_ts = None - self._pending_time_by_state = {} - for state in SUPPORTED_PENDING_STATES: - self._pending_time_by_state[state] = datetime.timedelta( - seconds=config[state][CONF_PENDING_TIME]) + self._delay_time_by_state = { + state: config[state][CONF_DELAY_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._trigger_time_by_state = { + state: config[state][CONF_TRIGGER_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._pending_time_by_state = { + state: config[state][CONF_PENDING_TIME] + for state in SUPPORTED_PENDING_STATES} @property def should_poll(self): @@ -121,15 +162,16 @@ class ManualAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: + if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time_by_state[self._state] + - self._trigger_time) < dt_util.utcnow(): + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED else: - self._state = self._pre_trigger_state + self._state = self._previous_state return self._state if self._state in SUPPORTED_PENDING_STATES and \ @@ -138,9 +180,21 @@ class ManualAlarm(alarm.AlarmControlPanel): return self._state - def _within_pending_time(self, state): + @property + def _active_state(self): + if self.state == STATE_ALARM_PENDING: + return self._previous_state + else: + return self._state + + def _pending_time(self, state): pending_time = self._pending_time_by_state[state] - return self._state_ts + pending_time > dt_util.utcnow() + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property def code_format(self): @@ -185,26 +239,35 @@ class ManualAlarm(alarm.AlarmControlPanel): self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) def alarm_trigger(self, code=None): - """Send alarm trigger command. No code needed.""" - self._pre_trigger_state = self._state + """ + Send alarm trigger command. + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return self._update_state(STATE_ALARM_TRIGGERED) def _update_state(self, state): + if self._state == state: + return + + self._previous_state = self._state self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - pending_time = self._pending_time_by_state[state] - - if state == STATE_ALARM_TRIGGERED and self._trigger_time: + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: track_point_in_time( self._hass, self.async_update_ha_state, self._state_ts + pending_time) + trigger_time = self._trigger_time_by_state[self._previous_state] track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._trigger_time + pending_time) + self._state_ts + pending_time + trigger_time) elif state in SUPPORTED_PENDING_STATES and pending_time: track_point_in_time( self._hass, self.async_update_ha_state, @@ -212,7 +275,14 @@ class ManualAlarm(alarm.AlarmControlPanel): def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) return check @@ -223,6 +293,7 @@ class ManualAlarm(alarm.AlarmControlPanel): state_attr = {} if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state state_attr[ATTR_POST_PENDING_STATE] = self._state return state_attr diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 44247616b59..9e388806e73 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -16,8 +16,8 @@ import homeassistant.util.dt as dt_util from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, - CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, - CONF_DISARM_AFTER_TRIGGER) + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME, + CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt from homeassistant.helpers.event import async_track_state_change @@ -26,28 +26,44 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time +CONF_CODE_TEMPLATE = 'code_template' + CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' -DEFAULT_PENDING_TIME = 60 -DEFAULT_TRIGGER_TIME = 120 +DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) +DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60) +DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120) DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' -SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] +SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED] +SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_TRIGGERED] + +SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_DISARMED] + +ATTR_PRE_PENDING_STATE = 'pre_pending_state' ATTR_POST_PENDING_STATE = 'post_pending_state' def _state_validator(config): config = copy.deepcopy(config) + for state in SUPPORTED_PRETRIGGER_STATES: + if CONF_DELAY_TIME not in config[state]: + config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + if CONF_TRIGGER_TIME not in config[state]: + config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] for state in SUPPORTED_PENDING_STATES: if CONF_PENDING_TIME not in config[state]: config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] @@ -55,27 +71,44 @@ def _state_validator(config): return config -STATE_SETTING_SCHEMA = vol.Schema({ - vol.Optional(CONF_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)) -}) +def _state_schema(state): + schema = {} + if state in SUPPORTED_PRETRIGGER_STATES: + schema[vol.Optional(CONF_DELAY_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + if state in SUPPORTED_PENDING_STATES: + schema[vol.Optional(CONF_PENDING_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + return vol.Schema(schema) + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, + vol.Exclusive(CONF_CODE, 'code validation'): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): - vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, - vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): + _state_schema(STATE_ALARM_ARMED_AWAY), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): + _state_schema(STATE_ALARM_ARMED_HOME), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): + _state_schema(STATE_ALARM_ARMED_NIGHT), + vol.Optional(STATE_ALARM_DISARMED, default={}): + _state_schema(STATE_ALARM_DISARMED), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): + _state_schema(STATE_ALARM_TRIGGERED), vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, @@ -93,8 +126,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass, config[CONF_NAME], config.get(CONF_CODE), - config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), - config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), + config.get(CONF_CODE_TEMPLATE), config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), config.get(mqtt.CONF_STATE_TOPIC), config.get(mqtt.CONF_COMMAND_TOPIC), @@ -111,13 +143,15 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): Representation of an alarm status. When armed, will be pending for 'pending_time', after that armed. - When triggered, will be pending for 'trigger_time'. After that will be - triggered for 'trigger_time', after that we return to the previous state - or disarm if `disarm_after_trigger` is true. + When triggered, will be pending for the triggering state's 'delay_time' + plus the triggered state's 'pending_time'. + After that will be triggered for 'trigger_time', after that we return to + the previous state or disarm if `disarm_after_trigger` is true. + A trigger_time of zero disables the alarm_trigger service. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger, + def __init__(self, hass, name, code, code_template, + disarm_after_trigger, state_topic, command_topic, qos, payload_disarm, payload_arm_home, payload_arm_away, payload_arm_night, config): @@ -125,17 +159,24 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name - self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) - self._trigger_time = datetime.timedelta(seconds=trigger_time) + if code_template: + self._code = code_template + self._code.hass = hass + else: + self._code = code or None self._disarm_after_trigger = disarm_after_trigger - self._pre_trigger_state = self._state + self._previous_state = self._state self._state_ts = None - self._pending_time_by_state = {} - for state in SUPPORTED_PENDING_STATES: - self._pending_time_by_state[state] = datetime.timedelta( - seconds=config[state][CONF_PENDING_TIME]) + self._delay_time_by_state = { + state: config[state][CONF_DELAY_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._trigger_time_by_state = { + state: config[state][CONF_TRIGGER_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._pending_time_by_state = { + state: config[state][CONF_PENDING_TIME] + for state in SUPPORTED_PENDING_STATES} self._state_topic = state_topic self._command_topic = command_topic @@ -158,15 +199,16 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: + if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time_by_state[self._state] + - self._trigger_time) < dt_util.utcnow(): + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED else: - self._state = self._pre_trigger_state + self._state = self._previous_state return self._state if self._state in SUPPORTED_PENDING_STATES and \ @@ -175,9 +217,21 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): return self._state - def _within_pending_time(self, state): + @property + def _active_state(self): + if self.state == STATE_ALARM_PENDING: + return self._previous_state + else: + return self._state + + def _pending_time(self, state): pending_time = self._pending_time_by_state[state] - return self._state_ts + pending_time > dt_util.utcnow() + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property def code_format(self): @@ -215,26 +269,35 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): - """Send alarm trigger command. No code needed.""" - self._pre_trigger_state = self._state + """ + Send alarm trigger command. + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return self._update_state(STATE_ALARM_TRIGGERED) def _update_state(self, state): + if self._state == state: + return + + self._previous_state = self._state self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - pending_time = self._pending_time_by_state[state] - - if state == STATE_ALARM_TRIGGERED and self._trigger_time: + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: track_point_in_time( self._hass, self.async_update_ha_state, self._state_ts + pending_time) + trigger_time = self._trigger_time_by_state[self._previous_state] track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._trigger_time + pending_time) + self._state_ts + pending_time + trigger_time) elif state in SUPPORTED_PENDING_STATES and pending_time: track_point_in_time( self._hass, self.async_update_ha_state, @@ -242,7 +305,14 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) return check @@ -253,6 +323,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): state_attr = {} if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state state_attr[ATTR_POST_PENDING_STATE] = self._state return state_attr diff --git a/homeassistant/const.py b/homeassistant/const.py index beb34146e70..9d394bb4a99 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -52,6 +52,7 @@ CONF_CURRENCY = 'currency' CONF_CUSTOMIZE = 'customize' CONF_CUSTOMIZE_DOMAIN = 'customize_domain' CONF_CUSTOMIZE_GLOB = 'customize_glob' +CONF_DELAY_TIME = 'delay_time' CONF_DEVICE = 'device' CONF_DEVICE_CLASS = 'device_class' CONF_DEVICES = 'devices' diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index d65568b0844..c47ed941b65 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -140,6 +140,32 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_AWAY, self.hass.states.get(entity_id).state) + def test_arm_home_with_template_code(self): + """Attempt to arm with a template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': '{{ "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + def test_arm_away_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -257,6 +283,13 @@ class TestAlarmControlPanelManual(unittest.TestCase): state = self.hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_NIGHT + # Do not go to the pending state when updating to the same state + alarm_control_panel.alarm_arm_night(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + def test_arm_night_with_invalid_code(self): """Attempt to night home without a valid code.""" self.assertTrue(setup_component( @@ -311,6 +344,93 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_trigger_with_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) + + def test_trigger_zero_trigger_time(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 0, + 'trigger_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_zero_trigger_time_with_pending(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -355,6 +475,203 @@ class TestAlarmControlPanelManual(unittest.TestCase): state = self.hass.states.get(entity_id) assert state.state == STATE_ALARM_DISARMED + def test_trigger_with_unused_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 5, + 'pending_time': 0, + 'armed_home': { + 'delay_time': 10 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + def test_armed_home_with_specific_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -518,6 +835,101 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_zero_specific_trigger_time(self): + """Test trigger method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'disarmed': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_unused_zero_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'armed_home': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'disarmed': { + 'trigger_time': 5 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_no_disarm_after_trigger(self): """Test disarm after trigger.""" self.assertTrue(setup_component( @@ -684,6 +1096,45 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_disarm_with_template_code(self): + """Attempt to disarm with a valid or invalid template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': + '{{ "" if from_state == "disarmed" else "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_DISARMED, state.state) + def test_arm_custom_bypass_no_pending(self): """Test arm custom bypass method.""" self.assertTrue(setup_component( @@ -795,3 +1246,75 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_CUSTOM_BYPASS, self.hass.states.get(entity_id).state) + + def test_arm_away_after_disabled_disarmed(self): + """Test pending state with and without zero trigger time.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'delay_time': 1, + 'armed_away': { + 'pending_time': 1, + }, + 'disarmed': { + 'trigger_time': 0 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_AWAY, state.state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index e56b6865e6e..83254d9104f 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -162,6 +162,34 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_AWAY, self.hass.states.get(entity_id).state) + def test_arm_home_with_template_code(self): + """Attempt to arm with a template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_template': '{{ "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + def test_arm_away_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -287,6 +315,13 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_NIGHT, self.hass.states.get(entity_id).state) + # Do not go to the pending state when updating to the same state + alarm_control_panel.alarm_arm_night(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + def test_arm_night_with_invalid_code(self): """Attempt to arm night without a valid code.""" self.assertTrue(setup_component( @@ -345,6 +380,99 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_trigger_with_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) + + def test_trigger_zero_trigger_time(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 0, + 'trigger_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_zero_trigger_time_with_pending(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -425,6 +553,107 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_zero_specific_trigger_time(self): + """Test trigger method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'disarmed': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_unused_zero_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'armed_home': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'disarmed': { + 'trigger_time': 5 + }, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): """Test no disarm after back to back trigger.""" self.assertTrue(setup_component( @@ -559,6 +788,211 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_trigger_with_unused_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 5, + 'pending_time': 0, + 'armed_home': { + 'delay_time': 10 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + def test_armed_home_with_specific_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -674,21 +1108,6 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): entity_id = 'alarm_control_panel.test' - alarm_control_panel.alarm_arm_home(self.hass) - self.hass.block_till_done() - - self.assertEqual(STATE_ALARM_PENDING, - self.hass.states.get(entity_id).state) - - future = dt_util.utcnow() + timedelta(seconds=10) - with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - self.assertEqual(STATE_ALARM_ARMED_HOME, - self.hass.states.get(entity_id).state) - alarm_control_panel.alarm_trigger(self.hass) self.hass.block_till_done() @@ -710,9 +1129,124 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_HOME, + self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_arm_away_after_disabled_disarmed(self): + """Test pending state with and without zero trigger time.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'delay_time': 1, + 'armed_away': { + 'pending_time': 1, + }, + 'disarmed': { + 'trigger_time': 0 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_AWAY, state.state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) + + def test_disarm_with_template_code(self): + """Attempt to disarm with a valid or invalid template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_template': + '{{ "" if from_state == "disarmed" else "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_DISARMED, state.state) + def test_arm_home_via_command_topic(self): """Test arming home via command topic.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { From 4390fed1686a4f6e3828ace4000f77be8ea12d37 Mon Sep 17 00:00:00 2001 From: Nicko van Someren Date: Sun, 3 Dec 2017 08:30:25 -0700 Subject: [PATCH 06/97] Unpacking RESTful sensor JSON results into attributes. (#10753) * Added support for extracting JSON attributes from RESTful values Setting the json_attributes configuration option to true on the RESTful sensor will cause the result of the REST request to be parsed as a JSON string and if successful the resulting dictionary will be used for the attributes of the sensor. * Added support for extracting JSON attributes from RESTful values Setting the json_attributes configuration option to true on the RESTful sensor will cause the result of the REST request to be parsed as a JSON string and if successful the resulting dictionary will be used for the attributes of the sensor. * Added requirement that RESTful JSON results used as attributes must be objects, not lists. * Expanded test coverage to test REFTful JSON attributes with and without a value template. * Added support for extracting JSON attributes from RESTful values Setting the json_attributes configuration option to true on the RESTful sensor will cause the result of the REST request to be parsed as a JSON string and if successful the resulting dictionary will be used for the attributes of the sensor. * Added requirement that RESTful JSON results used as attributes must be objects, not lists. * Expanded test coverage to test REFTful JSON attributes with and without a value template. * sensor.envirophat: add missing requirement (#7451) Adding requirements that is not explicitly pulled in by the library that manages the Enviro pHAT. * PyPI Openzwave (#7415) * Remove default zwave config path PYOZW now has much more comprehensive default handling for the config path (in src-lib/libopenzwave/libopenzwave.pyx:getConfig()). It looks in the same place we were looking, plus _many_ more. It will certainly do a much better job of finding the config files than we will (and will be updated as the library is changed, so we don't end up chasing it). The getConfig() method has been there for a while, but was subsntially improved recently. This change simply leaves the config_path as None if it is not specified, which will trigger the default handling in PYOZW. * Install python-openzwave from PyPI As of version 0.4, python-openzwave supports installation from PyPI, which means we can use our 'normal' dependency management tooling to install it. Yay. This uses the default 'embed' build (which goes and downloads statically sources to avoid having to compile anything locally). Check out the python-openzwave readme for more details. * Add python-openzwave deps to .travis.yml Python OpenZwave require the libudev headers to build. This adds the libudev-dev package to Travis runs via the 'apt' addon for Travis. Thanks to @MartinHjelmare for this fix. * Update docker build for PyPI openzwave Now that PYOZW can be install from PyPI, the docker image build process can be simplified to remove the explicit compilation of PYOZW. * Add datadog component (#7158) * Add datadog component * Improve test_invalid_config datadog test * Use assert_setup_component for test setup * Fix object type for default KNX port #7429 describes a TypeError that is raised if the port is omitted in the config for the KNX component (integer is required (got type str)). This commit changes the default port from a string to an integer. I expect this will resolve that issue... * Added support for extracting JSON attributes from RESTful values Setting the json_attributes configuration option to true on the RESTful sensor will cause the result of the REST request to be parsed as a JSON string and if successful the resulting dictionary will be used for the attributes of the sensor. * Added requirement that RESTful JSON results used as attributes must be objects, not lists. * Expanded test coverage to test REFTful JSON attributes with and without a value template. * Added support for extracting JSON attributes from RESTful values Setting the json_attributes configuration option to true on the RESTful sensor will cause the result of the REST request to be parsed as a JSON string and if successful the resulting dictionary will be used for the attributes of the sensor. * Added requirement that RESTful JSON results used as attributes must be objects, not lists. * Expanded test coverage to test REFTful JSON attributes with and without a value template. * Added support for extracting JSON attributes from RESTful values Setting the json_attributes configuration option to true on the RESTful sensor will cause the result of the REST request to be parsed as a JSON string and if successful the resulting dictionary will be used for the attributes of the sensor. * Added requirement that RESTful JSON results used as attributes must be objects, not lists. * Expanded test coverage to test REFTful JSON attributes with and without a value template. * Fixed breaks cause by manual upstream merge. * Added one extra blank line to make PyLint happy. * Switched json_attributes to be a list of keys rather than a boolean. The value of json_attributes can now be either a comma sepaated list of key names or a YAML list of key names. Only matching keys in a retuned JSON dictionary will be mapped to sensor attributes. Updated test cases to handle json_attributes being a list. Also fixed two minor issues arrising from manual merge with 0.58 master. * Added an explicit default value to the json_attributes config entry. * Removed self.update() from __init__() body. * Expended unit tests for error cases of json_attributes processing. * Align quotes --- homeassistant/components/sensor/rest.py | 32 ++++++++++++- tests/components/sensor/test_rest.py | 60 ++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 2ae1c3674ea..86362e8f2d9 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.rest/ """ import logging +import json import voluptuous as vol import requests @@ -25,6 +26,7 @@ DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' DEFAULT_VERIFY_SSL = True +CONF_JSON_ATTRS = 'json_attributes' METHODS = ['POST', 'GET'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -32,6 +34,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional(CONF_HEADERS): {cv.string: cv.string}, + vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -55,6 +58,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): headers = config.get(CONF_HEADERS) unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) + json_attrs = config.get(CONF_JSON_ATTRS) + if value_template is not None: value_template.hass = hass @@ -68,13 +73,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest = RestData(method, resource, auth, headers, payload, verify_ssl) rest.update() - add_devices([RestSensor(hass, rest, name, unit, value_template)], True) + add_devices([RestSensor( + hass, rest, name, unit, value_template, json_attrs)], True) class RestSensor(Entity): """Implementation of a REST sensor.""" - def __init__(self, hass, rest, name, unit_of_measurement, value_template): + def __init__(self, hass, rest, name, + unit_of_measurement, value_template, json_attrs): """Initialize the REST sensor.""" self._hass = hass self.rest = rest @@ -82,6 +89,8 @@ class RestSensor(Entity): self._state = STATE_UNKNOWN self._unit_of_measurement = unit_of_measurement self._value_template = value_template + self._json_attrs = json_attrs + self._attributes = None @property def name(self): @@ -108,6 +117,20 @@ class RestSensor(Entity): self.rest.update() value = self.rest.data + if self._json_attrs: + self._attributes = {} + try: + json_dict = json.loads(value) + if isinstance(json_dict, dict): + attrs = {k: json_dict[k] for k in self._json_attrs + if k in json_dict} + self._attributes = attrs + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning("REST result could not be parsed as JSON") + _LOGGER.debug("Erroneous JSON: %s", value) + if value is None: value = STATE_UNKNOWN elif self._value_template is not None: @@ -116,6 +139,11 @@ class RestSensor(Entity): self._state = value + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + class RestData(object): """Class for handling the data retrieval.""" diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index a083dbfb1a2..1bda8ab82f3 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -133,9 +133,9 @@ class TestRestSensor(unittest.TestCase): self.value_template = template('{{ value_json.key }}') self.value_template.hass = self.hass - self.sensor = rest.RestSensor( - self.hass, self.rest, self.name, self.unit_of_measurement, - self.value_template) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, + self.value_template, []) def tearDown(self): """Stop everything that was started.""" @@ -181,12 +181,62 @@ class TestRestSensor(unittest.TestCase): self.rest.update = Mock('rest.RestData.update', side_effect=self.update_side_effect( 'plain_state')) - self.sensor = rest.RestSensor( - self.hass, self.rest, self.name, self.unit_of_measurement, None) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, []) self.sensor.update() self.assertEqual('plain_state', self.sensor.state) self.assertTrue(self.sensor.available) + def test_update_with_json_attrs(self): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '{ "key": "some_json_value" }')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key']) + self.sensor.update() + self.assertEqual('some_json_value', + self.sensor.device_state_attributes['key']) + + @patch('homeassistant.components.sensor.rest._LOGGER') + def test_update_with_json_attrs_not_dict(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '["list", "of", "things"]')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key']) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + + @patch('homeassistant.components.sensor.rest._LOGGER') + def test_update_with_json_attrs_bad_JSON(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + 'This is text rather than JSON data.')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key']) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + self.assertTrue(mock_logger.debug.called) + + def test_update_with_json_attrs_and_template(self): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '{ "key": "json_state_updated_value" }')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, + self.value_template, ['key']) + self.sensor.update() + + self.assertEqual('json_state_updated_value', self.sensor.state) + self.assertEqual('json_state_updated_value', + self.sensor.device_state_attributes['key']) + class TestRestData(unittest.TestCase): """Tests for RestData.""" From fce994ea7658f1e025a03c70d23387bb863f5978 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 3 Dec 2017 16:47:21 +0100 Subject: [PATCH 07/97] Bump dev to 0.60.0.dev0 (#10912) --- homeassistant/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9d394bb4a99..85047f0482e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 59 -PATCH_VERSION = '0' +MINOR_VERSION = 60 +PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From 8ceaa72ba31c950c40d2fb4a3c3b7fd9924454aa Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Sun, 3 Dec 2017 16:48:07 +0100 Subject: [PATCH 08/97] Update eliqonline.py (#10914) Channel id is now required (change in API) --- homeassistant/components/sensor/eliqonline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index dc879fe0d3e..3e736ed719f 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -30,7 +30,7 @@ UNIT_OF_MEASUREMENT = 'W' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_CHANNEL_ID): cv.positive_int, + vol.Required(CONF_CHANNEL_ID): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) From 9e82433a3e78dac7700df4d330e251610e84548f Mon Sep 17 00:00:00 2001 From: Ludovico de Nittis Date: Sun, 3 Dec 2017 16:48:12 +0100 Subject: [PATCH 09/97] Add iAlarm support (#10878) * Add iAlarm support * Minor fixes to iAlarm * Rename ialarmpanel to ialarm and add a check for the host value * corrections in the value validation of ialarm * add a missing period on ialarm --- .coveragerc | 1 + .../components/alarm_control_panel/ialarm.py | 107 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 111 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/ialarm.py diff --git a/.coveragerc b/.coveragerc index b091b376579..0f95db71ec7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -263,6 +263,7 @@ omit = homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/egardia.py + homeassistant/components/alarm_control_panel/ialarm.py homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/alarm_control_panel/simplisafe.py diff --git a/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant/components/alarm_control_panel/ialarm.py new file mode 100644 index 00000000000..3fb6e2dcb90 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/ialarm.py @@ -0,0 +1,107 @@ +""" +Interfaces with iAlarm control panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.ialarm/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_HOST, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, CONF_NAME) + +REQUIREMENTS = ['pyialarm==0.2'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'iAlarm' + + +def no_application_protocol(value): + """Validate that value is without the application protocol.""" + protocol_separator = "://" + if not value or protocol_separator in value: + raise vol.Invalid( + 'Invalid host, {} is not allowed'.format(protocol_separator)) + + return value + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HOST): vol.All(cv.string, no_application_protocol), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up an iAlarm control panel.""" + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + + url = 'http://{}'.format(host) + ialarm = IAlarmPanel(name, username, password, url) + add_devices([ialarm], True) + + +class IAlarmPanel(alarm.AlarmControlPanel): + """Represent an iAlarm status.""" + + def __init__(self, name, username, password, url): + """Initialize the iAlarm status.""" + from pyialarm import IAlarm + + self._name = name + self._username = username + self._password = password + self._url = url + self._state = None + self._client = IAlarm(username, password, url) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Return the state of the device.""" + status = self._client.get_status() + _LOGGER.debug('iAlarm status: %s', status) + if status: + status = int(status) + + if status == self._client.DISARMED: + state = STATE_ALARM_DISARMED + elif status == self._client.ARMED_AWAY: + state = STATE_ALARM_ARMED_AWAY + elif status == self._client.ARMED_STAY: + state = STATE_ALARM_ARMED_HOME + else: + state = None + + self._state = state + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._client.disarm() + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._client.arm_away() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._client.arm_stay() diff --git a/requirements_all.txt b/requirements_all.txt index 42f9d175377..3b96c8557f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -682,6 +682,9 @@ pyhomematic==0.1.35 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.3.1 +# homeassistant.components.alarm_control_panel.ialarm +pyialarm==0.2 + # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 From 6b410d80768ab814013a691b5b2a2564a4bccbaf Mon Sep 17 00:00:00 2001 From: Touliloup Date: Sun, 3 Dec 2017 18:34:45 +0100 Subject: [PATCH 10/97] Correction of Samsung Power OFF behaviour (#10907) * Correction of Samsung Power OFF behaviour Addition of a delay after powering OFF a Samsung TV, this avoid status update from powering the TV back ON. Deletion of update() return statement, return value not used. * Rename self._end_of_power_off_command into self._end_of_power_off * Removal of unused line break in Samsung TV component --- .../components/media_player/samsungtv.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 0153eb687ff..721b095c083 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.samsungtv/ """ import logging import socket +from datetime import timedelta import voluptuous as vol @@ -17,6 +18,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT, CONF_MAC) import homeassistant.helpers.config_validation as cv +from homeassistant.util import dt as dt_util REQUIREMENTS = ['samsungctl==0.6.0', 'wakeonlan==0.2.2'] @@ -100,6 +102,9 @@ class SamsungTVDevice(MediaPlayerDevice): self._playing = True self._state = STATE_UNKNOWN self._remote = None + # Mark the end of a shutdown command (need to wait 15 seconds before + # sending the next command to avoid turning the TV back ON). + self._end_of_power_off = None # Generate a configuration for the Samsung library self._config = { 'name': 'HomeAssistant', @@ -118,7 +123,7 @@ class SamsungTVDevice(MediaPlayerDevice): def update(self): """Retrieve the latest data.""" # Send an empty key to see if we are still connected - return self.send_key('KEY') + self.send_key('KEY') def get_remote(self): """Create or return a remote control instance.""" @@ -130,6 +135,10 @@ class SamsungTVDevice(MediaPlayerDevice): def send_key(self, key): """Send a key to the tv and handles exceptions.""" + if self._power_off_in_progress() \ + and not (key == 'KEY_POWER' or key == 'KEY_POWEROFF'): + _LOGGER.info("TV is powering off, not sending command: %s", key) + return try: self.get_remote().control(key) self._state = STATE_ON @@ -139,13 +148,16 @@ class SamsungTVDevice(MediaPlayerDevice): # BrokenPipe can occur when the commands is sent to fast self._state = STATE_ON self._remote = None - return False + return except (self._exceptions_class.ConnectionClosed, OSError): self._state = STATE_OFF self._remote = None - return False + if self._power_off_in_progress(): + self._state = STATE_OFF - return True + def _power_off_in_progress(self): + return self._end_of_power_off is not None and \ + self._end_of_power_off > dt_util.utcnow() @property def name(self): @@ -171,6 +183,8 @@ class SamsungTVDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" + self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=15) + if self._config['method'] == 'websocket': self.send_key('KEY_POWER') else: From 9577525b0b0b675481c3581437ca51a136129038 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 3 Dec 2017 21:34:59 +0100 Subject: [PATCH 11/97] Add Alpha Vantage sensor (#10873) * Add Alpha Vantage sensor * Remove data object * Remove unused vars and change return * Fix typo --- .coveragerc | 3 +- .../components/sensor/alpha_vantage.py | 110 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sensor/alpha_vantage.py diff --git a/.coveragerc b/.coveragerc index 0f95db71ec7..2af48f0abb0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -473,6 +473,7 @@ omit = homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py homeassistant/components/sensor/airvisual.py + homeassistant/components/sensor/alpha_vantage.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -483,8 +484,8 @@ omit = homeassistant/components/sensor/bom.py homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/buienradar.py - homeassistant/components/sensor/citybikes.py homeassistant/components/sensor/cert_expiry.py + homeassistant/components/sensor/citybikes.py homeassistant/components/sensor/comed_hourly_pricing.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/crimereports.py diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py new file mode 100644 index 00000000000..88ead3301b6 --- /dev/null +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -0,0 +1,110 @@ +""" +Stock market information from Alpha Vantage. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.alpha_vantage/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['alpha_vantage==1.3.6'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CLOSE = 'close' +ATTR_HIGH = 'high' +ATTR_LOW = 'low' +ATTR_VOLUME = 'volume' + +CONF_ATTRIBUTION = "Stock market information provided by Alpha Vantage." +CONF_SYMBOLS = 'symbols' + +DEFAULT_SYMBOL = 'GOOGL' + +ICON = 'mdi:currency-usd' + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SYMBOLS, default=[DEFAULT_SYMBOL]): + vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Alpha Vantage sensor.""" + from alpha_vantage.timeseries import TimeSeries + + api_key = config.get(CONF_API_KEY) + symbols = config.get(CONF_SYMBOLS) + + timeseries = TimeSeries(key=api_key) + + dev = [] + for symbol in symbols: + try: + timeseries.get_intraday(symbol) + except ValueError: + _LOGGER.error( + "API Key is not valid or symbol '%s' not known", symbol) + return + dev.append(AlphaVantageSensor(timeseries, symbol)) + + add_devices(dev, True) + + +class AlphaVantageSensor(Entity): + """Representation of a Alpha Vantage sensor.""" + + def __init__(self, timeseries, symbol): + """Initialize the sensor.""" + self._name = symbol + self._timeseries = timeseries + self._symbol = symbol + self.values = None + self._unit_of_measurement = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._symbol + + @property + def state(self): + """Return the state of the sensor.""" + return self.values['1. open'] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.values is not None: + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_CLOSE: self.values['4. close'], + ATTR_HIGH: self.values['2. high'], + ATTR_LOW: self.values['3. low'], + ATTR_VOLUME: self.values['5. volume'], + } + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data and updates the states.""" + all_values, _ = self._timeseries.get_intraday(self._symbol) + self.values = next(iter(all_values.values())) diff --git a/requirements_all.txt b/requirements_all.txt index 3b96c8557f1..4ce775ab2f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,6 +83,9 @@ aiopvapi==1.5.4 # homeassistant.components.alarmdecoder alarmdecoder==0.12.3 +# homeassistant.components.sensor.alpha_vantage +alpha_vantage==1.3.6 + # homeassistant.components.amcrest amcrest==1.2.1 From 3a246df5447191d2531075ca0d2c9cc17ef53a47 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 3 Dec 2017 21:51:32 +0100 Subject: [PATCH 12/97] Don't repeat getting receiver name on each update / pushed to denonavr 0.5.5 (#10915) --- homeassistant/components/media_player/denonavr.py | 13 ++++++------- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 7fffc09696c..0a03af0e1bf 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.4'] +REQUIREMENTS = ['denonavr==0.5.5'] _LOGGER = logging.getLogger(__name__) @@ -102,12 +102,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if config.get(CONF_HOST) is None and discovery_info is None: d_receivers = denonavr.discover() # More than one receiver could be discovered by that method - if d_receivers is not None: - for d_receiver in d_receivers: - host = d_receiver["host"] - name = d_receiver["friendlyName"] - new_hosts.append( - NewHost(host=host, name=name)) + for d_receiver in d_receivers: + host = d_receiver["host"] + name = d_receiver["friendlyName"] + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later diff --git a/requirements_all.txt b/requirements_all.txt index 4ce775ab2f3..218f1796efd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -200,7 +200,7 @@ defusedxml==0.5.0 deluge-client==1.0.5 # homeassistant.components.media_player.denonavr -denonavr==0.5.4 +denonavr==0.5.5 # homeassistant.components.media_player.directv directpy==0.2 From 879e32f670e2354813667173a328dca6a79a8f5c Mon Sep 17 00:00:00 2001 From: Brent Hughes Date: Sun, 3 Dec 2017 16:39:54 -0600 Subject: [PATCH 13/97] Add Min and Event Count Metrics To Prometheus (#10530) * Added min and Events sensor types to prometheus * Updated prometheus client and fixed invalid swith state * Added metric to count number of times an automation is triggered * Removed assumption that may not apply to everybody * Fixed tests * Updated requirements_test_all * Fixed unit tests --- homeassistant/components/prometheus.py | 27 +++++++++++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/test_prometheus.py | 4 +++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 0396cafd4ff..0195021829b 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -19,7 +19,7 @@ from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius -REQUIREMENTS = ['prometheus_client==0.0.19'] +REQUIREMENTS = ['prometheus_client==0.0.21'] _LOGGER = logging.getLogger(__name__) @@ -189,6 +189,14 @@ class Metrics(object): 'electricity_usage_w', self.prometheus_client.Gauge, 'Currently reported electricity draw in Watts', ), + 'min': ( + 'sensor_min', self.prometheus_client.Gauge, + 'Time in minutes reported by a sensor' + ), + 'Events': ( + 'sensor_event_count', self.prometheus_client.Gauge, + 'Number of events for a sensor' + ), } unit = state.attributes.get('unit_of_measurement') @@ -212,12 +220,25 @@ class Metrics(object): self.prometheus_client.Gauge, 'State of the switch (0/1)', ) - value = state_helper.state_as_number(state) - metric.labels(**self._labels(state)).set(value) + + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass def _handle_zwave(self, state): self._battery(state) + def _handle_automation(self, state): + metric = self._metric( + 'automation_triggered_count', + self.prometheus_client.Counter, + 'Count of times an automation has been triggered', + ) + + metric.labels(**self._labels(state)).inc() + class PrometheusView(HomeAssistantView): """Handle Prometheus requests.""" diff --git a/requirements_all.txt b/requirements_all.txt index 218f1796efd..309da835c3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -560,7 +560,7 @@ pocketcasts==0.1 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.0.19 +prometheus_client==0.0.21 # homeassistant.components.sensor.systemmonitor psutil==5.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b02d80ad0e3..7a33e9b4dd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -113,7 +113,7 @@ pilight==0.1.1 pmsensor==0.4 # homeassistant.components.prometheus -prometheus_client==0.0.19 +prometheus_client==0.0.21 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index dd8cbfe55e0..052292b015d 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -30,4 +30,6 @@ def test_view(prometheus_client): # pylint: disable=redefined-outer-name assert len(body) > 3 # At least two comment lines and a metric for line in body: if line: - assert line.startswith('# ') or line.startswith('process_') + assert line.startswith('# ') \ + or line.startswith('process_') \ + or line.startswith('python_info') From 6776e942d7b3cc414621917c620f92ce441988c2 Mon Sep 17 00:00:00 2001 From: Will Boyce Date: Sun, 3 Dec 2017 22:59:22 +0000 Subject: [PATCH 14/97] fix ios component config generation (#10923) Fixes: #19020 --- homeassistant/components/ios.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index cfa1693f571..ebabcdb0e79 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -264,7 +264,7 @@ class iOSIdentifyDeviceView(HomeAssistantView): # return self.json_message(humanize_error(request.json, ex), # HTTP_BAD_REQUEST) - data[ATTR_LAST_SEEN_AT] = datetime.datetime.now() + data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat() name = data.get(ATTR_DEVICE_ID) From 0d6c95ac44c7cf6eb6eaea821b1e226a6bcef7ef Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Mon, 4 Dec 2017 00:08:10 +0100 Subject: [PATCH 15/97] Fix Notifications for Android TV (#10798) * Fixed icon path, added dynamic icon * Addressing requested changes * Using DEFAULT_ICON * Using CONF_ICON from const * Getting hass_frontend path via import * Lint * Using embedded 1px transparent icon * woof -.- * Lint --- homeassistant/components/notify/nfandroidtv.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/notify/nfandroidtv.py b/homeassistant/components/notify/nfandroidtv.py index 6c4f7e49dde..1fa8f1dab78 100644 --- a/homeassistant/components/notify/nfandroidtv.py +++ b/homeassistant/components/notify/nfandroidtv.py @@ -4,8 +4,9 @@ Notifications for Android TV notification service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.nfandroidtv/ """ -import os import logging +import io +import base64 import requests import voluptuous as vol @@ -31,6 +32,9 @@ DEFAULT_TRANSPARENCY = 'default' DEFAULT_COLOR = 'grey' DEFAULT_INTERRUPT = False DEFAULT_TIMEOUT = 5 +DEFAULT_ICON = ( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApo' + 'cMXEAAAAASUVORK5CYII=') ATTR_DURATION = 'duration' ATTR_POSITION = 'position' @@ -110,16 +114,13 @@ class NFAndroidTVNotificationService(BaseNotificationService): self._default_color = color self._default_interrupt = interrupt self._timeout = timeout - self._icon_file = os.path.join( - os.path.dirname(__file__), '..', 'frontend', 'www_static', 'icons', - 'favicon-192x192.png') + self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON)) def send_message(self, message="", **kwargs): """Send a message to a Android TV device.""" _LOGGER.debug("Sending notification to: %s", self._target) - payload = dict(filename=('icon.png', - open(self._icon_file, 'rb'), + payload = dict(filename=('icon.png', self._icon_file, 'application/octet-stream', {'Expires': '0'}), type='0', title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), @@ -129,7 +130,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): transparency='%i' % TRANSPARENCIES.get( self._default_transparency), offset='0', app=ATTR_TITLE_DEFAULT, force='true', - interrupt='%i' % self._default_interrupt) + interrupt='%i' % self._default_interrupt,) data = kwargs.get(ATTR_DATA) if data: From 0c43466225a4d4adde4bafb830b6bfa6000695b1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Dec 2017 16:42:18 -0800 Subject: [PATCH 16/97] Update coveragerc (#10931) * Sort coveragerc * Add climate.honeywell and vacuum.xiaomi_miio to coveragerc --- .coveragerc | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.coveragerc b/.coveragerc index 2af48f0abb0..4f23fd9d8bf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -285,9 +285,9 @@ omit = homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/mjpeg.py - homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/onvif.py homeassistant/components/camera/ring.py + homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/synology.py homeassistant/components/camera/yi.py homeassistant/components/climate/ephember.py @@ -295,6 +295,7 @@ omit = homeassistant/components/climate/flexit.py homeassistant/components/climate/heatmiser.py homeassistant/components/climate/homematic.py + homeassistant/components/climate/honeywell.py homeassistant/components/climate/knx.py homeassistant/components/climate/oem.py homeassistant/components/climate/proliphix.py @@ -332,10 +333,10 @@ omit = homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py - homeassistant/components/device_tracker/thomson.py - homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tado.py + homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tile.py + homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py @@ -353,8 +354,8 @@ omit = homeassistant/components/keyboard.py homeassistant/components/keyboard_remote.py homeassistant/components/light/avion.py - homeassistant/components/light/blinkt.py homeassistant/components/light/blinksticklight.py + homeassistant/components/light/blinkt.py homeassistant/components/light/decora.py homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py @@ -365,8 +366,8 @@ omit = homeassistant/components/light/limitlessled.py homeassistant/components/light/mystrom.py homeassistant/components/light/osramlightify.py - homeassistant/components/light/rpi_gpio_pwm.py homeassistant/components/light/piglow.py + homeassistant/components/light/rpi_gpio_pwm.py homeassistant/components/light/sensehat.py homeassistant/components/light/tikteck.py homeassistant/components/light/tplink.py @@ -377,9 +378,9 @@ omit = homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/lock/lockitron.py homeassistant/components/lock/nello.py homeassistant/components/lock/nuki.py - homeassistant/components/lock/lockitron.py homeassistant/components/lock/sesame.py homeassistant/components/media_extractor.py homeassistant/components/media_player/anthemav.py @@ -623,8 +624,8 @@ omit = homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/snmp.py - homeassistant/components/switch/tplink.py homeassistant/components/switch/telnet.py + homeassistant/components/switch/tplink.py homeassistant/components/switch/transmission.py homeassistant/components/switch/xiaomi_miio.py homeassistant/components/telegram_bot/* @@ -633,7 +634,9 @@ omit = homeassistant/components/tts/baidu.py homeassistant/components/tts/microsoft.py homeassistant/components/tts/picotts.py + homeassistant/components/vacuum/mqtt.py homeassistant/components/vacuum/roomba.py + homeassistant/components/vacuum/xiaomi_miio.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py homeassistant/components/weather/metoffice.py @@ -642,7 +645,6 @@ omit = homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py - homeassistant/components/vacuum/mqtt.py [report] # Regexes for lines to exclude from consideration From 29fad3fa3ccd24d6b02efd29fc85aac1a45e609c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Dec 2017 17:59:58 -0800 Subject: [PATCH 17/97] Update frontend to 20171204.0 (#10934) --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b71a6508049..e1121fd0c4e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171130.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171204.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 309da835c3b..da2290ddcb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -334,7 +334,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171130.0 +home-assistant-frontend==20171204.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a33e9b4dd0..b858c8a1c0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171130.0 +home-assistant-frontend==20171204.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From bd6a17a3a5104659534e8a58c4fc819a7b40f6cf Mon Sep 17 00:00:00 2001 From: "Craig J. Ward" Date: Sun, 3 Dec 2017 21:34:58 -0600 Subject: [PATCH 18/97] Dominos no order fix (#10935) * check for none * fix error from no store being set * typo * Lint * fix default as per notes. Lint fix and make closest store None not False * update default --- homeassistant/components/dominos.py | 55 ++++++++++++++++++----------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/dominos.py b/homeassistant/components/dominos.py index 867bdfafc6b..633ea1b0c5e 100644 --- a/homeassistant/components/dominos.py +++ b/homeassistant/components/dominos.py @@ -58,7 +58,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(ATTR_PHONE): cv.string, vol.Required(ATTR_ADDRESS): cv.string, vol.Optional(ATTR_SHOW_MENU): cv.boolean, - vol.Optional(ATTR_ORDERS): vol.All(cv.ensure_list, [_ORDERS_SCHEMA]), + vol.Optional(ATTR_ORDERS, default=[]): vol.All( + cv.ensure_list, [_ORDERS_SCHEMA]), }), }, extra=vol.ALLOW_EXTRA) @@ -81,7 +82,8 @@ def setup(hass, config): order = DominosOrder(order_info, dominos) entities.append(order) - component.add_entities(entities) + if entities: + component.add_entities(entities) # Return boolean to indicate that initialization was successfully. return True @@ -93,7 +95,8 @@ class Dominos(): def __init__(self, hass, config): """Set up main service.""" conf = config[DOMAIN] - from pizzapi import Address, Customer, Store + from pizzapi import Address, Customer + from pizzapi.address import StoreException self.hass = hass self.customer = Customer( conf.get(ATTR_FIRST_NAME), @@ -105,7 +108,10 @@ class Dominos(): *self.customer.address.split(','), country=conf.get(ATTR_COUNTRY)) self.country = conf.get(ATTR_COUNTRY) - self.closest_store = Store() + try: + self.closest_store = self.address.closest_store() + except StoreException: + self.closest_store = None def handle_order(self, call): """Handle ordering pizza.""" @@ -123,29 +129,31 @@ class Dominos(): from pizzapi.address import StoreException try: self.closest_store = self.address.closest_store() + return True except StoreException: - self.closest_store = False + self.closest_store = None + return False def get_menu(self): """Return the products from the closest stores menu.""" - if self.closest_store is False: + if self.closest_store is None: _LOGGER.warning('Cannot get menu. Store may be closed') - return + return [] + else: + menu = self.closest_store.get_menu() + product_entries = [] - menu = self.closest_store.get_menu() - product_entries = [] + for product in menu.products: + item = {} + if isinstance(product.menu_data['Variants'], list): + variants = ', '.join(product.menu_data['Variants']) + else: + variants = product.menu_data['Variants'] + item['name'] = product.name + item['variants'] = variants + product_entries.append(item) - for product in menu.products: - item = {} - if isinstance(product.menu_data['Variants'], list): - variants = ', '.join(product.menu_data['Variants']) - else: - variants = product.menu_data['Variants'] - item['name'] = product.name - item['variants'] = variants - product_entries.append(item) - - return product_entries + return product_entries class DominosProductListView(http.HomeAssistantView): @@ -192,7 +200,7 @@ class DominosOrder(Entity): @property def state(self): """Return the state either closed, orderable or unorderable.""" - if self.dominos.closest_store is False: + if self.dominos.closest_store is None: return 'closed' else: return 'orderable' if self._orderable else 'unorderable' @@ -217,6 +225,11 @@ class DominosOrder(Entity): def order(self): """Create the order object.""" from pizzapi import Order + from pizzapi.address import StoreException + + if self.dominos.closest_store is None: + raise StoreException + order = Order( self.dominos.closest_store, self.dominos.customer, From 17f3cf0389b639ae49e15023e31a6da6fb1f4fb8 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Mon, 4 Dec 2017 07:33:22 +0000 Subject: [PATCH 19/97] Report availability of TP-Link smart sockets (#10933) * Report availability of TP-Link smart sockets * Changes according to our style guide --- homeassistant/components/switch/tplink.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 8fa6493862c..6e8c1a6b9bb 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.tplink/ """ import logging - import time import voluptuous as vol @@ -47,6 +46,7 @@ class SmartPlugSwitch(SwitchDevice): self.smartplug = smartplug self._name = name self._state = None + self._available = True # Set up emeter cache self._emeter_params = {} @@ -55,6 +55,11 @@ class SmartPlugSwitch(SwitchDevice): """Return the name of the Smart Plug, if any.""" return self._name + @property + def available(self) -> bool: + """Return if switch is available.""" + return self._available + @property def is_on(self): """Return true if switch is on.""" @@ -77,6 +82,7 @@ class SmartPlugSwitch(SwitchDevice): """Update the TP-Link switch's state.""" from pyHS100 import SmartDeviceException try: + self._available = True self._state = self.smartplug.state == \ self.smartplug.SWITCH_STATE_ON @@ -100,8 +106,9 @@ class SmartPlugSwitch(SwitchDevice): self._emeter_params[ATTR_DAILY_CONSUMPTION] \ = "%.2f kW" % emeter_statics[int(time.strftime("%e"))] except KeyError: - # device returned no daily history + # Device returned no daily history pass except (SmartDeviceException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self.name, ex) + _LOGGER.warning("Could not read state for %s: %s", self.name, ex) + self._available = False From 19a97580fc84cda20bd7d9d06d3f25982f9c4b73 Mon Sep 17 00:00:00 2001 From: Nicolas Bougues Date: Mon, 4 Dec 2017 08:34:42 +0100 Subject: [PATCH 20/97] Set percent unit for battery level so that history displays properly; edited variable name for consistency (#10932) --- homeassistant/components/sensor/tesla.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py index 824fec41580..3f36a1128d6 100644 --- a/homeassistant/components/sensor/tesla.py +++ b/homeassistant/components/sensor/tesla.py @@ -39,7 +39,7 @@ class TeslaSensor(TeslaDevice, Entity): def __init__(self, tesla_device, controller, sensor_type=None): """Initialisation of the sensor.""" self.current_value = None - self._temperature_units = None + self._unit = None self.last_changed_time = None self.type = sensor_type super().__init__(tesla_device, controller) @@ -59,7 +59,7 @@ class TeslaSensor(TeslaDevice, Entity): @property def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" - return self._temperature_units + return self._unit def update(self): """Update the state from the sensor.""" @@ -74,8 +74,9 @@ class TeslaSensor(TeslaDevice, Entity): tesla_temp_units = self.tesla_device.measurement if tesla_temp_units == 'F': - self._temperature_units = TEMP_FAHRENHEIT + self._unit = TEMP_FAHRENHEIT else: - self._temperature_units = TEMP_CELSIUS + self._unit = TEMP_CELSIUS else: self.current_value = self.tesla_device.battery_level() + self._unit = "%" From 31cedf83c75f273d1983149a905124075cc09492 Mon Sep 17 00:00:00 2001 From: "drop table USERS; --" <17239583+hudashot@users.noreply.github.com> Date: Mon, 4 Dec 2017 12:39:26 +0000 Subject: [PATCH 21/97] Export climate status and target temperature to Prometheus (#10919) * Export climate metrics to Prometheus. This adds climate_state and temperature_c metrics for each climate device. * Add more climate states to state_as_number --- homeassistant/components/prometheus.py | 25 +++++++++++++++++++++++-- homeassistant/helpers/state.py | 8 +++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 0195021829b..0ecfa50ee63 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -14,7 +14,8 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components import recorder from homeassistant.const import ( CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, TEMP_CELSIUS, - EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN) + EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius @@ -159,6 +160,26 @@ class Metrics(object): value = state_helper.state_as_number(state) metric.labels(**self._labels(state)).set(value) + def _handle_climate(self, state): + temp = state.attributes.get(ATTR_TEMPERATURE) + if temp: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit == TEMP_FAHRENHEIT: + temp = fahrenheit_to_celsius(temp) + metric = self._metric( + 'temperature_c', self.prometheus_client.Gauge, + 'Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(temp) + + metric = self._metric( + 'climate_state', self.prometheus_client.Gauge, + 'State of the thermostat (0/1)') + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + def _handle_sensor(self, state): _sensor_types = { TEMP_CELSIUS: ( @@ -199,7 +220,7 @@ class Metrics(object): ), } - unit = state.attributes.get('unit_of_measurement') + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) metric = _sensor_types.get(unit) if metric is not None: diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 8b98bfadb68..254a48c3d0a 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -21,7 +21,8 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_OPERATION_MODE, ATTR_SWING_MODE, SERVICE_SET_AUX_HEAT, SERVICE_SET_AWAY_MODE, SERVICE_SET_HOLD_MODE, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_OPERATION_MODE, - SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE) + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, STATE_HEAT, STATE_COOL, + STATE_IDLE) from homeassistant.components.climate.ecobee import ( ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) @@ -210,10 +211,11 @@ def state_as_number(state): Raises ValueError if this is not possible. """ if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON, - STATE_OPEN, STATE_HOME): + STATE_OPEN, STATE_HOME, STATE_HEAT, STATE_COOL): return 1 elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, - STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME): + STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME, + STATE_IDLE): return 0 return float(state.state) From ef1cbd3aea09a3d550bdbc2c5c1f0af505d050cf Mon Sep 17 00:00:00 2001 From: dasos Date: Mon, 4 Dec 2017 13:55:04 +0000 Subject: [PATCH 22/97] Tado ignore invalid devices (#10927) * Ignore devices without temperatures * Typo * Linting * Removing return false * Another typo. :( * Spelling received correctly --- homeassistant/components/climate/tado.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index d58acac5373..a8054b838ef 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -59,8 +59,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): climate_devices = [] for zone in zones: - climate_devices.append(create_climate_device( - tado, hass, zone, zone['name'], zone['id'])) + device = create_climate_device( + tado, hass, zone, zone['name'], zone['id']) + if not device: + continue + climate_devices.append(device) if climate_devices: add_devices(climate_devices, True) @@ -75,8 +78,11 @@ def create_climate_device(tado, hass, zone, name, zone_id): if ac_mode: temperatures = capabilities['HEAT']['temperatures'] - else: + elif 'temperatures' in capabilities: temperatures = capabilities['temperatures'] + else: + _LOGGER.debug("Received zone %s has no temperature; not adding", name) + return min_temp = float(temperatures['celsius']['min']) max_temp = float(temperatures['celsius']['max']) From 4652b8aea136452081543b24ad9c24270308643d Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Mon, 4 Dec 2017 17:26:07 +0100 Subject: [PATCH 23/97] Upgrade tellduslive library version (closes https://github.com/home-assistant/home-assistant/issues/10922) (#10950) --- homeassistant/components/tellduslive.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index ba7c1afd286..28bf65bc4c5 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -24,7 +24,7 @@ APPLICATION_NAME = 'Home Assistant' DOMAIN = 'tellduslive' -REQUIREMENTS = ['tellduslive==0.10.3'] +REQUIREMENTS = ['tellduslive==0.10.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index da2290ddcb2..838e8e42008 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1069,7 +1069,7 @@ tellcore-net==0.3 tellcore-py==1.1.2 # homeassistant.components.tellduslive -tellduslive==0.10.3 +tellduslive==0.10.4 # homeassistant.components.sensor.temper temperusb==1.5.3 From 2e2d0f48fb2e9c9c265edfa01eb9f27ee550ca74 Mon Sep 17 00:00:00 2001 From: Paul Annekov Date: Mon, 4 Dec 2017 19:26:41 +0300 Subject: [PATCH 24/97] don't ignore voltage data if sensor data changed (#10925) --- homeassistant/components/xiaomi_aqara.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index f875edef310..678ead981c1 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -219,7 +219,9 @@ class XiaomiDevice(Entity): def push_data(self, data): """Push from Hub.""" _LOGGER.debug("PUSH >> %s: %s", self, data) - if self.parse_data(data) or self.parse_voltage(data): + is_data = self.parse_data(data) + is_voltage = self.parse_voltage(data) + if is_data or is_voltage: self.schedule_update_ha_state() def parse_voltage(self, data): From 38a1f06d14d632923ac88e34c4f24ebca70d55c3 Mon Sep 17 00:00:00 2001 From: Mateusz Drab Date: Mon, 4 Dec 2017 17:58:52 +0000 Subject: [PATCH 25/97] Fix linksys_ap.py by inheriting DeviceScanner (#10947) As per issue #8638, the class wasn't inheriting from DeviceScanner, this commit patches it up. --- homeassistant/components/device_tracker/linksys_ap.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 196235f32f4..20dc9052e11 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -11,7 +11,8 @@ import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL) @@ -38,7 +39,7 @@ def get_scanner(hass, config): return None -class LinksysAPDeviceScanner(object): +class LinksysAPDeviceScanner(DeviceScanner): """This class queries a Linksys Access Point.""" def __init__(self, config): From 53d9fd18b7d2b93484adb26b0eeebc8068eba843 Mon Sep 17 00:00:00 2001 From: Stefan Lehmann Date: Tue, 5 Dec 2017 09:44:22 +0100 Subject: [PATCH 26/97] Add ADS component (#10142) * add ads hub, light and switch * add binary sensor prototype * switch: use adsvar for connection * fix some issues with binary sensor * fix binary sensor * fix all platforms * use latest pyads * fixed error with multiple binary sensors * add sensor * add ads sensor * clean up after shutdown * ads component with platforms switch, binary_sensor, light, sensor add locking poll sensors at startup update state of ads switch and light update ads requirements remove update() from constructors on ads platforms omit ads coverage ads catch read error when polling * add ads service * add default settings for use_notify and poll_interval * fix too long line * Fix style issues * no pydocstyle errors * Send and receive native brightness data to ADS device to prevent issues with math.floor reducing brightness -1 at every switch * Enable non dimmable lights * remove setting of self._state in switch * remove polling * Revert "remove polling" This reverts commit 7da420f82385a4a5c66a929af7025c00ed197e86. * add service schema, add links to documentation * fix naming, cleanup * re-remove polling * use async_added_to_hass for setup of callbacks * fix comment. * add callbacks for changed values * use async_add_job for creating device notifications * set should_poll to False for all platforms * change should_poll to property * add service description to services.yaml * add for brigthness not being None * put ads component in package * Remove whitespace * omit ads package --- .coveragerc | 3 + homeassistant/components/ads/__init__.py | 217 ++++++++++++++++++ homeassistant/components/ads/services.yaml | 15 ++ homeassistant/components/binary_sensor/ads.py | 87 +++++++ homeassistant/components/light/ads.py | 117 ++++++++++ homeassistant/components/sensor/ads.py | 103 +++++++++ homeassistant/components/services.yaml | 8 +- homeassistant/components/switch/ads.py | 85 +++++++ requirements_all.txt | 3 + 9 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/ads/__init__.py create mode 100644 homeassistant/components/ads/services.yaml create mode 100644 homeassistant/components/binary_sensor/ads.py create mode 100644 homeassistant/components/light/ads.py create mode 100644 homeassistant/components/sensor/ads.py create mode 100644 homeassistant/components/switch/ads.py diff --git a/.coveragerc b/.coveragerc index 4f23fd9d8bf..33380c34ed7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,6 +11,9 @@ omit = homeassistant/components/abode.py homeassistant/components/*/abode.py + homeassistant/components/ads/__init__.py + homeassistant/components/*/ads.py + homeassistant/components/alarmdecoder.py homeassistant/components/*/alarmdecoder.py diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py new file mode 100644 index 00000000000..3d9de28ded3 --- /dev/null +++ b/homeassistant/components/ads/__init__.py @@ -0,0 +1,217 @@ +""" +ADS Component. + +For more details about this component, please refer to the documentation. +https://home-assistant.io/components/ads/ + +""" +import os +import threading +import struct +import logging +import ctypes +from collections import namedtuple +import voluptuous as vol +from homeassistant.const import CONF_DEVICE, CONF_PORT, CONF_IP_ADDRESS, \ + EVENT_HOMEASSISTANT_STOP +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyads==2.2.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_ADS = 'data_ads' + +# Supported Types +ADSTYPE_INT = 'int' +ADSTYPE_UINT = 'uint' +ADSTYPE_BYTE = 'byte' +ADSTYPE_BOOL = 'bool' + +DOMAIN = 'ads' + +# config variable names +CONF_ADS_VAR = 'adsvar' +CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness' +CONF_ADS_TYPE = 'adstype' +CONF_ADS_FACTOR = 'factor' +CONF_ADS_VALUE = 'value' + +SERVICE_WRITE_DATA_BY_NAME = 'write_data_by_name' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Required(CONF_ADS_TYPE): vol.In([ADSTYPE_INT, ADSTYPE_UINT, + ADSTYPE_BYTE]), + vol.Required(CONF_ADS_VALUE): cv.match_all +}) + + +def setup(hass, config): + """Set up the ADS component.""" + import pyads + conf = config[DOMAIN] + + # get ads connection parameters from config + net_id = conf.get(CONF_DEVICE) + ip_address = conf.get(CONF_IP_ADDRESS) + port = conf.get(CONF_PORT) + + # create a new ads connection + client = pyads.Connection(net_id, port, ip_address) + + # add some constants to AdsHub + AdsHub.ADS_TYPEMAP = { + ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, + ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, + ADSTYPE_INT: pyads.PLCTYPE_INT, + ADSTYPE_UINT: pyads.PLCTYPE_UINT, + } + + AdsHub.PLCTYPE_BOOL = pyads.PLCTYPE_BOOL + AdsHub.PLCTYPE_BYTE = pyads.PLCTYPE_BYTE + AdsHub.PLCTYPE_INT = pyads.PLCTYPE_INT + AdsHub.PLCTYPE_UINT = pyads.PLCTYPE_UINT + AdsHub.ADSError = pyads.ADSError + + # connect to ads client and try to connect + try: + ads = AdsHub(client) + except pyads.pyads.ADSError: + _LOGGER.error( + 'Could not connect to ADS host (netid=%s, port=%s)', net_id, port + ) + return False + + # add ads hub to hass data collection, listen to shutdown + hass.data[DATA_ADS] = ads + hass.bus.listen(EVENT_HOMEASSISTANT_STOP, ads.shutdown) + + def handle_write_data_by_name(call): + """Write a value to the connected ADS device.""" + ads_var = call.data.get(CONF_ADS_VAR) + ads_type = call.data.get(CONF_ADS_TYPE) + value = call.data.get(CONF_ADS_VALUE) + + try: + ads.write_by_name(ads_var, value, ads.ADS_TYPEMAP[ads_type]) + except pyads.ADSError as err: + _LOGGER.error(err) + + # load descriptions from services.yaml + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + hass.services.register( + DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, + descriptions[SERVICE_WRITE_DATA_BY_NAME], + schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME + ) + + return True + + +# tuple to hold data needed for notification +NotificationItem = namedtuple( + 'NotificationItem', 'hnotify huser name plc_datatype callback' +) + + +class AdsHub: + """Representation of a PyADS connection.""" + + def __init__(self, ads_client): + """Initialize the ADS Hub.""" + self._client = ads_client + self._client.open() + + # all ADS devices are registered here + self._devices = [] + self._notification_items = {} + self._lock = threading.Lock() + + def shutdown(self, *args, **kwargs): + """Shutdown ADS connection.""" + _LOGGER.debug('Shutting down ADS') + for notification_item in self._notification_items.values(): + self._client.del_device_notification( + notification_item.hnotify, + notification_item.huser + ) + _LOGGER.debug( + 'Deleting device notification %d, %d', + notification_item.hnotify, notification_item.huser + ) + self._client.close() + + def register_device(self, device): + """Register a new device.""" + self._devices.append(device) + + def write_by_name(self, name, value, plc_datatype): + """Write a value to the device.""" + with self._lock: + return self._client.write_by_name(name, value, plc_datatype) + + def read_by_name(self, name, plc_datatype): + """Read a value from the device.""" + with self._lock: + return self._client.read_by_name(name, plc_datatype) + + def add_device_notification(self, name, plc_datatype, callback): + """Add a notification to the ADS devices.""" + from pyads import NotificationAttrib + attr = NotificationAttrib(ctypes.sizeof(plc_datatype)) + + with self._lock: + hnotify, huser = self._client.add_device_notification( + name, attr, self._device_notification_callback + ) + hnotify = int(hnotify) + + _LOGGER.debug( + 'Added Device Notification %d for variable %s', hnotify, name + ) + + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback + ) + + def _device_notification_callback(self, addr, notification, huser): + """Handle device notifications.""" + contents = notification.contents + + hnotify = int(contents.hNotification) + _LOGGER.debug('Received Notification %d', hnotify) + data = contents.data + + try: + notification_item = self._notification_items[hnotify] + except KeyError: + _LOGGER.debug('Unknown Device Notification handle: %d', hnotify) + return + + # parse data to desired datatype + if notification_item.plc_datatype == self.PLCTYPE_BOOL: + value = bool(struct.unpack(' Date: Tue, 5 Dec 2017 03:47:48 -0600 Subject: [PATCH 27/97] Reload closest store on api menu request (#10962) * reload closest store on api request * revert change from debugging --- homeassistant/components/dominos.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/dominos.py b/homeassistant/components/dominos.py index 633ea1b0c5e..0d6645f37c1 100644 --- a/homeassistant/components/dominos.py +++ b/homeassistant/components/dominos.py @@ -136,6 +136,7 @@ class Dominos(): def get_menu(self): """Return the products from the closest stores menu.""" + self.update_closest_store() if self.closest_store is None: _LOGGER.warning('Cannot get menu. Store may be closed') return [] From 821cf7135d9393088380bcd24a7177a94c8be9d8 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 5 Dec 2017 12:32:43 +0100 Subject: [PATCH 28/97] Gearbest sensor (#10556) * Added Gearbest Sensor * Updated required files * Fixed houndci-bout findings * Fix tox lint errors * Changed code according to review Implemented library version 1.0.5 * Fixed houndci-bot findings * Fixed tox lint issues * Updated item schema to use has_at_least_one_key Added conf constants * Remove CONF_ constants and import them from homeassistant.const * Removed CurrencyConverter from hass Fixed couple of issues found by MartinHjelmare --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/sensor/gearbest.py | 127 ++++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 132 insertions(+) create mode 100755 homeassistant/components/sensor/gearbest.py diff --git a/.coveragerc b/.coveragerc index 33380c34ed7..0f721155389 100644 --- a/.coveragerc +++ b/.coveragerc @@ -517,6 +517,7 @@ omit = homeassistant/components/sensor/fixer.py homeassistant/components/sensor/fritzbox_callmonitor.py homeassistant/components/sensor/fritzbox_netmonitor.py + homeassistant/components/sensor/gearbest.py homeassistant/components/sensor/geizhals.py homeassistant/components/sensor/gitter.py homeassistant/components/sensor/glances.py diff --git a/CODEOWNERS b/CODEOWNERS index fe415a619db..ac0f794482a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,6 +54,7 @@ homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/monoprice.py @etsinko homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen homeassistant/components/sensor/sytadin.py @gautric diff --git a/homeassistant/components/sensor/gearbest.py b/homeassistant/components/sensor/gearbest.py new file mode 100755 index 00000000000..2bc7e5b3b3a --- /dev/null +++ b/homeassistant/components/sensor/gearbest.py @@ -0,0 +1,127 @@ +""" +Parse prices of a item from gearbest. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.gearbest/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval +from homeassistant.const import (CONF_NAME, CONF_ID, CONF_URL, CONF_CURRENCY) + +REQUIREMENTS = ['gearbest_parser==1.0.5'] +_LOGGER = logging.getLogger(__name__) + +CONF_ITEMS = 'items' + +ICON = 'mdi:coin' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2*60*60) # 2h +MIN_TIME_BETWEEN_CURRENCY_UPDATES = timedelta(seconds=12*60*60) # 12h + + +_ITEM_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_URL, 'XOR'): cv.string, + vol.Exclusive(CONF_ID, 'XOR'): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_CURRENCY): cv.string + }), cv.has_at_least_one_key(CONF_URL, CONF_ID) +) + +_ITEMS_SCHEMA = vol.Schema([_ITEM_SCHEMA]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ITEMS): _ITEMS_SCHEMA, + vol.Required(CONF_CURRENCY): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Gearbest sensor.""" + from gearbest_parser import CurrencyConverter + currency = config.get(CONF_CURRENCY) + + sensors = [] + items = config.get(CONF_ITEMS) + + converter = CurrencyConverter() + converter.update() + + for item in items: + try: + sensors.append(GearbestSensor(converter, item, currency)) + except ValueError as exc: + _LOGGER.error(exc) + + def currency_update(event_time): + """Update currency list.""" + converter.update() + + track_time_interval(hass, + currency_update, + MIN_TIME_BETWEEN_CURRENCY_UPDATES) + + add_devices(sensors, True) + + +class GearbestSensor(Entity): + """Implementation of the sensor.""" + + def __init__(self, converter, item, currency): + """Initialize the sensor.""" + from gearbest_parser import GearbestParser + + self._name = item.get(CONF_NAME) + self._parser = GearbestParser() + self._parser.set_currency_converter(converter) + self._item = self._parser.load(item.get(CONF_ID), + item.get(CONF_URL), + item.get(CONF_CURRENCY, currency)) + if self._item is None: + raise ValueError("id and url could not be resolved") + + @property + def name(self): + """Return the name of the item.""" + return self._name if self._name is not None else self._item.name + + @property + def icon(self): + """Return the icon for the frontend.""" + return ICON + + @property + def state(self): + """Return the price of the selected product.""" + return self._item.price + + @property + def unit_of_measurement(self): + """Return the currency.""" + return self._item.currency + + @property + def entity_picture(self): + """Return the image.""" + return self._item.image + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {'name': self._item.name, + 'description': self._item.description, + 'currency': self._item.currency, + 'url': self._item.url} + return attrs + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest price from gearbest and updates the state.""" + self._item.update() diff --git a/requirements_all.txt b/requirements_all.txt index 3157f87d4ba..32b552b9c5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,6 +291,9 @@ gTTS-token==1.1.1 # homeassistant.components.device_tracker.bluetooth_le_tracker # gattlib==0.20150805 +# homeassistant.components.sensor.gearbest +gearbest_parser==1.0.5 + # homeassistant.components.sensor.gitter gitterpy==0.1.6 From 379c10985b56367a48056feaa72f0b1385e3edce Mon Sep 17 00:00:00 2001 From: Menno Blom Date: Tue, 5 Dec 2017 14:22:27 +0100 Subject: [PATCH 29/97] Add Ziggo Mediabox XL media_player (#10514) * Add Ziggo Mediabox XL media_player * Using pypi module ziggo-mediabox-xl now. * Code review changes --- .coveragerc | 1 + .../media_player/ziggo_mediabox_xl.py | 174 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 178 insertions(+) create mode 100644 homeassistant/components/media_player/ziggo_mediabox_xl.py diff --git a/.coveragerc b/.coveragerc index 0f721155389..e97d197ca94 100644 --- a/.coveragerc +++ b/.coveragerc @@ -430,6 +430,7 @@ omit = homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py homeassistant/components/media_player/yamaha_musiccast.py + homeassistant/components/media_player/ziggo_mediabox_xl.py homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py diff --git a/homeassistant/components/media_player/ziggo_mediabox_xl.py b/homeassistant/components/media_player/ziggo_mediabox_xl.py new file mode 100644 index 00000000000..1886cd751ea --- /dev/null +++ b/homeassistant/components/media_player/ziggo_mediabox_xl.py @@ -0,0 +1,174 @@ +""" +Support for interface with a Ziggo Mediabox XL. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ziggo_mediabox_xl/ +""" +import logging +import socket + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, MediaPlayerDevice, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, + SUPPORT_PLAY, SUPPORT_PAUSE) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['ziggo-mediabox-xl==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +DATA_KNOWN_DEVICES = 'ziggo_mediabox_xl_known_devices' + +SUPPORT_ZIGGO = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Ziggo Mediabox XL platform.""" + from ziggo_mediabox_xl import ZiggoMediaboxXL + + hass.data[DATA_KNOWN_DEVICES] = known_devices = set() + + # Is this a manual configuration? + if config.get(CONF_HOST) is not None: + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + elif discovery_info is not None: + host = discovery_info.get('host') + name = discovery_info.get('name') + else: + _LOGGER.error("Cannot determine device") + return + + # Only add a device once, so discovered devices do not override manual + # config. + hosts = [] + ip_addr = socket.gethostbyname(host) + if ip_addr not in known_devices: + try: + mediabox = ZiggoMediaboxXL(ip_addr) + if mediabox.test_connection(): + hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name)) + known_devices.add(ip_addr) + else: + _LOGGER.error("Can't connect to %s", host) + except socket.error as error: + _LOGGER.error("Can't connect to %s: %s", host, error) + else: + _LOGGER.info("Ignoring duplicate Ziggo Mediabox XL %s", host) + add_devices(hosts, True) + + +class ZiggoMediaboxXLDevice(MediaPlayerDevice): + """Representation of a Ziggo Mediabox XL Device.""" + + def __init__(self, mediabox, host, name): + """Initialize the device.""" + # Generate a configuration for the Samsung library + self._mediabox = mediabox + self._host = host + self._name = name + self._state = None + + def update(self): + """Retrieve the state of the device.""" + try: + if self._mediabox.turned_on(): + if self._state != STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + except socket.error: + _LOGGER.error("Couldn't fetch state from %s", self._host) + + def send_keys(self, keys): + """Send keys to the device and handle exceptions.""" + try: + self._mediabox.send_keys(keys) + except socket.error: + _LOGGER.error("Couldn't send keys to %s", self._host) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def source_list(self): + """List of available sources (channels).""" + return [self._mediabox.channels()[c] + for c in sorted(self._mediabox.channels().keys())] + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ZIGGO + + def turn_on(self): + """Turn the media player on.""" + self.send_keys(['POWER']) + self._state = STATE_ON + + def turn_off(self): + """Turn off media player.""" + self.send_keys(['POWER']) + self._state = STATE_OFF + + def media_play(self): + """Send play command.""" + self.send_keys(['PLAY']) + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self.send_keys(['PAUSE']) + self._state = STATE_PAUSED + + def media_play_pause(self): + """Simulate play pause media player.""" + self.send_keys(['PAUSE']) + if self._state == STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_PAUSED + + def media_next_track(self): + """Channel up.""" + self.send_keys(['CHAN_UP']) + self._state = STATE_PLAYING + + def media_previous_track(self): + """Channel down.""" + self.send_keys(['CHAN_DOWN']) + self._state = STATE_PLAYING + + def select_source(self, source): + """Select the channel.""" + if str(source).isdigit(): + digits = str(source) + else: + digits = next(( + key for key, value in self._mediabox.channels().items() + if value == source), None) + if digits is None: + return + + self.send_keys(['NUM_{}'.format(digit) + for digit in str(digits)]) + self._state = STATE_PLAYING diff --git a/requirements_all.txt b/requirements_all.txt index 32b552b9c5d..da2492edebd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1185,3 +1185,6 @@ zengge==0.2 # homeassistant.components.zeroconf zeroconf==0.19.1 + +# homeassistant.components.media_player.ziggo_mediabox_xl +ziggo-mediabox-xl==1.0.0 From 69d5738e47fe847d79f534dcbef7d3509bc542fe Mon Sep 17 00:00:00 2001 From: ziotibia81 Date: Tue, 5 Dec 2017 15:00:33 +0100 Subject: [PATCH 30/97] Generic thermostat initial_operation_mode (#10690) * Generic thermostat restore operation mode * Test restore operation mode * Fix trailing whitespace * Fix line too long * Fix test duplicate entity_id * Fix test * async_added_to_hass modify modify internal state * Test inital_operation_mode * More restore state tests * Fix whitespace * fix test_custom_setup_param * Test "None" target temp --- .../components/climate/generic_thermostat.py | 35 +++++++++++++------ .../climate/test_generic_thermostat.py | 35 ++++++++++++++++++- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 987708834cc..6574a4d5396 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -13,7 +13,8 @@ from homeassistant.core import callback from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components.climate import ( STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, - STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) + STATE_AUTO, ATTR_OPERATION_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) @@ -40,7 +41,7 @@ CONF_MIN_DUR = 'min_cycle_duration' CONF_COLD_TOLERANCE = 'cold_tolerance' CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' - +CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -58,6 +59,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), vol.Optional(CONF_KEEP_ALIVE): vol.All( cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_OPERATION_MODE): + vol.In([STATE_AUTO, STATE_OFF]) }) @@ -75,11 +78,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): cold_tolerance = config.get(CONF_COLD_TOLERANCE) hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) + initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive)]) + hot_tolerance, keep_alive, initial_operation_mode)]) class GenericThermostat(ClimateDevice): @@ -87,7 +91,8 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, - cold_tolerance, hot_tolerance, keep_alive): + cold_tolerance, hot_tolerance, keep_alive, + initial_operation_mode): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -97,7 +102,11 @@ class GenericThermostat(ClimateDevice): self._cold_tolerance = cold_tolerance self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive - self._enabled = True + self._initial_operation_mode = initial_operation_mode + if initial_operation_mode == STATE_OFF: + self._enabled = False + else: + self._enabled = True self._active = False self._cur_temp = None @@ -122,14 +131,20 @@ class GenericThermostat(ClimateDevice): @asyncio.coroutine def async_added_to_hass(self): """Run when entity about to be added.""" - # If we have an old state and no target temp, restore - if self._target_temp is None: - old_state = yield from async_get_last_state(self.hass, - self.entity_id) - if old_state is not None: + # Check If we have an old state + old_state = yield from async_get_last_state(self.hass, + self.entity_id) + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: self._target_temp = float( old_state.attributes[ATTR_TEMPERATURE]) + # If we have no initial operation mode, restore + if self._initial_operation_mode is None: + if old_state.attributes[ATTR_OPERATION_MODE] == STATE_OFF: + self._enabled = False + @property def should_poll(self): """Return the polling state.""" diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 5982a6c16d8..63bbce2e7c6 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -205,6 +205,10 @@ class TestClimateGenericThermostat(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get(ENTITY) self.assertEqual(30.0, state.attributes.get('temperature')) + climate.set_temperature(self.hass, None) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(30.0, state.attributes.get('temperature')) def test_sensor_bad_unit(self): """Test sensor that have bad unit.""" @@ -888,19 +892,22 @@ def test_custom_setup_params(hass): 'min_temp': MIN_TEMP, 'max_temp': MAX_TEMP, 'target_temp': TARGET_TEMP, + 'initial_operation_mode': STATE_OFF, }}) assert result state = hass.states.get(ENTITY) assert state.attributes.get('min_temp') == MIN_TEMP assert state.attributes.get('max_temp') == MAX_TEMP assert state.attributes.get('temperature') == TARGET_TEMP + assert state.attributes.get(climate.ATTR_OPERATION_MODE) == STATE_OFF @asyncio.coroutine def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( - State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20"}), + State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", + climate.ATTR_OPERATION_MODE: "off"}), )) hass.state = CoreState.starting @@ -915,3 +922,29 @@ def test_restore_state(hass): state = hass.states.get('climate.test_thermostat') assert(state.attributes[ATTR_TEMPERATURE] == 20) + assert(state.attributes[climate.ATTR_OPERATION_MODE] == "off") + + +@asyncio.coroutine +def test_no_restore_state(hass): + """Ensure states are not restored on startup if not needed.""" + mock_restore_cache(hass, ( + State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", + climate.ATTR_OPERATION_MODE: "off"}), + )) + + hass.state = CoreState.starting + + yield from async_setup_component( + hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test_thermostat', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'target_temp': 22, + 'initial_operation_mode': 'auto', + }}) + + state = hass.states.get('climate.test_thermostat') + assert(state.attributes[ATTR_TEMPERATURE] == 22) + assert(state.attributes[climate.ATTR_OPERATION_MODE] != "off") From 3af527b1b5fc0212e95968925394ddbc6158635d Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 5 Dec 2017 09:13:09 -0500 Subject: [PATCH 31/97] Use new build path for dev translations (#10937) --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e1121fd0c4e..1fe27d7c74d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -303,7 +303,7 @@ def async_setup(hass, config): "/home-assistant-polymer", repo_path, False) hass.http.register_static_path( "/static/translations", - os.path.join(repo_path, "build-translations"), False) + os.path.join(repo_path, "build-translations/output"), False) sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js") sw_path_latest = os.path.join(repo_path, "build/service_worker.js") static_path = os.path.join(repo_path, 'hass_frontend') From 8e4942088e3b0214faa40806e2e9b32818c293d6 Mon Sep 17 00:00:00 2001 From: Mitko Masarliev Date: Wed, 6 Dec 2017 07:56:43 +0200 Subject: [PATCH 32/97] Add option to set default hide if away for new devices (#10762) * Option to change hide_if_away * tests fix * change new device defaults * tests and requested changes * fix assert --- .../components/device_tracker/__init__.py | 22 ++++++++++++++----- .../components/device_tracker/test_asuswrt.py | 15 ++++++++++--- tests/components/device_tracker/test_init.py | 22 ++++++++++++++----- .../device_tracker/test_unifi_direct.py | 9 ++++++-- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 0b18cc72f6e..28505900f14 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -53,6 +53,7 @@ YAML_DEVICES = 'known_devices.yaml' CONF_TRACK_NEW = 'track_new_devices' DEFAULT_TRACK_NEW = True +CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults' CONF_CONSIDER_HOME = 'consider_home' DEFAULT_CONSIDER_HOME = timedelta(seconds=180) @@ -81,12 +82,18 @@ ATTR_VENDOR = 'vendor' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' +NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ + vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, +})) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, vol.Optional(CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME): vol.All( - cv.time_period, cv.positive_timedelta) + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_NEW_DEVICE_DEFAULTS, + default={}): NEW_DEVICE_DEFAULTS_SCHEMA }) @@ -125,9 +132,11 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): conf = conf[0] if conf else {} consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) track_new = conf.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) devices = yield from async_load_config(yaml_path, hass, consider_home) - tracker = DeviceTracker(hass, consider_home, track_new, devices) + tracker = DeviceTracker( + hass, consider_home, track_new, defaults, devices) @asyncio.coroutine def async_setup_platform(p_type, p_config, disc_info=None): @@ -211,13 +220,15 @@ class DeviceTracker(object): """Representation of a device tracker.""" def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track_new: bool, devices: Sequence) -> None: + track_new: bool, defaults: dict, + devices: Sequence) -> None: """Initialize a device tracker.""" self.hass = hass self.devices = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} self.consider_home = consider_home - self.track_new = track_new + self.track_new = defaults.get(CONF_TRACK_NEW, track_new) + self.defaults = defaults self.group = None self._is_updating = asyncio.Lock(loop=hass.loop) @@ -274,7 +285,8 @@ class DeviceTracker(object): device = Device( self.hass, self.consider_home, self.track_new, dev_id, mac, (host_name or dev_id).replace('_', ' '), - picture=picture, icon=icon) + picture=picture, icon=icon, + hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) self.devices[dev_id] = device if mac is not None: self.mac_to_dev[mac] = device diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index b507bfea7c9..a6827d165cd 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -9,7 +9,8 @@ import voluptuous as vol from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, CONF_TRACK_NEW) + CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_NEW_DEVICE_DEFAULTS, + CONF_AWAY_HIDE) from homeassistant.components.device_tracker.asuswrt import ( CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, DOMAIN, CONF_PORT, PLATFORM_SCHEMA) @@ -78,7 +79,11 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): CONF_USERNAME: 'fake_user', CONF_PASSWORD: 'fake_pass', CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { + CONF_TRACK_NEW: True, + CONF_AWAY_HIDE: False + } } } @@ -104,7 +109,11 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): CONF_USERNAME: 'fake_user', CONF_PUB_KEY: FAKEFILE, CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { + CONF_TRACK_NEW: True, + CONF_AWAY_HIDE: False + } } } diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 704b2590f12..34c7ecf465d 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -123,7 +123,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'My device', None, None, False), device_tracker.Device(self.hass, True, True, 'your_device', 'AB:01', 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, devices) + device_tracker.DeviceTracker(self.hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) assert mock_warning.call_count == 1, \ "The only warning call should be duplicates (check DEBUG)" @@ -137,7 +137,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'AB:01', 'My device', None, None, False), device_tracker.Device(self.hass, True, True, 'my_device', None, 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, devices) + device_tracker.DeviceTracker(self.hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) assert mock_warning.call_count == 1, \ @@ -299,7 +299,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): vendor_string = 'Raspberry Pi Foundation' tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, []) + self.hass, timedelta(seconds=60), 0, {}, []) with mock_aiohttp_client() as aioclient_mock: aioclient_mock.get('http://api.macvendors.com/b8:27:eb', @@ -622,7 +622,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_see_failures(self, mock_warning): """Test that the device tracker see failures.""" tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, []) + self.hass, timedelta(seconds=60), 0, {}, []) # MAC is not a string (but added) tracker.see(mac=567, host_name="Number MAC") @@ -654,7 +654,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_picture_and_icon_on_see_discovery(self): """Test that picture and icon are set in initial see.""" tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, []) + self.hass, timedelta(seconds=60), False, {}, []) tracker.see(dev_id=11, picture='pic_url', icon='mdi:icon') self.hass.block_till_done() config = device_tracker.load_config(self.yaml_devices, self.hass, @@ -663,6 +663,18 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert config[0].icon == 'mdi:icon' assert config[0].entity_picture == 'pic_url' + def test_default_hide_if_away_is_used(self): + """Test that default track_new is used.""" + tracker = device_tracker.DeviceTracker( + self.hass, timedelta(seconds=60), False, + {device_tracker.CONF_AWAY_HIDE: True}, []) + tracker.see(dev_id=12) + self.hass.block_till_done() + config = device_tracker.load_config(self.yaml_devices, self.hass, + timedelta(seconds=0)) + assert len(config) == 1 + self.assertTrue(config[0].hidden) + @asyncio.coroutine def test_async_added_to_hass(hass): diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index 0e22758d07e..b378118141a 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, CONF_TRACK_NEW) + CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_AWAY_HIDE, + CONF_NEW_DEVICE_DEFAULTS) from homeassistant.components.device_tracker.unifi_direct import ( DOMAIN, CONF_PORT, PLATFORM_SCHEMA, _response_to_json, get_scanner) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, @@ -54,7 +55,11 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): CONF_USERNAME: 'fake_user', CONF_PASSWORD: 'fake_pass', CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { + CONF_TRACK_NEW: True, + CONF_AWAY_HIDE: False + } } } From 454d8535f8a90439672513bbeb9d2f5d61efc087 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Wed, 6 Dec 2017 01:07:59 -0500 Subject: [PATCH 33/97] Allow chime to work for wink siren/chime (#10961) * Allow Wink siren/chimes to work * Updated requirements_all.txt --- homeassistant/components/wink/__init__.py | 16 +++++++++------- requirements_all.txt | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 426893ec306..18e14b2e912 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -28,7 +28,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['python-wink==1.7.0', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.7.1', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -460,10 +460,11 @@ def setup(hass, config): sirens_to_set.append(siren) for siren in sirens_to_set: + _man = siren.wink.device_manufacturer() if (service.service != SERVICE_SET_AUTO_SHUTOFF and service.service != SERVICE_ENABLE_SIREN and - siren.wink.device_manufacturer() != 'dome'): - _LOGGER.error("Service only valid for Dome sirens.") + (_man != 'dome' and _man != 'wink')): + _LOGGER.error("Service only valid for Dome or Wink sirens.") return if service.service == SERVICE_ENABLE_SIREN: @@ -494,10 +495,11 @@ def setup(hass, config): component = EntityComponent(_LOGGER, DOMAIN, hass) sirens = [] - has_dome_siren = False + has_dome_or_wink_siren = False for siren in pywink.get_sirens(): - if siren.device_manufacturer() == "dome": - has_dome_siren = True + _man = siren.device_manufacturer() + if _man == "dome" or _man == "wink": + has_dome_or_wink_siren = True _id = siren.object_id() + siren.name() if _id not in hass.data[DOMAIN]['unique_ids']: sirens.append(WinkSirenDevice(siren, hass)) @@ -514,7 +516,7 @@ def setup(hass, config): descriptions.get(SERVICE_ENABLE_SIREN), schema=ENABLED_SIREN_SCHEMA) - if has_dome_siren: + if has_dome_or_wink_siren: hass.services.register(DOMAIN, SERVICE_SET_SIREN_TONE, service_handle, diff --git a/requirements_all.txt b/requirements_all.txt index da2492edebd..01532893ef3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -895,7 +895,7 @@ python-velbus==2.0.11 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.7.0 +python-wink==1.7.1 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.0.3 From ddec566e10c701b410a162314d6cd0d29939ad76 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Dec 2017 22:08:09 -0800 Subject: [PATCH 34/97] Revert pychromecast update (#10989) * Revert pychromecast update * Update cast.py --- homeassistant/components/media_player/cast.py | 4 +++- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index ca3da7ae165..6ae44495e3e 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -20,7 +20,9 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==1.0.2'] +# Do not upgrade to 1.0.2, it breaks a bunch of stuff +# https://github.com/home-assistant/home-assistant/issues/10926 +REQUIREMENTS = ['pychromecast==0.8.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 01532893ef3..7f2bab02545 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -632,7 +632,7 @@ pybbox==0.0.5-alpha # pybluez==0.22 # homeassistant.components.media_player.cast -pychromecast==1.0.2 +pychromecast==0.8.2 # homeassistant.components.media_player.cmus pycmus==0.1.0 From 87fe674c70c520d39d65c9f3dc8bb1db49755695 Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 6 Dec 2017 08:09:41 +0200 Subject: [PATCH 35/97] Require FF43 for latest js (#10941) * Require FF43 for latest js `Array.prototype.includes` added in Firefox 43 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes * Update __init__.py --- homeassistant/components/frontend/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 1fe27d7c74d..83e42d7651e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -583,9 +583,9 @@ def _is_latest(js_option, request): family_min_version = { 'Chrome': 50, # Probably can reduce this - 'Firefox': 41, # Destructuring added in 41 + 'Firefox': 43, # Array.protopype.includes added in 43 'Opera': 40, # Probably can reduce this - 'Edge': 14, # Maybe can reduce this + 'Edge': 14, # Array.protopype.includes added in 14 'Safari': 10, # many features not supported by 9 } version = family_min_version.get(useragent.browser.family) From 5f4baa67dc2ef3be59eaf01c78a1e1007cc2713f Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Wed, 6 Dec 2017 07:38:27 +0000 Subject: [PATCH 36/97] Allow disabling the LEDs on TP-Link smart plugs (#10980) --- homeassistant/components/switch/tplink.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 6e8c1a6b9bb..0772cc9277c 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -22,9 +22,12 @@ ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_DAILY_CONSUMPTION = 'daily_consumption' ATTR_CURRENT = 'current' +CONF_LEDS = 'enable_leds' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LEDS, default=True): cv.boolean, }) @@ -34,17 +37,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from pyHS100 import SmartPlug host = config.get(CONF_HOST) name = config.get(CONF_NAME) + leds_on = config.get(CONF_LEDS) - add_devices([SmartPlugSwitch(SmartPlug(host), name)], True) + add_devices([SmartPlugSwitch(SmartPlug(host), name, leds_on)], True) class SmartPlugSwitch(SwitchDevice): """Representation of a TPLink Smart Plug switch.""" - def __init__(self, smartplug, name): + def __init__(self, smartplug, name, leds_on): """Initialize the switch.""" self.smartplug = smartplug self._name = name + self._leds_on = leds_on self._state = None self._available = True # Set up emeter cache @@ -89,6 +94,8 @@ class SmartPlugSwitch(SwitchDevice): if self._name is None: self._name = self.smartplug.alias + self.smartplug.led = self._leds_on + if self.smartplug.has_emeter: emeter_readings = self.smartplug.get_emeter_realtime() From c13b510ba390ab4e833b496879f1c62f05a6e6af Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Dec 2017 23:40:31 -0800 Subject: [PATCH 37/97] Update frontend to 20171206.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 83e42d7651e..3d669ddc4d1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171204.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171206.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 7f2bab02545..840ed5a834a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -337,7 +337,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171204.0 +home-assistant-frontend==20171206.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b858c8a1c0e..72325d6305b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -74,7 +74,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171204.0 +home-assistant-frontend==20171206.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From e66268dffe54358e98b5c8cfdf2a0b9dfa3ae9dc Mon Sep 17 00:00:00 2001 From: Mitko Masarliev Date: Wed, 6 Dec 2017 10:24:20 +0200 Subject: [PATCH 38/97] Meraki AP Device tracker (#10971) * Device tracker for meraki AP * styles fix * fix again * again * and again :) * fix hide if away * docs and optimization * tests and fixes * styles * styles * styles * styles * styles fix. Hope last * clear track new * changes * fix accuracy error and requested changes * remove meraki from .coveragerc * tests and minor changes * remove location --- .../components/device_tracker/meraki.py | 116 +++++++++++++++ .../components/device_tracker/test_meraki.py | 139 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 homeassistant/components/device_tracker/meraki.py create mode 100644 tests/components/device_tracker/test_meraki.py diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py new file mode 100644 index 00000000000..319c19d7b73 --- /dev/null +++ b/homeassistant/components/device_tracker/meraki.py @@ -0,0 +1,116 @@ +""" +Support for the Meraki CMX location service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.meraki/ + +""" +import asyncio +import logging +import json + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY) +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER) + +CONF_VALIDATOR = 'validator' +CONF_SECRET = 'secret' +DEPENDENCIES = ['http'] +URL = '/api/meraki' +VERSION = '2.0' + + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_VALIDATOR): cv.string, + vol.Required(CONF_SECRET): cv.string +}) + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an endpoint for the Meraki tracker.""" + hass.http.register_view( + MerakiView(config, async_see)) + + return True + + +class MerakiView(HomeAssistantView): + """View to handle Meraki requests.""" + + url = URL + name = 'api:meraki' + + def __init__(self, config, async_see): + """Initialize Meraki URL endpoints.""" + self.async_see = async_see + self.validator = config[CONF_VALIDATOR] + self.secret = config[CONF_SECRET] + + @asyncio.coroutine + def get(self, request): + """Meraki message received as GET.""" + return self.validator + + @asyncio.coroutine + def post(self, request): + """Meraki CMX message received.""" + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data)) + if not data.get('secret', False): + _LOGGER.error("secret invalid") + return self.json_message('No secret', HTTP_UNPROCESSABLE_ENTITY) + if data['secret'] != self.secret: + _LOGGER.error("Invalid Secret received from Meraki") + return self.json_message('Invalid secret', + HTTP_UNPROCESSABLE_ENTITY) + elif data['version'] != VERSION: + _LOGGER.error("Invalid API version: %s", data['version']) + return self.json_message('Invalid version', + HTTP_UNPROCESSABLE_ENTITY) + else: + _LOGGER.debug('Valid Secret') + if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'): + _LOGGER.error("Unknown Device %s", data['type']) + return self.json_message('Invalid device type', + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.debug("Processing %s", data['type']) + if len(data["data"]["observations"]) == 0: + _LOGGER.debug("No observations found") + return + self._handle(request.app['hass'], data) + + @callback + def _handle(self, hass, data): + for i in data["data"]["observations"]: + data["data"]["secret"] = "hidden" + mac = i["clientMac"] + _LOGGER.debug("clientMac: %s", mac) + attrs = {} + if i.get('os', False): + attrs['os'] = i['os'] + if i.get('manufacturer', False): + attrs['manufacturer'] = i['manufacturer'] + if i.get('ipv4', False): + attrs['ipv4'] = i['ipv4'] + if i.get('ipv6', False): + attrs['ipv6'] = i['ipv6'] + if i.get('seenTime', False): + attrs['seenTime'] = i['seenTime'] + if i.get('ssid', False): + attrs['ssid'] = i['ssid'] + hass.async_add_job(self.async_see( + mac=mac, + source_type=SOURCE_TYPE_ROUTER, + attributes=attrs + )) diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py new file mode 100644 index 00000000000..a739df804fd --- /dev/null +++ b/tests/components/device_tracker/test_meraki.py @@ -0,0 +1,139 @@ +"""The tests the for Meraki device tracker.""" +import asyncio +import json +from unittest.mock import patch +import pytest +from homeassistant.components.device_tracker.meraki import ( + CONF_VALIDATOR, CONF_SECRET) +from homeassistant.setup import async_setup_component +import homeassistant.components.device_tracker as device_tracker +from homeassistant.const import CONF_PLATFORM +from homeassistant.components.device_tracker.meraki import URL + + +@pytest.fixture +def meraki_client(loop, hass, test_client): + """Meraki mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'meraki', + CONF_VALIDATOR: 'validator', + CONF_SECRET: 'secret' + + } + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_invalid_or_missing_data(meraki_client): + """Test validator with invalid or missing data.""" + req = yield from meraki_client.get(URL) + text = yield from req.text() + assert req.status == 200 + assert text == 'validator' + + req = yield from meraki_client.post(URL, data=b"invalid") + text = yield from req.json() + assert req.status == 400 + assert text['message'] == 'Invalid JSON' + + req = yield from meraki_client.post(URL, data=b"{}") + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'No secret' + + data = { + "version": "1.0", + "secret": "secret" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid version' + + data = { + "version": "2.0", + "secret": "invalid" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid secret' + + data = { + "version": "2.0", + "secret": "secret", + "type": "InvalidType" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid device type' + + data = { + "version": "2.0", + "secret": "secret", + "type": "BluetoothDevicesSeen", + "data": { + "observations": [] + } + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + assert req.status == 200 + + +@asyncio.coroutine +def test_data_will_be_saved(hass, meraki_client): + """Test with valid data.""" + data = { + "version": "2.0", + "secret": "secret", + "type": "DevicesSeen", + "data": { + "observations": [ + { + "location": { + "lat": "51.5355157", + "lng": "21.0699035", + "unc": "46.3610585", + }, + "seenTime": "2016-09-12T16:23:13Z", + "ssid": 'ssid', + "os": 'HA', + "ipv6": '2607:f0d0:1002:51::4/64', + "clientMac": "00:26:ab:b8:a9:a4", + "seenEpoch": "147369739", + "rssi": "20", + "manufacturer": "Seiko Epson" + }, + { + "location": { + "lat": "51.5355357", + "lng": "21.0699635", + "unc": "46.3610585", + }, + "seenTime": "2016-09-12T16:21:13Z", + "ssid": 'ssid', + "os": 'HA', + "ipv4": '192.168.0.1', + "clientMac": "00:26:ab:b8:a9:a5", + "seenEpoch": "147369750", + "rssi": "20", + "manufacturer": "Seiko Epson" + } + ] + } + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + assert req.status == 200 + state_name = hass.states.get('{}.{}'.format('device_tracker', + '0026abb8a9a4')).state + assert 'home' == state_name + + state_name = hass.states.get('{}.{}'.format('device_tracker', + '0026abb8a9a5')).state + assert 'home' == state_name From 9cff6c7e6abc14bc0ea5232ef4861832d727ca67 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 6 Dec 2017 12:44:41 +0100 Subject: [PATCH 39/97] Update tradfri.py (#10991) --- homeassistant/components/light/tradfri.py | 34 +++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 3bba6da8dd3..dc8e7f4c996 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -99,7 +99,7 @@ class TradfriGroup(Light): @asyncio.coroutine def async_turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self.hass.async_add_job(self._api(self._group.set_state(0))) + yield from self._api(self._group.set_state(0)) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -112,10 +112,10 @@ class TradfriGroup(Light): if kwargs[ATTR_BRIGHTNESS] == 255: kwargs[ATTR_BRIGHTNESS] = 254 - self.hass.async_add_job(self._api( - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys))) + yield from self._api( + self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys)) else: - self.hass.async_add_job(self._api(self._group.set_state(1))) + yield from self._api(self._group.set_state(1)) @callback def _async_start_observe(self, exc=None): @@ -140,11 +140,11 @@ class TradfriGroup(Light): self._group = group self._name = group.name + @callback def _observe_update(self, tradfri_device): """Receive new state data for this light.""" self._refresh(tradfri_device) - - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() class TradfriLight(Light): @@ -238,8 +238,7 @@ class TradfriLight(Light): @asyncio.coroutine def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - self.hass.async_add_job(self._api( - self._light_control.set_state(False))) + yield from self._api(self._light_control.set_state(False)) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -250,17 +249,17 @@ class TradfriLight(Light): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self.hass.async_add_job(self._api( + yield from self._api( self._light.light_control.set_rgb_color( - *kwargs[ATTR_RGB_COLOR]))) + *kwargs[ATTR_RGB_COLOR])) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and \ self._temp_supported: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - self.hass.async_add_job(self._api( - self._light_control.set_kelvin_color(kelvin))) + yield from self._api( + self._light_control.set_kelvin_color(kelvin)) keys = {} if ATTR_TRANSITION in kwargs: @@ -270,12 +269,12 @@ class TradfriLight(Light): if kwargs[ATTR_BRIGHTNESS] == 255: kwargs[ATTR_BRIGHTNESS] = 254 - self.hass.async_add_job(self._api( + yield from self._api( self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS], - **keys))) + **keys)) else: - self.hass.async_add_job(self._api( - self._light_control.set_state(True))) + yield from self._api( + self._light_control.set_state(True)) @callback def _async_start_observe(self, exc=None): @@ -318,10 +317,11 @@ class TradfriLight(Light): self._temp_supported = self._light.device_info.manufacturer \ in ALLOWED_TEMPERATURES + @callback def _observe_update(self, tradfri_device): """Receive new state data for this light.""" self._refresh(tradfri_device) self._rgb_color = color_util.rgb_hex_to_rgb_list( self._light_data.hex_color_inferred ) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() From 0fc7f371856fb8161432ee17c5d39284656a433a Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Wed, 6 Dec 2017 08:48:17 -0500 Subject: [PATCH 40/97] webostv: Ensure source exists before use (#10959) In a case where either (a) an incorrect source name is used, or (b) the TV isn't currently queryable (e.g. it's off), we get tracebacks because we assume the source that we are being asked to select exists in self._source_list. This makes the lookup code a little more rugged, and adds in a warning message (in place of the current exception). --- homeassistant/components/media_player/webostv.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 3215ad82a7c..0abdb90e67a 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -322,12 +322,15 @@ class LgWebOSDevice(MediaPlayerDevice): def select_source(self, source): """Select input source.""" - if self._source_list.get(source).get('title'): - self._current_source_id = self._source_list[source]['id'] + source = self._source_list.get(source) + if source is None: + _LOGGER.warning("Source %s not found for %s", source, self.name) + return + self._current_source_id = self._source_list[source]['id'] + if source.get('title'): self._current_source = self._source_list[source]['title'] self._client.launch_app(self._source_list[source]['id']) - elif self._source_list.get(source).get('label'): - self._current_source_id = self._source_list[source]['id'] + elif source.get('label'): self._current_source = self._source_list[source]['label'] self._client.set_input(self._source_list[source]['id']) From c952f2e18a0a76d9fe18f31485707fbde4d2c5f7 Mon Sep 17 00:00:00 2001 From: Richard Leurs Date: Wed, 6 Dec 2017 15:00:58 +0100 Subject: [PATCH 41/97] Ensure Docker script files uses LF line endings to support Docker for Windows. (#10067) --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..214efef6e4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure Docker script files uses LF to support Docker for Windows. +setup_docker_prereqs eol=lf +/virtualization/Docker/scripts/* eol=lf \ No newline at end of file From 39d33c97ffa1e66644e3154c6ccb8db65379d591 Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Wed, 6 Dec 2017 23:47:19 -0700 Subject: [PATCH 42/97] Added Vera scenes (#10424) * Added Vera scenes * Fixed flake8 issues * Fixed comments * Moved vera to use hass.data * Made requested changes --- .../components/binary_sensor/vera.py | 4 +- homeassistant/components/climate/vera.py | 4 +- homeassistant/components/cover/vera.py | 4 +- homeassistant/components/light/vera.py | 3 +- homeassistant/components/lock/vera.py | 4 +- homeassistant/components/scene/vera.py | 60 +++++++++++++++++++ homeassistant/components/sensor/vera.py | 4 +- homeassistant/components/switch/vera.py | 4 +- homeassistant/components/vera.py | 29 ++++++--- requirements_all.txt | 2 +- 10 files changed, 95 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/scene/vera.py diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py index e16f4e17fa0..e87886376bc 100644 --- a/homeassistant/components/binary_sensor/vera.py +++ b/homeassistant/components/binary_sensor/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Vera controller devices.""" add_devices( - VeraBinarySensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['binary_sensor']) + VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['binary_sensor']) class VeraBinarySensor(VeraDevice, BinarySensorDevice): diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index 4644f86cba2..c9d22e41d81 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -32,8 +32,8 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up of Vera thermostats.""" add_devices_callback( - VeraThermostat(device, VERA_CONTROLLER) for - device in VERA_DEVICES['climate']) + VeraThermostat(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['climate']) class VeraThermostat(VeraDevice, ClimateDevice): diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 05be125ec6f..6cf269b75b3 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -18,8 +18,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera covers.""" add_devices( - VeraCover(device, VERA_CONTROLLER) for - device in VERA_DEVICES['cover']) + VeraCover(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['cover']) class VeraCover(VeraDevice, CoverDevice): diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index b3be93d82e2..102ca814882 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -21,7 +21,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera lights.""" add_devices( - VeraLight(device, VERA_CONTROLLER) for device in VERA_DEVICES['light']) + VeraLight(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['light']) class VeraLight(VeraDevice, Light): diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index 04962566821..b3aae5e159f 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -19,8 +19,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" add_devices( - VeraLock(device, VERA_CONTROLLER) for - device in VERA_DEVICES['lock']) + VeraLock(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['lock']) class VeraLock(VeraDevice, LockDevice): diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py new file mode 100644 index 00000000000..3dbb68d214f --- /dev/null +++ b/homeassistant/components/scene/vera.py @@ -0,0 +1,60 @@ +""" +Support for Vera scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.vera/ +""" +import logging + +from homeassistant.util import slugify +from homeassistant.components.scene import Scene +from homeassistant.components.vera import ( + VERA_CONTROLLER, VERA_SCENES, VERA_ID_FORMAT) + +DEPENDENCIES = ['vera'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vera scenes.""" + add_devices( + [VeraScene(scene, hass.data[VERA_CONTROLLER]) + for scene in hass.data[VERA_SCENES]], True) + + +class VeraScene(Scene): + """Representation of a Vera scene entity.""" + + def __init__(self, vera_scene, controller): + """Initialize the scene.""" + self.vera_scene = vera_scene + self.controller = controller + + self._name = self.vera_scene.name + # Append device id to prevent name clashes in HA. + self.vera_id = VERA_ID_FORMAT.format( + slugify(vera_scene.name), vera_scene.scene_id) + + def update(self): + """Update the scene status.""" + self.vera_scene.refresh() + + def activate(self, **kwargs): + """Activate the scene.""" + self.vera_scene.activate() + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'vera_scene_id': self.vera_scene.vera_scene_id} + + @property + def should_poll(self): + """Return that polling is not necessary.""" + return False diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index f901bd27dca..c81c208e33e 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -25,8 +25,8 @@ SCAN_INTERVAL = timedelta(seconds=5) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera controller devices.""" add_devices( - VeraSensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['sensor']) + VeraSensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['sensor']) class VeraSensor(VeraDevice, Entity): diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index 1e92612b9a9..d7c284e4ccf 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera switches.""" add_devices( - VeraSwitch(device, VERA_CONTROLLER) for - device in VERA_DEVICES['switch']) + VeraSwitch(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['switch']) class VeraSwitch(VeraDevice, SwitchDevice): diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 7418ca812a1..b15c4ddabfd 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,13 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.38'] +REQUIREMENTS = ['pyvera==0.2.39'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'vera' -VERA_CONTROLLER = None +VERA_CONTROLLER = 'vera_controller' CONF_CONTROLLER = 'vera_controller_url' @@ -34,7 +34,8 @@ VERA_ID_FORMAT = '{}_{}' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" -VERA_DEVICES = defaultdict(list) +VERA_DEVICES = 'vera_devices' +VERA_SCENES = 'vera_scenes' VERA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -47,20 +48,20 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) VERA_COMPONENTS = [ - 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'climate', 'cover' + 'binary_sensor', 'sensor', 'light', 'switch', + 'lock', 'climate', 'cover', 'scene' ] # pylint: disable=unused-argument, too-many-function-args def setup(hass, base_config): """Set up for Vera devices.""" - global VERA_CONTROLLER import pyvera as veraApi def stop_subscription(event): """Shutdown Vera subscriptions and subscription thread on exit.""" _LOGGER.info("Shutting down subscriptions") - VERA_CONTROLLER.stop() + hass.data[VERA_CONTROLLER].stop() config = base_config.get(DOMAIN) @@ -70,11 +71,14 @@ def setup(hass, base_config): exclude_ids = config.get(CONF_EXCLUDE) # Initialize the Vera controller. - VERA_CONTROLLER, _ = veraApi.init_controller(base_url) + controller, _ = veraApi.init_controller(base_url) + hass.data[VERA_CONTROLLER] = controller hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) try: - all_devices = VERA_CONTROLLER.get_devices() + all_devices = controller.get_devices() + + all_scenes = controller.get_scenes() except RequestException: # There was a network related error connecting to the Vera controller. _LOGGER.exception("Error communicating with Vera API") @@ -84,12 +88,19 @@ def setup(hass, base_config): devices = [device for device in all_devices if device.device_id not in exclude_ids] + vera_devices = defaultdict(list) for device in devices: device_type = map_vera_device(device, light_ids) if device_type is None: continue - VERA_DEVICES[device_type].append(device) + vera_devices[device_type].append(device) + hass.data[VERA_DEVICES] = vera_devices + + vera_scenes = [] + for scene in all_scenes: + vera_scenes.append(scene) + hass.data[VERA_SCENES] = vera_scenes for component in VERA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, base_config) diff --git a/requirements_all.txt b/requirements_all.txt index 840ed5a834a..b34b7b9bb50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -925,7 +925,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.38 +pyvera==0.2.39 # homeassistant.components.media_player.vizio pyvizio==0.0.2 From f21da7cfdc6ff5917efc43421cf453d64d0cf3d5 Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Thu, 7 Dec 2017 12:39:34 +0100 Subject: [PATCH 43/97] Fix Egardia alarm status shown as unknown after restart (#11010) --- .../components/alarm_control_panel/egardia.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 7719ab884bc..82c26c98104 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -116,12 +116,20 @@ class EgardiaAlarm(alarm.AlarmControlPanel): """Return the state of the device.""" return self._status + @property + def should_poll(self): + """Poll if no report server is enabled.""" + if not self._rs_enabled: + return True + return False + def handle_system_status_event(self, event): """Handle egardia_system_status_event.""" if event.data.get('status') is not None: statuscode = event.data.get('status') status = self.lookupstatusfromcode(statuscode) self.parsestatus(status) + self.schedule_update_ha_state() def listen_to_system_status(self): """Subscribe to egardia_system_status event.""" @@ -161,9 +169,8 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def update(self): """Update the alarm status.""" - if not self._rs_enabled: - status = self._egardiasystem.getstate() - self.parsestatus(status) + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" From 3c1f8cd882d16912f9c972ca96be460fbba5717e Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Thu, 7 Dec 2017 16:30:51 +0000 Subject: [PATCH 44/97] Handle OSError when forcibly turning off media_player.samsungtv (#10997) --- homeassistant/components/media_player/samsungtv.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 721b095c083..d42bd9ea012 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -190,7 +190,10 @@ class SamsungTVDevice(MediaPlayerDevice): else: self.send_key('KEY_POWEROFF') # Force closing of remote session to provide instant UI feedback - self.get_remote().close() + try: + self.get_remote().close() + except OSError: + _LOGGER.debug("Could not establish connection.") def volume_up(self): """Volume up the media player.""" From 929d49ed6fec0eb77de9e0087445e322ec4172e5 Mon Sep 17 00:00:00 2001 From: Marcus Schmidt Date: Thu, 7 Dec 2017 20:44:06 +0100 Subject: [PATCH 45/97] Shuffle support in Sonos (#10875) * initial commit of shuffle option for sonos * added test * Small adjustments to adhere to review requests * Removed unnessesary setting of variable. Use shuffle state from soco instead --- .../components/media_player/sonos.py | 24 +++++++++++++++---- tests/components/media_player/test_sonos.py | 14 +++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 47786e793ca..61c2773df05 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_STOP, - SUPPORT_PLAY) + SUPPORT_PLAY, SUPPORT_SHUFFLE_SET) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID, CONF_HOSTS, ATTR_TIME) @@ -43,7 +43,7 @@ _REQUESTS_LOGGER.setLevel(logging.ERROR) SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -331,6 +331,7 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = True self._support_stop = False self._support_pause = False self._current_track_uri = None @@ -450,6 +451,7 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = False self._support_stop = False self._support_pause = False self._is_playing_tv = False @@ -536,6 +538,7 @@ class SonosDevice(MediaPlayerDevice): support_play = False support_stop = True support_pause = False + support_shuffle_set = False if is_playing_tv: media_artist = SUPPORT_SOURCE_TV @@ -558,6 +561,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = False + support_shuffle_set = False source_name = 'Radio' # Check if currently playing radio station is in favorites @@ -622,6 +626,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = True + support_shuffle_set = True position_info = self._player.avTransport.GetPositionInfo( [('InstanceID', 0), @@ -694,6 +699,7 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = support_previous_track self._support_next_track = support_next_track self._support_play = support_play + self._support_shuffle_set = support_shuffle_set self._support_stop = support_stop self._support_pause = support_pause self._is_playing_tv = is_playing_tv @@ -762,6 +768,11 @@ class SonosDevice(MediaPlayerDevice): """Return true if volume is muted.""" return self._player_volume_muted + @property + def shuffle(self): + """Shuffling state.""" + return True if self._player.play_mode == 'SHUFFLE' else False + @property def media_content_id(self): """Content ID of current playing media.""" @@ -850,7 +861,8 @@ class SonosDevice(MediaPlayerDevice): if not self._support_play: supported = supported ^ SUPPORT_PLAY - + if not self._support_shuffle_set: + supported = supported ^ SUPPORT_SHUFFLE_SET if not self._support_stop: supported = supported ^ SUPPORT_STOP @@ -874,6 +886,11 @@ class SonosDevice(MediaPlayerDevice): """Set volume level, range 0..1.""" self._player.volume = str(int(volume * 100)) + @soco_error + def set_shuffle(self, shuffle): + """Enable/Disable shuffle mode.""" + self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + @soco_error def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" @@ -932,7 +949,6 @@ class SonosDevice(MediaPlayerDevice): self._player.stop() self._player.clear_queue() - self._player.play_mode = 'NORMAL' self._player.add_to_queue(didl) @property diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 8c62c6c84e9..33f7a0e882d 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -281,6 +281,20 @@ class TestSonosMediaPlayer(unittest.TestCase): self.assertEqual(unjoinMock.call_count, 1) self.assertEqual(unjoinMock.call_args, mock.call()) + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_set_shuffle(self, shuffle_set_mock, *args): + """Ensuring soco methods called for sonos_snapshot service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + device.set_shuffle(True) + self.assertEqual(shuffle_set_mock.call_count, 1) + self.assertEqual(device._player.play_mode, 'SHUFFLE') + @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(SoCoMock, 'set_sleep_timer') From f892c3394b4a2d2a2e77e9472b91409380875cf0 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Fri, 8 Dec 2017 01:40:45 -0800 Subject: [PATCH 46/97] Add support for Canary component and platforms (#10306) * Add Canary component * Made some change to how canary data is updated and stored * Updated to use py-canary:0.1.2 * Addressed flake8 warnings * Import canary API locally * Import canary API locally again * Addressed pylint errors * Updated requirements_all.txt * Fixed incorrect unit of measurement for air quality sensor * Added tests for Canary component and sensors * Updated canary component to handle exception better when initializing * Fixed tests * Fixed tests again * Addressed review comments * Fixed houndci error * Addressed comment about camera force update * Addressed comment regarding timeout when fetching camera image * Updated to use py-canary==0.2.2 * Increased update frequency to 30 seconds * Added support for Canary alarm control panel * Address review comments * Fixed houndci error * Fixed lint errors * Updated test to only test setup component / platform * Fixed flake error * Fixed failing test * Uptake py-canary:0.2.3 * canary.alarm_control_panel DISARM is now mapped to canary PRIVACY mode * Fixed failing tests * Removed unnecessary methods * Removed polling in canary camera component and update camera info when getting camera image * Added more tests to cover Canary sensors * Address review comments * Addressed review comment in tests * Fixed pylint errors * Excluded canary alarm_control_panel and camera from coverage calculation --- .coveragerc | 2 + .../components/alarm_control_panel/canary.py | 92 +++++++++++++ homeassistant/components/camera/canary.py | 95 +++++++++++++ homeassistant/components/canary.py | 117 ++++++++++++++++ homeassistant/components/sensor/canary.py | 85 ++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_canary.py | 125 ++++++++++++++++++ tests/components/test_canary.py | 85 ++++++++++++ 10 files changed, 608 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/canary.py create mode 100644 homeassistant/components/camera/canary.py create mode 100644 homeassistant/components/canary.py create mode 100644 homeassistant/components/sensor/canary.py create mode 100644 tests/components/sensor/test_canary.py create mode 100644 tests/components/test_canary.py diff --git a/.coveragerc b/.coveragerc index e97d197ca94..9db732dfbde 100644 --- a/.coveragerc +++ b/.coveragerc @@ -264,6 +264,7 @@ omit = homeassistant/components/*/zoneminder.py homeassistant/components/alarm_control_panel/alarmdotcom.py + homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/egardia.py homeassistant/components/alarm_control_panel/ialarm.py @@ -285,6 +286,7 @@ omit = homeassistant/components/browser.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py + homeassistant/components/camera/canary.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/mjpeg.py diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py new file mode 100644 index 00000000000..fb5c4c37e8d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -0,0 +1,92 @@ +""" +Support for Canary alarm. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.canary/ +""" +import logging + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, \ + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_HOME + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary alarms.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + devices.append(CanaryAlarm(data, location.location_id)) + + add_devices(devices, True) + + +class CanaryAlarm(AlarmControlPanel): + """Representation of a Canary alarm control panel.""" + + def __init__(self, data, location_id): + """Initialize a Canary security camera.""" + self._data = data + self._location_id = location_id + + @property + def name(self): + """Return the name of the alarm.""" + location = self._data.get_location(self._location_id) + return location.name + + @property + def state(self): + """Return the state of the device.""" + from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \ + LOCATION_MODE_NIGHT + + location = self._data.get_location(self._location_id) + + if location.is_private: + return STATE_ALARM_DISARMED + + mode = location.mode + if mode.name == LOCATION_MODE_AWAY: + return STATE_ALARM_ARMED_AWAY + elif mode.name == LOCATION_MODE_HOME: + return STATE_ALARM_ARMED_HOME + elif mode.name == LOCATION_MODE_NIGHT: + return STATE_ALARM_ARMED_NIGHT + else: + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + location = self._data.get_location(self._location_id) + return { + 'private': location.is_private + } + + def alarm_disarm(self, code=None): + """Send disarm command.""" + location = self._data.get_location(self._location_id) + self._data.set_location_mode(self._location_id, location.mode.name, + True) + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + from canary.api import LOCATION_MODE_HOME + self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + from canary.api import LOCATION_MODE_AWAY + self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) + + def alarm_arm_night(self, code=None): + """Send arm night command.""" + from canary.api import LOCATION_MODE_NIGHT + self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py new file mode 100644 index 00000000000..302758eee94 --- /dev/null +++ b/homeassistant/components/camera/canary.py @@ -0,0 +1,95 @@ +""" +Support for Canary camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.canary/ +""" +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_MOTION_START_TIME = "motion_start_time" +ATTR_MOTION_END_TIME = "motion_end_time" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + entries = data.get_motion_entries(location.location_id) + if entries: + devices.append(CanaryCamera(data, location.location_id, + DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class CanaryCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, location_id, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._location_id = location_id + self._timeout = timeout + + self._location = None + self._motion_entry = None + self._image_content = None + + def camera_image(self): + """Update the status of the camera and return bytes of camera image.""" + self.update() + return self._image_content + + @property + def name(self): + """Return the name of this device.""" + return self._location.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._location.is_recording + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + if self._motion_entry is None: + return None + + return { + ATTR_MOTION_START_TIME: self._motion_entry.start_time, + ATTR_MOTION_END_TIME: self._motion_entry.end_time, + } + + def update(self): + """Update the status of the camera.""" + self._data.update() + self._location = self._data.get_location(self._location_id) + + entries = self._data.get_motion_entries(self._location_id) + if entries: + current = entries[0] + previous = self._motion_entry + + if previous is None or previous.entry_id != current.entry_id: + self._motion_entry = current + self._image_content = requests.get( + current.thumbnails[0].image_url, + timeout=self._timeout).content + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return not self._location.is_recording diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py new file mode 100644 index 00000000000..8ab7218e201 --- /dev/null +++ b/homeassistant/components/canary.py @@ -0,0 +1,117 @@ +""" +Support for Canary. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/canary/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import ConnectTimeout, HTTPError + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ['py-canary==0.2.3'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'canary_notification' +NOTIFICATION_TITLE = 'Canary Setup' + +DOMAIN = 'canary' +DATA_CANARY = 'canary' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +CANARY_COMPONENTS = [ + 'alarm_control_panel', 'camera', 'sensor' +] + + +def setup(hass, config): + """Set up the Canary component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + timeout = conf.get(CONF_TIMEOUT) + + try: + hass.data[DATA_CANARY] = CanaryData(username, password, timeout) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Canary service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component in CANARY_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class CanaryData(object): + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + from canary.api import Api + self._api = Api(username, password, timeout) + + self._locations_by_id = {} + self._readings_by_device_id = {} + self._entries_by_location_id = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Get the latest data from py-canary.""" + for location in self._api.get_locations(): + location_id = location.location_id + + self._locations_by_id[location_id] = location + self._entries_by_location_id[location_id] = self._api.get_entries( + location_id, entry_type="motion", limit=1) + + for device in location.devices: + if device.is_online: + self._readings_by_device_id[device.device_id] = \ + self._api.get_latest_readings(device.device_id) + + @property + def locations(self): + """Return a list of locations.""" + return self._locations_by_id.values() + + def get_motion_entries(self, location_id): + """Return a list of motion entries based on location_id.""" + return self._entries_by_location_id.get(location_id, []) + + def get_location(self, location_id): + """Return a location based on location_id.""" + return self._locations_by_id.get(location_id, []) + + def get_readings(self, device_id): + """Return a list of readings based on device_id.""" + return self._readings_by_device_id.get(device_id, []) + + def set_location_mode(self, location_id, mode_name, is_private=False): + """Set location mode.""" + self._api.set_location_mode(location_id, mode_name, is_private) + self.update(no_throttle=True) diff --git a/homeassistant/components/sensor/canary.py b/homeassistant/components/sensor/canary.py new file mode 100644 index 00000000000..b0d2c27ae5d --- /dev/null +++ b/homeassistant/components/sensor/canary.py @@ -0,0 +1,85 @@ +""" +Support for Canary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.canary/ +""" +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['canary'] + +SENSOR_VALUE_PRECISION = 1 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + from canary.api import SensorType + for location in data.locations: + for device in location.devices: + if device.is_online: + for sensor_type in SensorType: + devices.append(CanarySensor(data, sensor_type, location, + device)) + + add_devices(devices, True) + + +class CanarySensor(Entity): + """Representation of a Canary sensor.""" + + def __init__(self, data, sensor_type, location, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device_id = device.device_id + self._is_celsius = location.is_celsius + self._sensor_value = None + + sensor_type_name = sensor_type.value.replace("_", " ").title() + self._name = '{} {} {}'.format(location.name, + device.name, + sensor_type_name) + + @property + def name(self): + """Return the name of the Canary sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._sensor_value + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "sensor_canary_{}_{}".format(self._device_id, + self._sensor_type.value) + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + from canary.api import SensorType + if self._sensor_type == SensorType.TEMPERATURE: + return TEMP_CELSIUS if self._is_celsius else TEMP_FAHRENHEIT + elif self._sensor_type == SensorType.HUMIDITY: + return "%" + elif self._sensor_type == SensorType.AIR_QUALITY: + return "" + return None + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() + + readings = self._data.get_readings(self._device_id) + value = next(( + reading.value for reading in readings + if reading.sensor_type == self._sensor_type), None) + if value is not None: + self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION) diff --git a/requirements_all.txt b/requirements_all.txt index b34b7b9bb50..c09b4f6a93b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -581,6 +581,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72325d6305b..c932ce7ead9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -115,6 +115,9 @@ pmsensor==0.4 # homeassistant.components.prometheus prometheus_client==0.0.21 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fbd60ffdadc..bdc75f3a69c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -61,6 +61,7 @@ TEST_REQUIREMENTS = ( 'pilight', 'pmsensor', 'prometheus_client', + 'py-canary', 'pydispatcher', 'PyJWT', 'pylitejet', diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py new file mode 100644 index 00000000000..99df05f36a4 --- /dev/null +++ b/tests/components/sensor/test_canary.py @@ -0,0 +1,125 @@ +"""The tests for the Canary sensor platform.""" +import copy +import unittest +from unittest.mock import patch, Mock + +from canary.api import SensorType +from homeassistant.components import canary as base_canary +from homeassistant.components.canary import DATA_CANARY +from homeassistant.components.sensor import canary +from homeassistant.components.sensor.canary import CanarySensor +from tests.common import (get_test_home_assistant) +from tests.components.test_canary import mock_device, mock_reading, \ + mock_location + +VALID_CONFIG = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } +} + + +class TestCanarySensorSetup(unittest.TestCase): + """Test the Canary platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = copy.deepcopy(VALID_CONFIG) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData') + def test_setup_sensors(self, mock_canary): + """Test the sensor setup.""" + base_canary.setup(self.hass, self.config) + + online_device_at_home = mock_device(20, "Dining Room", True) + offline_device_at_home = mock_device(21, "Front Yard", False) + online_device_at_work = mock_device(22, "Office", True) + + self.hass.data[DATA_CANARY] = mock_canary() + self.hass.data[DATA_CANARY].locations = [ + mock_location("Home", True, devices=[online_device_at_home, + offline_device_at_home]), + mock_location("Work", True, devices=[online_device_at_work]), + ] + + canary.setup_platform(self.hass, self.config, self.add_devices, None) + + self.assertEqual(6, len(self.DEVICES)) + + def test_celsius_temperature_sensor(self): + """Test temperature sensor with celsius.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", True) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1234)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("sensor_canary_10_temperature", sensor.unique_id) + self.assertEqual("°C", sensor.unit_of_measurement) + self.assertEqual(21.1, sensor.state) + + def test_fahrenheit_temperature_sensor(self): + """Test temperature sensor with fahrenheit.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", False) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1567)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("°F", sensor.unit_of_measurement) + self.assertEqual(21.2, sensor.state) + + def test_humidity_sensor(self): + """Test humidity sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.HUMIDITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.HUMIDITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Humidity", sensor.name) + self.assertEqual("%", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) + + def test_air_quality_sensor(self): + """Test air quality sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.AIR_QUALITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.AIR_QUALITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Air Quality", sensor.name) + self.assertEqual("", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) diff --git a/tests/components/test_canary.py b/tests/components/test_canary.py new file mode 100644 index 00000000000..67122813fb7 --- /dev/null +++ b/tests/components/test_canary.py @@ -0,0 +1,85 @@ +"""The tests for the Canary component.""" +import unittest +from unittest.mock import patch, MagicMock, PropertyMock + +import homeassistant.components.canary as canary +from homeassistant import setup +from tests.common import ( + get_test_home_assistant) + + +def mock_device(device_id, name, is_online=True): + """Mock Canary Device class.""" + device = MagicMock() + type(device).device_id = PropertyMock(return_value=device_id) + type(device).name = PropertyMock(return_value=name) + type(device).is_online = PropertyMock(return_value=is_online) + return device + + +def mock_location(name, is_celsius=True, devices=[]): + """Mock Canary Location class.""" + location = MagicMock() + type(location).name = PropertyMock(return_value=name) + type(location).is_celsius = PropertyMock(return_value=is_celsius) + type(location).devices = PropertyMock(return_value=devices) + return location + + +def mock_reading(sensor_type, sensor_value): + """Mock Canary Reading class.""" + reading = MagicMock() + type(reading).sensor_type = PropertyMock(return_value=sensor_type) + type(reading).value = PropertyMock(return_value=sensor_value) + return reading + + +class TestCanary(unittest.TestCase): + """Tests the Canary component.""" + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData.update') + @patch('canary.api.Api.login') + def test_setup_with_valid_config(self, mock_login, mock_update): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } + } + + self.assertTrue( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + mock_update.assert_called_once_with() + mock_login.assert_called_once_with() + + def test_setup_with_missing_password(self): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + def test_setup_with_missing_username(self): + """Test setup component.""" + config = { + "canary": { + "password": "bar", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) From 0a7e6ac222ccddb2b6f8121741a785fc373674d1 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 8 Dec 2017 12:01:10 +0100 Subject: [PATCH 47/97] Ignore Sonos players with unknown hostnames (#11013) --- homeassistant/components/media_player/sonos.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 61c2773df05..f9a18a212f5 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -140,7 +140,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts = hosts.split(',') if isinstance(hosts, str) else hosts players = [] for host in hosts: - players.append(soco.SoCo(socket.gethostbyname(host))) + try: + players.append(soco.SoCo(socket.gethostbyname(host))) + except OSError: + _LOGGER.warning("Failed to initialize '%s'", host) if not players: players = soco.discover( From 4d6070e33a47bfbddee249d3656fc0023c355a48 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 8 Dec 2017 17:43:37 +0100 Subject: [PATCH 48/97] Support LIFX Mini products (#10996) * Support new LIFX products * Remove lint --- homeassistant/components/light/lifx.py | 82 +++++++++++--------------- requirements_all.txt | 2 +- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index ad2cf204463..06a00954d3b 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.6.0', 'aiolifx_effects==0.1.2'] +REQUIREMENTS = ['aiolifx==0.6.1', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -157,20 +157,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return True -def lifxwhite(device): - """Return whether this is a white-only bulb.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return not features["color"] - return False - - -def lifxmultizone(device): - """Return whether this is a multizone bulb/strip.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return features["multizone"] - return False +def lifx_features(device): + """Return a feature map for this device, or a default map if unknown.""" + return aiolifx().products.features_map.get(device.product) or \ + aiolifx().products.features_map.get(1) def find_hsbk(**kwargs): @@ -342,12 +332,12 @@ class LIFXManager(object): device.retry_count = MESSAGE_RETRIES device.unregister_timeout = UNAVAILABLE_GRACE - if lifxwhite(device): - entity = LIFXWhite(device, self.effects_conductor) - elif lifxmultizone(device): + if lifx_features(device)["multizone"]: entity = LIFXStrip(device, self.effects_conductor) - else: + elif lifx_features(device)["color"]: entity = LIFXColor(device, self.effects_conductor) + else: + entity = LIFXWhite(device, self.effects_conductor) _LOGGER.debug("%s register READY", entity.who) self.entities[device.mac_addr] = entity @@ -427,6 +417,29 @@ class LIFXLight(Light): """Return a string identifying the device.""" return "%s (%s)" % (self.device.ip_addr, self.name) + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['max_kelvin'] + return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['min_kelvin'] + return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def supported_features(self): + """Flag supported features.""" + support = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_EFFECT + + device_features = lifx_features(self.device) + if device_features['min_kelvin'] != device_features['max_kelvin']: + support |= SUPPORT_COLOR_TEMP + + return support + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -571,22 +584,6 @@ class LIFXLight(Light): class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(6500)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2700)) - - @property - def supported_features(self): - """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT) - @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -599,21 +596,12 @@ class LIFXWhite(LIFXLight): class LIFXColor(LIFXLight): """Representation of a color LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(9000)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2500)) - @property def supported_features(self): """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT | SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) + support = super().supported_features + support |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR + return support @property def effect_list(self): diff --git a/requirements_all.txt b/requirements_all.txt index c09b4f6a93b..bde26c64846 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -72,7 +72,7 @@ aiohttp_cors==0.5.3 aioimaplib==0.7.13 # homeassistant.components.light.lifx -aiolifx==0.6.0 +aiolifx==0.6.1 # homeassistant.components.light.lifx aiolifx_effects==0.1.2 From fed7bd947321badc4115ef4b18cfd041f0287d45 Mon Sep 17 00:00:00 2001 From: tschmidty69 Date: Fri, 8 Dec 2017 12:16:08 -0500 Subject: [PATCH 49/97] Update snips to listen on new mqtt topic and utilize rawValue (#11020) * Updated snips to listen on new mqtt topic and use rawValue if value not present in slot * Too late at night * Trying to make minor changes via web * Update test_snips.py * Update __init__.py * Updated wrong branch cause I'm a monkey --- homeassistant/components/snips.py | 13 +++++++++---- tests/components/test_snips.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 1f64f78e9c8..a302f25bd00 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -15,7 +15,7 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' -INTENT_TOPIC = 'hermes/nlu/intentParsed' +INTENT_TOPIC = 'hermes/intent/#' _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,8 @@ INTENT_SCHEMA = vol.Schema({ vol.Required('slotName'): str, vol.Required('value'): { vol.Required('kind'): str, - vol.Required('value'): cv.match_all + vol.Optional('value'): cv.match_all, + vol.Optional('rawValue'): cv.match_all } }] }, extra=vol.ALLOW_EXTRA) @@ -59,8 +60,12 @@ def async_setup(hass, config): return intent_type = request['intent']['intentName'].split('__')[-1] - slots = {slot['slotName']: {'value': slot['value']['value']} - for slot in request.get('slots', [])} + slots = {} + for slot in request.get('slots', []): + if 'value' in slot['value']: + slots[slot['slotName']] = {'value': slot['value']['value']} + else: + slots[slot['slotName']] = {'value': slot['rawValue']} try: yield from intent.async_handle( diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 5e49bbd0382..a3e6fac0295 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -34,7 +34,7 @@ def test_snips_call_action(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') - async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', + async_fire_mqtt_message(hass, 'hermes/intent/activateLights', EXAMPLE_MSG) yield from hass.async_block_till_done() assert len(intents) == 1 From f7c2ec19ef95db6474c7c20778c9a7907dc2d4ae Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 8 Dec 2017 19:16:26 +0200 Subject: [PATCH 50/97] Change default js version to auto (#10999) --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3d669ddc4d1..36270beede2 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -35,7 +35,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' -JS_DEFAULT_OPTION = 'es5' +JS_DEFAULT_OPTION = 'auto' JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' From 1f1115f631cb3fae99f1c68a3812e2ac79814e92 Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Fri, 8 Dec 2017 09:18:52 -0800 Subject: [PATCH 51/97] Serialize mochad requests (#11029) All mochad devices are sharing a single socket interface. When multiple threads are issuing requests to the mochad daemon at the same time the write read cycle might get crossed between the threads. This is normally not an issue for 1-way X10 devices because as long as the request issued successfully and data is read over the socket then we know as much as mochad will tell us (since there is no ACK from the request for most X10 devices). However, where it does matter is on the device __init__() because we're relying on the mochad daemon's internal state to take an educated guess at the device's state to intialize things with. When there are multiple devices being initialized at the same time the wires can get crossed between and the wrong device state may be read. To address this potential issue this commit adds locking using a semaphore around all pairs of send_cmd() and read_data() (which is what pymochad.device.Device.get_status() does internally) calls to the mochad controller to ensure we're only ever dealing with a single request at a time. Fixes mtreinish/pymochad#4 --- homeassistant/components/light/mochad.py | 13 ++++++++----- homeassistant/components/mochad.py | 3 +++ homeassistant/components/switch/mochad.py | 13 ++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index fffaa293188..3d67edaf7cb 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -62,7 +62,8 @@ class MochadLight(Light): def _get_device_status(self): """Get the status of the light from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property @@ -88,12 +89,14 @@ class MochadLight(Light): def turn_on(self, **kwargs): """Send the command to turn the light on.""" self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd("xdim {}".format(self._brightness)) + self._controller.read_data() self._state = True def turn_off(self, **kwargs): """Send the command to turn the light on.""" - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() self._state = False diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py index 165c43f488f..3cc4eda7675 100644 --- a/homeassistant/components/mochad.py +++ b/homeassistant/components/mochad.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mochad/ """ import logging +import threading import voluptuous as vol @@ -23,6 +24,8 @@ CONF_COMM_TYPE = 'comm_type' DOMAIN = 'mochad' +REQ_LOCK = threading.Lock() + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST, default='localhost'): cv.string, diff --git a/homeassistant/components/switch/mochad.py b/homeassistant/components/switch/mochad.py index a67b27a6a91..da8f96dc1f0 100644 --- a/homeassistant/components/switch/mochad.py +++ b/homeassistant/components/switch/mochad.py @@ -60,18 +60,21 @@ class MochadSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn the switch on.""" self._state = True - self.device.send_cmd('on') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('on') + self._controller.read_data() def turn_off(self, **kwargs): """Turn the switch off.""" self._state = False - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() def _get_device_status(self): """Get the status of the switch from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property From 20f1e1609f045a10ce80adb4d5a9ea19d84d728c Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 9 Dec 2017 03:25:16 +0200 Subject: [PATCH 52/97] In dev mode expose only relevant sources (#11026) --- homeassistant/components/frontend/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 36270beede2..9d97a7439bd 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -299,8 +299,13 @@ def async_setup(hass, config): hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: - hass.http.register_static_path( - "/home-assistant-polymer", repo_path, False) + for subpath in ["src", "build-translations", "build-temp", "build", + "hass_frontend", "bower_components", "panels"]: + hass.http.register_static_path( + "/home-assistant-polymer/{}".format(subpath), + os.path.join(repo_path, subpath), + False) + hass.http.register_static_path( "/static/translations", os.path.join(repo_path, "build-translations/output"), False) From 44797611316f900b510e375461045b461052990e Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sat, 9 Dec 2017 14:18:45 +0700 Subject: [PATCH 53/97] Added force_update for REST sensor (#11016) * Added force_update for REST sensor * Linting error --- homeassistant/components/sensor/miflora.py | 4 +-- homeassistant/components/sensor/mqtt.py | 4 +-- homeassistant/components/sensor/rest.py | 25 ++++++++++++----- homeassistant/const.py | 1 + tests/components/sensor/test_rest.py | 31 +++++++++++++++------- 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 063c4e8068e..349e55abb5d 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -12,7 +12,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC) + CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC +) REQUIREMENTS = ['miflora==0.1.16'] @@ -20,7 +21,6 @@ _LOGGER = logging.getLogger(__name__) CONF_ADAPTER = 'adapter' CONF_CACHE = 'cache_value' -CONF_FORCE_UPDATE = 'force_update' CONF_MEDIAN = 'median' CONF_RETRIES = 'retries' CONF_TIMEOUT = 'timeout' diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 70b1294c13f..bf7de94b5d7 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) + CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, + CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv @@ -22,7 +23,6 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_FORCE_UPDATE = 'force_update' CONF_EXPIRE_AFTER = 'expire_after' DEFAULT_NAME = 'MQTT Sensor' diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 86362e8f2d9..19f5a1c271e 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -13,10 +13,11 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, - CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VERIFY_SSL, CONF_USERNAME, - CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, CONF_HEADERS) + CONF_AUTHENTICATION, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_NAME, + CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -25,6 +26,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' DEFAULT_VERIFY_SSL = True +DEFAULT_FORCE_UPDATE = False CONF_JSON_ATTRS = 'json_attributes' METHODS = ['POST', 'GET'] @@ -43,6 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, }) @@ -59,6 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) + force_update = config.get(CONF_FORCE_UPDATE) if value_template is not None: value_template.hass = hass @@ -74,14 +78,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest.update() add_devices([RestSensor( - hass, rest, name, unit, value_template, json_attrs)], True) + hass, rest, name, unit, value_template, json_attrs, force_update + )], True) class RestSensor(Entity): """Implementation of a REST sensor.""" - def __init__(self, hass, rest, name, - unit_of_measurement, value_template, json_attrs): + def __init__(self, hass, rest, name, unit_of_measurement, + value_template, json_attrs, force_update): """Initialize the REST sensor.""" self._hass = hass self.rest = rest @@ -91,6 +96,7 @@ class RestSensor(Entity): self._value_template = value_template self._json_attrs = json_attrs self._attributes = None + self._force_update = force_update @property def name(self): @@ -112,6 +118,11 @@ class RestSensor(Entity): """Return the state of the device.""" return self._state + @property + def force_update(self): + """Force update.""" + return self._force_update + def update(self): """Get the latest data from REST API and update the state.""" self.rest.update() diff --git a/homeassistant/const.py b/homeassistant/const.py index 85047f0482e..4f075249e57 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -75,6 +75,7 @@ CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' CONF_FOR = 'for' +CONF_FORCE_UPDATE = 'force_update' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 1bda8ab82f3..eddab8caf4d 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -132,10 +132,12 @@ class TestRestSensor(unittest.TestCase): self.unit_of_measurement = 'MB' self.value_template = template('{{ value_json.key }}') self.value_template.hass = self.hass + self.force_update = False - self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, - self.value_template, []) + self.sensor = rest.RestSensor( + self.hass, self.rest, self.name, self.unit_of_measurement, + self.value_template, [], self.force_update + ) def tearDown(self): """Stop everything that was started.""" @@ -154,6 +156,11 @@ class TestRestSensor(unittest.TestCase): self.assertEqual( self.unit_of_measurement, self.sensor.unit_of_measurement) + def test_force_update(self): + """Test the unit of measurement.""" + self.assertEqual( + self.force_update, self.sensor.force_update) + def test_state(self): """Test the initial state.""" self.sensor.update() @@ -182,7 +189,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'plain_state')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, []) + self.unit_of_measurement, None, [], + self.force_update) self.sensor.update() self.assertEqual('plain_state', self.sensor.state) self.assertTrue(self.sensor.available) @@ -193,7 +201,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '{ "key": "some_json_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual('some_json_value', self.sensor.device_state_attributes['key']) @@ -205,7 +214,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( '["list", "of", "things"]')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual({}, self.sensor.device_state_attributes) self.assertTrue(mock_logger.warning.called) @@ -217,7 +227,8 @@ class TestRestSensor(unittest.TestCase): side_effect=self.update_side_effect( 'This is text rather than JSON data.')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, - self.unit_of_measurement, None, ['key']) + self.unit_of_measurement, None, ['key'], + self.force_update) self.sensor.update() self.assertEqual({}, self.sensor.device_state_attributes) self.assertTrue(mock_logger.warning.called) @@ -230,12 +241,14 @@ class TestRestSensor(unittest.TestCase): '{ "key": "json_state_updated_value" }')) self.sensor = rest.RestSensor(self.hass, self.rest, self.name, self.unit_of_measurement, - self.value_template, ['key']) + self.value_template, ['key'], + self.force_update) self.sensor.update() self.assertEqual('json_state_updated_value', self.sensor.state) self.assertEqual('json_state_updated_value', - self.sensor.device_state_attributes['key']) + self.sensor.device_state_attributes['key'], + self.force_update) class TestRestData(unittest.TestCase): From bee80c5b797d8b80a47e193bca879199d34dd1d6 Mon Sep 17 00:00:00 2001 From: GreenTurtwig Date: Sat, 9 Dec 2017 19:01:23 +0000 Subject: [PATCH 54/97] Add support for Logitech UE Smart Radios. (#10077) * Add support for Logitech UE Smart Radios. * Removed full stops to please Hound's line limit. * Updated with requested changes. * Fix Pylint Flake8 problem. * Updated with requested changes. --- .coveragerc | 1 + .../components/media_player/ue_smart_radio.py | 207 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 homeassistant/components/media_player/ue_smart_radio.py diff --git a/.coveragerc b/.coveragerc index 9db732dfbde..c4b003708d5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -427,6 +427,7 @@ omit = homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/ue_smart_radio.py homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py diff --git a/homeassistant/components/media_player/ue_smart_radio.py b/homeassistant/components/media_player/ue_smart_radio.py new file mode 100644 index 00000000000..2684a819417 --- /dev/null +++ b/homeassistant/components/media_player/ue_smart_radio.py @@ -0,0 +1,207 @@ +""" +Support for Logitech UE Smart Radios. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ue_smart_radio/ +""" + +import logging +import voluptuous as vol +import requests + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_OFF, STATE_IDLE, STATE_PLAYING, + STATE_PAUSED) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:radio" +URL = "http://decibel.logitechmusic.com/jsonrpc.js" + +SUPPORT_UE_SMART_RADIO = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + +PLAYBACK_DICT = {"play": STATE_PLAYING, + "pause": STATE_PAUSED, + "stop": STATE_IDLE} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def send_request(payload, session): + """Send request to radio.""" + try: + request = requests.post(URL, + cookies={"sdi_squeezenetwork_session": + session}, + json=payload, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.error("Timed out when sending request") + except requests.exceptions.ConnectionError: + _LOGGER.error("An error occurred while connecting") + else: + return request.json() + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Logitech UE Smart Radio platform.""" + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + session_request = requests.post("https://www.uesmartradio.com/user/login", + data={"email": email, "password": + password}) + session = session_request.cookies["sdi_squeezenetwork_session"] + + player_request = send_request({"params": ["", ["serverstatus"]]}, session) + player_id = player_request["result"]["players_loop"][0]["playerid"] + player_name = player_request["result"]["players_loop"][0]["name"] + + add_devices([UERadioDevice(session, player_id, player_name)]) + + +class UERadioDevice(MediaPlayerDevice): + """Representation of a Logitech UE Smart Radio device.""" + + def __init__(self, session, player_id, player_name): + """Initialize the Logitech UE Smart Radio device.""" + self._session = session + self._player_id = player_id + self._name = player_name + self._state = None + self._volume = 0 + self._last_volume = 0 + self._media_title = None + self._media_artist = None + self._media_artwork_url = None + + def send_command(self, command): + """Send command to radio.""" + send_request({"method": "slim.request", "params": + [self._player_id, command]}, self._session) + + def update(self): + """Get the latest details from the device.""" + request = send_request({ + "method": "slim.request", "params": + [self._player_id, ["status", "-", 1, + "tags:cgABbehldiqtyrSuoKLN"]]}, self._session) + + if request["error"] is not None: + self._state = None + return + + if request["result"]["power"] == 0: + self._state = STATE_OFF + else: + self._state = PLAYBACK_DICT[request["result"]["mode"]] + + media_info = request["result"]["playlist_loop"][0] + + self._volume = request["result"]["mixer volume"] / 100 + self._media_artwork_url = media_info["artwork_url"] + self._media_title = media_info["title"] + if "artist" in media_info: + self._media_artist = media_info["artist"] + else: + self._media_artist = media_info.get("remote_title") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return True if self._volume <= 0 else False + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORT_UE_SMART_RADIO + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_image_url(self): + """Image URL of current playing media.""" + return self._media_artwork_url + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + def turn_on(self): + """Turn on specified media player or all.""" + self.send_command(["power", 1]) + + def turn_off(self): + """Turn off specified media player or all.""" + self.send_command(["power", 0]) + + def media_play(self): + """Send the media player the command for play/pause.""" + self.send_command(["play"]) + + def media_pause(self): + """Send the media player the command for pause.""" + self.send_command(["pause"]) + + def media_stop(self): + """Send the media player the stop command.""" + self.send_command(["stop"]) + + def media_previous_track(self): + """Send the media player the command for prev track.""" + self.send_command(["button", "rew"]) + + def media_next_track(self): + """Send the media player the command for next track.""" + self.send_command(["button", "fwd"]) + + def mute_volume(self, mute): + """Send mute command.""" + if mute: + self._last_volume = self._volume + self.send_command(["mixer", "volume", 0]) + else: + self.send_command(["mixer", "volume", self._last_volume * 100]) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.send_command(["mixer", "volume", volume * 100]) From cb4e886a4ff24d62d74ca444c8b14333667b1b55 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Dec 2017 13:14:16 -0800 Subject: [PATCH 55/97] Make notify.html5 depend on config (#11052) --- homeassistant/components/notify/html5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 2314722a2ab..fb3cf0bbecd 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -29,7 +29,7 @@ from homeassistant.util import ensure_unique_string REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] -DEPENDENCIES = ['frontend'] +DEPENDENCIES = ['frontend', 'config'] _LOGGER = logging.getLogger(__name__) From 4e91e6d103a2bc3922300737775f59749eecb263 Mon Sep 17 00:00:00 2001 From: tringler Date: Sun, 10 Dec 2017 01:58:52 +0100 Subject: [PATCH 56/97] This change fixes the error `OSError: [WinError 193]` on Windows debuggers (i.e. PyCharm) (#11034) --- homeassistant/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index a8852b910c2..b7301e13bea 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -230,7 +230,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if sys.argv[0].endswith(os.path.sep + '__main__.py'): + if os.path.basename(sys.argv[0]) == '__main__.py': modulepath = os.path.dirname(sys.argv[0]) os.environ['PYTHONPATH'] = os.path.dirname(modulepath) return [sys.executable] + [arg for arg in sys.argv if From 3b228c78c001c28f309e839ecdd9df6a0eb30957 Mon Sep 17 00:00:00 2001 From: perfalk <33936236+perfalk@users.noreply.github.com> Date: Sun, 10 Dec 2017 17:35:10 +0100 Subject: [PATCH 57/97] Added support for cover in tellstick (#10858) * Added support for cover in tellstick * Fixed comments from PR * Fixed comments from PR * Address comments --- homeassistant/components/cover/tellstick.py | 65 +++++++++++++++++++++ homeassistant/components/tellstick.py | 18 ++++-- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100755 homeassistant/components/cover/tellstick.py diff --git a/homeassistant/components/cover/tellstick.py b/homeassistant/components/cover/tellstick.py new file mode 100755 index 00000000000..56a5a24b409 --- /dev/null +++ b/homeassistant/components/cover/tellstick.py @@ -0,0 +1,65 @@ +""" +Support for Tellstick covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tellstick/ +""" + + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.tellstick import ( + DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, + DATA_TELLSTICK, TellstickDevice) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tellstick covers.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + signal_repetitions = discovery_info.get( + ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) + + add_devices([TellstickCover(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) + + +class TellstickCover(TellstickDevice, CoverDevice): + """Representation of a Tellstick cover.""" + + @property + def is_closed(self): + """Return the current position of the cover is not possible.""" + return None + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + + def close_cover(self, **kwargs): + """Close the cover.""" + self._tellcore_device.down() + + def open_cover(self, **kwargs): + """Open the cover.""" + self._tellcore_device.up() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._tellcore_device.stop() + + def _parse_tellcore_data(self, tellcore_data): + """Turn the value received from tellcore into something useful.""" + pass + + def _parse_ha_data(self, kwargs): + """Turn the value from HA into something useful.""" + pass + + def _update_model(self, new_state, data): + """Update the device entity state to match the arguments.""" + pass diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index bcef0d3fb85..9746dbf749f 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -67,7 +67,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" - from tellcore.constants import TELLSTICK_DIM + from tellcore.constants import (TELLSTICK_DIM, TELLSTICK_UP) from tellcore.telldus import AsyncioCallbackDispatcher from tellcore.telldus import TelldusCore from tellcorenet import TellCoreClient @@ -102,16 +102,22 @@ def setup(hass, config): hass.data[DATA_TELLSTICK] = {device.id: device for device in tellcore_devices} - # Discover the switches - _discover(hass, config, 'switch', - [device.id for device in tellcore_devices - if not device.methods(TELLSTICK_DIM)]) - # Discover the lights _discover(hass, config, 'light', [device.id for device in tellcore_devices if device.methods(TELLSTICK_DIM)]) + # Discover the cover + _discover(hass, config, 'cover', + [device.id for device in tellcore_devices + if device.methods(TELLSTICK_UP)]) + + # Discover the switches + _discover(hass, config, 'switch', + [device.id for device in tellcore_devices + if (not device.methods(TELLSTICK_UP) and + not device.methods(TELLSTICK_DIM))]) + @callback def async_handle_callback(tellcore_id, tellcore_command, tellcore_data, cid): From 04cb893d10c623d9d6d1864f677debbae4adeb68 Mon Sep 17 00:00:00 2001 From: maxlaverse Date: Sun, 10 Dec 2017 17:44:28 +0100 Subject: [PATCH 58/97] Add a caldav calendar component (#10842) * Add caldav component * Code review - 1 * Code review - 2 * Sort imports --- .coveragerc | 1 + homeassistant/components/calendar/caldav.py | 230 +++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/calendar/test_caldav.py | 302 ++++++++++++++++++++ 6 files changed, 540 insertions(+) create mode 100644 homeassistant/components/calendar/caldav.py create mode 100644 tests/components/calendar/test_caldav.py diff --git a/.coveragerc b/.coveragerc index c4b003708d5..b73d847f431 100644 --- a/.coveragerc +++ b/.coveragerc @@ -284,6 +284,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/caldav.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/canary.py diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py new file mode 100644 index 00000000000..1647b9522b8 --- /dev/null +++ b/homeassistant/components/calendar/caldav.py @@ -0,0 +1,230 @@ +""" +Support for WebDav Calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.caldav/ +""" +import logging +import re +from datetime import datetime, timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) +from homeassistant.util import dt, Throttle + +REQUIREMENTS = ['caldav==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' +CONF_CALENDARS = 'calendars' +CONF_CUSTOM_CALENDARS = 'custom_calendars' +CONF_CALENDAR = 'calendar' +CONF_ALL_DAY = 'all_day' +CONF_SEARCH = 'search' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): vol.Url, + vol.Optional(CONF_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + cv.string + ])), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_SEARCH): cv.string + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Set up the WebDav Calendar platform.""" + import caldav + + client = caldav.DAVClient(config.get(CONF_URL), + None, + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + # Retrieve all the remote calendars + calendars = client.principal().calendars() + + calendar_devices = [] + for calendar in list(calendars): + # If a calendar name was given in the configuration, + # ignore all the others + if (config.get(CONF_CALENDARS) + and calendar.name not in config.get(CONF_CALENDARS)): + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering + # rules + for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): + # Check that the base calendar matches + if cust_calendar.get(CONF_CALENDAR) != calendar.name: + continue + + device_data = { + CONF_NAME: cust_calendar.get(CONF_NAME), + CONF_DEVICE_ID: "{} {}".format( + cust_calendar.get(CONF_CALENDAR), + cust_calendar.get(CONF_NAME)), + } + + calendar_devices.append( + WebDavCalendarEventDevice(hass, + device_data, + calendar, + cust_calendar.get(CONF_ALL_DAY), + cust_calendar.get(CONF_SEARCH)) + ) + + # Create a default calendar if there was no custom one + if not config.get(CONF_CUSTOM_CALENDARS): + device_data = { + CONF_NAME: calendar.name, + CONF_DEVICE_ID: calendar.name + } + calendar_devices.append( + WebDavCalendarEventDevice(hass, device_data, calendar) + ) + + # Finally add all the calendars we've created + add_devices(calendar_devices) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, + hass, + device_data, + calendar, + all_day=False, + search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + super().__init__(hass, device_data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + return attributes + + +class WebDavCalendarData(object): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, calendar, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + self.calendar = calendar + self.include_all_day = include_all_day + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = self.calendar.date_search( + dt.start_of_local_day(), + dt.start_of_local_day() + timedelta(days=1) + ) + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + results.sort(key=lambda x: self.to_datetime( + x.instance.vevent.dtstart.value + )) + + vevent = next(( + event.instance.vevent for event in results + if (self.is_matching(event.instance.vevent, self.search) + and (not self.is_all_day(event.instance.vevent) + or self.include_all_day) + and not self.is_over(event.instance.vevent))), None) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(results), + self.calendar.name, + ) + self.event = None + return True + + # Populate the entity attributes with the event values + self.event = { + "summary": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(vevent.dtend.value), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description") + } + return True + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter critera.""" + if search is None: + return True + + pattern = re.compile(search) + return (hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value)) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt.now() > WebDavCalendarData.to_datetime(vevent.dtend.value) + + @staticmethod + def get_hass_date(obj): + """Return if the event matches.""" + if isinstance(obj, datetime): + return {"dateTime": obj.isoformat()} + + return {"date": obj.isoformat()} + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return obj + return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/requirements_all.txt b/requirements_all.txt index bde26c64846..f6655d06baa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,6 +160,9 @@ broadlink==0.5 # homeassistant.components.weather.buienradar buienradar==0.9 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c932ce7ead9..6e1b617ef66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,6 +36,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.3.0 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index bdc75f3a69c..0bfb5f9e607 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -38,6 +38,7 @@ TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'apns2', + 'caldav', 'coinmarketcap', 'defusedxml', 'dsmr_parser', diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py new file mode 100644 index 00000000000..8a44f96fe87 --- /dev/null +++ b/tests/components/calendar/test_caldav.py @@ -0,0 +1,302 @@ +"""The tests for the webdav calendar component.""" +# pylint: disable=protected-access +import datetime +import logging +import unittest +from unittest.mock import (patch, Mock, MagicMock) + +import homeassistant.components.calendar as calendar_base +import homeassistant.components.calendar.caldav as caldav +from caldav.objects import Event +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.util import dt +from tests.common import get_test_home_assistant + +TEST_PLATFORM = {calendar_base.DOMAIN: {CONF_PLATFORM: 'test'}} + +_LOGGER = logging.getLogger(__name__) + + +DEVICE_DATA = { + "name": "Private Calendar", + "device_id": "Private Calendar" +} + +EVENTS = [ + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:1 +DTSTAMP:20171125T000000Z +DTSTART:20171127T170000Z +DTEND:20171127T180000Z +SUMMARY:This is a normal event +LOCATION:Hamburg +DESCRIPTION:Surprisingly rainy +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Dynamics.//CalDAV Client//EN +BEGIN:VEVENT +UID:2 +DTSTAMP:20171125T000000Z +DTSTART:20171127T100000Z +DTEND:20171127T110000Z +SUMMARY:This is an offset event !!-02:00 +LOCATION:Hamburg +DESCRIPTION:Surprisingly shiny +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:3 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +DTEND:20171128 +SUMMARY:This is an all day event +LOCATION:Hamburg +DESCRIPTION:What a beautiful day +END:VEVENT +END:VCALENDAR +""" +] + + +def _local_datetime(hours, minutes): + """Build a datetime object for testing in the correct timezone.""" + return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) + + +def _mocked_dav_client(*args, **kwargs): + """Mock requests.get invocations.""" + calendars = [ + _mock_calendar("First"), + _mock_calendar("Second") + ] + principal = Mock() + principal.calendars = MagicMock(return_value=calendars) + + client = Mock() + client.principal = MagicMock(return_value=principal) + return client + + +def _mock_calendar(name): + events = [] + for idx, event in enumerate(EVENTS): + events.append(Event(None, "%d.ics" % idx, event, None, str(idx))) + + calendar = Mock() + calendar.date_search = MagicMock(return_value=events) + calendar.name = name + return calendar + + +class TestComponentsWebDavCalendar(unittest.TestCase): + """Test the WebDav calendar.""" + + hass = None # HomeAssistant + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calendar = _mock_calendar("Private") + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component(self, req_mock): + """Test setup component with calendars.""" + def _add_device(devices): + assert len(devices) == 2 + assert devices[0].name == "First" + assert devices[0].dev_id == "First" + assert devices[1].name == "Second" + assert devices[1].dev_id == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_no_calendar_matching(self, req_mock): + """Test setup component with wrong calendar.""" + def _add_device(devices): + assert not devices + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["none"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_a_calendar_match(self, req_mock): + """Test setup component with right calendar.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["Second"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_one_custom_calendar(self, req_mock): + """Test setup component with custom calendars.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "HomeOffice" + assert devices[0].dev_id == "Second HomeOffice" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [ + { + "name": "HomeOffice", + "calendar": "Second", + "filter": "HomeOffice" + }] + }, + _add_device) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_ongoing_event(self, mock_now): + """Test that the ongoing event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) + def test_ongoing_event_with_offset(self, mock_now): + """Test that the offset is taken into account.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.state, STATE_OFF) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an offset event", + "all_day": False, + "offset_reached": True, + "start_time": "2017-11-27 10:00:00", + "end_time": "2017-11-27 11:00:00", + "location": "Hamburg", + "description": "Surprisingly shiny" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter(self, mock_now): + """Test that the matching event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter_real_regexp(self, mock_now): + """Test that the event matching the regexp is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "^This.*event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00)) + def test_filter_matching_past_event(self, mock_now): + """Test that the matching past event is not returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_no_result_with_filtering(self, mock_now): + """Test that nothing is returned since nothing matches.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a non-existing event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_all_day_event_returned(self, mock_now): + """Test that the event lasting the whole day is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + True) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day" + }) From b2c5a9f5feea5a129dac2c5b05b4a2c11956dcc6 Mon Sep 17 00:00:00 2001 From: Adde Lovein Date: Sun, 10 Dec 2017 18:47:14 +0100 Subject: [PATCH 59/97] Add GPS coords to meraki (#10998) --- .../components/device_tracker/meraki.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py index 319c19d7b73..9437486a0aa 100644 --- a/homeassistant/components/device_tracker/meraki.py +++ b/homeassistant/components/device_tracker/meraki.py @@ -94,8 +94,26 @@ class MerakiView(HomeAssistantView): def _handle(self, hass, data): for i in data["data"]["observations"]: data["data"]["secret"] = "hidden" + + lat = i["location"]["lat"] + lng = i["location"]["lng"] + try: + accuracy = int(float(i["location"]["unc"])) + except ValueError: + accuracy = 0 + mac = i["clientMac"] _LOGGER.debug("clientMac: %s", mac) + + if lat == "NaN" or lng == "NaN": + _LOGGER.debug( + "No coordinates received, skipping location for: " + mac + ) + gps_location = None + accuracy = None + else: + gps_location = (lat, lng) + attrs = {} if i.get('os', False): attrs['os'] = i['os'] @@ -110,7 +128,9 @@ class MerakiView(HomeAssistantView): if i.get('ssid', False): attrs['ssid'] = i['ssid'] hass.async_add_job(self.async_see( + gps=gps_location, mac=mac, source_type=SOURCE_TYPE_ROUTER, + gps_accuracy=accuracy, attributes=attrs )) From 81974885ee49e4376272f8cee665831544275797 Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Sun, 10 Dec 2017 18:15:01 +0000 Subject: [PATCH 60/97] Refactor hue to split bridge support from light platform (#10691) * Introduce a new Hue component that knows how to talk to a Hue bridge, but doesn't actually set up lights. * Refactor the hue lights platform to use the HueBridge class from the hue component. * Reimplement support for multiple bridges * Auto discover bridges. * Provide some migration support by showing a persistent notification. * Address most feedback from code review. * Call load_platform from inside HueBridge.setup passing the bridge id. Not only this looks nicer, but it also nicely solves additional bridges being added after initial setup (e.g. pairing a second bridge should work now, I believe it required a restart before). * Add a unit test for hue_activate_scene * Address feedback from code review. * After feedback from @andrey-git I was able to find a way to not import phue in tests, yay! * Inject a mock phue in a couple of places --- homeassistant/components/discovery.py | 3 +- homeassistant/components/hue.py | 241 +++++++++++++ homeassistant/components/light/hue.py | 358 ++++++++----------- requirements_all.txt | 2 +- tests/components/light/test_hue.py | 479 ++++++++++++++++++++++++++ tests/components/test_hue.py | 402 +++++++++++++++++++++ 6 files changed, 1269 insertions(+), 216 deletions(-) create mode 100644 homeassistant/components/hue.py create mode 100644 tests/components/light/test_hue.py create mode 100644 tests/components/test_hue.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 5d362f21cef..dde33aa10a2 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -36,6 +36,7 @@ SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' +SERVICE_HUE = 'philips_hue' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -48,7 +49,7 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - 'philips_hue': ('light', 'hue'), + SERVICE_HUE: ('hue', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py new file mode 100644 index 00000000000..778dcc8dfab --- /dev/null +++ b/homeassistant/components/hue.py @@ -0,0 +1,241 @@ +""" +This component provides basic support for the Philips Hue system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hue/ +""" +import json +import logging +import os +import socket + +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_HUE +from homeassistant.config import load_yaml_config_file +from homeassistant.const import CONF_FILENAME, CONF_HOST +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ['phue==1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "hue" +SERVICE_HUE_SCENE = "hue_activate_scene" + +CONF_BRIDGES = "bridges" + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False + +PHUE_CONFIG_FILE = 'phue.conf' + +CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" +DEFAULT_ALLOW_IN_EMULATED_HUE = True + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True + +BRIDGE_CONFIG_SCHEMA = vol.Schema([{ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, + vol.Optional(CONF_ALLOW_UNREACHABLE, + default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, + vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, + default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, + vol.Optional(CONF_ALLOW_HUE_GROUPS, + default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, +}]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BRIDGES, default=[]): BRIDGE_CONFIG_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +SCENE_SCHEMA = vol.Schema({ + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, +}) + +CONFIG_INSTRUCTIONS = """ +Press the button on the bridge to register Philips Hue with Home Assistant. + +![Location of button on bridge](/static/images/config_philips_hue.jpg) +""" + + +def setup(hass, config): + """Set up the Hue platform.""" + config = config.get(DOMAIN) + if config is None: + config = {} + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + discovery.listen( + hass, + SERVICE_HUE, + lambda service, discovery_info: + bridge_discovered(hass, service, discovery_info)) + + bridges = config.get(CONF_BRIDGES, []) + for bridge in bridges: + filename = bridge.get(CONF_FILENAME) + allow_unreachable = bridge.get(CONF_ALLOW_UNREACHABLE) + allow_in_emulated_hue = bridge.get(CONF_ALLOW_IN_EMULATED_HUE) + allow_hue_groups = bridge.get(CONF_ALLOW_HUE_GROUPS) + + host = bridge.get(CONF_HOST) + + if host is None: + host = _find_host_from_config(hass, filename) + + if host is None: + _LOGGER.error("No host found in configuration") + return False + + setup_bridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + + return True + + +def bridge_discovered(hass, service, discovery_info): + """Dispatcher for Hue discovery events.""" + host = discovery_info.get('host') + serial = discovery_info.get('serial') + + filename = 'phue-{}.conf'.format(serial) + setup_bridge(host, hass, filename) + + +def setup_bridge(host, hass, filename=None, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Set up a given Hue bridge.""" + # Only register a device once + if socket.gethostbyname(host) in hass.data[DOMAIN]: + return + + bridge = HueBridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + bridge.setup() + + +def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): + """Attempt to detect host based on existing configuration.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as inp: + return next(iter(json.load(inp).keys())) + except (ValueError, AttributeError, StopIteration): + # ValueError if can't parse as JSON + # AttributeError if JSON value is not a dict + # StopIteration if no keys + return None + + +class HueBridge(object): + """Manages a single Hue bridge.""" + + def __init__(self, host, hass, filename, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Initialize the system.""" + self.host = host + self.hass = hass + self.filename = filename + self.allow_unreachable = allow_unreachable + self.allow_in_emulated_hue = allow_in_emulated_hue + self.allow_hue_groups = allow_hue_groups + + self.bridge = None + + self.configured = False + self.config_request_id = None + + hass.data[DOMAIN][socket.gethostbyname(host)] = self + + def setup(self): + """Set up a phue bridge based on host parameter.""" + import phue + + try: + self.bridge = phue.Bridge( + self.host, + config_file_path=self.hass.config.path(self.filename)) + except ConnectionRefusedError: # Wrong host was given + _LOGGER.error("Error connecting to the Hue bridge at %s", + self.host) + return + except phue.PhueRegistrationException: + _LOGGER.warning("Connected to Hue at %s but not registered.", + self.host) + self.request_configuration() + return + + # If we came here and configuring this host, mark as done + if self.config_request_id: + request_id = self.config_request_id + self.config_request_id = None + configurator = self.hass.components.configurator + configurator.request_done(request_id) + + self.configured = True + + discovery.load_platform( + self.hass, 'light', DOMAIN, + {'bridge_id': socket.gethostbyname(self.host)}) + + # create a service for calling run_scene directly on the bridge, + # used to simplify automation rules. + def hue_activate_scene(call): + """Service to call directly into bridge to set scenes.""" + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + self.bridge.run_scene(group_name, scene_name) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + self.hass.services.register( + DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, + descriptions.get(SERVICE_HUE_SCENE), + schema=SCENE_SCHEMA) + + def request_configuration(self): + """Request configuration steps from the user.""" + configurator = self.hass.components.configurator + + # We got an error if this method is called while we are configuring + if self.config_request_id: + configurator.notify_errors( + self.config_request_id, + "Failed to register, please try again.") + return + + self.config_request_id = configurator.request_config( + "Philips Hue", + lambda data: self.setup(), + description=CONFIG_INSTRUCTIONS, + entity_picture="/static/images/logo_philips_hue.png", + submit_caption="I have pressed the button" + ) + + def get_api(self): + """Return the full api dictionary from phue.""" + return self.bridge.get_api() + + def set_light(self, light_id, command): + """Adjust properties of one or more lights. See phue for details.""" + return self.bridge.set_light(light_id, command) + + def set_group(self, light_id, command): + """Change light settings for a group. See phue for detail.""" + return self.bridge.set_group(light_id, command) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index fe7dd765d01..a454143bcd2 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -1,19 +1,21 @@ """ -Support for Hue lights. +This component provides light support for the Philips Hue system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hue/ """ -import json -import logging -import os -import random -import socket from datetime import timedelta +import logging +import random +import re +import socket import voluptuous as vol +import homeassistant.components.hue as hue + import homeassistant.util as util +from homeassistant.util import yaml import homeassistant.util.color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, @@ -21,30 +23,21 @@ from homeassistant.components.light import ( FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file -from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) +from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE_HIDDEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['phue==1.0'] +DEPENDENCIES = ['hue'] -# Track previously setup bridges -_CONFIGURED_BRIDGES = {} -# Map ip to request id for configuring -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' - -DEFAULT_ALLOW_UNREACHABLE = False -DOMAIN = "light" -SERVICE_HUE_SCENE = "hue_activate_scene" +DATA_KEY = 'hue_lights' +DATA_LIGHTS = 'lights' +DATA_LIGHTGROUPS = 'lightgroups' MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -PHUE_CONFIG_FILE = 'phue.conf' - SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION) SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) @@ -60,10 +53,14 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" -DEFAULT_ALLOW_IN_EMULATED_HUE = True +ATTR_IS_HUE_GROUP = 'is_hue_group' -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +# Legacy configuration, will be removed in 0.60 +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False +CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue' +DEFAULT_ALLOW_IN_EMULATED_HUE = True +CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups' DEFAULT_ALLOW_HUE_GROUPS = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -75,236 +72,168 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, }) -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -SCENE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, -}) +MIGRATION_ID = 'light_hue_config_migration' +MIGRATION_TITLE = 'Philips Hue Configuration Migration' +MIGRATION_INSTRUCTIONS = """ +Configuration for the Philips Hue component has changed; action required. -ATTR_IS_HUE_GROUP = "is_hue_group" +You have configured at least one bridge: -CONFIG_INSTRUCTIONS = """ -Press the button on the bridge to register Philips Hue with Home Assistant. + hue: +{config} -![Location of button on bridge](/static/images/config_philips_hue.jpg) +This configuration is deprecated, please check the +[Hue component](https://home-assistant.io/components/hue/) page for more +information. """ -def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): - """Attempt to detect host based on existing configuration.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - try: - with open(path) as inp: - return next(json.loads(''.join(inp)).keys().__iter__()) - except (ValueError, AttributeError, StopIteration): - # ValueError if can't parse as JSON - # AttributeError if JSON value is not a dict - # StopIteration if no keys - return None - - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Hue lights.""" - # Default needed in case of discovery - filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE) - allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE) - allow_in_emulated_hue = config.get(CONF_ALLOW_IN_EMULATED_HUE, - DEFAULT_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = config.get(CONF_ALLOW_HUE_GROUPS) - - if discovery_info is not None: - if "HASS Bridge" in discovery_info.get('name', ''): - _LOGGER.info("Emulated hue found, will not add") - return False - - host = discovery_info.get('host') - else: - host = config.get(CONF_HOST, None) - - if host is None: - host = _find_host_from_config(hass, filename) - - if host is None: - _LOGGER.error("No host found in configuration") - return False - - # Only act if we are not already configuring this host - if host in _CONFIGURING or \ - socket.gethostbyname(host) in _CONFIGURED_BRIDGES: + if discovery_info is None or 'bridge_id' not in discovery_info: return - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + setup_data(hass) + + if config is not None and len(config) > 0: + # Legacy configuration, will be removed in 0.60 + config_str = yaml.dump([config]) + # Indent so it renders in a fixed-width font + config_str = re.sub('(?m)^', ' ', config_str) + hass.components.persistent_notification.async_create( + MIGRATION_INSTRUCTIONS.format(config=config_str), + title=MIGRATION_TITLE, + notification_id=MIGRATION_ID) + + bridge_id = discovery_info['bridge_id'] + bridge = hass.data[hue.DOMAIN][bridge_id] + unthrottled_update_lights(hass, bridge, add_devices) -def setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups): - """Set up a phue bridge based on host parameter.""" +def setup_data(hass): + """Initialize internal data. Useful from tests.""" + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {DATA_LIGHTS: {}, DATA_LIGHTGROUPS: {}} + + +@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) +def update_lights(hass, bridge, add_devices): + """Update the Hue light objects with latest info from the bridge.""" + return unthrottled_update_lights(hass, bridge, add_devices) + + +def unthrottled_update_lights(hass, bridge, add_devices): + """Internal version of update_lights.""" import phue + if not bridge.configured: + return + try: - bridge = phue.Bridge( - host, - config_file_path=hass.config.path(filename)) - except ConnectionRefusedError: # Wrong host was given - _LOGGER.error("Error connecting to the Hue bridge at %s", host) - + api = bridge.get_api() + except phue.PhueRequestTimeout: + _LOGGER.warning('Timeout trying to reach the bridge') + return + except ConnectionRefusedError: + _LOGGER.error('The bridge refused the connection') + return + except socket.error: + # socket.error when we cannot reach Hue + _LOGGER.exception('Cannot reach the bridge') return - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", host) + bridge_type = get_bridge_type(api) - request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups) + new_lights = process_lights( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + if bridge.allow_hue_groups: + new_lightgroups = process_groups( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + new_lights.extend(new_lightgroups) - return + if new_lights: + add_devices(new_lights) - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - lights = {} - lightgroups = {} - skip_groups = not allow_hue_groups +def get_bridge_type(api): + """Return the bridge type.""" + api_name = api.get('config').get('name') + if api_name in ('RaspBee-GW', 'deCONZ-GW'): + return 'deconz' + else: + return 'hue' - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the Hue light objects with latest info from the bridge.""" - nonlocal skip_groups - try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") - return +def process_lights(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all lights.""" + api_lights = api.get('lights') - api_lights = api.get('lights') + if not isinstance(api_lights, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + new_lights = [] - if skip_groups: - api_groups = {} + lights = hass.data[DATA_KEY][DATA_LIGHTS] + for light_id, info in api_lights.items(): + if light_id not in lights: + lights[light_id] = HueLight( + int(light_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue) + new_lights.append(lights[light_id]) else: - api_groups = api.get('groups') + lights[light_id].info = info + lights[light_id].schedule_update_ha_state() - if not isinstance(api_groups, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + return new_lights - new_lights = [] - api_name = api.get('config').get('name') - if api_name in ('RaspBee-GW', 'deCONZ-GW'): - bridge_type = 'deconz' +def process_groups(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all groups.""" + api_groups = api.get('groups') + + if not isinstance(api_groups, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] + + new_lights = [] + + groups = hass.data[DATA_KEY][DATA_LIGHTGROUPS] + for lightgroup_id, info in api_groups.items(): + if 'state' not in info: + _LOGGER.warning('Group info does not contain state. ' + 'Please update your hub.') + return [] + + if lightgroup_id not in groups: + groups[lightgroup_id] = HueLight( + int(lightgroup_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue, True) + new_lights.append(groups[lightgroup_id]) else: - bridge_type = 'hue' + groups[lightgroup_id].info = info + groups[lightgroup_id].schedule_update_ha_state() - for light_id, info in api_lights.items(): - if light_id not in lights: - lights[light_id] = HueLight(int(light_id), info, - bridge, update_lights, - bridge_type, allow_unreachable, - allow_in_emulated_hue) - new_lights.append(lights[light_id]) - else: - lights[light_id].info = info - lights[light_id].schedule_update_ha_state() - - for lightgroup_id, info in api_groups.items(): - if 'state' not in info: - _LOGGER.warning("Group info does not contain state. " - "Please update your hub.") - skip_groups = True - break - - if lightgroup_id not in lightgroups: - lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, update_lights, - bridge_type, allow_unreachable, allow_in_emulated_hue, - True) - new_lights.append(lightgroups[lightgroup_id]) - else: - lightgroups[lightgroup_id].info = info - lightgroups[lightgroup_id].schedule_update_ha_state() - - if new_lights: - add_devices(new_lights) - - _CONFIGURED_BRIDGES[socket.gethostbyname(host)] = True - - # create a service for calling run_scene directly on the bridge, - # used to simplify automation rules. - def hue_activate_scene(call): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - bridge.run_scene(group_name, scene_name) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - descriptions.get(SERVICE_HUE_SCENE), - schema=SCENE_SCHEMA) - - update_lights() - - -def request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[host], "Failed to register, please try again.") - - return - - # pylint: disable=unused-argument - def hue_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) - - _CONFIGURING[host] = configurator.request_config( - "Philips Hue", hue_configuration_callback, - description=CONFIG_INSTRUCTIONS, - entity_picture="/static/images/logo_philips_hue.png", - submit_caption="I have pressed the button" - ) + return new_lights class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights, + def __init__(self, light_id, info, bridge, update_lights_cb, bridge_type, allow_unreachable, allow_in_emulated_hue, is_group=False): """Initialize the light.""" self.light_id = light_id self.info = info self.bridge = bridge - self.update_lights = update_lights + self.update_lights = update_lights_cb self.bridge_type = bridge_type self.allow_unreachable = allow_unreachable self.is_group = is_group @@ -381,14 +310,15 @@ class HueLight(Light): command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": - hue, sat = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - command['hue'] = hue + if self.info.get('manufacturername') == 'OSRAM': + color_hue, sat = color_util.color_xy_to_hs( + *kwargs[ATTR_XY_COLOR]) + command['hue'] = color_hue command['sat'] = sat else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": + if self.info.get('manufacturername') == 'OSRAM': hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['hue'] = hsv[0] diff --git a/requirements_all.txt b/requirements_all.txt index f6655d06baa..7349d7dbd35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -533,7 +533,7 @@ pdunehd==1.3 # homeassistant.components.media_player.pandora pexpect==4.0.1 -# homeassistant.components.light.hue +# homeassistant.components.hue phue==1.0 # homeassistant.components.rpi_pfio diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py new file mode 100644 index 00000000000..5e5bd4f6c7f --- /dev/null +++ b/tests/components/light/test_hue.py @@ -0,0 +1,479 @@ +"""Philips Hue lights platform tests.""" + +import logging +import unittest +import unittest.mock as mock +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import hue +import homeassistant.components.light.hue as hue_light + +from tests.common import get_test_home_assistant, MockDependency + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue light platform.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def setup_mocks_for_update_lights(self): + """Set up all mocks for update_lights tests.""" + self.mock_bridge = MagicMock() + self.mock_bridge.allow_hue_groups = False + self.mock_api = MagicMock() + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + self.mock_lights = [] + self.mock_groups = [] + self.mock_add_devices = MagicMock() + hue_light.setup_data(self.hass) + + def setup_mocks_for_process_lights(self): + """Set up all mocks for process_lights tests.""" + self.mock_bridge = MagicMock() + self.mock_api = MagicMock() + self.mock_api.get.return_value = {} + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + hue_light.setup_data(self.hass) + + def setup_mocks_for_process_groups(self): + """Set up all mocks for process_groups tests.""" + self.mock_bridge = MagicMock() + self.mock_bridge.get_group.return_value = { + 'name': 'Group 0', 'state': {'any_on': True}} + self.mock_api = MagicMock() + self.mock_api.get.return_value = {} + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + hue_light.setup_data(self.hass) + + def test_setup_platform_no_discovery_info(self): + """Test setup_platform without discovery info.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices) + + mock_add_devices.assert_not_called() + + def test_setup_platform_no_bridge_id(self): + """Test setup_platform without a bridge.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices, {}) + + mock_add_devices.assert_not_called() + + def test_setup_platform_one_bridge(self): + """Test setup_platform with one bridge.""" + mock_bridge = MagicMock() + self.hass.data[hue.DOMAIN] = {'10.0.0.1': mock_bridge} + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + mock_update_lights.assert_called_once_with( + self.hass, mock_bridge, mock_add_devices) + + def test_setup_platform_multiple_bridges(self): + """Test setup_platform wuth multiple bridges.""" + mock_bridge = MagicMock() + mock_bridge2 = MagicMock() + self.hass.data[hue.DOMAIN] = { + '10.0.0.1': mock_bridge, + '192.168.0.10': mock_bridge2, + } + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '192.168.0.10'}) + + mock_update_lights.assert_has_calls([ + call(self.hass, mock_bridge, mock_add_devices), + call(self.hass, mock_bridge2, mock_add_devices), + ]) + + @MockDependency('phue') + def test_update_lights_with_no_lights(self, mock_phue): + """Test the update_lights function when no lights are found.""" + self.setup_mocks_for_update_lights() + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=[]) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_not_called() + + @MockDependency('phue') + def test_update_lights_with_some_lights(self, mock_phue): + """Test the update_lights function with some lights.""" + self.setup_mocks_for_update_lights() + self.mock_lights = ['some', 'light'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_no_groups(self, mock_phue): + """Test the update_lights function when no groups are found.""" + self.setup_mocks_for_update_lights() + self.mock_bridge.allow_hue_groups = True + self.mock_lights = ['some', 'light'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_with_lights_and_groups(self, mock_phue): + """Test the update_lights function with both lights and groups.""" + self.setup_mocks_for_update_lights() + self.mock_bridge.allow_hue_groups = True + self.mock_lights = ['some', 'light'] + self.mock_groups = ['and', 'groups'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + def test_process_lights_api_error(self): + """Test the process_lights function when the bridge errors out.""" + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = None + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + + def test_process_lights_no_lights(self): + """Test the process_lights function when bridge returns no lights.""" + self.setup_mocks_for_process_lights() + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_some_lights(self, mock_hue_light): + """Test the process_lights function with multiple groups.""" + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + self.assertEquals( + len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), + 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_new_light(self, mock_hue_light): + """ + Test the process_lights function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTS][1] = MagicMock() + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS][ + 1].schedule_update_ha_state.assert_called_once_with() + self.assertEquals( + len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), + 2) + + def test_process_groups_api_error(self): + """Test the process_groups function when the bridge errors out.""" + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = None + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + + def test_process_groups_no_state(self): + """Test the process_groups function when bridge returns no status.""" + self.setup_mocks_for_process_groups() + self.mock_bridge.get_group.return_value = {'name': 'Group 0'} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_some_groups(self, mock_hue_light): + """Test the process_groups function with multiple groups.""" + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + self.assertEquals( + len(self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), + 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_new_group(self, mock_hue_light): + """ + Test the process_groups function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS][1] = MagicMock() + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS][ + 1].schedule_update_ha_state.assert_called_once_with() + self.assertEquals( + len(self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), + 2) + + +class TestHueLight(unittest.TestCase): + """Test the HueLight class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + self.light_id = 42 + self.mock_info = MagicMock() + self.mock_bridge = MagicMock() + self.mock_update_lights = MagicMock() + self.mock_bridge_type = MagicMock() + self.mock_allow_unreachable = MagicMock() + self.mock_is_group = MagicMock() + self.mock_allow_in_emulated_hue = MagicMock() + self.mock_is_group = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def buildLight( + self, light_id=None, info=None, update_lights=None, is_group=None): + """Helper to build a HueLight object with minimal fuss.""" + return hue_light.HueLight( + light_id if light_id is not None else self.light_id, + info if info is not None else self.mock_info, + self.mock_bridge, + (update_lights + if update_lights is not None + else self.mock_update_lights), + self.mock_bridge_type, + self.mock_allow_unreachable, self.mock_allow_in_emulated_hue, + is_group if is_group is not None else self.mock_is_group) + + def test_unique_id_for_light(self): + """Test the unique_id method with lights.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}) + self.assertEquals( + class_name+'.Unnamed Device.Light.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}) + self.assertEquals( + class_name+'.my-name.Light.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight(info={'name': 'a name', 'type': 'my-type'}) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) + + def test_unique_id_for_group(self): + """Test the unique_id method with groups.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}, is_group=True) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.Group.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}, is_group=True) + self.assertEquals( + class_name+'.my-name.Group.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight( + info={'name': 'a name', 'type': 'my-type'}, + is_group=True) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py new file mode 100644 index 00000000000..227295594db --- /dev/null +++ b/tests/components/test_hue.py @@ -0,0 +1,402 @@ +"""Generic Philips Hue component tests.""" + +import logging +import unittest +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import configurator, hue +from homeassistant.const import CONF_FILENAME, CONF_HOST +from homeassistant.setup import setup_component + +from tests.common import ( + assert_setup_component, get_test_home_assistant, get_test_config_dir, + MockDependency +) + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_no_domain(self, mock_phue): + """If it's not in the config we won't even try.""" + with assert_setup_component(0): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_no_host(self, mock_phue): + """No host specified in any way.""" + with assert_setup_component(1): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {hue.DOMAIN: {}})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_with_host(self, mock_phue): + """Host specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_phue_conf(self, mock_phue): + """No host in the config file, but one is cached in phue.conf.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch( + 'homeassistant.components.hue._find_host_from_config', + return_value='localhost'): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_FILENAME: 'phue.conf'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_multiple_hosts(self, mock_phue): + """Multiple hosts specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}, + {CONF_HOST: '192.168.0.1'}]}})) + + mock_bridge.assert_has_calls([ + call( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)), + call( + '192.168.0.1', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE))]) + mock_load.mock_bridge.assert_not_called() + mock_load.assert_has_calls([ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}), + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.1'}), + ], any_order=True) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(2, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_discovered(self, mock_phue): + """Bridge discovery.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.0.10', 'serial': 'foobar'} + + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + mock_bridge.assert_called_once_with( + '192.168.0.10', + config_file_path=get_test_config_dir('phue-foobar.conf')) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.10'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_configure_and_discovered(self, mock_phue): + """Bridge is in the config file, then we discover it.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.1.10', 'serial': 'foobar'} + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + # First we set up the component from config + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: '192.168.1.10'}]}})) + + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + calls_to_mock_load = [ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.1.10'}), + ] + mock_load.assert_has_calls(calls_to_mock_load) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + # Then we discover the same bridge + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + # No additional calls + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_has_calls(calls_to_mock_load) + + # Still only one + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + +class TestHueBridge(unittest.TestCase): + """Test the HueBridge class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.data[hue.DOMAIN] = {} + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_bridge_connection_refused(self, mock_phue): + """Test a registration failed with a connection refused exception.""" + mock_bridge = mock_phue.Bridge + mock_bridge.side_effect = ConnectionRefusedError() + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_exception(self, mock_phue): + """Test a registration failed with an exception.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + self.assertTrue(isinstance(bridge.config_request_id, str)) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_succeeds(self, mock_phue): + """Test a registration success sequence.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, registration is done + None, + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertTrue(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configured', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_fails(self, mock_phue): + """ + Test a registration failure sequence. + + This may happen when we start the registration process, the user + responds to the request but the bridge has become unreachable. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, the bridge has gone away + ConnectionRefusedError(), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # The request should still be pending + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_retry(self, mock_phue): + """ + Test a registration retry sequence. + + This may happen when we start the registration process, the user + responds to the request but we fail to confirm it with the bridge. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, for whatever reason authentication fails + mock_phue.PhueRegistrationException(1, 2), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + self.assertEqual( + 'Failed to register, please try again.', + self.hass.states.all()[0].attributes.get(configurator.ATTR_ERRORS)) + + @MockDependency('phue') + def test_hue_activate_scene(self, mock_phue): + """Test the hue_activate_scene service.""" + with patch('homeassistant.helpers.discovery.load_platform'): + bridge = hue.HueBridge('localhost', self.hass, + hue.PHUE_CONFIG_FILE) + bridge.setup() + + # No args + self.hass.services.call(hue.DOMAIN, hue.SERVICE_HUE_SCENE, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Only one arg + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Both required args + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group', hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_called_once_with('group', 'scene') From b078f6c3422ee36692a6cd047c6d92c35119a300 Mon Sep 17 00:00:00 2001 From: uchagani Date: Sun, 10 Dec 2017 16:02:12 -0500 Subject: [PATCH 61/97] add custom bypass status to total connect (#11042) * add custom bypass status to total connect * remove logger line --- .../components/alarm_control_panel/totalconnect.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 6f22d6a358c..5c1323989d4 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -14,7 +14,9 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME, + STATE_ALARM_ARMED_CUSTOM_BYPASS) + REQUIREMENTS = ['total_connect_client==0.16'] @@ -76,6 +78,8 @@ class TotalConnect(alarm.AlarmControlPanel): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS elif status == self._client.ARMING: state = STATE_ALARM_ARMING elif status == self._client.DISARMING: From a4214afddbc10730c4fcbd33815a1b533b3ca359 Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Sun, 10 Dec 2017 22:57:44 +0100 Subject: [PATCH 62/97] Volvo on call: Optional use of Scandinavian miles. Also add average fuel consumption property (#11051) --- .../components/sensor/volvooncall.py | 33 ++++++++++++++++--- homeassistant/components/volvooncall.py | 10 ++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 622261941d6..32b228ca1f9 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,8 +6,10 @@ https://home-assistant.io/components/sensor.volvooncall/ """ import logging +from math import floor -from homeassistant.components.volvooncall import VolvoEntity, RESOURCES +from homeassistant.components.volvooncall import ( + VolvoEntity, RESOURCES, CONF_SCANDINAVIAN_MILES) _LOGGER = logging.getLogger(__name__) @@ -26,14 +28,37 @@ class VolvoSensor(VolvoEntity): def state(self): """Return the state of the sensor.""" val = getattr(self.vehicle, self._attribute) + + if val is None: + return val + if self._attribute == 'odometer': - return round(val / 1000) # km - return val + val /= 1000 # m -> km + + if 'mil' in self.unit_of_measurement: + val /= 10 # km -> mil + + if self._attribute == 'average_fuel_consumption': + val /= 10 # L/1000km -> L/100km + if 'mil' in self.unit_of_measurement: + return round(val, 2) + else: + return round(val, 1) + elif self._attribute == 'distance_to_empty': + return int(floor(val)) + else: + return int(round(val)) @property def unit_of_measurement(self): """Return the unit of measurement.""" - return RESOURCES[self._attribute][3] + unit = RESOURCES[self._attribute][3] + if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: + if self._attribute == 'average_fuel_consumption': + return 'L/mil' + else: + return unit.replace('km', 'mil') + return unit @property def icon(self): diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 4cee6ea2139..dcd4ed518d0 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -26,11 +26,13 @@ REQUIREMENTS = ['volvooncall==0.4.0'] _LOGGER = logging.getLogger(__name__) -CONF_UPDATE_INTERVAL = 'update_interval' MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) + +CONF_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' +CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) @@ -41,6 +43,8 @@ RESOURCES = {'position': ('device_tracker',), 'fuel_amount': ('sensor', 'Fuel amount', 'mdi:gas-station', 'L'), 'fuel_amount_level': ( 'sensor', 'Fuel level', 'mdi:water-percent', '%'), + 'average_fuel_consumption': ( + 'sensor', 'Fuel consumption', 'mdi:gas-station', 'L/100 km'), 'distance_to_empty': ('sensor', 'Range', 'mdi:ruler', 'km'), 'washer_fluid_level': ('binary_sensor', 'Washer fluid'), 'brake_fluid': ('binary_sensor', 'Brake Fluid'), @@ -61,6 +65,7 @@ CONFIG_SCHEMA = vol.Schema({ cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -123,7 +128,8 @@ class VolvoData: """Initialize the component state.""" self.entities = {} self.vehicles = {} - self.names = config[DOMAIN].get(CONF_NAME) + self.config = config[DOMAIN] + self.names = self.config.get(CONF_NAME) def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" From 7259cc878e9ab4643c7a6e3d23b0dd478511fac8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 11 Dec 2017 11:34:48 +0100 Subject: [PATCH 63/97] Allow tradfri to read the available state of the device (#11056) * Allow tradfri to read the available state of the device * Update tradfri.py --- homeassistant/components/light/tradfri.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index dc8e7f4c996..bb2fa44c15c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -160,6 +160,7 @@ class TradfriLight(Light): self._rgb_color = None self._features = SUPPORTED_FEATURES self._temp_supported = False + self._available = True self._refresh(light) @@ -196,6 +197,11 @@ class TradfriLight(Light): """Start thread when added to hass.""" self._async_start_observe() + @property + def available(self): + """Return True if entity is available.""" + return self._available + @property def should_poll(self): """No polling needed for tradfri light.""" @@ -299,6 +305,7 @@ class TradfriLight(Light): self._light = light # Caching of LightControl and light object + self._available = light.reachable self._light_control = light.light_control self._light_data = light.light_control.lights[0] self._name = light.name From 7777d5811f4d4e22ffc735718e7432ec1e97d4e3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:50:55 +0100 Subject: [PATCH 64/97] Upgrade aiohttp to 2.3.6 (#11079) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e7acb212e2..aac86d17a5b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.5 +aiohttp==2.3.6 yarl==0.15.0 async_timeout==2.0.0 chardet==3.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 7349d7dbd35..49bf3338fec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.5 +aiohttp==2.3.6 yarl==0.15.0 async_timeout==2.0.0 chardet==3.0.4 diff --git a/setup.py b/setup.py index d79f11732ad..49e3358cb72 100755 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.3.5', # If updated, check if yarl also needs an update! + 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! 'yarl==0.15.0', 'async_timeout==2.0.0', 'chardet==3.0.4', From 02451896705d9b65cce9d0b75b00920d4477929d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:52:22 +0100 Subject: [PATCH 65/97] Upgrade yarl to 0.16.0 (#11078) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aac86d17a5b..3080160dfce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.3.6 -yarl==0.15.0 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 49bf3338fec..70136737aea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.3.6 -yarl==0.15.0 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 49e3358cb72..fe60a15e32e 100755 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! - 'yarl==0.15.0', + 'yarl==0.16.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', From 0cfff13be1d2f1cc8de964530c2643ade061eaba Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:52:43 +0100 Subject: [PATCH 66/97] Upgrade psutil to 5.4.2 (#11083) --- homeassistant/components/sensor/systemmonitor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 324d3029c99..8e6f7b404fd 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.1'] +REQUIREMENTS = ['psutil==5.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 70136737aea..663b3311088 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -569,7 +569,7 @@ proliphix==0.4.1 prometheus_client==0.0.21 # homeassistant.components.sensor.systemmonitor -psutil==5.4.1 +psutil==5.4.2 # homeassistant.components.wink pubnubsub-handler==1.0.2 From c461a7c7e2d3824f5a25204b4c41abc7735e4816 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 13:53:01 +0100 Subject: [PATCH 67/97] Upgrade youtube_dl to 2017.12.10 (#11080) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 9d5e88282ae..669390b3b90 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.11.26'] +REQUIREMENTS = ['youtube_dl==2017.12.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 663b3311088..b606131d0ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1184,7 +1184,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.11.26 +youtube_dl==2017.12.10 # homeassistant.components.light.zengge zengge==0.2 From 1b3963299d65fb53e17b940bf7b2bd735c2f5971 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 11 Dec 2017 16:44:15 +0100 Subject: [PATCH 68/97] Upgrade shodan to 1.7.7 (#11084) --- homeassistant/components/sensor/shodan.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 3d86d940f4d..720158e1029 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.5'] +REQUIREMENTS = ['shodan==1.7.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b606131d0ad..5cddd076014 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1016,7 +1016,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.5 +shodan==1.7.7 # homeassistant.components.notify.simplepush simplepush==1.1.4 From 6bf23f9167ae025d8efaee92a3ab22b1d29c582b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 11 Dec 2017 18:32:48 +0100 Subject: [PATCH 69/97] Update tellcore-net to 0.4 (#11087) * Update tellcore-net to 0.4 * Update requirements_all.txt --- homeassistant/components/tellstick.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index 9746dbf749f..0eef2c4ece1 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.3'] +REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5cddd076014..1b606774859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1075,7 +1075,7 @@ tank_utility==1.4.0 tapsaff==0.1.3 # homeassistant.components.tellstick -tellcore-net==0.3 +tellcore-net==0.4 # homeassistant.components.tellstick tellcore-py==1.1.2 From a79c7ee217af548af6da943dd0e597d6c966268a Mon Sep 17 00:00:00 2001 From: Jan Almeroth Date: Mon, 11 Dec 2017 22:29:52 +0100 Subject: [PATCH 70/97] Bump pymusiccast to version 0.1.6 (#11091) --- homeassistant/components/media_player/yamaha_musiccast.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index bfcffff6bb4..b42a5ae474c 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -36,7 +36,7 @@ SUPPORTED_FEATURES = ( KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.5'] +REQUIREMENTS = ['pymusiccast==0.1.6'] DEFAULT_PORT = 5005 DEFAULT_INTERVAL = 480 diff --git a/requirements_all.txt b/requirements_all.txt index 1b606774859..fc8a4037b85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ pymodbus==1.3.1 pymonoprice==0.2 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.5 +pymusiccast==0.1.6 # homeassistant.components.cover.myq pymyq==0.0.8 From ed06b8cead707c6ee4d4aa8f7a8cdc38898b6550 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 12 Dec 2017 08:09:47 +0100 Subject: [PATCH 71/97] Use luftdaten module (#10970) * Use luftdaten module * Refactoring * Check meta data * Make name consistent * Remove try block --- homeassistant/components/sensor/luftdaten.py | 121 ++++++++++--------- requirements_all.txt | 3 + 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index e317e89030f..8c5fcc15ec2 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -5,85 +5,94 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ import asyncio -import json -import logging from datetime import timedelta +import logging -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS, - TEMP_CELSIUS) -from homeassistant.helpers.entity import Entity + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['luftdaten==0.1.1'] _LOGGER = logging.getLogger(__name__) +ATTR_SENSOR_ID = 'sensor_id' + +CONF_ATTRIBUTION = "Data provided by luftdaten.info" + + VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' SENSOR_TEMPERATURE = 'temperature' SENSOR_HUMIDITY = 'humidity' SENSOR_PM10 = 'P1' SENSOR_PM2_5 = 'P2' +SENSOR_PRESSURE = 'pressure' SENSOR_TYPES = { SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], SENSOR_HUMIDITY: ['Humidity', '%'], + SENSOR_PRESSURE: ['Pressure', 'Pa'], SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] } -DEFAULT_NAME = 'Luftdaten Sensor' -DEFAULT_RESOURCE = 'https://api.luftdaten.info/v1/sensor/' -DEFAULT_VERIFY_SSL = True +DEFAULT_NAME = 'Luftdaten' CONF_SENSORID = 'sensorid' -SCAN_INTERVAL = timedelta(minutes=3) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORID): cv.positive_int, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Luftdaten sensor.""" + from luftdaten import Luftdaten + name = config.get(CONF_NAME) - sensorid = config.get(CONF_SENSORID) - verify_ssl = config.get(CONF_VERIFY_SSL) + sensor_id = config.get(CONF_SENSORID) - resource = '{}{}/'.format(config.get(CONF_RESOURCE), sensorid) + session = async_get_clientsession(hass) + luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) - rest_client = LuftdatenData(resource, verify_ssl) - rest_client.update() + yield from luftdaten.async_update() - if rest_client.data is None: - _LOGGER.error("Unable to fetch Luftdaten data") - return False + if luftdaten.data is None: + _LOGGER.error("Sensor is not available: %s", sensor_id) + return devices = [] for variable in config[CONF_MONITORED_CONDITIONS]: - devices.append(LuftdatenSensor(rest_client, name, variable)) + if luftdaten.data.values[variable] is None: + _LOGGER.warning("It might be that sensor %s is not providing " + "measurements for %s", sensor_id, variable) + devices.append(LuftdatenSensor(luftdaten, name, variable, sensor_id)) - async_add_devices(devices, True) + async_add_devices(devices) class LuftdatenSensor(Entity): - """Implementation of a LuftdatenSensor sensor.""" + """Implementation of a Luftdaten sensor.""" - def __init__(self, rest_client, name, sensor_type): - """Initialize the LuftdatenSensor sensor.""" - self.rest_client = rest_client + def __init__(self, luftdaten, name, sensor_type, sensor_id): + """Initialize the Luftdaten sensor.""" + self.luftdaten = luftdaten self._name = name self._state = None + self._sensor_id = sensor_id self.sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -95,48 +104,50 @@ class LuftdatenSensor(Entity): @property def state(self): """Return the state of the device.""" - return self._state + return self.luftdaten.data.values[self.sensor_type] @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - def update(self): - """Get the latest data from REST API and update the state.""" - self.rest_client.update() - value = self.rest_client.data + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.luftdaten.data.meta is None: + return - if value is None: - self._state = None - else: - parsed_json = json.loads(value) + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_SENSOR_ID: self._sensor_id, + 'lat': self.luftdaten.data.meta['latitude'], + 'long': self.luftdaten.data.meta['longitude'], + } + return attr - log_entries_count = len(parsed_json) - 1 - latest_log_entry = parsed_json[log_entries_count] - sensordata_values = latest_log_entry['sensordatavalues'] - for sensordata_value in sensordata_values: - if sensordata_value['value_type'] == self.sensor_type: - self._state = sensordata_value['value'] + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info and update the state.""" + try: + yield from self.luftdaten.async_update() + except TypeError: + pass class LuftdatenData(object): """Class for handling the data retrieval.""" - def __init__(self, resource, verify_ssl): + def __init__(self, data): """Initialize the data object.""" - self._request = requests.Request('GET', resource).prepare() - self._verify_ssl = verify_ssl - self.data = None + self.data = data + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info.""" + from luftdaten.exceptions import LuftdatenError - def update(self): - """Get the latest data from Luftdaten service.""" try: - with requests.Session() as sess: - response = sess.send( - self._request, timeout=10, verify=self._verify_ssl) - - self.data = response.text - except requests.exceptions.RequestException: - _LOGGER.error("Error fetching data: %s", self._request) - self.data = None + yield from self.data.async_get_data() + except LuftdatenError: + _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/requirements_all.txt b/requirements_all.txt index fc8a4037b85..a5d2936ec36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -444,6 +444,9 @@ liveboxplaytv==2.0.0 # homeassistant.components.notify.lametric lmnotify==0.0.4 +# homeassistant.components.sensor.luftdaten +luftdaten==0.1.1 + # homeassistant.components.sensor.lyft lyft_rides==0.2 From c7e327ea87d231c50a9e03837377ef581c8b5d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 12 Dec 2017 16:52:39 +0100 Subject: [PATCH 72/97] Bump pyatv to 0.3.9 (#11104) --- homeassistant/components/apple_tv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index c8eb1841c0d..bb6bfa0e9db 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.8'] +REQUIREMENTS = ['pyatv==0.3.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a5d2936ec36..8f40709d187 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -631,7 +631,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.8 +pyatv==0.3.9 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From aeba81e193bb3d6b2f5431d286c9d0bb746698ed Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Tue, 12 Dec 2017 16:18:46 +0000 Subject: [PATCH 73/97] Report availability for TP-Link smart bulbs (#10976) --- homeassistant/components/light/tplink.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index f6a544950c0..692a5fb86ec 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -72,6 +72,7 @@ class TPLinkSmartBulb(Light): if name is not None: self._name = name self._state = None + self._available = True self._color_temp = None self._brightness = None self._rgb = None @@ -83,6 +84,11 @@ class TPLinkSmartBulb(Light): """Return the name of the Smart Bulb, if any.""" return self._name + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._available + @property def device_state_attributes(self): """Return the state attributes of the device.""" @@ -132,6 +138,7 @@ class TPLinkSmartBulb(Light): """Update the TP-Link Bulb's state.""" from pyHS100 import SmartDeviceException try: + self._available = True if self._supported_features == 0: self.get_features() self._state = ( @@ -163,8 +170,10 @@ class TPLinkSmartBulb(Light): except KeyError: # device returned no daily/monthly history pass + except (SmartDeviceException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self._name, ex) + _LOGGER.warning("Could not read state for %s: %s", self._name, ex) + self._available = False @property def supported_features(self): From 95cd2035b69b614af01d8bd611904d96069c36a3 Mon Sep 17 00:00:00 2001 From: Eitan Mosenkis Date: Wed, 13 Dec 2017 01:04:42 +0200 Subject: [PATCH 74/97] Fix incorrect comment. (#11111) --- homeassistant/helpers/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 46eeef45f14..6a527021c77 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -149,7 +149,7 @@ def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. Warning: Do not yield from this inside a setup method to avoid a dead lock. - Use `hass.loop.async_add_job(async_load_platform(..))` instead. + Use `hass.async_add_job(async_load_platform(..))` instead. This method is a coroutine. """ From 168065b9bc79e6ad7b7028a91a90828b02d70fec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Dec 2017 21:12:41 -0800 Subject: [PATCH 75/97] Update Warrant (#11101) * Update Warrant * Lint --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/auth_api.py | 7 +++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 9bd91d22beb..2844b0c88f3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -16,7 +16,7 @@ from homeassistant.components.alexa import smart_home from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS -REQUIREMENTS = ['warrant==0.5.0'] +REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 95bf5596835..9cad3ec77f3 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -68,11 +68,14 @@ def register(cloud, email, password): from botocore.exceptions import ClientError cognito = _cognito(cloud) + # Workaround for bug in Warrant. PR with fix: + # https://github.com/capless/warrant/pull/82 + cognito.add_base_attributes() try: if cloud.cognito_email_based: - cognito.register(email, password, email=email) + cognito.register(email, password) else: - cognito.register(_generate_username(email), password, email=email) + cognito.register(_generate_username(email), password) except ClientError as err: raise _map_aws_exception(err) diff --git a/requirements_all.txt b/requirements_all.txt index 8f40709d187..d21b58bc82a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ wakeonlan==0.2.2 waqiasync==1.0.0 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e1b617ef66..363f561de0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -182,7 +182,7 @@ vultr==0.1.2 wakeonlan==0.2.2 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 From 37efd5a5cd385fcab607668320a8c21cea7245cd Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Wed, 13 Dec 2017 09:17:12 +0000 Subject: [PATCH 76/97] Fixed typo in automation.py (#11116) --- homeassistant/components/config/automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 64eccfaa2b8..6ede91e9b66 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,4 +1,4 @@ -"""Provide configuration end points for Z-Wave.""" +"""Provide configuration end points for Automations.""" import asyncio from homeassistant.components.config import EditIdBasedConfigView From 638dd25affb797037988adec94d7c0fbe4b90e92 Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Wed, 13 Dec 2017 10:58:49 +0100 Subject: [PATCH 77/97] Add media position properties (#10076) * Add media progress information * Remove unnecessary comments * Remove datetime import * Remove pysonyavr dependency * Fix doc syntax (D205) * Lint fix: no-else-return * Don't attempt to set media progress info if program is None * Fix Python 3.4 compatibility * Explicitely depend on pyteleloisirs * Only update remaining play time when it changed * Fix floot state table --- .../components/media_player/liveboxplaytv.py | 42 ++++++++++++++++--- requirements_all.txt | 5 ++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 15698ec5022..8093f0d3dbe 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -20,8 +20,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_ON, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME) import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['liveboxplaytv==2.0.0'] +REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.3'] _LOGGER = logging.getLogger(__name__) @@ -76,19 +77,32 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): self._channel_list = {} self._current_channel = None self._current_program = None + self._media_duration = None + self._media_remaining_time = None self._media_image_url = None + self._media_last_updated = None @asyncio.coroutine def async_update(self): """Retrieve the latest data.""" + import pyteleloisirs try: self._state = self.refresh_state() # Update current channel channel = self._client.channel if channel is not None: - self._current_program = yield from \ - self._client.async_get_current_program_name() self._current_channel = channel + program = yield from \ + self._client.async_get_current_program() + if program and self._current_program != program.get('name'): + self._current_program = program.get('name') + # Media progress info + self._media_duration = \ + pyteleloisirs.get_program_duration(program) + rtime = pyteleloisirs.get_remaining_time(program) + if rtime != self._media_remaining_time: + self._media_remaining_time = rtime + self._media_last_updated = dt_util.utcnow() # Set media image to current program if a thumbnail is # available. Otherwise we'll use the channel's image. img_size = 800 @@ -100,7 +114,6 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): chan_img_url = \ self._client.get_current_channel_image(img_size) self._media_image_url = chan_img_url - self.refresh_channel_list() except requests.ConnectionError: self._state = None @@ -149,8 +162,25 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): if self._current_program: return '{}: {}'.format(self._current_channel, self._current_program) - else: - return self._current_channel + return self._current_channel + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_remaining_time + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._media_last_updated @property def supported_features(self): diff --git a/requirements_all.txt b/requirements_all.txt index d21b58bc82a..bfdf3ba601e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -438,7 +438,7 @@ limitlessled==1.0.8 linode-api==4.1.4b2 # homeassistant.components.media_player.liveboxplaytv -liveboxplaytv==2.0.0 +liveboxplaytv==2.0.2 # homeassistant.components.lametric # homeassistant.components.notify.lametric @@ -816,6 +816,9 @@ pysma==0.1.3 # homeassistant.components.switch.snmp pysnmp==4.4.2 +# homeassistant.components.media_player.liveboxplaytv +pyteleloisirs==3.3 + # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner pythinkingcleaner==0.0.3 From 4ec3289f9c7e626a36e491010a11cf0ade2633e2 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Wed, 13 Dec 2017 15:21:14 -0500 Subject: [PATCH 78/97] update pyripple (#11122) --- homeassistant/components/sensor/ripple.py | 6 ++++-- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/ripple.py b/homeassistant/components/sensor/ripple.py index 6f92a1a3390..d516706fdc0 100644 --- a/homeassistant/components/sensor/ripple.py +++ b/homeassistant/components/sensor/ripple.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-ripple-api==0.0.2'] +REQUIREMENTS = ['python-ripple-api==0.0.3'] CONF_ADDRESS = 'address' CONF_ATTRIBUTION = "Data provided by ripple.com" @@ -71,4 +71,6 @@ class RippleSensor(Entity): def update(self): """Get the latest state of the sensor.""" from pyripple import get_balance - self._state = get_balance(self.address) + balance = get_balance(self.address) + if balance is not None: + self._state = balance diff --git a/requirements_all.txt b/requirements_all.txt index bfdf3ba601e..6431d526359 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -883,7 +883,7 @@ python-nmap==0.6.1 python-pushover==0.3 # homeassistant.components.sensor.ripple -python-ripple-api==0.0.2 +python-ripple-api==0.0.3 # homeassistant.components.media_player.roku python-roku==3.1.3 From d547345f90ffac51525d467f7bfa301d7ac44692 Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Thu, 14 Dec 2017 04:00:30 +0000 Subject: [PATCH 79/97] Skip HASS emulated Hue bridges from detection. (#11128) When refactoring the Hue support we lost a check for HASS bridges; restore that. --- homeassistant/components/hue.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 778dcc8dfab..3dad4429b53 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -107,6 +107,9 @@ def setup(hass, config): def bridge_discovered(hass, service, discovery_info): """Dispatcher for Hue discovery events.""" + if "HASS Bridge" in discovery_info.get('name', ''): + return + host = discovery_info.get('host') serial = discovery_info.get('serial') From e62754447980f89594f00fb0e07496679d16bb9a Mon Sep 17 00:00:00 2001 From: Andrea Campi Date: Thu, 14 Dec 2017 04:01:59 +0000 Subject: [PATCH 80/97] Always consume the no_throttle keyword argument. (#11126) The current code relies on the assumption that the first invocation will never specify no_throttle=True. However that puts us in a pickle when writing unit tests: if we had a fictitious: def setup_platform(): update() @Throttle(MIN_TIME_BETWEEN_SCANS) def update(): pass Then given multiple tests, the second and some of subsequent tests would be throttled (depending on timing). But we also can't change that code to call `update(no_throttle=True)' because that's not currently accepted. This diff shouldn't change the visibile behavior of any component, but allows this extra flexibility. --- homeassistant/util/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 646edcf1c35..cb3ebeb7ee6 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -299,7 +299,7 @@ class Throttle(object): return None # Check if method is never called or no_throttle is given - force = not throttle[1] or kwargs.pop('no_throttle', False) + force = kwargs.pop('no_throttle', False) or not throttle[1] try: if force or utcnow() - throttle[1] > self.min_time: From b5d3a4736b3f8efa2c5356f71d9f01d1c98d83c7 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 13 Dec 2017 23:02:24 -0500 Subject: [PATCH 81/97] Add problem device class (#11130) --- homeassistant/components/binary_sensor/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 9e48a30d04a..a0c141914ed 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -34,6 +34,7 @@ DEVICE_CLASSES = [ 'plug', # On means plugged in, Off means unplugged 'power', # Power, over-current, etc 'presence', # On means home, Off means away + 'problem', # On means there is a problem, Off means the status is OK 'safety', # Generic on=unsafe, off=safe 'smoke', # Smoke detector 'sound', # On means sound detected, Off means no sound From 2cced1dac35dfe8535ca37f00195b4b0c861bbf9 Mon Sep 17 00:00:00 2001 From: Michael Pollett Date: Thu, 14 Dec 2017 04:03:41 +0000 Subject: [PATCH 82/97] set default utc offset to 0 (#11114) --- homeassistant/components/sensor/efergy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 0f24905c5f5..c14a33dce01 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -31,6 +31,7 @@ CONF_COST = 'cost' CONF_CURRENT_VALUES = 'current_values' DEFAULT_PERIOD = 'year' +DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { CONF_INSTANT: ['Energy Usage', 'W'], @@ -50,7 +51,7 @@ SENSORS_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_APPTOKEN): cv.string, - vol.Optional(CONF_UTC_OFFSET): cv.string, + vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA] }) From 3473ef63af8587c23c9a398e4403de9bc8ab0be2 Mon Sep 17 00:00:00 2001 From: BryanJacobs Date: Thu, 14 Dec 2017 15:07:23 +1100 Subject: [PATCH 83/97] Allow using more than one keyboard remote (#11061) * Allow using more than one keyboard remote This sets up one thread per keyboard remote, listening for events. * Remove enclosing block in keyboard_remote * Remove unnecessary semantic check for keyboard_remote --- homeassistant/components/keyboard_remote.py | 69 ++++++++++++++------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index 5a81f6d2a9e..d737c555873 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -21,7 +21,7 @@ REQUIREMENTS = ['evdev==0.6.1'] _LOGGER = logging.getLogger(__name__) DEVICE_DESCRIPTOR = 'device_descriptor' -DEVICE_ID_GROUP = 'Device descriptor or name' +DEVICE_ID_GROUP = 'Device description' DEVICE_NAME = 'device_name' DOMAIN = 'keyboard_remote' @@ -36,12 +36,13 @@ KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected' TYPE = 'type' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, - vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, - vol.Optional(TYPE, default='key_up'): - vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')), - }), + DOMAIN: + vol.All(cv.ensure_list, [vol.Schema({ + vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, + vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, + vol.Optional(TYPE, default='key_up'): + vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')) + })]) }, extra=vol.ALLOW_EXTRA) @@ -49,11 +50,6 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - if not config.get(DEVICE_DESCRIPTOR) and\ - not config.get(DEVICE_NAME): - _LOGGER.error("No device_descriptor or device_name found") - return - keyboard_remote = KeyboardRemote( hass, config @@ -63,7 +59,7 @@ def setup(hass, config): keyboard_remote.run() def _stop_keyboard_remote(_event): - keyboard_remote.stopped.set() + keyboard_remote.stop() hass.bus.listen_once( EVENT_HOMEASSISTANT_START, @@ -77,19 +73,21 @@ def setup(hass, config): return True -class KeyboardRemote(threading.Thread): +class KeyboardRemoteThread(threading.Thread): """This interfaces with the inputdevice using evdev.""" - def __init__(self, hass, config): - """Construct a KeyboardRemote interface object.""" - from evdev import InputDevice, list_devices + def __init__(self, hass, device_name, device_descriptor, key_value): + """Construct a thread listening for events on one device.""" + self.hass = hass + self.device_name = device_name + self.device_descriptor = device_descriptor + self.key_value = key_value - self.device_descriptor = config.get(DEVICE_DESCRIPTOR) - self.device_name = config.get(DEVICE_NAME) if self.device_descriptor: self.device_id = self.device_descriptor else: self.device_id = self.device_name + self.dev = self._get_keyboard_device() if self.dev is not None: _LOGGER.debug("Keyboard connected, %s", self.device_id) @@ -103,6 +101,7 @@ class KeyboardRemote(threading.Thread): id_folder = '/dev/input/by-id/' if os.path.isdir(id_folder): + from evdev import InputDevice, list_devices device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( @@ -116,7 +115,6 @@ class KeyboardRemote(threading.Thread): threading.Thread.__init__(self) self.stopped = threading.Event() self.hass = hass - self.key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) def _get_keyboard_device(self): """Get the keyboard device.""" @@ -145,7 +143,7 @@ class KeyboardRemote(threading.Thread): while not self.stopped.isSet(): # Sleeps to ease load on processor - time.sleep(.1) + time.sleep(.05) if self.dev is None: self.dev = self._get_keyboard_device() @@ -178,3 +176,32 @@ class KeyboardRemote(threading.Thread): KEYBOARD_REMOTE_COMMAND_RECEIVED, {KEY_CODE: event.code} ) + + +class KeyboardRemote(object): + """Sets up one thread per device.""" + + def __init__(self, hass, config): + """Construct a KeyboardRemote interface object.""" + self.threads = [] + for dev_block in config: + device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + device_name = dev_block.get(DEVICE_NAME) + key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up')) + + if device_descriptor is not None\ + or device_name is not None: + thread = KeyboardRemoteThread(hass, device_name, + device_descriptor, + key_value) + self.threads.append(thread) + + def run(self): + """Run all event listener threads.""" + for thread in self.threads: + thread.start() + + def stop(self): + """Stop all event listener threads.""" + for thread in self.threads: + thread.stopped.set() From 1c8b5838cdc6f633f5564c5a2be8fe91e90d9898 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 13 Dec 2017 20:14:56 -0800 Subject: [PATCH 84/97] ISY994 sensor improvements (#10805) * Fire events for ISY994 control events This allows hass to react directly to Insteon button presses (on switches and remotes), including presses, double-presses, and long holds * Move change event subscription to after entity is added to hass The event handler method requires `self.hass` to exist, which doesn't have a value until the async_added_to_hass method is called. Should eliminate a race condition. * Overhaul binary sensors in ISY994 to be functional "out of the box" We now smash all of the subnodes from the ISY994 in to one Hass binary_sensor, and automatically support both paradigms of state reporting that Insteon sensors can do. Sometimes a single node's state represents the sensor's state, other times two nodes are used and only "ON" events are sent from each. The logic between the two forunately do not conflict so we can support both without knowing which mode the device is in. This also allows us to handle the heartbeat functionality that certain sensors have - we simply store the timestamp of the heartbeat as an attribute on the sensor device. It defaults to Unknown on bootup if and only if the device supports heartbeats, due to the presence of subnode 4. * Parse the binary sensor device class from the ISY's device "type" Now we automatically know which sensors are moisture, motion, and openings! (We also reverse the moisture sensor state, because Insteon reports ON for dry on the primary node.) * Code review tweaks The one material change here is that the event subscribers were moved to the `async_added_to_hass` method, as the handlers depend on things that only exist after the entity has been added. * Handle cases where a sensor's state is unknown When the ISY first boots up, if a battery-powered sensor has not reported in yet (due to heartbeat or a change in state), the state is unknown until it does. * Clean up from code review Fix coroutine await, remove unnecessary exception check, and return None when state is unknown * Unknown value from PyISY is now -inf rather than -1 * Move heartbeat handling to a separate sensor Now all heartbeat-compatible sensors will have a separate `binary_sensor` device that represents the battery state (on = dead) * Add support for Unknown state, which is being added in next PyISY PyISY will report unknown states as the number "-inf". This is implemented in the base ISY994 component, but subcomponents that override the `state` method needed some extra logic to handle it as well. * Change a couple try blocks to explicit None checks * Bump PyISY to 1.1.0, now that it has been published! * Remove -inf checking from base component The implementation of the -inf checking was improved in another branch which has been merged in to this branch already. * Restrict negative-node and heartbeat support to known compatible types Not all Insteon sensors use the same subnode IDs for the same things, so we need to use different logic depending on device type. Negative node and heartbeat support is now only used for leak sensors and open/close sensors. * Use new style string formatting * Add binary sensor detection for pre-5.x firmware Meant to do this originally; writing documentation revealed that this requirement was missed! --- .../components/binary_sensor/isy994.py | 335 +++++++++++++++++- homeassistant/components/cover/isy994.py | 5 +- homeassistant/components/isy994.py | 64 +++- homeassistant/components/lock/isy994.py | 5 +- homeassistant/components/sensor/isy994.py | 3 + homeassistant/components/switch/isy994.py | 5 +- requirements_all.txt | 2 +- 7 files changed, 396 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fd6269e3630..a5b61c9ffed 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -4,24 +4,31 @@ Support for ISY994 binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.isy994/ """ + +import asyncio import logging +from datetime import timedelta from typing import Callable # noqa +from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN import homeassistant.components.isy994 as isy from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - UOM = ['2', '78'] STATES = [STATE_OFF, STATE_ON, 'true', 'false'] +ISY_DEVICE_TYPES = { + 'moisture': ['16.8', '16.13', '16.14'], + 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], + 'motion': ['16.1', '16.4', '16.5', '16.3'] +} + # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, @@ -32,10 +39,46 @@ def setup_platform(hass, config: ConfigType, return False devices = [] + devices_by_nid = {} + child_nodes = [] for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, states=STATES): - devices.append(ISYBinarySensorDevice(node)) + if node.parent_node is None: + device = ISYBinarySensorDevice(node) + devices.append(device) + devices_by_nid[node.nid] = device + else: + # We'll process the child nodes last, to ensure all parent nodes + # have been processed + child_nodes.append(node) + + for node in child_nodes: + try: + parent_device = devices_by_nid[node.parent_node.nid] + except KeyError: + _LOGGER.error("Node %s has a parent node %s, but no device " + "was created for the parent. Skipping.", + node.nid, node.parent_nid) + else: + device_type = _detect_device_type(node) + if device_type in ['moisture', 'opening']: + subnode_id = int(node.nid[-1]) + # Leak and door/window sensors work the same way with negative + # nodes and heartbeat nodes + if subnode_id == 4: + # Subnode 4 is the heartbeat node, which we will represent + # as a separate binary_sensor + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) + elif subnode_id == 2: + parent_device.add_negative_node(node) + else: + # We don't yet have any special logic for other sensor types, + # so add the nodes as individual devices + device = ISYBinarySensorDevice(node) + devices.append(device) for program in isy.PROGRAMS.get(DOMAIN, []): try: @@ -48,23 +91,281 @@ def setup_platform(hass, config: ConfigType, add_devices(devices) +def _detect_device_type(node) -> str: + try: + device_type = node.type + except AttributeError: + # The type attribute didn't exist in the ISY's API response + return None + + split_type = device_type.split('.') + for device_class, ids in ISY_DEVICE_TYPES.items(): + if '{}.{}'.format(split_type[0], split_type[1]) in ids: + return device_class + + return None + + +def _is_val_unknown(val): + """Determine if a number value represents UNKNOWN from PyISY.""" + return val == -1*float('inf') + + class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): - """Representation of an ISY994 binary sensor device.""" + """Representation of an ISY994 binary sensor device. + + Often times, a single device is represented by multiple nodes in the ISY, + allowing for different nuances in how those devices report their on and + off events. This class turns those multiple nodes in to a single Hass + entity and handles both ways that ISY binary sensors can work. + """ def __init__(self, node) -> None: """Initialize the ISY994 binary sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) + self._negative_node = None + self._heartbeat_device = None + self._device_class_from_type = _detect_device_type(self._node) + # pylint: disable=protected-access + if _is_val_unknown(self._node.status._val): + self._computed_state = None + else: + self._computed_state = bool(self._node.status._val) + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe(self._positive_node_control_handler) + + if self._negative_node is not None: + self._negative_node.controlEvents.subscribe( + self._negative_node_control_handler) + + def add_heartbeat_device(self, device) -> None: + """Register a heartbeat device for this sensor. + + The heartbeat node beats on its own, but we can gain a little + reliability by considering any node activity for this sensor + to be a heartbeat as well. + """ + self._heartbeat_device = device + + def _heartbeat(self) -> None: + """Send a heartbeat to our heartbeat device, if we have one.""" + if self._heartbeat_device is not None: + self._heartbeat_device.heartbeat() + + def add_negative_node(self, child) -> None: + """Add a negative node to this binary sensor device. + + The negative node is a node that can receive the 'off' events + for the sensor, depending on device configuration and type. + """ + self._negative_node = child + + if not _is_val_unknown(self._negative_node): + # If the negative node has a value, it means the negative node is + # in use for this device. Therefore, we cannot determine the state + # of the sensor until we receive our first ON event. + self._computed_state = None + + def _negative_node_control_handler(self, event: object) -> None: + """Handle an "On" control event from the "negative" node.""" + if event == 'DON': + _LOGGER.debug("Sensor %s turning Off via the Negative node " + "sending a DON command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + def _positive_node_control_handler(self, event: object) -> None: + """Handle On and Off control event coming from the primary node. + + Depending on device configuration, sometimes only On events + will come to this node, with the negative node representing Off + events + """ + if event == 'DON': + _LOGGER.debug("Sensor %s turning On via the Primary node " + "sending a DON command", self.name) + self._computed_state = True + self.schedule_update_ha_state() + self._heartbeat() + if event == 'DOF': + _LOGGER.debug("Sensor %s turning Off via the Primary node " + "sending a DOF command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore primary node status updates. + + We listen directly to the Control events on all nodes for this + device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of the device. + + Insteon leak sensors set their primary node to On when the state is + DRY, not WET, so we invert the binary state if the user indicates + that it is a moisture sensor. + """ + if self._computed_state is None: + # Do this first so we don't invert None on moisture sensors + return None + + if self.device_class == 'moisture': + return not self._computed_state + + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Return the class of this device. + + This was discovered by parsing the device type code during init + """ + return self._device_class_from_type + + +class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): + """Representation of the battery state of an ISY994 sensor.""" + + def __init__(self, node, parent_device) -> None: + """Initialize the ISY994 binary sensor device.""" + super().__init__(node) + self._computed_state = None + self._parent_device = parent_device + self._heartbeat_timer = None + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe( + self._heartbeat_node_control_handler) + + # Start the timer on bootup, so we can change from UNKNOWN to ON + self._restart_timer() + + def _heartbeat_node_control_handler(self, event: object) -> None: + """Update the heartbeat timestamp when an On event is sent.""" + if event == 'DON': + self.heartbeat() + + def heartbeat(self): + """Mark the device as online, and restart the 25 hour timer. + + This gets called when the heartbeat node beats, but also when the + parent sensor sends any events, as we can trust that to mean the device + is online. This mitigates the risk of false positives due to a single + missed heartbeat event. + """ + self._computed_state = False + self._restart_timer() + self.schedule_update_ha_state() + + def _restart_timer(self): + """Restart the 25 hour timer.""" + try: + self._heartbeat_timer() + self._heartbeat_timer = None + except TypeError: + # No heartbeat timer is active + pass + + # pylint: disable=unused-argument + @callback + def timer_elapsed(now) -> None: + """Heartbeat missed; set state to indicate dead battery.""" + self._computed_state = True + self._heartbeat_timer = None + self.schedule_update_ha_state() + + point_in_time = dt_util.utcnow() + timedelta(hours=25) + _LOGGER.debug("Timer starting. Now: %s Then: %s", + dt_util.utcnow(), point_in_time) + + self._heartbeat_timer = async_track_point_in_utc_time( + self.hass, timer_elapsed, point_in_time) + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore node status updates. + + We listen directly to the Control events for this device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of this sensor.""" + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Get the class of this device.""" + return 'battery' + + @property + def device_state_attributes(self): + """Get the state attributes for the device.""" + attr = super().device_state_attributes + attr['parent_entity_id'] = self._parent_device.entity_id + return attr + + +class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor program. + + This does not need all of the subnode logic in the device version of binary + sensors. + """ + + def __init__(self, name, node) -> None: + """Initialize the ISY994 binary sensor program.""" + super().__init__(node) + self._name = name @property def is_on(self) -> bool: """Get whether the ISY994 binary sensor device is on.""" return bool(self.value) - - -class ISYBinarySensorProgram(ISYBinarySensorDevice): - """Representation of an ISY994 binary sensor program.""" - - def __init__(self, name, node) -> None: - """Initialize the ISY994 binary sensor program.""" - ISYBinarySensorDevice.__init__(self, node) - self._name = name diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 1e83038278c..4dd1c9be364 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -69,7 +69,10 @@ class ISYCoverDevice(isy.ISYDevice, CoverDevice): @property def state(self) -> str: """Get the state of the ISY994 cover device.""" - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7686eb7dc7d..af1846c7bf8 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -4,6 +4,7 @@ Support the ISY-994 controllers. For configuration details please visit the documentation for this component at https://home-assistant.io/components/isy994/ """ +import asyncio from collections import namedtuple import logging from urllib.parse import urlparse @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.8'] +REQUIREMENTS = ['PyISY==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -91,6 +92,34 @@ def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: return filtered_nodes +def _is_node_a_sensor(node, path: str, sensor_identifier: str) -> bool: + """Determine if the given node is a sensor.""" + if not isinstance(node, PYISY.Nodes.Node): + return False + + if sensor_identifier in path or sensor_identifier in node.name: + return True + + # This method is most reliable but only works on 5.x firmware + try: + if node.node_def_id == 'BinaryAlarm': + return True + except AttributeError: + pass + + # This method works on all firmwares, but only for Insteon devices + try: + device_type = node.type + except AttributeError: + # Node has no type; most likely not an Insteon device + pass + else: + split_type = device_type.split('.') + return split_type[0] == '16' # 16 represents Insteon binary sensors + + return False + + def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: """Categorize the ISY994 nodes.""" global SENSOR_NODES @@ -106,7 +135,7 @@ def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: hidden = hidden_identifier in path or hidden_identifier in node.name if hidden: node.name += hidden_identifier - if sensor_identifier in path or sensor_identifier in node.name: + if _is_node_a_sensor(node, path, sensor_identifier): SENSOR_NODES.append(node) elif isinstance(node, PYISY.Nodes.Node): NODES.append(node) @@ -227,15 +256,31 @@ class ISYDevice(Entity): def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._change_handler = None + self._control_handler = None + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node change events.""" self._change_handler = self._node.status.subscribe( 'changed', self.on_update) + if hasattr(self._node, 'controlEvents'): + self._control_handler = self._node.controlEvents.subscribe( + self.on_control) + # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() + def on_control(self, event: object) -> None: + """Handle a control event from the ISY994 Node.""" + self.hass.bus.fire('isy994_control', { + 'entity_id': self.entity_id, + 'control': event + }) + @property def domain(self) -> str: """Get the domain of the device.""" @@ -270,6 +315,21 @@ class ISYDevice(Entity): # pylint: disable=protected-access return self._node.status._val + def is_unknown(self) -> bool: + """Get whether or not the value of this Entity's node is unknown. + + PyISY reports unknown values as -inf + """ + return self.value == -1 * float('inf') + + @property + def state(self): + """Return the state of the ISY device.""" + if self.is_unknown(): + return None + else: + return super().state + @property def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index edbb8a34f24..63272b90b1f 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -66,7 +66,10 @@ class ISYLockDevice(isy.ISYDevice, LockDevice): @property def state(self) -> str: """Get the state of the lock.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index f64fa6191e2..e961c63a1b5 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -282,6 +282,9 @@ class ISYSensorDevice(isy.ISYDevice): @property def state(self) -> str: """Get the state of the ISY994 sensor device.""" + if self.is_unknown(): + return None + if len(self._node.uom) == 1: if self._node.uom[0] in UOM_TO_STATES: states = UOM_TO_STATES.get(self._node.uom[0]) diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index b930bedc2c7..0f1ec62eaee 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -69,7 +69,10 @@ class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): @property def state(self) -> str: """Get the state of the ISY994 device.""" - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) def turn_off(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch.""" diff --git a/requirements_all.txt b/requirements_all.txt index 6431d526359..ee1c2938195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,7 +23,7 @@ certifi>=2017.4.17 DoorBirdPy==0.1.0 # homeassistant.components.isy994 -PyISY==1.0.8 +PyISY==1.1.0 # homeassistant.components.notify.html5 PyJWT==1.5.3 From 823e260c2a64a76217af472d9ec66f483cb3cad2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 14 Dec 2017 00:15:25 -0800 Subject: [PATCH 85/97] Disable html5 notify dependency (#11135) --- homeassistant/components/notify/html5.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index fb3cf0bbecd..2314722a2ab 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -29,7 +29,7 @@ from homeassistant.util import ensure_unique_string REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] -DEPENDENCIES = ['frontend', 'config'] +DEPENDENCIES = ['frontend'] _LOGGER = logging.getLogger(__name__) From de4c8adca257e0d72aadb48b7b9c1e7b560c0932 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Fri, 15 Dec 2017 00:34:27 +0100 Subject: [PATCH 86/97] Upgrade Homematic (#11149) * Update pyhomematic * Update pyhomematic --- homeassistant/components/homematic.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index 5e8cd3dc58e..0ab6f01805f 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyhomematic==0.1.35'] +REQUIREMENTS = ['pyhomematic==0.1.36'] DOMAIN = 'homematic' diff --git a/requirements_all.txt b/requirements_all.txt index ee1c2938195..6466be2f651 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -695,7 +695,7 @@ pyhik==0.1.4 pyhiveapi==0.2.5 # homeassistant.components.homematic -pyhomematic==0.1.35 +pyhomematic==0.1.36 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.3.1 From ca81180e6dae7c33c308a56ad810b5ec1ac6a00e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 15 Dec 2017 10:06:06 +0100 Subject: [PATCH 87/97] Bump release to 0.60.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4f075249e57..dd15e1fb75d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 60 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) From f4d7bbd0446c609939b4c1d82a2731d42e22a7ea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Dec 2017 23:35:37 -0800 Subject: [PATCH 88/97] Update frontend --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9d97a7439bd..cd206135dde 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171206.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171216.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 6466be2f651..89711fe9c96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -340,7 +340,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171206.0 +home-assistant-frontend==20171216.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 363f561de0d..877e129e0ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -77,7 +77,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171206.0 +home-assistant-frontend==20171216.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 7db8bbf38599e97560dc224d8259a9b9d2c4265d Mon Sep 17 00:00:00 2001 From: Matthew Treinish Date: Sat, 16 Dec 2017 15:52:40 -0800 Subject: [PATCH 89/97] Fix X10 commands for mochad light turn on (#11146) * Fix X10 commands for mochad light turn on This commit attempts to address issues that a lot of people are having with the x10 light component. Originally this was written to use the xdim (extended dim) X10 command. However, not every X10 dimmer device supports the xdim command. Additionally, it turns out the number of dim/brighness levels the X10 device supports is device specific and there is no way to detect this (given the mostly 1 way nature of X10) To address these issues, this commit removes the usage of xdim and instead relies on using the 'on' command and the 'dim' command. This should work on all x10 light devices. In an attempt to address the different dim/brightness levels supported by different devices this commit also adds a new optional config value, 'brightness_levels', to specify if it's either 32, 64, or 256. By default 32 levels are used as this is the normal case and what is documented by mochad. Fixes #8943 * make code more readable * fix style * fix lint * fix tests --- homeassistant/components/light/mochad.py | 45 ++++++++++++++-- tests/components/light/test_mochad.py | 68 +++++++++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index 3d67edaf7cb..efc62b05434 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -12,13 +12,15 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.components import mochad -from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_DEVICES, - CONF_ADDRESS) +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, CONF_DEVICES, CONF_ADDRESS) from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['mochad'] _LOGGER = logging.getLogger(__name__) +CONF_BRIGHTNESS_LEVELS = 'brightness_levels' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): mochad.DOMAIN, @@ -26,6 +28,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.x10_address, vol.Optional(mochad.CONF_COMM_TYPE): cv.string, + vol.Optional(CONF_BRIGHTNESS_LEVELS, default=32): + vol.All(vol.Coerce(int), vol.In([32, 64, 256])), }] }) @@ -54,6 +58,7 @@ class MochadLight(Light): comm_type=self._comm_type) self._brightness = 0 self._state = self._get_device_status() + self._brightness_levels = dev.get(CONF_BRIGHTNESS_LEVELS) - 1 @property def brightness(self): @@ -86,12 +91,38 @@ class MochadLight(Light): """X10 devices are normally 1-way so we have to assume the state.""" return True + def _calculate_brightness_value(self, value): + return int(value * (float(self._brightness_levels) / 255.0)) + + def _adjust_brightness(self, brightness): + if self._brightness > brightness: + bdelta = self._brightness - brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("dim {}".format(mochad_brightness)) + self._controller.read_data() + elif self._brightness < brightness: + bdelta = brightness - self._brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("bright {}".format(mochad_brightness)) + self._controller.read_data() + def turn_on(self, **kwargs): """Send the command to turn the light on.""" - self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) with mochad.REQ_LOCK: - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + if self._brightness_levels > 32: + out_brightness = self._calculate_brightness_value(brightness) + self.device.send_cmd('xdim {}'.format(out_brightness)) + self._controller.read_data() + else: + self.device.send_cmd("on") + self._controller.read_data() + # There is no persistence for X10 modules so a fresh on command + # will be full brightness + if self._brightness == 0: + self._brightness = 255 + self._adjust_brightness(brightness) + self._brightness = brightness self._state = True def turn_off(self, **kwargs): @@ -99,4 +130,8 @@ class MochadLight(Light): with mochad.REQ_LOCK: self.device.send_cmd('off') self._controller.read_data() + # There is no persistence for X10 modules so we need to prepare + # to track a fresh on command will full brightness + if self._brightness_levels == 31: + self._brightness = 0 self._state = False diff --git a/tests/components/light/test_mochad.py b/tests/components/light/test_mochad.py index e69ebdb4aef..5c82ab06085 100644 --- a/tests/components/light/test_mochad.py +++ b/tests/components/light/test_mochad.py @@ -60,7 +60,8 @@ class TestMochadLight(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() controller_mock = mock.MagicMock() - dev_dict = {'address': 'a1', 'name': 'fake_light'} + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 32} self.light = mochad.MochadLight(self.hass, controller_mock, dev_dict) @@ -72,6 +73,39 @@ class TestMochadLight(unittest.TestCase): """Test the name.""" self.assertEqual('fake_light', self.light.name) + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('on') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_has_calls( + [mock.call('on'), mock.call('dim 25')]) + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight256Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 256} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + def test_turn_on_with_no_brightness(self): """Test turn_on.""" self.light.turn_on() @@ -86,3 +120,35 @@ class TestMochadLight(unittest.TestCase): """Test turn_off.""" self.light.turn_off() self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight64Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 64} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('xdim 63') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_called_once_with('xdim 11') + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') From ec9638f4d153922374fd50c67a7535f5c03ee765 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 21:22:36 +0100 Subject: [PATCH 90/97] Homematic next (#11156) * Cleanup logic & New gen of HomeMatic * fix lint * cleanup * fix coverage * cleanup * name consistenc * fix lint * Rename ip * cleanup wrong property * fix bug * handle callback better * fix lint * Running now --- .coveragerc | 2 +- .../{homematic.py => homematic/__init__.py} | 197 +++++++++--------- .../components/homematic/services.yaml | 52 +++++ homeassistant/components/services.yaml | 49 ----- 4 files changed, 146 insertions(+), 154 deletions(-) rename homeassistant/components/{homematic.py => homematic/__init__.py} (83%) create mode 100644 homeassistant/components/homematic/services.yaml diff --git a/.coveragerc b/.coveragerc index b73d847f431..96936655c51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -88,7 +88,7 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py - homeassistant/components/homematic.py + homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py homeassistant/components/insteon_local.py diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic/__init__.py similarity index 83% rename from homeassistant/components/homematic.py rename to homeassistant/components/homematic/__init__.py index 0ab6f01805f..af3a54b861d 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic/__init__.py @@ -12,14 +12,13 @@ from functools import partial import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_USERNAME, CONF_PASSWORD, - CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) + EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, + CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval -from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyhomematic==0.1.36'] @@ -41,7 +40,7 @@ ATTR_CHANNEL = 'channel' ATTR_NAME = 'name' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' -ATTR_PROXY = 'proxy' +ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' @@ -51,8 +50,8 @@ EVENT_ERROR = 'homematic.error' SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' -SERVICE_SET_VAR_VALUE = 'set_var_value' -SERVICE_SET_DEV_VALUE = 'set_dev_value' +SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' +SERVICE_SET_DEVICE_VALUE = 'set_device_value' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ @@ -90,12 +89,14 @@ HM_ATTRIBUTE_SUPPORT = { 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], - 'CONTROL_MODE': ['mode', {0: 'Auto', - 1: 'Manual', - 2: 'Away', - 3: 'Boost', - 4: 'Comfort', - 5: 'Lowering'}], + 'CONTROL_MODE': ['mode', { + 0: 'Auto', + 1: 'Manual', + 2: 'Away', + 3: 'Boost', + 4: 'Comfort', + 5: 'Lowering' + }], 'POWER': ['power', {}], 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], @@ -124,12 +125,12 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' +DATA_CONF = 'homematic_conf' +CONF_INTERFACES = 'interfaces' CONF_LOCAL_IP = 'local_ip' CONF_LOCAL_PORT = 'local_port' -CONF_IP = 'ip' CONF_PORT = 'port' CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' @@ -146,37 +147,33 @@ DEFAULT_PORT = 2001 DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' -DEFAULT_VARIABLES = False -DEFAULT_DEVICES = True -DEFAULT_PRIMARY = False DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'homematic', vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, - vol.Required(ATTR_PROXY): cv.string, + vol.Required(ATTR_INTERFACE): cv.string, vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOSTS): {cv.match_all: { - vol.Required(CONF_IP): cv.string, + vol.Optional(CONF_INTERFACES, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): - cv.boolean, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), - vol.Optional(CONF_DEVICES, default=DEFAULT_DEVICES): cv.boolean, - vol.Optional(CONF_PRIMARY, default=DEFAULT_PRIMARY): cv.boolean, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, }}, + vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, }), @@ -186,33 +183,33 @@ SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): cv.string, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) -SCHEMA_SERVICE_SET_VAR_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_VARIABLE_VALUE = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.match_all, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SCHEMA_SERVICE_SET_DEV_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), vol.Required(ATTR_VALUE): cv.match_all, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) SCHEMA_SERVICE_RECONNECT = vol.Schema({}) -def virtualkey(hass, address, channel, param, proxy=None): +def virtualkey(hass, address, channel, param, interface=None): """Send virtual keypress to homematic controlller.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) @@ -225,20 +222,20 @@ def set_var_value(hass, entity_id, value): ATTR_VALUE: value, } - hass.services.call(DOMAIN, SERVICE_SET_VAR_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, proxy=None): - """Call setValue XML-RPC method of supplied proxy.""" +def set_dev_value(hass, address, channel, param, value, interface=None): + """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, ATTR_VALUE: value, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } - hass.services.call(DOMAIN, SERVICE_SET_DEV_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) def reconnect(hass): @@ -250,31 +247,32 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DEVINIT] = {} + conf = config[DOMAIN] + hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() # Create hosts-dictionary for pyhomematic - remotes = {} - hosts = {} - for rname, rconfig in config[DOMAIN][CONF_HOSTS].items(): - server = rconfig.get(CONF_IP) + for rname, rconfig in conf[CONF_INTERFACES].items(): + remotes[rname] = { + 'ip': rconfig.get(CONF_HOST), + 'port': rconfig.get(CONF_PORT), + 'path': rconfig.get(CONF_PATH), + 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'username': rconfig.get(CONF_USERNAME), + 'password': rconfig.get(CONF_PASSWORD), + 'callbackip': rconfig.get(CONF_CALLBACK_IP), + 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'connect': True, + } - remotes[rname] = {} - remotes[rname][CONF_IP] = server - remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT) - remotes[rname][CONF_PATH] = rconfig.get(CONF_PATH) - remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES) - remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME) - remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD) - remotes[rname]['callbackip'] = rconfig.get(CONF_CALLBACK_IP) - remotes[rname]['callbackport'] = rconfig.get(CONF_CALLBACK_PORT) - - if server not in hosts or rconfig.get(CONF_PRIMARY): - hosts[server] = { - CONF_VARIABLES: rconfig.get(CONF_VARIABLES), - CONF_NAME: rname, - } - hass.data[DATA_DEVINIT][rname] = rconfig.get(CONF_DEVICES) + for sname, sconfig in conf[CONF_HOSTS].items(): + remotes[sname] = { + 'ip': sconfig.get(CONF_HOST), + 'port': DEFAULT_PORT, + 'username': sconfig.get(CONF_USERNAME), + 'password': sconfig.get(CONF_PASSWORD), + 'connect': False, + } # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) @@ -295,9 +293,8 @@ def setup(hass, config): # Init homematic hubs entity_hubs = [] - for _, hub_data in hosts.items(): - entity_hubs.append(HMHub( - hass, homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + for hub_name in conf[CONF_HOSTS].keys(): + entity_hubs.append(HMHub(hass, homematic, hub_name)) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -331,8 +328,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - descriptions[DOMAIN][SERVICE_VIRTUALKEY], - schema=SCHEMA_SERVICE_VIRTUALKEY) + descriptions[SERVICE_VIRTUALKEY], schema=SCHEMA_SERVICE_VIRTUALKEY) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -354,9 +350,9 @@ def setup(hass, config): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VAR_VALUE, _service_handle_value, - descriptions[DOMAIN][SERVICE_SET_VAR_VALUE], - schema=SCHEMA_SERVICE_SET_VAR_VALUE) + DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, + descriptions[SERVICE_SET_VARIABLE_VALUE], + schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" @@ -364,8 +360,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - descriptions[DOMAIN][SERVICE_RECONNECT], - schema=SCHEMA_SERVICE_RECONNECT) + descriptions[SERVICE_RECONNECT], schema=SCHEMA_SERVICE_RECONNECT) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -383,9 +378,9 @@ def setup(hass, config): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEV_VALUE, _service_handle_device, - descriptions[DOMAIN][SERVICE_SET_DEV_VALUE], - schema=SCHEMA_SERVICE_SET_DEV_VALUE) + DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, + descriptions[SERVICE_SET_DEVICE_VALUE], + schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) return True @@ -395,10 +390,10 @@ def _system_callback_handler(hass, config, src, *args): # New devices available at hub if src == 'newDevices': (interface_id, dev_descriptions) = args - proxy = interface_id.split('-')[-1] + interface = interface_id.split('-')[-1] # Device support active? - if not hass.data[DATA_DEVINIT][proxy]: + if not hass.data[DATA_CONF][interface]['connect']: return addresses = [] @@ -410,9 +405,9 @@ def _system_callback_handler(hass, config, src, *args): # Register EVENTS # Search all devices with an EVENTNODE that includes data - bound_event_callback = partial(_hm_event_handler, hass, proxy) + bound_event_callback = partial(_hm_event_handler, hass, interface) for dev in addresses: - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(dev) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: hmdevice.setEventCallback( @@ -429,7 +424,7 @@ def _system_callback_handler(hass, config, src, *args): ('climate', DISCOVER_CLIMATE)): # Get all devices of a specific type found_devices = _get_devices( - hass, discovery_type, addresses, proxy) + hass, discovery_type, addresses, interface) # When devices of this type are found # they are setup in HASS and an discovery event is fired @@ -448,12 +443,12 @@ def _system_callback_handler(hass, config, src, *args): }) -def _get_devices(hass, discovery_type, keys, proxy): +def _get_devices(hass, discovery_type, keys, interface): """Get the HomeMatic devices for given discovery_type.""" device_arr = [] for key in keys: - device = hass.data[DATA_HOMEMATIC].devices[proxy][key] + device = hass.data[DATA_HOMEMATIC].devices[interface][key] class_name = device.__class__.__name__ metadata = {} @@ -485,7 +480,7 @@ def _get_devices(hass, discovery_type, keys, proxy): device_dict = { CONF_PLATFORM: "homematic", ATTR_ADDRESS: key, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel } @@ -521,12 +516,12 @@ def _create_ha_name(name, channel, param, count): return "{} {} {}".format(name, channel, param) -def _hm_event_handler(hass, proxy, device, caller, attribute, value): +def _hm_event_handler(hass, interface, device, caller, attribute, value): """Handle all pyhomematic device events.""" try: channel = int(device.split(":")[1]) address = device.split(":")[0] - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address) except (TypeError, ValueError): _LOGGER.error("Event handling channel convert error!") return @@ -561,14 +556,14 @@ def _hm_event_handler(hass, proxy, device, caller, attribute, value): def _device_from_servicecall(hass, service): """Extract HomeMatic device from service call.""" address = service.data.get(ATTR_ADDRESS) - proxy = service.data.get(ATTR_PROXY) + interface = service.data.get(ATTR_INTERFACE) if address == 'BIDCOS-RF': address = 'BidCoS-RF' - if proxy: - return hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + if interface: + return hass.data[DATA_HOMEMATIC].devices[interface].get(address) - for _, devices in hass.data[DATA_HOMEMATIC].devices.items(): + for devices in hass.data[DATA_HOMEMATIC].devices.values(): if address in devices: return devices[address] @@ -576,25 +571,23 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, homematic, name, use_variables): + def __init__(self, hass, homematic, name): """Initialize HomeMatic hub.""" self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) self._homematic = homematic self._variables = {} self._name = name - self._state = STATE_UNKNOWN - self._use_variables = use_variables + self._state = None # Load data - track_time_interval( - self.hass, self._update_hub, SCAN_INTERVAL_HUB) + self.hass.helpers.event.track_time_interval( + self._update_hub, SCAN_INTERVAL_HUB) self.hass.add_job(self._update_hub, None) - if self._use_variables: - track_time_interval( - self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self.hass.add_job(self._update_variables, None) + self.hass.helpers.event.track_time_interval( + self._update_variables, SCAN_INTERVAL_VARIABLES) + self.hass.add_job(self._update_variables, None) @property def name(self): @@ -672,7 +665,7 @@ class HMDevice(Entity): """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) - self._proxy = config.get(ATTR_PROXY) + self._interface = config.get(ATTR_INTERFACE) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} @@ -700,11 +693,6 @@ class HMDevice(Entity): """Return the name of the device.""" return self._name - @property - def assumed_state(self): - """Return true if unable to access real state of the device.""" - return not self._available - @property def available(self): """Return true if device is available.""" @@ -728,7 +716,7 @@ class HMDevice(Entity): # Static attributes attr['id'] = self._hmdevice.ADDRESS - attr['proxy'] = self._proxy + attr['interface'] = self._interface return attr @@ -739,7 +727,8 @@ class HMDevice(Entity): # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] - self._hmdevice = self._homematic.devices[self._proxy][self._address] + self._hmdevice = \ + self._homematic.devices[self._interface][self._address] self._connected = True try: diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml new file mode 100644 index 00000000000..76ecdbd0a4f --- /dev/null +++ b/homeassistant/components/homematic/services.yaml @@ -0,0 +1,52 @@ +# Describes the format for available component services + +virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote. + example: BidCoS-RF + channel: + description: Channel for calling a keypress. + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + example: PRESS_LONG + interface: + description: (Optional) for set a hosts value. + example: Hosts name from config + +set_variable_value: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of homematic central to set value. + example: 'homematic.ccu2' + name: + description: Name of the variable to set. + example: 'testvariable' + value: + description: New value + example: 1 + +set_device_value: + description: Set a device property on RPC XML interface. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + channel: + description: Channel for calling a keypress + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG + interface: + description: (Optional) for set a hosts value + example: Hosts name from config + value: + description: New value + example: 1 + +reconnect: + description: Reconnect to all Homematic Hubs. diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index c532c0dfd20..90a1bbbc613 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -32,55 +32,6 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 -homematic: - virtualkey: - description: Press a virtual key from CCU/Homegear or simulate keypress. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote. - example: BidCoS-RF - channel: - description: Channel for calling a keypress. - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT. - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value. - example: Hosts name from config - set_var_value: - description: Set the name of a node. - fields: - entity_id: - description: Name(s) of homematic central to set value. - example: 'homematic.ccu2' - name: - description: Name of the variable to set. - example: 'testvariable' - value: - description: New value - example: 1 - set_dev_value: - description: Set a device property on RPC XML interface. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote - example: BidCoS-RF - channel: - description: Channel for calling a keypress - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value - example: Hosts name from config - value: - description: New value - example: 1 - reconnect: - description: Reconnect to all Homematic Hubs. - microsoft_face: create_group: description: Create a new person group. From 5860267410bdd23fd775991e09a5ec263715a35c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Dec 2017 22:54:54 +0100 Subject: [PATCH 91/97] Resolve hostnames (#11160) --- homeassistant/components/homematic/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index af3a54b861d..ffee6278f40 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -5,10 +5,11 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ import asyncio -import os -import logging from datetime import timedelta from functools import partial +import logging +import os +import socket import voluptuous as vol @@ -254,7 +255,7 @@ def setup(hass, config): # Create hosts-dictionary for pyhomematic for rname, rconfig in conf[CONF_INTERFACES].items(): remotes[rname] = { - 'ip': rconfig.get(CONF_HOST), + 'ip': socket.gethostbyname(rconfig.get(CONF_HOST)), 'port': rconfig.get(CONF_PORT), 'path': rconfig.get(CONF_PATH), 'resolvenames': rconfig.get(CONF_RESOLVENAMES), @@ -267,7 +268,7 @@ def setup(hass, config): for sname, sconfig in conf[CONF_HOSTS].items(): remotes[sname] = { - 'ip': sconfig.get(CONF_HOST), + 'ip': socket.gethostbyname(sconfig.get(CONF_HOST)), 'port': DEFAULT_PORT, 'username': sconfig.get(CONF_USERNAME), 'password': sconfig.get(CONF_PASSWORD), From 564ed26aebce0b01699b579643150e87a93eb5c9 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 16 Dec 2017 03:04:27 -0500 Subject: [PATCH 92/97] Perform logbook filtering on the worker thread (#11161) --- homeassistant/components/logbook.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 63a271acdd5..1dc0861d737 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -135,9 +135,8 @@ class LogbookView(HomeAssistantView): hass = request.app['hass'] events = yield from hass.async_add_job( - _get_events, hass, start_day, end_day) - events = _exclude_events(events, self.config) - return self.json(humanify(events)) + _get_events, hass, self.config, start_day, end_day) + return self.json(events) class Entry(object): @@ -274,7 +273,7 @@ def humanify(events): entity_id) -def _get_events(hass, start_day, end_day): +def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events from homeassistant.components.recorder.util import ( @@ -285,7 +284,8 @@ def _get_events(hass, start_day, end_day): Events.time_fired).filter( (Events.time_fired > start_day) & (Events.time_fired < end_day)) - return execute(query) + events = execute(query) + return humanify(_exclude_events(events, config)) def _exclude_events(events, config): From 5ca006cc9c6f7ce190879e6185f1646bd1ba1f43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Dec 2017 00:42:25 -0800 Subject: [PATCH 93/97] Don't connect to cloud if subscription expired (#11163) * Final touch for cloud component * Fix test --- homeassistant/components/cloud/__init__.py | 14 +++++--------- homeassistant/components/cloud/const.py | 13 ++++++------- homeassistant/config.py | 3 +++ tests/components/cloud/test_init.py | 9 --------- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2844b0c88f3..58a2152f898 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -27,7 +27,7 @@ CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' MODE_DEV = 'development' -DEFAULT_MODE = MODE_DEV +DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] ALEXA_SCHEMA = vol.Schema({ @@ -42,10 +42,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV] + list(SERVERS)), # Change to optional when we include real servers - vol.Required(CONF_COGNITO_CLIENT_ID): str, - vol.Required(CONF_USER_POOL_ID): str, - vol.Required(CONF_REGION): str, - vol.Required(CONF_RELAYER): str, + vol.Optional(CONF_COGNITO_CLIENT_ID): str, + vol.Optional(CONF_USER_POOL_ID): str, + vol.Optional(CONF_REGION): str, + vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA }), }, extra=vol.ALLOW_EXTRA) @@ -117,10 +117,6 @@ class Cloud: @property def subscription_expired(self): """Return a boolen if the subscription has expired.""" - # For now, don't enforce subscriptions to exist - if 'custom:sub-exp' not in self.claims: - return False - return dt_util.utcnow() > self.expiration_date @property diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 440e4179eea..b13ec6d1e45 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -4,13 +4,12 @@ CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 SERVERS = { - # Example entry: - # 'production': { - # 'cognito_client_id': '', - # 'user_pool_id': '', - # 'region': '', - # 'relayer': '' - # } + 'production': { + 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', + 'user_pool_id': 'us-east-1_87ll5WOP8', + 'region': 'us-east-1', + 'relayer': 'wss://cloud.hass.io:8000/websocket' + } } MESSAGE_EXPIRATION = """ diff --git a/homeassistant/config.py b/homeassistant/config.py index c4c96804fca..fee7572a2c2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -110,6 +110,9 @@ sensor: tts: - platform: google +# Cloud +cloud: + group: !include groups.yaml automation: !include automations.yaml script: !include scripts.yaml diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c05fdabf465..c5bb6f7fda7 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -132,15 +132,6 @@ def test_write_user_info(): } -@asyncio.coroutine -def test_subscription_not_expired_without_sub_in_claim(): - """Test that we do not enforce subscriptions yet.""" - cl = cloud.Cloud(None, cloud.MODE_DEV) - cl.id_token = jwt.encode({}, 'test') - - assert not cl.subscription_expired - - @asyncio.coroutine def test_subscription_expired(): """Test subscription being expired.""" From a46ddcf6ddaadd85710db7a829ec1fb0fda91734 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 16 Dec 2017 14:22:23 +0100 Subject: [PATCH 94/97] Add install mode to homematic (#11164) --- .../components/homematic/__init__.py | 53 +++++++++++++++++-- .../components/homematic/services.yaml | 24 +++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index ffee6278f40..a11c8c0f22c 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,10 +20,11 @@ from homeassistant.const import ( from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass REQUIREMENTS = ['pyhomematic==0.1.36'] - DOMAIN = 'homematic' +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) @@ -44,6 +45,8 @@ ATTR_VALUE = 'value' ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' +ATTR_MODE = 'mode' +ATTR_TIME = 'time' EVENT_KEYPRESS = 'homematic.keypress' EVENT_IMPULSE = 'homematic.impulse' @@ -53,6 +56,7 @@ SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' SERVICE_SET_DEVICE_VALUE = 'set_device_value' +SERVICE_SET_INSTALL_MODE = 'set_install_mode' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ @@ -116,8 +120,6 @@ HM_IMPULSE_EVENTS = [ 'SEQUENCE_OK', ] -_LOGGER = logging.getLogger(__name__) - CONF_RESOLVENAMES_OPTIONS = [ 'metadata', 'json', @@ -203,7 +205,16 @@ SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ SCHEMA_SERVICE_RECONNECT = vol.Schema({}) +SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({ + vol.Required(ATTR_INTERFACE): cv.string, + vol.Optional(ATTR_TIME, default=60): cv.positive_int, + vol.Optional(ATTR_MODE, default=1): + vol.All(vol.Coerce(int), vol.In([1, 2])), + vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), +}) + +@bind_hass def virtualkey(hass, address, channel, param, interface=None): """Send virtual keypress to homematic controlller.""" data = { @@ -216,7 +227,8 @@ def virtualkey(hass, address, channel, param, interface=None): hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) -def set_var_value(hass, entity_id, value): +@bind_hass +def set_variable_value(hass, entity_id, value): """Change value of a Homematic system variable.""" data = { ATTR_ENTITY_ID: entity_id, @@ -226,7 +238,8 @@ def set_var_value(hass, entity_id, value): hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, interface=None): +@bind_hass +def set_device_value(hass, address, channel, param, value, interface=None): """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, @@ -239,6 +252,22 @@ def set_dev_value(hass, address, channel, param, value, interface=None): hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) +@bind_hass +def set_install_mode(hass, interface, mode=None, time=None, address=None): + """Call setInstallMode XML-RPC method of supplied inteface.""" + data = { + key: value for key, value in ( + (ATTR_INTERFACE, interface), + (ATTR_MODE, mode), + (ATTR_TIME, time), + (ATTR_ADDRESS, address) + ) if value + } + + hass.services.call(DOMAIN, SERVICE_SET_INSTALL_MODE, data) + + +@bind_hass def reconnect(hass): """Reconnect to CCU/Homegear.""" hass.services.call(DOMAIN, SERVICE_RECONNECT, {}) @@ -383,6 +412,20 @@ def setup(hass, config): descriptions[SERVICE_SET_DEVICE_VALUE], schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) + def _service_handle_install_mode(service): + """Service to set interface into install mode.""" + interface = service.data.get(ATTR_INTERFACE) + mode = service.data.get(ATTR_MODE) + time = service.data.get(ATTR_TIME) + address = service.data.get(ATTR_ADDRESS) + + homematic.setInstallMode(interface, t=time, mode=mode, address=address) + + hass.services.register( + DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, + descriptions[SERVICE_SET_INSTALL_MODE], + schema=SCHEMA_SERVICE_SET_INSTALL_MODE) + return True diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml index 76ecdbd0a4f..bf4d99af9e7 100644 --- a/homeassistant/components/homematic/services.yaml +++ b/homeassistant/components/homematic/services.yaml @@ -13,8 +13,8 @@ virtualkey: description: Event to send i.e. PRESS_LONG, PRESS_SHORT. example: PRESS_LONG interface: - description: (Optional) for set a hosts value. - example: Hosts name from config + description: (Optional) for set a interface value. + example: Interfaces name from config set_variable_value: description: Set the name of a node. @@ -42,11 +42,27 @@ set_device_value: description: Event to send i.e. PRESS_LONG, PRESS_SHORT example: PRESS_LONG interface: - description: (Optional) for set a hosts value - example: Hosts name from config + description: (Optional) for set a interface value + example: Interfaces name from config value: description: New value example: 1 reconnect: description: Reconnect to all Homematic Hubs. + +set_install_mode: + description: Set a RPC XML interface into installation mode. + fields: + interface: + description: Select the given interface into install mode + example: Interfaces name from config + mode: + description: (Default 1) 1= Normal mode / 2= Remove exists old links + example: 1 + time: + description: (Default 60) Time in seconds to run in install mode + example: 1 + address: + description: (Optional) Address of homematic device or BidCoS-RF to learn + example: LEQ3948571 From 294d8171a220841a1a24affe0172eeaed53d0c05 Mon Sep 17 00:00:00 2001 From: PhracturedBlue Date: Sat, 16 Dec 2017 15:52:59 -0800 Subject: [PATCH 95/97] convert alarmdecoder interface from async to sync (#11168) * convert alarmdecoder interface from async to sync * Convert he rest of alarmdecoder rom async to sync * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py * Update alarmdecoder.py --- .../alarm_control_panel/alarmdecoder.py | 52 ++++++------------- homeassistant/components/alarmdecoder.py | 51 ++++++------------ .../components/binary_sensor/alarmdecoder.py | 40 +++++--------- .../components/sensor/alarmdecoder.py | 21 +++----- 4 files changed, 54 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 3b58eb0b71d..d5fbbec5998 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -7,30 +7,21 @@ https://home-assistant.io/components/alarm_control_panel.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.components.alarm_control_panel as alarm - -from homeassistant.components.alarmdecoder import (DATA_AD, - SIGNAL_PANEL_MESSAGE) - +from homeassistant.components.alarmdecoder import ( + DATA_AD, SIGNAL_PANEL_MESSAGE) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN, STATE_ALARM_TRIGGERED) + STATE_ALARM_TRIGGERED) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up for AlarmDecoder alarm panels.""" - _LOGGER.debug("AlarmDecoderAlarmPanel: setup") - - device = AlarmDecoderAlarmPanel("Alarm Panel", hass) - - async_add_devices([device]) + add_devices([AlarmDecoderAlarmPanel()]) return True @@ -38,38 +29,35 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Representation of an AlarmDecoder-based alarm panel.""" - def __init__(self, name, hass): + def __init__(self): """Initialize the alarm panel.""" self._display = "" - self._name = name - self._state = STATE_UNKNOWN - - _LOGGER.debug("Setting up panel") + self._name = "Alarm Panel" + self._state = None @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def name(self): @@ -91,26 +79,20 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): """Return the state of the device.""" return self._state - @asyncio.coroutine - def async_alarm_disarm(self, code=None): + def alarm_disarm(self, code=None): """Send disarm command.""" - _LOGGER.debug("alarm_disarm: %s", code) if code: _LOGGER.debug("alarm_disarm: sending %s1", str(code)) self.hass.data[DATA_AD].send("{!s}1".format(code)) - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): + def alarm_arm_away(self, code=None): """Send arm away command.""" - _LOGGER.debug("alarm_arm_away: %s", code) if code: _LOGGER.debug("alarm_arm_away: sending %s2", str(code)) self.hass.data[DATA_AD].send("{!s}2".format(code)) - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): + def alarm_arm_home(self, code=None): """Send arm home command.""" - _LOGGER.debug("alarm_arm_home: %s", code) if code: _LOGGER.debug("alarm_arm_home: sending %s3", str(code)) self.hass.data[DATA_AD].send("{!s}3".format(code)) diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 011cc3ad21d..6e30a83d96a 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -4,16 +4,13 @@ Support for AlarmDecoder devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/alarmdecoder/ """ -import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.discovery import load_platform REQUIREMENTS = ['alarmdecoder==0.12.3'] @@ -71,9 +68,9 @@ ZONE_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_DEVICE): vol.Any(DEVICE_SOCKET_SCHEMA, - DEVICE_SERIAL_SCHEMA, - DEVICE_USB_SCHEMA), + vol.Required(CONF_DEVICE): vol.Any( + DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA, + DEVICE_USB_SCHEMA), vol.Optional(CONF_PANEL_DISPLAY, default=DEFAULT_PANEL_DISPLAY): cv.boolean, vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, @@ -81,8 +78,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +def setup(hass, config): """Set up for the AlarmDecoder devices.""" from alarmdecoder import AlarmDecoder from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice) @@ -99,32 +95,25 @@ def async_setup(hass, config): path = DEFAULT_DEVICE_PATH baud = DEFAULT_DEVICE_BAUD - sync_connect = asyncio.Future(loop=hass.loop) - - def handle_open(device): - """Handle the successful connection.""" - _LOGGER.info("Established a connection with the alarmdecoder") - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - sync_connect.set_result(True) - - @callback def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" _LOGGER.debug("Shutting down alarmdecoder") controller.close() - @callback def handle_message(sender, message): """Handle message from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, message) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_PANEL_MESSAGE, message) def zone_fault_callback(sender, zone): """Handle zone fault from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_FAULT, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_FAULT, zone) def zone_restore_callback(sender, zone): """Handle zone restore from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_RESTORE, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_RESTORE, zone) controller = False if device_type == 'socket': @@ -139,7 +128,6 @@ def async_setup(hass, config): AlarmDecoder(USBDevice.find()) return False - controller.on_open += handle_open controller.on_message += handle_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback @@ -148,21 +136,16 @@ def async_setup(hass, config): controller.open(baud) - result = yield from sync_connect + _LOGGER.debug("Established a connection with the alarmdecoder") + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - if not result: - return False - - hass.async_add_job( - async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, - config)) + load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) if zones: - hass.async_add_job(async_load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)) + load_platform( + hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) if display: - hass.async_add_job(async_load_platform( - hass, 'sensor', DOMAIN, conf, config)) + load_platform(hass, 'sensor', DOMAIN, conf, config) return True diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index bc05e4d84d8..f42d0de4bb0 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -7,39 +7,29 @@ https://home-assistant.io/components/binary_sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.binary_sensor import BinarySensorDevice - -from homeassistant.components.alarmdecoder import (ZONE_SCHEMA, - CONF_ZONES, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONE_FAULT, - SIGNAL_ZONE_RESTORE) - +from homeassistant.components.alarmdecoder import ( + ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, + SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE) DEPENDENCIES = ['alarmdecoder'] _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the AlarmDecoder binary sensor devices.""" configured_zones = discovery_info[CONF_ZONES] devices = [] - for zone_num in configured_zones: device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] - device = AlarmDecoderBinarySensor( - hass, zone_num, zone_name, zone_type) + device = AlarmDecoderBinarySensor(zone_num, zone_name, zone_type) devices.append(device) - async_add_devices(devices) + add_devices(devices) return True @@ -47,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type): + def __init__(self, zone_number, zone_name, zone_type): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type @@ -55,16 +45,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): self._name = zone_name self._type = zone_type - _LOGGER.debug("Setup up zone: %s", self._name) - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_FAULT, self._fault_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback) - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_RESTORE, self._restore_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback) @property def name(self): @@ -97,16 +85,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Return the class of this sensor, from DEVICE_CLASSES.""" return self._zone_type - @callback def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() - @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index 6b026298db0..ce709eee94c 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -7,25 +7,21 @@ https://home-assistant.io/components/sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.components.alarmdecoder import (SIGNAL_PANEL_MESSAGE) -from homeassistant.const import (STATE_UNKNOWN) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up for AlarmDecoder sensor devices.""" - _LOGGER.debug("AlarmDecoderSensor: async_setup_platform") + _LOGGER.debug("AlarmDecoderSensor: setup_platform") device = AlarmDecoderSensor(hass) - async_add_devices([device]) + add_devices([device]) class AlarmDecoderSensor(Entity): @@ -34,23 +30,20 @@ class AlarmDecoderSensor(Entity): def __init__(self, hass): """Initialize the alarm panel.""" self._display = "" - self._state = STATE_UNKNOWN + self._state = None self._icon = 'mdi:alarm-check' self._name = 'Alarm Panel Display' - _LOGGER.debug("Setting up panel") - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def icon(self): From 432304be82245c71b5e78ba2e91321b94d8ee7ec Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Sat, 16 Dec 2017 13:29:40 -0800 Subject: [PATCH 96/97] Remove logging (#11173) An error was being log that seems more like debug info --- homeassistant/components/sensor/octoprint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 85b388a1919..71b72b0a671 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -45,7 +45,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) tools = octoprint_api.get_tools() - _LOGGER.error(str(tools)) if "Temperatures" in monitored_conditions: if not tools: From c03d04d826d5e13efa0042b8959effc2cf696805 Mon Sep 17 00:00:00 2001 From: Brad Dixon Date: Sun, 17 Dec 2017 07:08:35 -0500 Subject: [PATCH 97/97] Revbump to SoCo 0.13 and add support for Night Sound and Speech Enhancement. (#10765) Sonos Playbar and Playbase devices support Night Sound and Speech Enhancement effects when playing from sources such as a TV. Adds a new service "sonos_set_option" whichs accepts boolean options to control these audio features. --- .../components/media_player/services.yaml | 12 ++++ .../components/media_player/sonos.py | 55 ++++++++++++++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/media_player/test_sonos.py | 18 ++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index f2d7b8e07dd..b2f98d378cf 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -215,6 +215,18 @@ sonos_clear_sleep_timer: description: Name(s) of entities that will have the timer cleared. example: 'media_player.living_room_sonos' +sonos_set_option: + description: Set Sonos sound options. + fields: + entity_id: + description: Name(s) of entities that will have options set. + example: 'media_player.living_room_sonos' + night_sound: + description: Enable Night Sound mode + example: 'true' + speech_enhance: + description: Enable Speech Enhancement mode + example: 'true' soundtouch_play_everywhere: description: Play on all Bose Soundtouch devices. diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index f9a18a212f5..3bd3a722b46 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -27,7 +27,7 @@ from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.12'] +REQUIREMENTS = ['SoCo==0.13'] _LOGGER = logging.getLogger(__name__) @@ -52,6 +52,7 @@ SERVICE_RESTORE = 'sonos_restore' SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' SERVICE_UPDATE_ALARM = 'sonos_update_alarm' +SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' @@ -69,6 +70,8 @@ ATTR_ENABLED = 'enabled' ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' ATTR_MASTER = 'master' ATTR_WITH_GROUP = 'with_group' +ATTR_NIGHT_SOUND = 'night_sound' +ATTR_SPEECH_ENHANCE = 'speech_enhance' ATTR_IS_COORDINATOR = 'is_coordinator' @@ -105,6 +108,11 @@ SONOS_UPDATE_ALARM_SCHEMA = SONOS_SCHEMA.extend({ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }) +SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ + vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, + vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" @@ -192,6 +200,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device.clear_sleep_timer() elif service.service == SERVICE_UPDATE_ALARM: device.update_alarm(**service.data) + elif service.service == SERVICE_SET_OPTION: + device.update_option(**service.data) device.schedule_update_ha_state(True) @@ -224,6 +234,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_UPDATE_ALARM), schema=SONOS_UPDATE_ALARM_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_SET_OPTION, service_handle, + descriptions.get(SERVICE_SET_OPTION), + schema=SONOS_SET_OPTION_SCHEMA) + def _parse_timespan(timespan): """Parse a time-span into number of seconds.""" @@ -337,6 +352,8 @@ class SonosDevice(MediaPlayerDevice): self._support_shuffle_set = True self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._current_track_uri = None self._current_track_is_radio_stream = False self._queue = None @@ -457,6 +474,8 @@ class SonosDevice(MediaPlayerDevice): self._support_shuffle_set = False self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._is_playing_tv = False self._is_playing_line_in = False self._source_name = None @@ -529,6 +548,9 @@ class SonosDevice(MediaPlayerDevice): media_position_updated_at = None source_name = None + night_sound = self._player.night_mode + speech_enhance = self._player.dialog_mode + is_radio_stream = \ current_media_uri.startswith('x-sonosapi-stream:') or \ current_media_uri.startswith('x-rincon-mp3radio:') @@ -705,6 +727,8 @@ class SonosDevice(MediaPlayerDevice): self._support_shuffle_set = support_shuffle_set self._support_stop = support_stop self._support_pause = support_pause + self._night_sound = night_sound + self._speech_enhance = speech_enhance self._is_playing_tv = is_playing_tv self._is_playing_line_in = is_playing_line_in self._source_name = source_name @@ -848,6 +872,16 @@ class SonosDevice(MediaPlayerDevice): return self._media_title + @property + def night_sound(self): + """Get status of Night Sound.""" + return self._night_sound + + @property + def speech_enhance(self): + """Get status of Speech Enhancement.""" + return self._speech_enhance + @property def supported_features(self): """Flag media player features that are supported.""" @@ -1179,7 +1213,24 @@ class SonosDevice(MediaPlayerDevice): a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] a.save() + @soco_error + def update_option(self, **data): + """Modify playback options.""" + if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + self.soco.night_mode = data[ATTR_NIGHT_SOUND] + + if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] + @property def device_state_attributes(self): """Return device specific state attributes.""" - return {ATTR_IS_COORDINATOR: self.is_coordinator} + attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} + + if self.night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self.night_sound + + if self.speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + + return attributes diff --git a/requirements_all.txt b/requirements_all.txt index 89711fe9c96..02a53b9c26e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -44,7 +44,7 @@ PyXiaomiGateway==0.6.0 RtmAPI==0.7.0 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.sensor.travisci TravisPy==0.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 877e129e0ff..a96c3af1fd9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,7 +24,7 @@ freezegun>=0.3.8 PyJWT==1.5.3 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 33f7a0e882d..815204e718a 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -389,3 +389,21 @@ class TestSonosMediaPlayer(unittest.TestCase): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) + + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_sonos_set_option(self, option_mock, *args): + """Ensuring soco methods called for sonos_set_option service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + option_mock.return_value = True + device._snapshot_coordinator = mock.MagicMock() + device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') + + device.update_option(night_sound=True, speech_enhance=True) + + self.assertEqual(option_mock.call_count, 1)