From 044b96e3cd9ccacb00dbc5bb010d949a2127dd1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 25 Aug 2017 08:44:35 -0700 Subject: [PATCH 001/108] Version bump to 0.53.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 19ce7e470c2..dd8a579b033 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 52 +MINOR_VERSION = 53 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 8775c54d29a4adcc11a55c4a37db7da54eccc6e7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 25 Aug 2017 17:58:05 +0200 Subject: [PATCH 002/108] Refactor mysensors callback and add validation (#9069) * Refactor mysensors callback and add validation * Add mysensors entity class. The mysensors entity class inherits from a more general mysensors device class. * Extract mysensors name function. * Add setup_mysensors_platform for mysensors platforms. * Add mysensors const schemas. * Update mysensors callback and add child validation. * Remove gateway wrapper class. * Add better logging for mysensors callback. * Add discover_persistent_devices function. * Remove discovery in mysensors component setup. * Clean up gateway storage in hass.data. * Update all mysensors platforms. * Add repr for MySensorsNotificationDevice. * Fix bug in mysensors climate target temperatures. * Clean up platforms. Child validation simplifies assumptions in platforms. * Remove not needed try except statements. All messages are validated already in pymysensors. * Clean up logging. * Add timer debug logging if callback is slow. * Upgrade pymysensors to 0.11.0. * Make dispatch callback async * Pass tuple device_args and optional add_devices * Also return new_devices as list instead of dictionary. --- .../components/binary_sensor/mysensors.py | 49 +- homeassistant/components/climate/mysensors.py | 94 +--- homeassistant/components/cover/mysensors.py | 34 +- .../components/device_tracker/mysensors.py | 90 ++-- homeassistant/components/light/mysensors.py | 172 ++----- homeassistant/components/mysensors.py | 480 +++++++++++++----- homeassistant/components/notify/mysensors.py | 45 +- homeassistant/components/sensor/mysensors.py | 79 +-- homeassistant/components/switch/mysensors.py | 116 +---- requirements_all.txt | 2 +- 10 files changed, 492 insertions(+), 669 deletions(-) diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 767ed858ec7..4b83f0c8f2d 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -4,62 +4,27 @@ Support for MySensors binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.binary_sensor import (DEVICE_CLASSES, +from homeassistant.components.binary_sensor import (DEVICE_CLASSES, DOMAIN, BinarySensorDevice) from homeassistant.const import STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_TRIPPED], - pres.S_MOTION: [set_req.V_TRIPPED], - pres.S_SMOKE: [set_req.V_TRIPPED], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_SPRINKLER: [set_req.V_TRIPPED], - pres.S_WATER_LEAK: [set_req.V_TRIPPED], - pres.S_SOUND: [set_req.V_TRIPPED], - pres.S_VIBRATION: [set_req.V_TRIPPED], - pres.S_MOISTURE: [set_req.V_TRIPPED], - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsBinarySensor, add_devices)) + """Setup the mysensors platform for binary sensors.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsBinarySensor, + add_devices=add_devices) class MySensorsBinarySensor( - mysensors.MySensorsDeviceEntity, BinarySensorDevice): + mysensors.MySensorsEntity, BinarySensorDevice): """Represent the value of a MySensors Binary Sensor child node.""" @property def is_on(self): """Return True if the binary sensor is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON @property def device_class(self): diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 82ed8a94e2b..d4316c2cfba 100755 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -4,15 +4,11 @@ MySensors platform that offers a Climate (MySensors-HVAC) component. For more details about this platform, please refer to the documentation https://home-assistant.io/components/climate.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.climate import ( - STATE_COOL, STATE_HEAT, STATE_OFF, STATE_AUTO, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE - -_LOGGER = logging.getLogger(__name__) + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, STATE_AUTO, + STATE_COOL, STATE_HEAT, STATE_OFF, ClimateDevice) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT DICT_HA_TO_MYS = { STATE_AUTO: 'AutoChangeOver', @@ -29,28 +25,12 @@ DICT_MYS_TO_HA = { def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the mysensors climate.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 1.5: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_HVAC: [set_req.V_HVAC_FLOW_STATE], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsHVAC, add_devices)) + """Setup the mysensors climate.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsHVAC, add_devices=add_devices) -class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): +class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property @@ -84,26 +64,28 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) + return float(temp) if temp is not None else None @property def target_temperature_high(self): """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_HEAT in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_COOL)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) + return float(temp) if temp is not None else None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_COOL in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_HEAT)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) + return float(temp) if temp is not None else None @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.gateway.const.SetReq.V_HVAC_FLOW_STATE) + return self._values.get(self.value_type) @property def operation_list(self): @@ -128,7 +110,7 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): high = kwargs.get(ATTR_TARGET_TEMP_HIGH) heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - updates = () + updates = [] if temp is not None: if heat is not None: # Set HEAT Target temperature @@ -146,7 +128,7 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[value_type] = value self.schedule_update_ha_state() @@ -156,54 +138,22 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): """Set new target temperature.""" - set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_FLOW_STATE, + self.node_id, self.child_id, self.value_type, DICT_HA_TO_MYS[operation_mode]) if self.gateway.optimistic: - # optimistically assume that switch has changed state - self._values[set_req.V_HVAC_FLOW_STATE] = operation_mode + # optimistically assume that device has changed state + self._values[self.value_type] = operation_mode self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - set_req = self.gateway.const.SetReq - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - if value_type == set_req.V_HVAC_FLOW_STATE: - self._values[value_type] = DICT_MYS_TO_HA[value] - else: - self._values[value_type] = value - - def set_humidity(self, humidity): - """Set new target humidity.""" - _LOGGER.error("Service Not Implemented yet") - - def set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_on(self): - """Turn away mode on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_off(self): - """Turn away mode off.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - _LOGGER.error("Service Not Implemented yet") + super().update() + self._values[self.value_type] = DICT_MYS_TO_HA[ + self._values[self.value_type]] diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index f48a2110eca..cd4ff62b3e9 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -4,42 +4,18 @@ Support for MySensors covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.cover import CoverDevice, ATTR_POSITION +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, DOMAIN from homeassistant.const import STATE_ON, STATE_OFF -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for covers.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_COVER: [set_req.V_DIMMER, set_req.V_LIGHT], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COVER: [set_req.V_PERCENTAGE, set_req.V_STATUS], - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsCover, add_devices)) + """Setup the mysensors platform for covers.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsCover, add_devices=add_devices) -class MySensorsCover(mysensors.MySensorsDeviceEntity, CoverDevice): +class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 4503c4d1b26..f68eb361ca0 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -4,61 +4,51 @@ Support for tracking MySensors devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.device_tracker import DOMAIN +from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.util import slugify -DEPENDENCIES = ['mysensors'] - -_LOGGER = logging.getLogger(__name__) - def setup_scanner(hass, config, see, discovery_info=None): - """Set up the MySensors tracker.""" - def mysensors_callback(gateway, msg): - """Set up callback for mysensors platform.""" - node = gateway.sensors[msg.node_id] - if node.sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return + """Set up the MySensors device scanner.""" + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsDeviceScanner, + device_args=(see, )) + if not new_devices: + return False - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - - child = node.children.get(msg.child_id) - if child is None: - return - position = child.values.get(set_req.V_POSITION) - if child.type != pres.S_GPS or position is None: - return - try: - latitude, longitude, _ = position.split(',') - except ValueError: - _LOGGER.error("Payload for V_POSITION %s is not of format " - "latitude, longitude, altitude", position) - return - name = '{} {} {}'.format( - node.sketch_name, msg.node_id, child.id) - attr = { - mysensors.ATTR_CHILD_ID: child.id, - mysensors.ATTR_DESCRIPTION: child.description, - mysensors.ATTR_DEVICE: gateway.device, - mysensors.ATTR_NODE_ID: msg.node_id, - } - see( - dev_id=slugify(name), - host_name=name, - gps=(latitude, longitude), - battery=node.battery_level, - attributes=attr - ) - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - gateway.platform_callbacks.append(mysensors_callback) + for device in new_devices: + dev_id = ( + id(device.gateway), device.node_id, device.child_id, + device.value_type) + dispatcher_connect( + hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + device.update_callback) return True + + +class MySensorsDeviceScanner(mysensors.MySensorsDevice): + """Represent a MySensors scanner.""" + + def __init__(self, see, *args): + """Set up instance.""" + super().__init__(*args) + self.see = see + + def update_callback(self): + """Update the device.""" + self.update() + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + position = child.values[self.value_type] + latitude, longitude, _ = position.split(',') + + self.see( + dev_id=slugify(self.name), + host_name=self.name, + gps=(latitude, longitude), + battery=node.battery_level, + attributes=self.device_state_attributes + ) diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 203119e5e51..c41f480c67e 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -4,64 +4,35 @@ Support for MySensors lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, Light) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import rgb_hex_to_rgb_list -_LOGGER = logging.getLogger(__name__) -ATTR_VALUE = 'value' -ATTR_VALUE_TYPE = 'value_type' - SUPPORT_MYSENSORS = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | SUPPORT_WHITE_VALUE) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for lights.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DIMMER: [set_req.V_DIMMER], - } - device_class_map = { - pres.S_DIMMER: MySensorsLightDimmer, - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_RGB_LIGHT: [set_req.V_RGB], - pres.S_RGBW_LIGHT: [set_req.V_RGBW], - }) - map_sv_types[pres.S_DIMMER].append(set_req.V_PERCENTAGE) - device_class_map.update({ - pres.S_RGB_LIGHT: MySensorsLightRGB, - pres.S_RGBW_LIGHT: MySensorsLightRGBW, - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, device_class_map, add_devices)) + """Setup the mysensors platform for lights.""" + device_class_map = { + 'S_DIMMER': MySensorsLightDimmer, + 'S_RGB_LIGHT': MySensorsLightRGB, + 'S_RGBW_LIGHT': MySensorsLightRGBW, + } + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, device_class_map, + add_devices=add_devices) -class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): +class MySensorsLight(mysensors.MySensorsEntity, Light): """Representation of a MySensors Light child node.""" def __init__(self, *args): """Initialize a MySensors Light.""" - mysensors.MySensorsDeviceEntity.__init__(self, *args) + super().__init__(*args) self._state = None self._brightness = None self._rgb = None @@ -101,7 +72,7 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): """Turn on light child device.""" set_req = self.gateway.const.SetReq - if self._state or set_req.V_LIGHT not in self._values: + if self._state: return self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 1) @@ -110,7 +81,6 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): # optimistically assume that light has changed state self._state = True self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() def _turn_on_dimmer(self, **kwargs): """Turn on dimmer child device.""" @@ -130,7 +100,6 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): # optimistically assume that light has changed state self._brightness = brightness self._values[set_req.V_DIMMER] = percent - self.schedule_update_ha_state() def _turn_on_rgb_and_w(self, hex_template, **kwargs): """Turn on RGB or RGBW child device.""" @@ -144,16 +113,11 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): return if new_rgb is not None: rgb = list(new_rgb) - if rgb is None: - return if hex_template == '%02x%02x%02x%02x': if new_white is not None: rgb.append(new_white) - elif white is not None: - rgb.append(white) else: - _LOGGER.error("White value is not updated for RGBW light") - return + rgb.append(white) hex_color = hex_template % tuple(rgb) if len(rgb) > 3: white = rgb.pop() @@ -164,104 +128,40 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): # optimistically assume that light has changed state self._rgb = rgb self._white = white - if hex_color: - self._values[self.value_type] = hex_color - self.schedule_update_ha_state() + self._values[self.value_type] = hex_color - def _turn_off_light(self, value_type=None, value=None): - """Turn off light child device.""" - set_req = self.gateway.const.SetReq - value_type = ( - set_req.V_LIGHT - if set_req.V_LIGHT in self._values else value_type) - value = 0 if set_req.V_LIGHT in self._values else value - return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value} - - def _turn_off_dimmer(self, value_type=None, value=None): - """Turn off dimmer child device.""" - set_req = self.gateway.const.SetReq - value_type = ( - set_req.V_DIMMER - if set_req.V_DIMMER in self._values else value_type) - value = 0 if set_req.V_DIMMER in self._values else value - return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value} - - def _turn_off_rgb_or_w(self, value_type=None, value=None): - """Turn off RGB or RGBW child device.""" - if float(self.gateway.protocol_version) >= 1.5: - set_req = self.gateway.const.SetReq - if self.value_type == set_req.V_RGB: - value = '000000' - elif self.value_type == set_req.V_RGBW: - value = '00000000' - return {ATTR_VALUE_TYPE: self.value_type, ATTR_VALUE: value} - - def _turn_off_main(self, value_type=None, value=None): + def turn_off(self): """Turn the device off.""" - set_req = self.gateway.const.SetReq - if value_type is None or value is None: - _LOGGER.warning( - "%s: value_type %s, value = %s, None is not valid argument " - "when setting child value", self._name, value_type, value) - return + value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( - self.node_id, self.child_id, value_type, value) + self.node_id, self.child_id, value_type, 0) if self.gateway.optimistic: # optimistically assume that light has changed state self._state = False - self._values[value_type] = ( - STATE_OFF if set_req.V_LIGHT in self._values else value) + self._values[value_type] = STATE_OFF self.schedule_update_ha_state() def _update_light(self): """Update the controller with values from light child.""" value_type = self.gateway.const.SetReq.V_LIGHT - if value_type in self._values: - self._values[value_type] = ( - STATE_ON if int(self._values[value_type]) == 1 else STATE_OFF) - self._state = self._values[value_type] == STATE_ON + self._state = self._values[value_type] == STATE_ON def _update_dimmer(self): """Update the controller with values from dimmer child.""" - set_req = self.gateway.const.SetReq - value_type = set_req.V_DIMMER + value_type = self.gateway.const.SetReq.V_DIMMER if value_type in self._values: self._brightness = round(255 * int(self._values[value_type]) / 100) if self._brightness == 0: self._state = False - if set_req.V_LIGHT not in self._values: - self._state = self._brightness > 0 def _update_rgb_or_w(self): """Update the controller with values from RGB or RGBW child.""" - set_req = self.gateway.const.SetReq value = self._values[self.value_type] - if len(value) != 6 and len(value) != 8: - _LOGGER.error( - "Wrong value %s for %s", value, set_req(self.value_type).name) - return color_list = rgb_hex_to_rgb_list(value) - if set_req.V_LIGHT not in self._values and \ - set_req.V_DIMMER not in self._values: - self._state = max(color_list) > 0 if len(color_list) > 3: - if set_req.V_RGBW != self.value_type: - _LOGGER.error( - "Wrong value %s for %s", - value, set_req(self.value_type).name) - return self._white = color_list.pop() self._rgb = color_list - def _update_main(self): - """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - self._values[value_type] = value - class MySensorsLightDimmer(MySensorsLight): """Dimmer child class to MySensorsLight.""" @@ -270,18 +170,12 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - - def turn_off(self, **kwargs): - """Turn the device off.""" - ret = self._turn_off_dimmer() - ret = self._turn_off_light( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - self._turn_off_main( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) + if self.gateway.optimistic: + self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - self._update_main() + super().update() self._update_light() self._update_dimmer() @@ -294,20 +188,12 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs) - - def turn_off(self, **kwargs): - """Turn the device off.""" - ret = self._turn_off_rgb_or_w() - ret = self._turn_off_dimmer( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - ret = self._turn_off_light( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) - self._turn_off_main( - value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE]) + if self.gateway.optimistic: + self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - self._update_main() + super().update() self._update_light() self._update_dimmer() self._update_rgb_or_w() @@ -316,8 +202,12 @@ class MySensorsLightRGB(MySensorsLight): class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" + # pylint: disable=too-many-ancestors + def turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs) + if self.gateway.optimistic: + self.schedule_update_ha_state() diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index ef863bfb34f..63bd1f6faac 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -4,30 +4,37 @@ Connect to a MySensors gateway via pymysensors API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ +import asyncio +from collections import defaultdict import logging import os import socket import sys +from timeit import default_timer as timer import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.setup import setup_component from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +from homeassistant.setup import setup_component -REQUIREMENTS = ['pymysensors==0.10.0'] +REQUIREMENTS = ['pymysensors==0.11.0'] _LOGGER = logging.getLogger(__name__) ATTR_CHILD_ID = 'child_id' ATTR_DESCRIPTION = 'description' ATTR_DEVICE = 'device' +ATTR_DEVICES = 'devices' ATTR_NODE_ID = 'node_id' CONF_BAUD_RATE = 'baud_rate' @@ -44,11 +51,16 @@ CONF_VERSION = 'version' DEFAULT_BAUD_RATE = 115200 DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = 1.4 +DEFAULT_VERSION = '1.4' DOMAIN = 'mysensors' MQTT_COMPONENT = 'mqtt' MYSENSORS_GATEWAYS = 'mysensors_gateways' +MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' +PLATFORM = 'platform' +SCHEMA = 'schema' +SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +TYPE = 'type' def is_socket_address(value): @@ -144,11 +156,127 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.Coerce(float), + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, })) }, extra=vol.ALLOW_EXTRA) +# mysensors const schemas +BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} +CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} +LIGHT_DIMMER_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_DIMMER', + SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} +LIGHT_PERCENTAGE_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_PERCENTAGE', + SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGB_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { + 'V_RGB': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGBW_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { + 'V_RGBW': cv.string, 'V_STATUS': cv.string}} +NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} +DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} +DUST_SCHEMA = [ + {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] +SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} +SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} +MYSENSORS_CONST_SCHEMA = { + 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SPRINKLER': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_WATER_LEAK': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SOUND': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_VIBRATION': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOISTURE': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_HVAC': [CLIMATE_SCHEMA], + 'S_COVER': [ + {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, + {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, + {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, + {PLATFORM: 'cover', TYPE: 'V_STATUS'}], + 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], + 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], + 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], + 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], + 'S_GPS': [ + DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], + 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], + 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], + 'S_BARO': [ + {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, + {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], + 'S_WIND': [ + {PLATFORM: 'sensor', TYPE: 'V_WIND'}, + {PLATFORM: 'sensor', TYPE: 'V_GUST'}, + {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], + 'S_RAIN': [ + {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, + {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], + 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], + 'S_WEIGHT': [ + {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_POWER': [ + {PLATFORM: 'sensor', TYPE: 'V_WATT'}, + {PLATFORM: 'sensor', TYPE: 'V_KWH'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR'}, + {PLATFORM: 'sensor', TYPE: 'V_VA'}, + {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], + 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], + 'S_LIGHT_LEVEL': [ + {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], + 'S_IR': [ + {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, + {PLATFORM: 'switch', TYPE: 'V_IR_SEND', + SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], + 'S_WATER': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_CUSTOM': [ + {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, + {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], + 'S_SCENE_CONTROLLER': [ + {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, + {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], + 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], + 'S_MULTIMETER': [ + {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, + {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_GAS': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_WATER_QUALITY': [ + {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, + {PLATFORM: 'sensor', TYPE: 'V_PH'}, + {PLATFORM: 'sensor', TYPE: 'V_ORP'}, + {PLATFORM: 'sensor', TYPE: 'V_EC'}, + {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_AIR_QUALITY': DUST_SCHEMA, + 'S_DUST': DUST_SCHEMA, + 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], + 'S_BINARY': [SWITCH_STATUS_SCHEMA], + 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], +} + + def setup(hass, config): """Set up the MySensors component.""" import mysensors.mysensors as mysensors @@ -197,20 +325,14 @@ def setup(hass, config): # invalid ip address return gateway.metric = hass.config.units.is_metric - optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) - gateway = GatewayWrapper(gateway, optimistic, device) - # pylint: disable=attribute-defined-outside-init - gateway.event_callback = gateway.callback_factory() + gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) + gateway.device = device + gateway.event_callback = gw_callback_factory(hass) def gw_start(event): """Trigger to start of the gateway and any persistence.""" if persistence: - for node_id in gateway.sensors: - node = gateway.sensors[node_id] - for child_id in node.children: - msg = mysensors.Message().modify( - node_id=node_id, child_id=child_id) - gateway.event_callback(msg) + discover_persistent_devices(hass, gateway) gateway.start() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: gateway.stop()) @@ -219,15 +341,8 @@ def setup(hass, config): return gateway - gateways = hass.data.get(MYSENSORS_GATEWAYS) - if gateways is not None: - _LOGGER.error( - "%s already exists in %s, will not setup %s component", - MYSENSORS_GATEWAYS, hass.data, DOMAIN) - return False - # Setup all devices from config - gateways = [] + gateways = {} conf_gateways = config[DOMAIN][CONF_GATEWAYS] for index, gway in enumerate(conf_gateways): @@ -243,7 +358,7 @@ def setup(hass, config): device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) if ready_gateway is not None: - gateways.append(ready_gateway) + gateways[id(ready_gateway)] = ready_gateway if not gateways: _LOGGER.error( @@ -252,115 +367,187 @@ def setup(hass, config): hass.data[MYSENSORS_GATEWAYS] = gateways - for component in ['sensor', 'switch', 'light', 'binary_sensor', 'climate', - 'cover']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - discovery.load_platform( - hass, 'device_tracker', DOMAIN, {}, config) - - discovery.load_platform( - hass, 'notify', DOMAIN, {CONF_NAME: DOMAIN}, config) - return True -def pf_callback_factory(map_sv_types, devices, entity_class, add_devices=None): - """Return a new callback for the platform.""" - def mysensors_callback(gateway, msg): - """Run when a message from the gateway arrives.""" - if gateway.sensors[msg.node_id].sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return - child = gateway.sensors[msg.node_id].children.get(msg.child_id) +def validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated + + +def discover_mysensors_platform(hass, platform, new_devices): + """Discover a mysensors platform.""" + discovery.load_platform( + hass, platform, DOMAIN, {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}) + + +def discover_persistent_devices(hass, gateway): + """Discover platforms for devices loaded via persistence file.""" + new_devices = defaultdict(list) + for node_id in gateway.sensors: + node = gateway.sensors[node_id] + for child in node.children.values(): + validated = validate_child(gateway, node_id, child) + for platform, dev_ids in validated.items(): + new_devices[platform].extend(dev_ids) + for platform, dev_ids in new_devices.items(): + discover_mysensors_platform(hass, platform, dev_ids) + + +def get_mysensors_devices(hass, domain): + """Return mysensors devices for a platform.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: + hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] + + +def gw_callback_factory(hass): + """Return a new callback for the gateway.""" + def mysensors_callback(msg): + """Default callback for a mysensors gateway.""" + start = timer() + _LOGGER.debug( + "Node update: node %s child %s", msg.node_id, msg.child_id) + + child = msg.gateway.sensors[msg.node_id].children.get(msg.child_id) if child is None: + _LOGGER.debug( + "Not a child update for node %s", msg.node_id) return - for value_type in child.values: - key = msg.node_id, child.id, value_type - if child.type not in map_sv_types or \ - value_type not in map_sv_types[child.type]: - continue - if key in devices: - if add_devices: - devices[key].schedule_update_ha_state(True) - else: - devices[key].update() - continue - name = '{} {} {}'.format( - gateway.sensors[msg.node_id].sketch_name, msg.node_id, - child.id) - if isinstance(entity_class, dict): - device_class = entity_class[child.type] - else: - device_class = entity_class - devices[key] = device_class( - gateway, msg.node_id, child.id, name, value_type) - if add_devices: - _LOGGER.info("Adding new devices: %s", [devices[key]]) - add_devices([devices[key]], True) - else: - devices[key].update() + + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + for idx, dev_id in enumerate(list(dev_ids)): + if dev_id in devices: + dev_ids.pop(idx) + signals.append(SIGNAL_CALLBACK.format(*dev_id)) + if dev_ids: + discover_mysensors_platform(hass, platform, dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + dispatcher_send(hass, signal) + end = timer() + if end - start > 0.1: + _LOGGER.debug( + "Callback for node %s child %s took %.3f seconds", + msg.node_id, msg.child_id, end - start) return mysensors_callback -class GatewayWrapper(object): - """Gateway wrapper class.""" - - def __init__(self, gateway, optimistic, device): - """Set up the class attributes on instantiation. - - Args: - gateway (mysensors.SerialGateway): Gateway to wrap. - optimistic (bool): Send values to actuators without feedback state. - device (str): Path to serial port, ip adress or mqtt. - - Attributes: - _wrapped_gateway (mysensors.SerialGateway): Wrapped gateway. - platform_callbacks (list): Callback functions, one per platform. - optimistic (bool): Send values to actuators without feedback state. - device (str): Device configured as gateway. - __initialised (bool): True if GatewayWrapper is initialised. - - """ - self._wrapped_gateway = gateway - self.platform_callbacks = [] - self.optimistic = optimistic - self.device = device - self.__initialised = True - - def __getattr__(self, name): - """See if this object has attribute name.""" - # Do not use hasattr, it goes into infinite recurrsion - if name in self.__dict__: - # This object has the attribute. - return getattr(self, name) - # The wrapped object has the attribute. - return getattr(self._wrapped_gateway, name) - - def __setattr__(self, name, value): - """See if this object has attribute name then set to value.""" - if '_GatewayWrapper__initialised' not in self.__dict__: - return object.__setattr__(self, name, value) - elif name in self.__dict__: - object.__setattr__(self, name, value) - else: - object.__setattr__(self._wrapped_gateway, name, value) - - def callback_factory(self): - """Return a new callback function.""" - def node_update(msg): - """Handle node updates from the MySensors gateway.""" - _LOGGER.debug( - "Update: node %s, child %s sub_type %s", - msg.node_id, msg.child_id, msg.sub_type) - for callback in self.platform_callbacks: - callback(self, msg) - - return node_update +def get_mysensors_name(gateway, node_id, child_id): + """Return a name for a node child.""" + return '{} {} {}'.format( + gateway.sensors[node_id].sketch_name, node_id, child_id) -class MySensorsDeviceEntity(object): - """Representation of a MySensors entity.""" +def get_mysensors_gateway(hass, gateway_id): + """Return gateway.""" + if MYSENSORS_GATEWAYS not in hass.data: + hass.data[MYSENSORS_GATEWAYS] = {} + gateways = hass.data.get(MYSENSORS_GATEWAYS) + return gateways.get(gateway_id) + + +def setup_mysensors_platform( + hass, domain, discovery_info, device_class, device_args=None, + add_devices=None): + """Set up a mysensors platform.""" + # Only act if called via mysensors by discovery event. + # Otherwise gateway is not setup. + if not discovery_info: + return + if device_args is None: + device_args = () + new_devices = [] + new_dev_ids = discovery_info[ATTR_DEVICES] + for dev_id in new_dev_ids: + devices = get_mysensors_devices(hass, domain) + if dev_id in devices: + continue + gateway_id, node_id, child_id, value_type = dev_id + gateway = get_mysensors_gateway(hass, gateway_id) + if not gateway: + continue + device_class_copy = device_class + if isinstance(device_class, dict): + child = gateway.sensors[node_id].children[child_id] + s_type = gateway.const.Presentation(child.type).name + device_class_copy = device_class[s_type] + name = get_mysensors_name(gateway, node_id, child_id) + + # python 3.4 cannot unpack inside tuple, but combining tuples works + args_copy = device_args + ( + gateway, node_id, child_id, name, value_type) + devices[dev_id] = device_class_copy(*args_copy) + new_devices.append(devices[dev_id]) + if new_devices: + _LOGGER.info("Adding new devices: %s", new_devices) + if add_devices is not None: + add_devices(new_devices, True) + return new_devices + + +class MySensorsDevice(object): + """Representation of a MySensors device.""" def __init__(self, gateway, node_id, child_id, name, value_type): """Set up the MySensors device.""" @@ -373,11 +560,6 @@ class MySensorsDeviceEntity(object): self.child_type = child.type self._values = {} - @property - def should_poll(self): - """Mysensor gateway pushes its state to HA.""" - return False - @property def name(self): """Return the name of this entity.""" @@ -399,18 +581,9 @@ class MySensorsDeviceEntity(object): set_req = self.gateway.const.SetReq for value_type, value in self._values.items(): - try: - attr[set_req(value_type).name] = value - except ValueError: - _LOGGER.error("Value_type %s is not valid for mysensors " - "version %s", value_type, - self.gateway.protocol_version) - return attr + attr[set_req(value_type).name] = value - @property - def available(self): - """Return true if entity is available.""" - return self.value_type in self._values + return attr def update(self): """Update the controller with the latest value from a sensor.""" @@ -419,7 +592,8 @@ class MySensorsDeviceEntity(object): set_req = self.gateway.const.SetReq for value_type, value in child.values.items(): _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) + "Entity update: %s: value_type %s, value = %s", + self._name, value_type, value) if value_type in (set_req.V_ARMED, set_req.V_LIGHT, set_req.V_LOCK_STATUS, set_req.V_TRIPPED): self._values[value_type] = ( @@ -428,3 +602,29 @@ class MySensorsDeviceEntity(object): self._values[value_type] = int(value) else: self._values[value_type] = value + + +class MySensorsEntity(MySensorsDevice, Entity): + """Representation of a MySensors entity.""" + + @property + def should_poll(self): + """Mysensor gateway pushes its state to HA.""" + return False + + @property + def available(self): + """Return true if entity is available.""" + return self.value_type in self._values + + def _async_update_callback(self): + """Update the entity.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update callback.""" + dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + async_dispatcher_connect( + self.hass, SIGNAL_CALLBACK.format(*dev_id), + self._async_update_callback) diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index d9576767f25..8ae697048f5 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -6,35 +6,19 @@ https://home-assistant.io/components/notify.mysensors/ """ from homeassistant.components import mysensors from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) + ATTR_TARGET, DOMAIN, BaseNotificationService) def get_service(hass, config, discovery_info=None): """Get the MySensors notification service.""" - if discovery_info is None: + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsNotificationDevice) + if not new_devices: return - platform_devices = [] - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_INFO: [set_req.V_TEXT], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsNotificationDevice)) - platform_devices.append(devices) - - return MySensorsNotificationService(platform_devices) + return MySensorsNotificationService(hass) -class MySensorsNotificationDevice(mysensors.MySensorsDeviceEntity): +class MySensorsNotificationDevice(mysensors.MySensorsDevice): """Represent a MySensors Notification device.""" def send_msg(self, msg): @@ -44,24 +28,25 @@ class MySensorsNotificationDevice(mysensors.MySensorsDeviceEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, sub_msg) + def __repr__(self): + """Return the representation.""" + return "".format(self.name) + class MySensorsNotificationService(BaseNotificationService): - """Implement MySensors notification service.""" + """Implement a MySensors notification service.""" # pylint: disable=too-few-public-methods - def __init__(self, platform_devices): + def __init__(self, hass): """Initialize the service.""" - self.platform_devices = platform_devices + self.devices = mysensors.get_mysensors_devices(hass, DOMAIN) def send_message(self, message="", **kwargs): """Send a message to a user.""" target_devices = kwargs.get(ATTR_TARGET) - devices = [] - for gw_devs in self.platform_devices: - for device in gw_devs.values(): - if target_devices is None or device.name in target_devices: - devices.append(device) + devices = [device for device in self.devices.values() + if target_devices is None or device.name in target_devices] for device in devices: device.send_msg(message) diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index d46680c7b66..a8daf212e57 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -4,89 +4,18 @@ Support for MySensors sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.sensor import DOMAIN from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_TEMP: [set_req.V_TEMP], - pres.S_HUM: [set_req.V_HUM], - pres.S_BARO: [set_req.V_PRESSURE, set_req.V_FORECAST], - pres.S_WIND: [set_req.V_WIND, set_req.V_GUST, set_req.V_DIRECTION], - pres.S_RAIN: [set_req.V_RAIN, set_req.V_RAINRATE], - pres.S_UV: [set_req.V_UV], - pres.S_WEIGHT: [set_req.V_WEIGHT, set_req.V_IMPEDANCE], - pres.S_POWER: [set_req.V_WATT, set_req.V_KWH], - pres.S_DISTANCE: [set_req.V_DISTANCE], - pres.S_LIGHT_LEVEL: [set_req.V_LIGHT_LEVEL], - pres.S_IR: [set_req.V_IR_RECEIVE], - pres.S_WATER: [set_req.V_FLOW, set_req.V_VOLUME], - pres.S_CUSTOM: [set_req.V_VAR1, - set_req.V_VAR2, - set_req.V_VAR3, - set_req.V_VAR4, - set_req.V_VAR5], - pres.S_SCENE_CONTROLLER: [set_req.V_SCENE_ON, - set_req.V_SCENE_OFF], - } - if float(gateway.protocol_version) < 1.5: - map_sv_types.update({ - pres.S_AIR_QUALITY: [set_req.V_DUST_LEVEL], - pres.S_DUST: [set_req.V_DUST_LEVEL], - }) - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COLOR_SENSOR: [set_req.V_RGB], - pres.S_MULTIMETER: [set_req.V_VOLTAGE, - set_req.V_CURRENT, - set_req.V_IMPEDANCE], - pres.S_SOUND: [set_req.V_LEVEL], - pres.S_VIBRATION: [set_req.V_LEVEL], - pres.S_MOISTURE: [set_req.V_LEVEL], - pres.S_AIR_QUALITY: [set_req.V_LEVEL], - pres.S_DUST: [set_req.V_LEVEL], - }) - map_sv_types[pres.S_LIGHT_LEVEL].append(set_req.V_LEVEL) - - if float(gateway.protocol_version) >= 2.0: - map_sv_types.update({ - pres.S_INFO: [set_req.V_TEXT], - pres.S_GAS: [set_req.V_FLOW, set_req.V_VOLUME], - pres.S_GPS: [set_req.V_POSITION], - pres.S_WATER_QUALITY: [set_req.V_TEMP, set_req.V_PH, - set_req.V_ORP, set_req.V_EC] - }) - map_sv_types[pres.S_CUSTOM].append(set_req.V_CUSTOM) - map_sv_types[pres.S_POWER].extend( - [set_req.V_VAR, set_req.V_VA, set_req.V_POWER_FACTOR]) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsSensor, add_devices)) + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsSensor, add_devices=add_devices) -class MySensorsSensor(mysensors.MySensorsDeviceEntity, Entity): +class MySensorsSensor(mysensors.MySensorsEntity): """Representation of a MySensors Sensor child node.""" @property diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 38f67ee3ee9..131ec58ae67 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -4,7 +4,6 @@ Support for MySensors switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mysensors/ """ -import logging import os import voluptuous as vol @@ -15,9 +14,6 @@ from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - ATTR_IR_CODE = 'V_IR_SEND' SERVICE_SEND_IR_CODE = 'mysensors_send_ir_code' @@ -29,82 +25,37 @@ SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the mysensors platform for switches.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - platform_devices = [] - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_ARMED], - pres.S_MOTION: [set_req.V_ARMED], - pres.S_SMOKE: [set_req.V_ARMED], - pres.S_LIGHT: [set_req.V_LIGHT], - pres.S_LOCK: [set_req.V_LOCK_STATUS], - pres.S_IR: [set_req.V_IR_SEND], - } - device_class_map = { - pres.S_DOOR: MySensorsSwitch, - pres.S_MOTION: MySensorsSwitch, - pres.S_SMOKE: MySensorsSwitch, - pres.S_LIGHT: MySensorsSwitch, - pres.S_LOCK: MySensorsSwitch, - pres.S_IR: MySensorsIRSwitch, - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_BINARY: [set_req.V_STATUS, set_req.V_LIGHT], - pres.S_SPRINKLER: [set_req.V_STATUS], - pres.S_WATER_LEAK: [set_req.V_ARMED], - pres.S_SOUND: [set_req.V_ARMED], - pres.S_VIBRATION: [set_req.V_ARMED], - pres.S_MOISTURE: [set_req.V_ARMED], - }) - map_sv_types[pres.S_LIGHT].append(set_req.V_STATUS) - device_class_map.update({ - pres.S_BINARY: MySensorsSwitch, - pres.S_SPRINKLER: MySensorsSwitch, - pres.S_WATER_LEAK: MySensorsSwitch, - pres.S_SOUND: MySensorsSwitch, - pres.S_VIBRATION: MySensorsSwitch, - pres.S_MOISTURE: MySensorsSwitch, - }) - if float(gateway.protocol_version) >= 2.0: - map_sv_types.update({ - pres.S_WATER_QUALITY: [set_req.V_STATUS], - }) - device_class_map.update({ - pres.S_WATER_QUALITY: MySensorsSwitch, - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, device_class_map, add_devices)) - platform_devices.append(devices) + device_class_map = { + 'S_DOOR': MySensorsSwitch, + 'S_MOTION': MySensorsSwitch, + 'S_SMOKE': MySensorsSwitch, + 'S_LIGHT': MySensorsSwitch, + 'S_LOCK': MySensorsSwitch, + 'S_IR': MySensorsIRSwitch, + 'S_BINARY': MySensorsSwitch, + 'S_SPRINKLER': MySensorsSwitch, + 'S_WATER_LEAK': MySensorsSwitch, + 'S_SOUND': MySensorsSwitch, + 'S_VIBRATION': MySensorsSwitch, + 'S_MOISTURE': MySensorsSwitch, + 'S_WATER_QUALITY': MySensorsSwitch, + } + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, device_class_map, + add_devices=add_devices) def send_ir_code_service(service): """Set IR code as device state attribute.""" entity_ids = service.data.get(ATTR_ENTITY_ID) ir_code = service.data.get(ATTR_IR_CODE) + devices = mysensors.get_mysensors_devices(hass, DOMAIN) if entity_ids: - _devices = [device for gw_devs in platform_devices - for device in gw_devs.values() + _devices = [device for device in devices.values() if isinstance(device, MySensorsIRSwitch) and device.entity_id in entity_ids] else: - _devices = [device for gw_devs in platform_devices - for device in gw_devs.values() + _devices = [device for device in devices.values() if isinstance(device, MySensorsIRSwitch)] kwargs = {ATTR_IR_CODE: ir_code} @@ -120,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): schema=SEND_IR_CODE_SERVICE_SCHEMA) -class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice): +class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" @property @@ -131,9 +82,7 @@ class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice): @property def is_on(self): """Return True if switch is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON def turn_on(self, **kwargs): """Turn the switch on.""" @@ -159,24 +108,18 @@ class MySensorsIRSwitch(MySensorsSwitch): def __init__(self, *args): """Set up instance attributes.""" - MySensorsSwitch.__init__(self, *args) + super().__init__(*args) self._ir_code = None @property def is_on(self): """Return True if switch is on.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT in self._values: - return self._values[set_req.V_LIGHT] == STATE_ON - return False + return self._values.get(set_req.V_LIGHT) == STATE_ON def turn_on(self, **kwargs): """Turn the IR switch on.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT not in self._values: - _LOGGER.error('missing value_type: %s at node: %s, child: %s', - set_req.V_LIGHT.name, self.node_id, self.child_id) - return if ATTR_IR_CODE in kwargs: self._ir_code = kwargs[ATTR_IR_CODE] self.gateway.set_child_value( @@ -194,10 +137,6 @@ class MySensorsIRSwitch(MySensorsSwitch): def turn_off(self, **kwargs): """Turn the IR switch off.""" set_req = self.gateway.const.SetReq - if set_req.V_LIGHT not in self._values: - _LOGGER.error('missing value_type: %s at node: %s, child: %s', - set_req.V_LIGHT.name, self.node_id, self.child_id) - return self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 0) if self.gateway.optimistic: @@ -207,6 +146,5 @@ class MySensorsIRSwitch(MySensorsSwitch): def update(self): """Update the controller with the latest value from a sensor.""" - MySensorsSwitch.update(self) - if self.value_type in self._values: - self._ir_code = self._values[self.value_type] + super().update() + self._ir_code = self._values.get(self.value_type) diff --git a/requirements_all.txt b/requirements_all.txt index cf6325e6572..f1fc40a2187 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -655,7 +655,7 @@ pymodbus==1.3.1 pymyq==0.0.8 # homeassistant.components.mysensors -pymysensors==0.10.0 +pymysensors==0.11.0 # homeassistant.components.lock.nello pynello==1.5 From 56083c0c6449cd4d2aa3e665b826495dbc280390 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 26 Aug 2017 06:27:31 +0200 Subject: [PATCH 003/108] Xiaomi Philips Lights integration (#9087) * Adds support for the Xiaomi Philips LED Ball and Ceiling Lamp * Documentation url updated. * New component to .coveragerc added. * Unused import removed. * translate labeled as static method. * Mixed parameters in log message fixed. * Order of requirements_all.txt fixed. * Plattform updated. It's async now. * Simplifiable if-statement fixed. * Some more clean-up of unneeded stuff. * Platform schema updated. * Component is called xiaomi_philipslight now. * Requirements all updated. * Initialization of some variables updated. * Raise PlatformNotReady exception if light cannot be discovered. * Import of math removed. Missing space added. * Remove unnecessary updates --- .coveragerc | 1 + .../components/light/xiaomi_philipslight.py | 212 ++++++++++++++++++ homeassistant/components/vacuum/xiaomi.py | 2 +- requirements_all.txt | 3 +- 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/light/xiaomi_philipslight.py diff --git a/.coveragerc b/.coveragerc index d8041b9fe6c..9a03544c1bb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -328,6 +328,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py + homeassistant/components/light/xiaomi_philipslight.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py new file mode 100644 index 00000000000..96d2d7ff9d2 --- /dev/null +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -0,0 +1,212 @@ +""" +Support for Xiaomi Philips Lights (LED Ball & Ceil). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.xiaomi_philipslight/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import ( + PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, + ATTR_COLOR_TEMP, SUPPORT_COLOR_TEMP, Light, ) + +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Philips Light' +PLATFORM = 'xiaomi_philipslight' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-mirobo==0.1.3'] + +# The light does not accept cct values < 1 +CCT_MIN = 1 +CCT_MAX = 100 + +SUCCESS = ['ok'] + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the light from config.""" + from mirobo import Ceil, DeviceException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + light = Ceil(host, token) + + philips_light = XiaomiPhilipsLight(name, light) + hass.data[PLATFORM][host] = philips_light + except DeviceException: + raise PlatformNotReady + + async_add_devices([philips_light], update_before_add=True) + + +class XiaomiPhilipsLight(Light): + """Representation of a Xiaomi Philips Light.""" + + def __init__(self, name, light): + """Initialize the light device.""" + self._name = name + + self._brightness = None + self._color_temp = None + + self._light = light + self._state = None + + @property + def should_poll(self): + """Poll the light.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 333 + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a light command handling error messages.""" + from mirobo import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from light: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = int(100 * brightness / 255) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + self.brightness, percent_brightness) + + result = yield from self._try_command( + "Setting brightness failed: %s", + self._light.set_bright, percent_brightness) + + if result: + self._brightness = brightness + + if ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + percent_color_temp = self.translate( + color_temp, self.max_mireds, + self.min_mireds, CCT_MIN, CCT_MAX) + + _LOGGER.debug( + "Setting color temperature: " + "%s mireds, %s%% cct", + color_temp, percent_color_temp) + + result = yield from self._try_command( + "Setting color temperature failed: %s cct", + self._light.set_cct, percent_color_temp) + + if result: + self._color_temp = color_temp + + result = yield from self._try_command( + "Turning the light on failed.", self._light.on) + + if result: + self._state = True + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + result = yield from self._try_command( + "Turning the light off failed.", self._light.off) + + if result: + self._state = True + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from mirobo import DeviceException + try: + state = yield from self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state.data) + + self._state = state.is_on + self._brightness = int(255 * 0.01 * state.bright) + self._color_temp = self.translate(state.cct, CCT_MIN, CCT_MAX, + self.max_mireds, + self.min_mireds) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @staticmethod + def translate(value, left_min, left_max, right_min, right_max): + """Map a value from left span to right span.""" + left_span = left_max - left_min + right_span = right_max - right_min + value_scaled = float(value - left_min) / float(left_span) + return int(right_min + (value_scaled * right_span)) diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py index 5e5081a2aa8..95d7478aa9f 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.1.2'] +REQUIREMENTS = ['python-mirobo==0.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f1fc40a2187..dc6cd8e7b38 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -740,8 +740,9 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 +# homeassistant.components.light.xiaomi_philipslight # homeassistant.components.vacuum.xiaomi -python-mirobo==0.1.2 +python-mirobo==0.1.3 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 From 0d3fa59d77ceb11c3072026034b6d215fbb9fc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 26 Aug 2017 09:36:54 +0200 Subject: [PATCH 004/108] Fix issue #9116 in pushbullet (#9128) * Fix issue #9116 in pushbullet --- homeassistant/components/notify/pushbullet.py | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 6d97d544905..353e833ae51 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -89,7 +89,7 @@ class PushBulletNotificationService(BaseNotificationService): if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, url) + self._push_data(filepath, message, title, self.pushbullet, url) _LOGGER.info("Sent notification to self") return @@ -104,7 +104,8 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - self._push_data(filepath, message, title, url, tname) + self._push_data(filepath, message, title, url, + self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -123,27 +124,27 @@ class PushBulletNotificationService(BaseNotificationService): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - if url: - self.pbtargets[ttype][tname].push_link( - title, url, body=message) - else: - self.pbtargets[ttype][tname].push_note(title, message) + self._push_data(filepath, message, title, url, + self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - except self.pushbullet.errors.PushError: - _LOGGER.error("Notify failed to: %s/%s", ttype, tname) - continue - def _push_data(self, filepath, message, title, url, tname=None): - if url: - self.pushbullet.push_link( - title, url, body=message, email=tname) - elif filepath and self.hass.config.is_allowed_path(filepath): - with open(filepath, "rb") as fileh: - filedata = self.pushbullet.upload_file(fileh, filepath) - self.pushbullet.push_file(title=title, body=message, - **filedata) - else: - self.pushbullet.push_note(title, message, email=tname) + def _push_data(self, filepath, message, title, url, pusher, tname=None): + from pushbullet import PushError + try: + if url: + pusher.push_link(title, url, body=message, email=tname) + elif filepath and self.hass.config.is_allowed_path(filepath): + with open(filepath, "rb") as fileh: + filedata = self.pushbullet.upload_file(fileh, filepath) + if filedata.get('file_type') == 'application/x-empty': + _LOGGER.error("Failed to send an empty file.") + return + pusher.push_file(title=title, body=message, **filedata) + else: + pusher.push_note(title, message, email=tname) + + except PushError as err: + _LOGGER.error("Notify failed: %s", err) From f4d464c0087490db934600c909c91613831af7ec Mon Sep 17 00:00:00 2001 From: Sean Dague Date: Sat, 26 Aug 2017 12:08:37 -0400 Subject: [PATCH 005/108] Fix import for foscam (#9140) While waiting for a new pyfoscam release, we can fix this for users just by changing the import. Foscam devices a pretty widely deployed, so a regression here is definitely no fun. Fixes Bug #8940 --- homeassistant/components/camera/foscam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 15138e2c253..8ea90d5a44e 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -53,7 +53,7 @@ class FoscamCam(Camera): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam import FoscamCamera + from foscam.foscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) From 493353e4deaa8f74234d5a6fff171e3c73d66b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 26 Aug 2017 18:12:51 +0200 Subject: [PATCH 006/108] bug fix pushbullet (#9139) --- homeassistant/components/notify/pushbullet.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 353e833ae51..0d596fb41ba 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -133,9 +133,13 @@ class PushBulletNotificationService(BaseNotificationService): def _push_data(self, filepath, message, title, url, pusher, tname=None): from pushbullet import PushError + from pushbullet import Device try: if url: - pusher.push_link(title, url, body=message, email=tname) + if isinstance(pusher, Device): + pusher.push_link(title, url, body=message) + else: + pusher.push_link(title, url, body=message, email=tname) elif filepath and self.hass.config.is_allowed_path(filepath): with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) @@ -144,7 +148,9 @@ class PushBulletNotificationService(BaseNotificationService): return pusher.push_file(title=title, body=message, **filedata) else: - pusher.push_note(title, message, email=tname) - + if isinstance(pusher, Device): + pusher.push_note(title, message) + else: + pusher.push_note(title, message, email=tname) except PushError as err: _LOGGER.error("Notify failed: %s", err) From c5377707861f116dd29e65d60b683b6c75397b9a Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sat, 26 Aug 2017 23:56:39 +0700 Subject: [PATCH 007/108] Close stream request once we end up with proxy (#9110) * Close stream request once we end up with proxy * Update aiohttp_client.py * Update aiohttp_client.py * Removed trailing whitespace --- homeassistant/helpers/aiohttp_client.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index a8b18351021..29e2a6260fd 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -90,8 +90,15 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, # Something went wrong with the connection raise HTTPBadGateway() from err - yield from async_aiohttp_proxy_stream(hass, request, req.content, - req.headers.get(CONTENT_TYPE)) + try: + yield from async_aiohttp_proxy_stream( + hass, + request, + req.content, + req.headers.get(CONTENT_TYPE) + ) + finally: + req.close() @asyncio.coroutine From c73338bf3ed3da8dae89df46f4d8f9fe6638488d Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 26 Aug 2017 20:02:32 +0300 Subject: [PATCH 008/108] Backend changes for customize config panel. (#9134) * Backend changes for customize config panel. * Backend changes for customize config panel. * Add customize.yaml to default config * Precreate customize.yaml * Add tests --- homeassistant/components/__init__.py | 6 + homeassistant/components/config/__init__.py | 20 ++-- homeassistant/components/config/customize.py | 39 ++++++ homeassistant/config.py | 7 ++ tests/components/config/test_customize.py | 118 +++++++++++++++++++ tests/test_config.py | 21 +++- 6 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/config/customize.py create mode 100644 tests/components/config/test_customize.py diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 1d437d35da7..6db147a5f59 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -101,6 +101,12 @@ def reload_core_config(hass): hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) +@asyncio.coroutine +def async_reload_core_config(hass): + """Reload the core config.""" + yield from hass.services.async_call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) + + @asyncio.coroutine def async_setup(hass, config): """Set up general services related to Home Assistant.""" diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9e447c8936a..9ce7f30529b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,7 +14,7 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'group', 'hassbian', 'automation', 'script') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') ON_DEMAND = ('zwave') @@ -77,11 +77,11 @@ class BaseEditConfigView(HomeAssistantView): """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" raise NotImplementedError - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" raise NotImplementedError @@ -90,7 +90,7 @@ class BaseEditConfigView(HomeAssistantView): """Fetch device specific config.""" hass = request.app['hass'] current = yield from self.read_config(hass) - value = self._get_value(current, config_key) + value = self._get_value(hass, current, config_key) if value is None: return self.json_message('Resource not found', 404) @@ -121,7 +121,7 @@ class BaseEditConfigView(HomeAssistantView): path = hass.config.path(self.path) current = yield from self.read_config(hass) - self._write_value(current, config_key, data) + self._write_value(hass, current, config_key, data) yield from hass.async_add_job(_write, path, current) @@ -149,11 +149,11 @@ class EditKeyBasedConfigView(BaseEditConfigView): """Return an empty config.""" return {} - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return data.get(config_key, {}) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" data.setdefault(config_key, {}).update(new_value) @@ -165,14 +165,14 @@ class EditIdBasedConfigView(BaseEditConfigView): """Return an empty config.""" return [] - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return next( (val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(data, config_key) + value = self._get_value(hass, data, config_key) if value is None: value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py new file mode 100644 index 00000000000..d25992ecc90 --- /dev/null +++ b/homeassistant/components/config/customize.py @@ -0,0 +1,39 @@ +"""Provide configuration end points for Customize.""" +import asyncio + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components import async_reload_core_config +from homeassistant.config import DATA_CUSTOMIZE + +import homeassistant.helpers.config_validation as cv + +CONFIG_PATH = 'customize.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the Customize config API.""" + hass.http.register_view(CustomizeConfigView( + 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, + post_write_hook=async_reload_core_config + )) + + return True + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + def _get_value(self, hass, data, config_key): + """Get value.""" + customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} + return {'global': customize, 'local': data.get(config_key, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/config.py b/homeassistant/config.py index c90c4517397..ee48ece67ab 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -57,6 +57,7 @@ DEFAULT_CORE_CONFIG = ( CONF_UNIT_SYSTEM_IMPERIAL)), (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' 'pedia.org/wiki/List_of_tz_database_time_zones'), + (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), ) # type: Tuple[Tuple[str, Any, Any, str], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend @@ -176,12 +177,15 @@ def create_default_config(config_dir, detect_location=True): CONFIG_PATH as AUTOMATION_CONFIG_PATH) from homeassistant.components.config.script import ( CONFIG_PATH as SCRIPT_CONFIG_PATH) + from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) config_path = os.path.join(config_dir, YAML_CONFIG_FILE) version_path = os.path.join(config_dir, VERSION_FILE) group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) + customize_yaml_path = os.path.join(config_dir, CUSTOMIZE_CONFIG_PATH) info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} @@ -229,6 +233,9 @@ def create_default_config(config_dir, detect_location=True): with open(script_yaml_path, 'wt'): pass + with open(customize_yaml_path, 'wt'): + pass + return config_path except IOError: diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py new file mode 100644 index 00000000000..f12774c25d9 --- /dev/null +++ b/tests/components/config/test_customize.py @@ -0,0 +1,118 @@ +"""Test Customize config panel.""" +import asyncio +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config +from homeassistant.config import DATA_CUSTOMIZE + + +@asyncio.coroutine +def test_get_entity(hass, test_client): + """Test getting entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + def mock_read(path): + """Mock reading data.""" + return { + 'hello.beer': { + 'free': 'beer', + }, + 'other.entity': { + 'do': 'something', + }, + } + hass.data[DATA_CUSTOMIZE] = {'hello.beer': {'cold': 'beer'}} + with patch('homeassistant.components.config._read', mock_read): + resp = yield from client.get( + '/api/config/customize/config/hello.beer') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {'local': {'free': 'beer'}, 'global': {'cold': 'beer'}} + + +@asyncio.coroutine +def test_update_entity(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + orig_data = { + 'hello.beer': { + 'ignored': True, + }, + 'other.entity': { + 'polling_intensity': 2, + }, + } + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + hass.states.async_set('hello.world', 'state', {'a': 'b'}) + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = yield from client.post( + '/api/config/customize/config/hello.world', data=json.dumps({ + 'name': 'Beer', + 'entities': ['light.top', 'light.bottom'], + })) + + assert resp.status == 200 + result = yield from resp.json() + assert result == {'result': 'ok'} + + state = hass.states.get('hello.world') + assert state.state == 'state' + assert dict(state.attributes) == { + 'a': 'b', 'name': 'Beer', 'entities': ['light.top', 'light.bottom']} + + orig_data['hello.world']['name'] = 'Beer' + orig_data['hello.world']['entities'] = ['light.top', 'light.bottom'] + + assert written[0] == orig_data + + +@asyncio.coroutine +def test_update_entity_invalid_key(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + resp = yield from client.post( + '/api/config/customize/config/not_entity', data=json.dumps({ + 'name': 'YO', + })) + + assert resp.status == 400 + + +@asyncio.coroutine +def test_update_entity_invalid_json(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + resp = yield from client.post( + '/api/config/customize/config/hello.beer', data='not json') + + assert resp.status == 400 diff --git a/tests/test_config.py b/tests/test_config.py index 8c889979a82..d1b9a052b72 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,8 @@ from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) +from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) from tests.common import ( get_test_config_dir, get_test_home_assistant, mock_coro) @@ -31,6 +33,7 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) +CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -65,8 +68,12 @@ class TestConfig(unittest.TestCase): if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) + if os.path.isfile(CUSTOMIZE_PATH): + os.remove(CUSTOMIZE_PATH) + self.hass.stop() + # pylint: disable=no-self-use def test_create_default_config(self): """Test creation of default config.""" config_util.create_default_config(CONFIG_DIR, False) @@ -75,6 +82,7 @@ class TestConfig(unittest.TestCase): assert os.path.isfile(VERSION_PATH) assert os.path.isfile(GROUP_PATH) assert os.path.isfile(AUTOMATIONS_PATH) + assert os.path.isfile(CUSTOMIZE_PATH) def test_find_config_file_yaml(self): """Test if it finds a YAML config file.""" @@ -169,7 +177,8 @@ class TestConfig(unittest.TestCase): CONF_ELEVATION: 101, CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, CONF_NAME: 'Home', - CONF_TIME_ZONE: 'America/Los_Angeles' + CONF_TIME_ZONE: 'America/Los_Angeles', + CONF_CUSTOMIZE: OrderedDict(), } assert expected_values == ha_conf @@ -334,11 +343,12 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() - def mock_isfile(filename): + def _mock_isfile(filename): return True with mock.patch('homeassistant.config.open', mock_open, create=True), \ - mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + mock.patch( + 'homeassistant.config.os.path.isfile', _mock_isfile): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version @@ -359,11 +369,12 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() - def mock_isfile(filename): + def _mock_isfile(filename): return False with mock.patch('homeassistant.config.open', mock_open, create=True), \ - mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + mock.patch( + 'homeassistant.config.os.path.isfile', _mock_isfile): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version From 21bf089b17fdb64f49f852b3bf4e6c21dc2ce84c Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 26 Aug 2017 17:09:57 -0400 Subject: [PATCH 009/108] Bump aioautomatic to prevent leaking exceptions (#9148) --- homeassistant/components/device_tracker/automatic.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 071edf42642..a4495926f82 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.0'] +REQUIREMENTS = ['aioautomatic==0.6.2'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index dc6cd8e7b38..f9810be6bac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,7 +42,7 @@ TwitterAPI==2.4.6 abodepy==0.7.1 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.0 +aioautomatic==0.6.2 # homeassistant.components.sensor.dnsip aiodns==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74717aa7d7b..f286555833e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -27,7 +27,7 @@ PyJWT==1.5.2 SoCo==0.12 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.0 +aioautomatic==0.6.2 # homeassistant.components.emulated_hue # homeassistant.components.http From 8605098ea002e8cf3692472a5a452576479b25d4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 26 Aug 2017 17:00:59 -0700 Subject: [PATCH 010/108] Wrap state when iterating a domain in templates (#9157) --- homeassistant/helpers/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index aa6ca186a8e..bdef5541983 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -209,7 +209,7 @@ class DomainStates(object): def __iter__(self): """Return the iteration over all the states.""" return iter(sorted( - (state for state in self._hass.states.async_all() + (_wrap_state(state) for state in self._hass.states.async_all() if state.domain == self._domain), key=lambda state: state.entity_id)) From ae5fca1ec970a7d3b33eb417dbeabf0d6b366162 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 27 Aug 2017 11:30:04 +0200 Subject: [PATCH 011/108] Upgrade async_timeout to 1.3.0 (#9156) --- 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 bea6d6fbe40..932ed076d3b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.2.1 +async_timeout==1.3.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index f9810be6bac..920c5880813 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.2.1 +async_timeout==1.3.0 chardet==3.0.4 astral==1.4 diff --git a/setup.py b/setup.py index 78a6a0bba71..d5a6294e3d2 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', - 'async_timeout==1.2.1', + 'async_timeout==1.3.0', 'chardet==3.0.4', 'astral==1.4', ] From 7062c2b257e1cf49f9d4d15b775ba5783e12c4a5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 27 Aug 2017 11:30:26 +0200 Subject: [PATCH 012/108] Remove links to gitter (#9155) --- README.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.rst b/README.rst index 039e8a922af..7f0d41b00ea 100644 --- a/README.rst +++ b/README.rst @@ -33,10 +33,6 @@ of a component, check the `Home Assistant help section Date: Sun, 27 Aug 2017 11:30:42 +0200 Subject: [PATCH 013/108] Upgrade sphinx-autodoc-typehints to 1.2.3 (#9151) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 7a79d7bff6d..ef9487836c0 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.6.3 -sphinx-autodoc-typehints==1.2.1 +sphinx-autodoc-typehints==1.2.3 sphinx-autodoc-annotation==1.0.post1 From 8fdd9712e62a0eb38ea04a2dedd3ad255a17e327 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 27 Aug 2017 11:31:06 +0200 Subject: [PATCH 014/108] Upgrade uber_rides to 0.5.2 (#9149) --- homeassistant/components/sensor/uber.py | 15 +++++++++------ requirements_all.txt | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index 5d8ff49cd5b..eb7050309bc 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['uber_rides==0.5.1'] +REQUIREMENTS = ['uber_rides==0.5.2'] _LOGGER = logging.getLogger(__name__) @@ -87,11 +87,14 @@ class UberSensor(Entity): if self._product.get('price_details') is not None: price_details = self._product['price_details'] self._unit_of_measurement = price_details.get('currency_code') - if price_details.get('low_estimate') is not None: - statekey = 'minimum' - else: - statekey = 'low_estimate' - self._state = int(price_details.get(statekey, 0)) + try: + if price_details.get('low_estimate') is not None: + statekey = 'minimum' + else: + statekey = 'low_estimate' + self._state = int(price_details.get(statekey)) + except TypeError: + self._state = 0 else: self._state = 0 diff --git a/requirements_all.txt b/requirements_all.txt index 920c5880813..828de6e08c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -954,7 +954,7 @@ transmissionrpc==0.11 twilio==5.7.0 # homeassistant.components.sensor.uber -uber_rides==0.5.1 +uber_rides==0.5.2 # homeassistant.components.sensor.ups upsmychoice==1.0.6 From c367021aa461c01561d69315a5af871f32f9a6d1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 27 Aug 2017 19:07:58 +0300 Subject: [PATCH 015/108] Allow specifying custom html urls to load. (#9150) * Allow specifying custom html urls to load. * Change add_extra_html_urls to accept a single URL --- homeassistant/components/frontend/__init__.py | 23 ++++++++++++++++++- .../components/frontend/templates/index.html | 6 ++++- tests/components/test_frontend.py | 22 +++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 29f6ef577e5..2f84abc745b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -28,6 +28,7 @@ URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') ATTR_THEMES = 'themes' +ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', @@ -50,6 +51,7 @@ for size in (192, 384, 512, 1024): }) DATA_PANELS = 'frontend_panels' +DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_INDEX_VIEW = 'frontend_index_view' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -66,6 +68,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(ATTR_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), + vol.Optional(ATTR_EXTRA_HTML_URL): + vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) @@ -169,6 +173,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) +@bind_hass +def add_extra_html_url(hass, url): + """Register extra html url to load.""" + url_set = hass.data.get(DATA_EXTRA_HTML_URL) + if url_set is None: + url_set = hass.data[DATA_EXTRA_HTML_URL] = set() + url_set.add(url) + + def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -208,6 +221,9 @@ def setup(hass, config): else: hass.data[DATA_PANELS] = {} + if DATA_EXTRA_HTML_URL not in hass.data: + hass.data[DATA_EXTRA_HTML_URL] = set() + register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', @@ -217,6 +233,9 @@ def setup(hass, config): themes = config.get(DOMAIN, {}).get(ATTR_THEMES) setup_themes(hass, themes) + for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url) + return True @@ -362,7 +381,9 @@ class IndexView(HomeAssistantView): compatibility_url=compatibility_url, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT]) + dev_mode=request.app[KEY_DEVELOPMENT], + theme_color=MANIFEST_JSON['theme_color'], + extra_urls=hass.data[DATA_EXTRA_HTML_URL]) return web.Response(text=resp, content_type='text/html') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6420bb79739..6d199a86a50 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -21,7 +21,7 @@ - + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 9c8b72d6bccbb68931d502f47b45899b3c0387f8..335c067e4b5e797fe65c72dd74463b0875e0733b 100644 GIT binary patch literal 32839 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 B+hYI# literal 26600 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 diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html index d55e008d907..53638dd582b 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html @@ -1,2 +1,2 @@ \ No newline at end of file + clear: both;white-space:pre-wrap;}.rendered.error{color:red;}
Templates
\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 9d8f4d9f5eb8e9586d9ab482c983d4042657c719..24fd95f17a7b7f52b707b380fb29594fe4469557 100644 GIT binary patch literal 1386 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 L-c=8;J`4Z=<(9Ov diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html index e2a93ae1cea..5f34f7bc28a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 801450f1bd8d95079910db89eeaac7099433f571..d9dd4c687fbc9ff1e4d195013ef305e915634976 100644 GIT binary patch literal 40802 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%0A>sI4vi8!5jx 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 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index a5544a8b165..b6a287fde84 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","535d629ec4d3936dba0ca4ca84dabeb2"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-f47b6910d8e4880e22cc508ca452f9b6.html","9aa0675e01373c6bc2737438bb84a9ec"],["/frontend/panels/map-c2544fff3eedb487d44105cf94b335ec.html","113c5bf9a68a74c62e50cd354034e78b"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6c8192a4393c9e83516dc8177b75c23d.html","56d5bfe9e11a8b81a686f20aeae3c359"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","f16ed1a09418a161f933bda67dc83fbe"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-3ce24a1e0bc1c6620373f38a2d11b359.html","4dcc9dcddbe093ebd5bc3ecc6dbaebb4"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 97665c7f31e22c8c6a05b60599ba2bd3ff53391e..7062ec35d679bda11a4cd0fb5132d862a3912b4c 100644 GIT binary patch literal 5139 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
d`&$yq~Z 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 Date: Mon, 28 Aug 2017 18:09:36 +0200 Subject: [PATCH 026/108] Prevent iCloud exceptions in logfile (#9179) * Prevent iCloud exceptions in logfile With this change ValueError exceptions in the logfile caused by this component will disappear. These errors are caused by the iCloud API returning an HTTP 450 error and the external lib throwing a ValueError because of it. A PR has been raised against the external library, but that fix did not yet make it into a new version of the library. This will catch the exception in the mean time.... https://github.com/picklepete/pyicloud/pull/138 * Align log messages --- .../components/device_tracker/icloud.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f20dad1fceb..e670287dd87 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -307,12 +307,15 @@ class Icloud(DeviceScanner): self.api.authenticate() currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) + try: + for devicename in self.devices: + interval = self._intervals.get(devicename, 1) + if ((currentminutes % interval == 0) or + (interval > 10 and + currentminutes % interval in [2, 4])): + self.update_device(devicename) + except ValueError: + _LOGGER.debug("iCloud API returned an error") def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" @@ -397,7 +400,7 @@ class Icloud(DeviceScanner): self.see(**kwargs) self.seen_devices[devicename] = True except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error("No iCloud Devices found") def lost_iphone(self, devicename): """Call the lost iPhone function if the device is found.""" From e76e9e0966b01dd16e8e5205a3825b6e8d30ce39 Mon Sep 17 00:00:00 2001 From: Mario Wenzel Date: Mon, 28 Aug 2017 21:46:31 +0200 Subject: [PATCH 027/108] Fix dht22 when no data was read initially #8976 (#9198) This fixes https://github.com/home-assistant/home-assistant/issues/8976 When no data was available the module crashes. --- homeassistant/components/sensor/dht.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 8fa34d50137..cbf06783dc7 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -127,7 +127,7 @@ class DHTSensor(Entity): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE: + if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", temperature, temperature_offset) @@ -135,7 +135,7 @@ class DHTSensor(Entity): self._state = round(temperature + temperature_offset, 1) if self.temp_unit == TEMP_FAHRENHEIT: self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY: + elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) From 6505019701d57bf497cd42fc087e4a2e7a9ef546 Mon Sep 17 00:00:00 2001 From: bobnwk Date: Tue, 29 Aug 2017 05:40:33 +0200 Subject: [PATCH 028/108] Update pushbullet.py (#9200) --- homeassistant/components/notify/pushbullet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 0d596fb41ba..e52348c3446 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -89,7 +89,7 @@ class PushBulletNotificationService(BaseNotificationService): if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, self.pushbullet, url) + self._push_data(filepath, message, title, url, self.pushbullet) _LOGGER.info("Sent notification to self") return From 0de6a3782283233fd9e2cc91c660eea64e800d65 Mon Sep 17 00:00:00 2001 From: aetolus Date: Tue, 29 Aug 2017 16:28:40 +1000 Subject: [PATCH 029/108] fix worldtidesinfo #9184 (#9201) --- homeassistant/components/sensor/worldtidesinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index c9a42f3cb11..f23d244cf3a 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -88,7 +88,7 @@ class WorldTidesInfoSensor(Entity): return "High tide at %s" % (tidetime) elif "Low" in str(self.data['extremes'][0]['type']): tidetime = time.strftime('%I:%M %p', time.localtime( - self.data['extremes'][1]['dt'])) + self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) else: return STATE_UNKNOWN From 75559cb81fc41be329e669cad730cc288219c27c Mon Sep 17 00:00:00 2001 From: Trevor Date: Tue, 29 Aug 2017 08:33:27 -0500 Subject: [PATCH 030/108] Add "status" to Sonarr sensor (#9204) * Use X-Api-Key header * Increase timeout * Add "status" to Sonarr sensor * Update test_sonarr.py * Update test_sonarr.py * Update test_sonarr.py * Update sonarr.py * Update sonarr.py --- homeassistant/components/sensor/radarr.py | 2 +- homeassistant/components/sensor/sonarr.py | 41 +++++++++++++-------- tests/components/sensor/test_sonarr.py | 44 +++++++++++++++++++++++ 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py index 03fbce3e79a..33a09a51aef 100644 --- a/homeassistant/components/sensor/radarr.py +++ b/homeassistant/components/sensor/radarr.py @@ -162,7 +162,7 @@ class RadarrSensor(Entity): res = requests.get( ENDPOINTS[self.type].format( self.ssl, self.host, self.port, self.urlbase, start, end), - headers={'X-Api-Key': self.apikey}, timeout=5) + headers={'X-Api-Key': self.apikey}, timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 143fcee0a61..4be5582b8c4 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -36,17 +36,19 @@ SENSOR_TYPES = { 'upcoming': ['Upcoming', 'Episodes', 'mdi:television'], 'wanted': ['Wanted', 'Episodes', 'mdi:television'], 'series': ['Series', 'Shows', 'mdi:television'], - 'commands': ['Commands', 'Commands', 'mdi:code-braces'] + 'commands': ['Commands', 'Commands', 'mdi:code-braces'], + 'status': ['Status', 'Status', 'mdi:information'] } ENDPOINTS = { - 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace?apikey={4}', - 'queue': 'http{0}://{1}:{2}/{3}api/queue?apikey={4}', + 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace', + 'queue': 'http{0}://{1}:{2}/{3}api/queue', 'upcoming': - 'http{0}://{1}:{2}/{3}api/calendar?apikey={4}&start={5}&end={6}', - 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing?apikey={4}', - 'series': 'http{0}://{1}:{2}/{3}api/series?apikey={4}', - 'commands': 'http{0}://{1}:{2}/{3}api/command?apikey={4}' + 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}', + 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing', + 'series': 'http{0}://{1}:{2}/{3}api/series', + 'commands': 'http{0}://{1}:{2}/{3}api/command', + 'status': 'http{0}://{1}:{2}/{3}api/system/status' } # Support to Yottabytes for the future, why not @@ -156,6 +158,8 @@ class SonarrSensor(Entity): for show in self.data: attributes[show['title']] = '{}/{} Episodes'.format( show['episodeFileCount'], show['episodeCount']) + elif self.type == 'status': + attributes = self.data return attributes @property @@ -168,9 +172,12 @@ class SonarrSensor(Entity): start = get_date(self._tz) end = get_date(self._tz, self.days) try: - res = requests.get(ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, self.apikey, - start, end), timeout=5) + res = requests.get( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, + self.urlbase, start, end), + headers={'X-Api-Key': self.apikey}, + timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False @@ -193,10 +200,13 @@ class SonarrSensor(Entity): self._state = len(self.data) elif self.type == 'wanted': data = res.json() - res = requests.get('{}&pageSize={}'.format( - ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, - self.apikey), data['totalRecords']), timeout=5) + res = requests.get( + '{}?pageSize={}'.format( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, self.urlbase), + data['totalRecords']), + headers={'X-Api-Key': self.apikey}, + timeout=10) self.data = res.json()['records'] self._state = len(self.data) elif self.type == 'diskspace': @@ -217,6 +227,9 @@ class SonarrSensor(Entity): self._unit ) ) + elif self.type == 'status': + self.data = res.json() + self._state = self.data['version'] self._available = True diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index b71b96e1400..bd0011597af 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -549,6 +549,25 @@ def mocked_requests_get(*args, **kwargs): "totalSpace": 499738734592 } ], 200) + elif 'api/system/status' in url: + return MockResponse({ + "version": "2.0.0.1121", + "buildTime": "2014-02-08T20:49:36.5560392Z", + "isDebug": "false", + "isProduction": "true", + "isAdmin": "true", + "isUserInteractive": "false", + "startupPath": "C:\\ProgramData\\NzbDrone\\bin", + "appData": "C:\\ProgramData\\NzbDrone", + "osVersion": "6.2.9200.0", + "isMono": "false", + "isLinux": "false", + "isWindows": "true", + "branch": "develop", + "authentication": "false", + "startOfWeek": 0, + "urlBase": "" + }, 200) else: return MockResponse({ "error": "Unauthorized" @@ -794,6 +813,31 @@ class TestSonarrSetup(unittest.TestCase): device.device_state_attributes["Bob's Burgers"] ) + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_system_status(self, req_mock): + """Test getting system status""" + config = { + 'platform': 'sonarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'status' + ] + } + sonarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('2.0.0.1121', device.state) + self.assertEqual('mdi:information', device.icon) + self.assertEqual('Sonarr Status', device.name) + self.assertEqual( + '6.2.9200.0', + device.device_state_attributes['osVersion']) + @pytest.mark.skip @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) def test_ssl(self, req_mock): From 5d800c1d519584e36c84ede7bd9f1aef3029e129 Mon Sep 17 00:00:00 2001 From: mjj4791 Date: Tue, 29 Aug 2017 15:33:47 +0200 Subject: [PATCH 031/108] Prevent error when no forecast data was available (#9176) * Prevent error when no forecast data was available Prevent an Error when buienradar data was available, but no forecasted data was retrieved for the requested day. * Update buienradar.py * Update buienradar.py --- homeassistant/components/sensor/buienradar.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 8961fa1dc74..1b5cfc4b491 100755 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -220,7 +220,12 @@ class BrSensor(Entity): # update all other sensors if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): - condition = data.get(FORECAST)[fcday].get(CONDITION) + try: + condition = data.get(FORECAST)[fcday].get(CONDITION) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + if condition: new_state = condition.get(CONDITION, None) if self.type.startswith(SYMBOL): @@ -240,7 +245,11 @@ class BrSensor(Entity): return True return False else: - new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + try: + new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False if new_state != self._state: self._state = new_state From 38071501b421627dadbceb4bbdced195ac4ec620 Mon Sep 17 00:00:00 2001 From: Dale Higgs Date: Tue, 29 Aug 2017 08:38:42 -0500 Subject: [PATCH 032/108] Fix and optimize digitalloggers platform (#9203) * Fix and optimize digitalloggers platform * Fix line length * Fix hanging indentation * Add missing docstring * Add period to end of docstring * Add second blank line --- .../components/switch/digitalloggers.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py index 26493122184..0625a42f765 100755 --- a/homeassistant/components/switch/digitalloggers.py +++ b/homeassistant/components/switch/digitalloggers.py @@ -46,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import dlipower host = config.get(CONF_HOST) - controllername = config.get(CONF_NAME) + controller_name = config.get(CONF_NAME) user = config.get(CONF_USERNAME) pswd = config.get(CONF_PASSWORD) tout = config.get(CONF_TIMEOUT) @@ -61,37 +61,42 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not connect to DIN III Relay") return False - devices = [] + outlets = [] parent_device = DINRelayDevice(power_switch) - devices.extend( - DINRelay(controllername, device.outlet_number, parent_device) - for device in power_switch + outlets.extend( + DINRelay(controller_name, parent_device, outlet) + for outlet in power_switch[0:] ) - add_devices(devices, True) + add_devices(outlets) class DINRelay(SwitchDevice): """Representation of a individual DIN III relay port.""" - def __init__(self, name, outletnumber, parent_device): + def __init__(self, controller_name, parent_device, outlet): """Initialize the DIN III Relay switch.""" + self._controller_name = controller_name self._parent_device = parent_device - self.controllername = name - self.outletnumber = outletnumber - self._outletname = '' - self._is_on = False + self._outlet = outlet + + self._outlet_number = self._outlet.outlet_number + self._name = self._outlet.description + self._state = self._outlet.state == 'ON' @property def name(self): """Return the display name of this relay.""" - return self._outletname + return '{}_{}'.format( + self._controller_name, + self._name + ) @property def is_on(self): """Return true if relay is on.""" - return self._is_on + return self._state @property def should_poll(self): @@ -100,41 +105,36 @@ class DINRelay(SwitchDevice): def turn_on(self, **kwargs): """Instruct the relay to turn on.""" - self._parent_device.turn_on(outlet=self.outletnumber) + self._outlet.on() def turn_off(self, **kwargs): """Instruct the relay to turn off.""" - self._parent_device.turn_off(outlet=self.outletnumber) + self._outlet.off() def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() - self._is_on = ( - self._parent_device.statuslocal[self.outletnumber - 1][2] == 'ON' - ) - self._outletname = '{}_{}'.format( - self.controllername, - self._parent_device.statuslocal[self.outletnumber - 1][1] - ) + + outlet_status = self._parent_device.get_outlet_status( + self._outlet_number) + + self._name = outlet_status[1] + self._state = outlet_status[2] == 'ON' class DINRelayDevice(object): """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, power_switch): """Initialize the DINRelay device.""" - self._device = device - self.statuslocal = None + self._power_switch = power_switch + self._statuslist = None - def turn_on(self, **kwargs): - """Instruct the relay to turn on.""" - self._device.on(**kwargs) - - def turn_off(self, **kwargs): - """Instruct the relay to turn off.""" - self._device.off(**kwargs) + def get_outlet_status(self, outlet_number): + """Get status of outlet from cached status list.""" + return self._statuslist[outlet_number - 1] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for this device.""" - self.statuslocal = self._device.statuslist() + self._statuslist = self._power_switch.statuslist() From 0687a457b1ff3ede52cdb99d77d768e27b2e91b3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 29 Aug 2017 15:44:36 +0200 Subject: [PATCH 033/108] Add counter component (#9146) --- homeassistant/components/counter.py | 220 +++++++++++++++++++++++++ homeassistant/components/services.yaml | 25 +++ tests/components/test_counter.py | 204 +++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 homeassistant/components/counter.py create mode 100644 tests/components/test_counter.py diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter.py new file mode 100644 index 00000000000..64421306644 --- /dev/null +++ b/homeassistant/components/counter.py @@ -0,0 +1,220 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def increment(hass, entity_id): + """Increment a counter.""" + hass.add_job(async_increment, hass, entity_id) + + +@callback +@bind_hass +def async_increment(hass, entity_id): + """Increment a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement a counter.""" + hass.add_job(async_decrement, hass, entity_id) + + +@callback +@bind_hass +def async_decrement(hass, entity_id): + """Decrement a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def reset(hass, entity_id): + """Reset a counter.""" + hass.add_job(async_reset, hass, entity_id) + + +@callback +@bind_hass +def async_reset(hass, entity_id): + """Reset a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a counter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, step, icon)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the counter services.""" + target_counters = component.async_extract_from_service(service) + + if service.service == SERVICE_INCREMENT: + attr = 'async_increment' + elif service.service == SERVICE_DECREMENT: + attr = 'async_decrement' + elif service.service == SERVICE_RESET: + attr = 'async_reset' + + tasks = [getattr(counter, attr)() for counter in target_counters] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + + hass.services.async_register( + DOMAIN, SERVICE_INCREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_DECREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESET, async_handler_service, + descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Counter(Entity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment a counter.""" + self._state += self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_reset(self): + """Reset a counter.""" + self._state = self._initial + yield from self.async_update_ha_state() diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 7315b6dc2d2..57820917cab 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -546,3 +546,28 @@ rflink: command: description: The command to be sent example: 'on' + +counter: + decrement: + description: Decrement a counter. + + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' + + increment: + description: Increment a counter. + + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' + + reset: + description: Reset a counter. + + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' diff --git a/tests/components/test_counter.py b/tests/components/test_counter.py new file mode 100644 index 00000000000..8dc04f0e76a --- /dev/null +++ b/tests/components/test_counter.py @@ -0,0 +1,204 @@ +"""The tests for the counter component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import logging + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.counter import ( + DOMAIN, decrement, increment, reset, CONF_INITIAL, CONF_STEP, CONF_NAME, + CONF_ICON) +from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME) + +from tests.common import (get_test_home_assistant, mock_restore_cache) + +_LOGGER = logging.getLogger(__name__) + + +class TestCounter(unittest.TestCase): + """Test the counter component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_methods(self): + """Test increment, decrement, and reset methods.""" + config = { + DOMAIN: { + 'test_1': {}, + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(2, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + reset(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + def test_methods_with_config(self): + """Test increment, decrement, and reset methods with configuration.""" + config = { + DOMAIN: { + 'test': { + CONF_NAME: 'Hello World', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test' + + state = self.hass.states.get(entity_id) + self.assertEqual(10, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(20, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + self.hass.block_till_done() + + _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('counter.test_1') + state_2 = self.hass.states.get('counter.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(0, int(state_1.state)) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(10, int(state_2.state)) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('counter.test1', '11'), + State('counter.test2', '-22'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': {}, + 'test2': { + CONF_INITIAL: 10, + }, + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 + + state = hass.states.get('counter.test2') + assert state + assert int(state.state) == 10 + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_STEP: 5, + } + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 From 3e0eb8763f94d544c6af1ffc867ff48ad9123354 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Tue, 29 Aug 2017 10:18:37 -0400 Subject: [PATCH 034/108] Support for season sensor (#8958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- homeassistant/components/sensor/season.py | 122 +++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_season.py | 183 ++++++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 homeassistant/components/sensor/season.py create mode 100644 tests/components/sensor/test_season.py diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py new file mode 100644 index 00000000000..e02f3cac2b0 --- /dev/null +++ b/homeassistant/components/sensor/season.py @@ -0,0 +1,122 @@ +""" +Support for tracking which astronomical or meteorological season it is. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor/season/ +""" +import logging +from datetime import datetime + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_TYPE +from homeassistant.helpers.entity import Entity +import homeassistant.util as util + +REQUIREMENTS = ['ephem==3.7.6.0'] + +_LOGGER = logging.getLogger(__name__) + +NORTHERN = 'northern' +SOUTHERN = 'southern' +EQUATOR = 'equator' +STATE_SPRING = 'Spring' +STATE_SUMMER = 'Summer' +STATE_AUTUMN = 'Autumn' +STATE_WINTER = 'Winter' +TYPE_ASTRONOMICAL = 'astronomical' +TYPE_METEOROLOGICAL = 'meteorological' +VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] + +HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, + STATE_SPRING: STATE_AUTUMN, + STATE_AUTUMN: STATE_SPRING, + STATE_SUMMER: STATE_WINTER} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Display the current season.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + latitude = util.convert(hass.config.latitude, float) + _type = config.get(CONF_TYPE) + + if latitude < 0: + hemisphere = SOUTHERN + elif latitude > 0: + hemisphere = NORTHERN + else: + hemisphere = EQUATOR + + _LOGGER.debug(_type) + add_devices([Season(hass, hemisphere, _type)]) + + return True + + +def get_season(date, hemisphere, season_tracking_type): + """Calculate the current season.""" + import ephem + + if hemisphere == 'equator': + return None + + if season_tracking_type == TYPE_ASTRONOMICAL: + spring_start = ephem.next_equinox(str(date.year)).datetime() + summer_start = ephem.next_solstice(str(date.year)).datetime() + autumn_start = ephem.next_equinox(spring_start).datetime() + winter_start = ephem.next_solstice(summer_start).datetime() + else: + spring_start = datetime(2017, 3, 1).replace(year=date.year) + summer_start = spring_start.replace(month=6) + autumn_start = spring_start.replace(month=9) + winter_start = spring_start.replace(month=12) + + if spring_start <= date < summer_start: + season = STATE_SPRING + elif summer_start <= date < autumn_start: + season = STATE_SUMMER + elif autumn_start <= date < winter_start: + season = STATE_AUTUMN + elif winter_start <= date or spring_start > date: + season = STATE_WINTER + + # If user is located in the southern hemisphere swap the season + if hemisphere == NORTHERN: + return season + return HEMISPHERE_SEASON_SWAP.get(season) + + +class Season(Entity): + """Representation of the current season.""" + + def __init__(self, hass, hemisphere, season_tracking_type): + """Initialize the season.""" + self.hass = hass + self.hemisphere = hemisphere + self.datetime = datetime.now() + self.type = season_tracking_type + self.season = get_season(self.datetime, self.hemisphere, self.type) + + @property + def name(self): + """Return the name.""" + return "Season" + + @property + def state(self): + """Return the current season.""" + return self.season + + def update(self): + """Update season.""" + self.datetime = datetime.now() + self.season = get_season(self.datetime, self.hemisphere, self.type) diff --git a/requirements_all.txt b/requirements_all.txt index 35170465cd9..34c1d5f1e72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -202,6 +202,9 @@ enocean==0.31 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.keyboard_remote # evdev==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f286555833e..7695f83497b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,6 +39,9 @@ apns2==0.1.1 # homeassistant.components.sensor.dsmr dsmr_parser==0.8 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.climate.honeywell evohomeclient==0.2.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ba7a49cc7c0..8a215cd2873 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -70,6 +70,7 @@ TEST_REQUIREMENTS = ( 'restrictedpython', 'pyunifi', 'prometheus_client', + 'ephem' ) IGNORE_PACKAGES = ( diff --git a/tests/components/sensor/test_season.py b/tests/components/sensor/test_season.py new file mode 100644 index 00000000000..10e147bcff9 --- /dev/null +++ b/tests/components/sensor/test_season.py @@ -0,0 +1,183 @@ +"""The tests for the Season sensor platform.""" +# pylint: disable=protected-access +import unittest +from datetime import datetime + +import homeassistant.components.sensor.season as season + +from tests.common import get_test_home_assistant + + +# pylint: disable=invalid-name +class TestSeason(unittest.TestCase): + """Test the season platform.""" + + DEVICE = None + CONFIG_ASTRONOMICAL = {'type': 'astronomical'} + CONFIG_METEOROLOGICAL = {'type': 'meteorological'} + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICE = device + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_season_should_be_summer_northern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_northern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_northern_astonomical(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_northern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_winter_northern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_northern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_northern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_northern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_winter_southern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_southern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_southern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_summer_southern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_southern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + autumn_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_southern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_on_equator_results_in_none(self): + """Test that season should be unknown.""" + # A known day in summer if astronomical and northern + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, + season.EQUATOR, + season.TYPE_ASTRONOMICAL) + self.assertEqual(None, current_season) From aa8dd8fbdd5a97bbe11574ff7ab70e00c5b9ff91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 29 Aug 2017 16:20:26 +0200 Subject: [PATCH 035/108] Issue #6893 in rfxtrx (#9130) * Issue #6893 in rfxtrx * Update rfxtrx.py * rfxtrx issue --- homeassistant/components/rfxtrx.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3ffc2f24a8..e3226418ea9 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -77,10 +77,6 @@ def _valid_device(value, device_type): if not len(key) % 2 == 0: key = '0' + key - if get_rfx_object(key) is None: - raise vol.Invalid('Rfxtrx device {} is invalid: ' - 'Invalid device id for {}'.format(key, value)) - if device_type == 'sensor': config[key] = DEVICE_SCHEMA_SENSOR(device) elif device_type == 'binary_sensor': @@ -292,6 +288,9 @@ def get_devices_from_config(config, device, hass): devices = [] for packet_id, entity_info in config[CONF_DEVICES].items(): event = get_rfx_object(packet_id) + if event is None: + _LOGGER.error("Invalid device: %s", packet_id) + continue device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue From ee28b439b37c2a60c57c55902a7af11bafdefd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 29 Aug 2017 16:22:28 +0200 Subject: [PATCH 036/108] Refactor rfxtrx (#9117) * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor * rfxtrx refactor --- homeassistant/components/cover/rfxtrx.py | 4 +- homeassistant/components/light/rfxtrx.py | 4 +- homeassistant/components/rfxtrx.py | 47 +++++++++++++---------- homeassistant/components/switch/rfxtrx.py | 4 +- requirements_all.txt | 2 +- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index f599ea3ede1..0e28d3ef701 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index f831d6c04ce..9248b0131f1 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx platform.""" import RFXtrx as rfxtrxmod - lights = rfxtrx.get_devices_from_config(config, RfxtrxLight, hass) + lights = rfxtrx.get_devices_from_config(config, RfxtrxLight) add_devices(lights) def light_update(event): @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): not event.device.known_to_be_dimmable: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxLight, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxLight) if new_device: add_devices([new_device]) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3226418ea9..259f8fa8ac6 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,6 +4,7 @@ Support for RFXtrx components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ + import logging from collections import OrderedDict import voluptuous as vol @@ -11,13 +12,14 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_ENTITY_ID, TEMP_CELSIUS, CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.19.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.0'] DOMAIN = 'rfxtrx' @@ -54,7 +56,7 @@ DATA_TYPES = OrderedDict([ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = None +RFXOBJECT = 'rfxobject' def _valid_device(value, device_type): @@ -167,24 +169,24 @@ def setup(hass, config): # Try to load the RFXtrx module. import RFXtrx as rfxtrxmod - # Init the rfxtrx module. - global RFXOBJECT - device = config[DOMAIN][ATTR_DEVICE] debug = config[DOMAIN][ATTR_DEBUG] dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - RFXOBJECT =\ - rfxtrxmod.Connect(device, handle_receive, debug=debug, + hass.data[RFXOBJECT] =\ + rfxtrxmod.Connect(device, None, debug=debug, transport_protocol=rfxtrxmod.DummyTransport2) else: - RFXOBJECT = rfxtrxmod.Connect(device, handle_receive, debug=debug) + hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + + def _start_rfxtrx(event): + hass.data[RFXOBJECT].event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - RFXOBJECT.close_connection() - + hass.data[RFXOBJECT].close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) return True @@ -281,7 +283,7 @@ def find_possible_pt2262_device(device_id): return None -def get_devices_from_config(config, device, hass): +def get_devices_from_config(config, device): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] @@ -302,13 +304,12 @@ def get_devices_from_config(config, device, hass): new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device devices.append(new_device) return devices -def get_new_device(event, config, device, hass): +def get_new_device(event, config, device): """Add entity if not exist and the automatic_add is True.""" device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: @@ -329,7 +330,6 @@ def get_new_device(event, config, device, hass): signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device return new_device @@ -437,31 +437,36 @@ class RfxtrxDevice(Entity): if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(RFXOBJECT.transport) + self._event.device.send_on(self.hass.data[RFXOBJECT] + .transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(RFXOBJECT.transport, - brightness) + self._event.device.send_dim(self.hass.data[RFXOBJECT] + .transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(RFXOBJECT.transport) + self._event.device.send_off(self.hass.data[RFXOBJECT] + .transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(RFXOBJECT.transport) + self._event.device.send_open(self.hass.data[RFXOBJECT] + .transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(RFXOBJECT.transport) + self._event.device.send_close(self.hass.data[RFXOBJECT] + .transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(RFXOBJECT.transport) + self._event.device.send_stop(self.hass.data[RFXOBJECT] + .transport) self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 36044f5f168..1361d22de18 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): import RFXtrx as rfxtrxmod # Add switch from config file - switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch, hass) + switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch) add_devices_callback(switches) def switch_update(event): @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch) if new_device: add_devices_callback([new_device]) diff --git a/requirements_all.txt b/requirements_all.txt index 34c1d5f1e72..ce2f2d5f560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -533,7 +533,7 @@ pyCEC==0.4.13 pyHS100==0.2.4.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.19.0 +pyRFXtrx==0.20.0 # homeassistant.components.switch.dlink pyW215==0.5.1 From b8d737c0cc7dda6113034ae0edc86c2d80a3d3c5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 29 Aug 2017 17:10:28 +0200 Subject: [PATCH 037/108] Upgrade pymysensors to 0.11.1 (#9212) --- homeassistant/components/mysensors.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 210c5773c53..62fecddb8c4 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component from homeassistant.setup import setup_component -REQUIREMENTS = ['pymysensors==0.11.0'] +REQUIREMENTS = ['pymysensors==0.11.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ce2f2d5f560..771b6475be6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -661,7 +661,7 @@ pymodbus==1.3.1 pymyq==0.0.8 # homeassistant.components.mysensors -pymysensors==0.11.0 +pymysensors==0.11.1 # homeassistant.components.lock.nello pynello==1.5 From 81a00bf3f1ba66d9ab00bf323066997f6c564af5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 08:10:38 -0700 Subject: [PATCH 038/108] Lint Sonarr tests --- tests/components/sensor/test_sonarr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index bd0011597af..9e2050e850c 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -815,7 +815,7 @@ class TestSonarrSetup(unittest.TestCase): @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) def test_system_status(self, req_mock): - """Test getting system status""" + """Test getting system status.""" config = { 'platform': 'sonarr', 'api_key': 'foo', From 33c906c20aa8ad62f19c974001ffa906759577f2 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Tue, 29 Aug 2017 08:34:19 -0700 Subject: [PATCH 039/108] Abode push events and lock, cover, and switch components (#9095) * Updated abodepy version to 0.7.1 * Refactored to use AbodeDevice. Added Abode Lock device. * Added push updates to abode devices. * Upgraded to 0.7.2 after finding issue with callbacks. * Refactored to use AbodeDevice. Added Abode Lock device. * Added push updates to abode devices. * Upgraded to 0.7.2 after finding issue with callbacks. * Bumped version to 0.8.2. Modified code to work with new constants and properties. Added cover and switch. * Fixed hound violations. * Updated to 0.8.3 to fix small bug with standby mode. Fixed comment in cover/abode.py. * Fix lint issues * Removed excessive logging. Moved device callback registration to async_added_to_hass. Moved abode controller from global into hass data. * Removed explicit None from dict.get() * Move device class into the constructor. * Changed constant name to platforms. * Changes as requested. * Removing stray blank line. * Added blank line of which I'm not sure how it was removed. * Updated version to 0.9.0. Fixed motion sensor. Added power_switch_meter device type. * Update abode.py * fix lint --- homeassistant/components/abode.py | 86 +++++++++++++++---- .../components/alarm_control_panel/abode.py | 50 +++++------ .../components/binary_sensor/abode.py | 76 ++++++---------- homeassistant/components/cover/abode.py | 49 +++++++++++ homeassistant/components/lock/abode.py | 49 +++++++++++ homeassistant/components/switch/abode.py | 53 ++++++++++++ requirements_all.txt | 2 +- 7 files changed, 270 insertions(+), 95 deletions(-) create mode 100644 homeassistant/components/cover/abode.py create mode 100644 homeassistant/components/lock/abode.py create mode 100644 homeassistant/components/switch/abode.py diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 677fcab4f5d..c8d4ee67d49 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -4,15 +4,20 @@ This component provides basic support for Abode Home Security system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/abode/ """ +import asyncio import logging import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.const import (ATTR_ATTRIBUTION, + CONF_USERNAME, CONF_PASSWORD, + CONF_NAME, EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.7.1'] +REQUIREMENTS = ['abodepy==0.9.0'] _LOGGER = logging.getLogger(__name__) @@ -20,8 +25,7 @@ CONF_ATTRIBUTION = "Data provided by goabode.com" DOMAIN = 'abode' DEFAULT_NAME = 'Abode' -DATA_ABODE = 'data_abode' -DEFAULT_ENTITY_NAMESPACE = 'abode' +DATA_ABODE = 'abode' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' @@ -34,19 +38,21 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ABODE_PLATFORMS = [ + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover' +] + def setup(hass, config): """Set up Abode component.""" + import abodepy + conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) try: - data = AbodeData(username, password) - hass.data[DATA_ABODE] = data - - for component in ['binary_sensor', 'alarm_control_panel']: - discovery.load_platform(hass, component, DOMAIN, {}, config) + hass.data[DATA_ABODE] = abode = abodepy.Abode(username, password) except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) @@ -58,18 +64,62 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + for platform in ABODE_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + def logout(event): + """Logout of Abode.""" + abode.stop_listener() + abode.logout() + _LOGGER.info("Logged out of Abode") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) + + def startup(event): + """Listen for push events.""" + abode.start_listener() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + return True -class AbodeData: - """Shared Abode data.""" +class AbodeDevice(Entity): + """Representation of an Abode device.""" - def __init__(self, username, password): - """Initialize Abode oject.""" - import abodepy + def __init__(self, controller, device): + """Initialize a sensor for Abode device.""" + self._controller = controller + self._device = device - self.abode = abodepy.Abode(username, password) - self.devices = self.abode.get_devices() + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + self.hass.async_add_job( + self._controller.register, self._device, + self._update_callback + ) - _LOGGER.debug("Abode Security set up with %s devices", - len(self.devices)) + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_low': self._device.battery_low, + 'no_response': self._device.no_response + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index 7d7ce931c20..7a615ffc7bf 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -6,10 +6,12 @@ https://home-assistant.io/components/alarm_control_panel.abode/ """ import logging -from homeassistant.components.abode import (DATA_ABODE, DEFAULT_NAME) -from homeassistant.const import (STATE_ALARM_ARMED_AWAY, +from homeassistant.components.abode import ( + AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION) +from homeassistant.components.alarm_control_panel import (AlarmControlPanel) +from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -import homeassistant.components.alarm_control_panel as alarm + DEPENDENCIES = ['abode'] @@ -20,30 +22,19 @@ ICON = 'mdi:security' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + abode = hass.data[DATA_ABODE] - add_devices([AbodeAlarm(hass, data, data.abode.get_alarm())]) + add_devices([AbodeAlarm(abode, abode.get_alarm())]) -class AbodeAlarm(alarm.AlarmControlPanel): +class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, hass, data, device): + def __init__(self, controller, device): """Initialize the alarm control panel.""" - super(AbodeAlarm, self).__init__() - self._device = device + AbodeDevice.__init__(self, controller, device) self._name = "{0}".format(DEFAULT_NAME) - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def icon(self): """Return icon.""" @@ -52,11 +43,11 @@ class AbodeAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._device.mode == "standby": + if self._device.is_standby: state = STATE_ALARM_DISARMED - elif self._device.mode == "away": + elif self._device.is_away: state = STATE_ALARM_ARMED_AWAY - elif self._device.mode == "home": + elif self._device.is_home: state = STATE_ALARM_ARMED_HOME else: state = None @@ -65,18 +56,21 @@ class AbodeAlarm(alarm.AlarmControlPanel): def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() - self.schedule_update_ha_state() def alarm_arm_home(self, code=None): """Send arm home command.""" self._device.set_home() - self.schedule_update_ha_state() def alarm_arm_away(self, code=None): """Send arm away command.""" self._device.set_away() - self.schedule_update_ha_state() - def update(self): - """Update the device state.""" - self._device.refresh() + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_backup': self._device.battery, + 'cellular_backup': self._device.is_cellular + } diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py index 9abff53026d..d3b0d662a94 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,76 +6,56 @@ https://home-assistant.io/components/binary_sensor.abode/ """ import logging -from homeassistant.components.abode import (CONF_ATTRIBUTION, DATA_ABODE) -from homeassistant.const import (ATTR_ATTRIBUTION) -from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.binary_sensor import BinarySensorDevice + DEPENDENCIES = ['abode'] _LOGGER = logging.getLogger(__name__) -# Sensor types: Name, device_class -SENSOR_TYPES = { - 'Door Contact': 'opening', - 'Motion Camera': 'motion', -} - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + abode = hass.data[DATA_ABODE] + + device_types = map_abode_device_class().keys() sensors = [] - for sensor in data.devices: - _LOGGER.debug('Sensor type %s', sensor.type) - if sensor.type in ['Door Contact', 'Motion Camera']: - sensors.append(AbodeBinarySensor(hass, data, sensor)) + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeBinarySensor(abode, sensor)) - _LOGGER.debug('Adding %d sensors', len(sensors)) add_devices(sensors) -class AbodeBinarySensor(BinarySensorDevice): +def map_abode_device_class(): + """Map Abode device types to Home Assistant binary sensor class.""" + import abodepy.helpers.constants as CONST + + return { + CONST.DEVICE_GLASS_BREAK: 'connectivity', + CONST.DEVICE_KEYPAD: 'connectivity', + CONST.DEVICE_DOOR_CONTACT: 'opening', + CONST.DEVICE_STATUS_DISPLAY: 'connectivity', + CONST.DEVICE_MOTION_CAMERA: 'connectivity', + CONST.DEVICE_WATER_SENSOR: 'moisture' + } + + +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, hass, data, device): + def __init__(self, controller, device): """Initialize a sensor for Abode device.""" - super(AbodeBinarySensor, self).__init__() - self._device = device - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return "{0} {1}".format(self._device.type, self._device.name) + AbodeDevice.__init__(self, controller, device) + self._device_class = map_abode_device_class().get(self._device.type) @property def is_on(self): """Return True if the binary sensor is on.""" - if self._device.type == 'Door Contact': - return self._device.status != 'Closed' - elif self._device.type == 'Motion Camera': - return self._device.get_value('motion_event') == '1' + return self._device.is_on @property def device_class(self): """Return the class of the binary sensor.""" - return SENSOR_TYPES.get(self._device.type) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - attrs['device_id'] = self._device.device_id - attrs['battery_low'] = self._device.battery_low - - return attrs - - def update(self): - """Update the device state.""" - self._device.refresh() + return self._device_class diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py new file mode 100644 index 00000000000..b09c9e5e007 --- /dev/null +++ b/homeassistant/components/cover/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): + sensors.append(AbodeCover(abode, sensor)) + + add_devices(sensors) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._device.is_open is False + + def close_cover(self): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py new file mode 100644 index 00000000000..aad720e0d7d --- /dev/null +++ b/homeassistant/components/lock/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA lock support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.lock import LockDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode lock devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_DOOR_LOCK)): + sensors.append(AbodeLock(abode, sensor)) + + add_devices(sensors) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py new file mode 100644 index 00000000000..bed0b9c0b60 --- /dev/null +++ b/homeassistant/components/switch/abode.py @@ -0,0 +1,53 @@ +""" +This component provides HA switch support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.switch import SwitchDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode switch devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + device_types = [ + CONST.DEVICE_POWER_SWITCH_SENSOR, + CONST.DEVICE_POWER_SWITCH_METER] + + sensors = [] + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeSwitch(abode, sensor)) + + add_devices(sensors) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on diff --git a/requirements_all.txt b/requirements_all.txt index 771b6475be6..b357f9ffc53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.7.1 +abodepy==0.9.0 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.2 From 0b58d5405e182746af97530f6cae0528d1d07146 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Aug 2017 13:40:08 -0700 Subject: [PATCH 040/108] Add cloud auth support (#9208) * Add initial cloud auth * Move hass.data to a dict * Move mode into helper * Fix bugs afte refactor * Add tests * Clean up scripts file after test config * Lint * Update __init__.py --- homeassistant/components/cloud/__init__.py | 49 +++ homeassistant/components/cloud/cloud_api.py | 297 +++++++++++++++++ homeassistant/components/cloud/const.py | 14 + homeassistant/components/cloud/http_api.py | 119 +++++++ homeassistant/components/cloud/util.py | 10 + tests/common.py | 2 +- tests/components/cloud/__init__.py | 1 + tests/components/cloud/test_cloud_api.py | 352 ++++++++++++++++++++ tests/components/cloud/test_http_api.py | 157 +++++++++ tests/test_config.py | 6 + tests/test_util/aiohttp.py | 1 + 11 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/cloud/__init__.py create mode 100644 homeassistant/components/cloud/cloud_api.py create mode 100644 homeassistant/components/cloud/const.py create mode 100644 homeassistant/components/cloud/http_api.py create mode 100644 homeassistant/components/cloud/util.py create mode 100644 tests/components/cloud/__init__.py create mode 100644 tests/components/cloud/test_cloud_api.py create mode 100644 tests/components/cloud/test_http_api.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000..8804f6d113f --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,49 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, cloud_api +from .const import DOMAIN + + +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + cloud = yield from cloud_api.async_load_auth(hass) + + if cloud is not None: + data['cloud'] = cloud + + yield from http_api.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000..6429da14516 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,297 @@ +"""Package to offer tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +import json +import logging +import os +from urllib.parse import urljoin + +import aiohttp +import async_timeout + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.dt import utcnow + +from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +URL_CREATE_TOKEN = 'o/token/' +URL_REVOKE_TOKEN = 'o/revoke_token/' +URL_ACCOUNT = 'account.json' + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + def __init__(self, reason=None, status=None): + """Initialize a cloud error.""" + super().__init__(reason) + self.status = status + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UnknownError(CloudError): + """Raised when an unknown error occurred.""" + + +@asyncio.coroutine +def async_load_auth(hass): + """Load authentication from disk and verify it.""" + auth = yield from hass.async_add_job(_read_auth, hass) + + if not auth: + return None + + cloud = Cloud(hass, auth) + + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + auth_check = yield from cloud.async_refresh_account_info() + + if not auth_check: + _LOGGER.error('Unable to validate credentials.') + return None + + return cloud + + except asyncio.TimeoutError: + _LOGGER.error('Unable to reach server to validate credentials.') + return None + + +@asyncio.coroutine +def async_login(hass, username, password, scope=None): + """Get a token using a username and password. + + Returns a coroutine. + """ + data = { + 'grant_type': 'password', + 'username': username, + 'password': password + } + if scope is not None: + data['scope'] = scope + + auth = yield from _async_get_token(hass, data) + + yield from hass.async_add_job(_write_auth, hass, auth) + + return Cloud(hass, auth) + + +@asyncio.coroutine +def _async_get_token(hass, data): + """Get a new token and return it as a dictionary. + + Raises exceptions when errors occur: + - Unauthenticated + - UnknownError + """ + session = async_get_clientsession(hass) + auth = aiohttp.BasicAuth(*_client_credentials(hass)) + + try: + req = yield from session.post( + _url(hass, URL_CREATE_TOKEN), + data=data, + auth=auth + ) + + if req.status == 401: + _LOGGER.error('Cloud login failed: %d', req.status) + raise Unauthenticated(status=req.status) + elif req.status != 200: + _LOGGER.error('Cloud login failed: %d', req.status) + raise UnknownError(status=req.status) + + response = yield from req.json() + response['expires_at'] = \ + (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() + + return response + + except aiohttp.ClientError: + raise UnknownError() + + +class Cloud: + """Store Hass Cloud info.""" + + def __init__(self, hass, auth): + """Initialize Hass cloud info object.""" + self.hass = hass + self.auth = auth + self.account = None + + @property + def access_token(self): + """Return access token.""" + return self.auth['access_token'] + + @property + def refresh_token(self): + """Get refresh token.""" + return self.auth['refresh_token'] + + @asyncio.coroutine + def async_refresh_account_info(self): + """Refresh the account info.""" + req = yield from self.async_request('get', URL_ACCOUNT) + + if req.status != 200: + return False + + self.account = yield from req.json() + return True + + @asyncio.coroutine + def async_refresh_access_token(self): + """Get a token using a refresh token.""" + try: + self.auth = yield from _async_get_token(self.hass, { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + }) + + yield from self.hass.async_add_job( + _write_auth, self.hass, self.auth) + + return True + except CloudError: + return False + + @asyncio.coroutine + def async_revoke_access_token(self): + """Revoke active access token.""" + session = async_get_clientsession(self.hass) + client_id, client_secret = _client_credentials(self.hass) + data = { + 'token': self.access_token, + 'client_id': client_id, + 'client_secret': client_secret + } + try: + req = yield from session.post( + _url(self.hass, URL_REVOKE_TOKEN), + data=data, + ) + + if req.status != 200: + _LOGGER.error('Cloud logout failed: %d', req.status) + raise UnknownError(status=req.status) + + self.auth = None + yield from self.hass.async_add_job( + _write_auth, self.hass, None) + + except aiohttp.ClientError: + raise UnknownError() + + @asyncio.coroutine + def async_request(self, method, path, **kwargs): + """Make a request to Home Assistant cloud. + + Will refresh the token if necessary. + """ + session = async_get_clientsession(self.hass) + url = _url(self.hass, path) + + if 'headers' not in kwargs: + kwargs['headers'] = {} + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + request = yield from session.request(method, url, **kwargs) + + if request.status != 403: + return request + + # Maybe token expired. Try refreshing it. + reauth = yield from self.async_refresh_access_token() + + if not reauth: + return request + + # Release old connection back to the pool. + yield from request.release() + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + # If we are not already fetching the account info, + # refresh the account info. + + if path != URL_ACCOUNT: + yield from self.async_refresh_account_info() + + request = yield from session.request(method, url, **kwargs) + + return request + + +def _read_auth(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_auth(hass, data): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if data is None: + content.pop(mode, None) + else: + content[mode] = data + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _client_credentials(hass): + """Get the client credentials. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] + + +def _url(hass, path): + """Generate a url for the cloud. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000..f55a4be21a2 --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + 'host': 'http://localhost:8000', + 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', + 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' + 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' + 'VBJrRyfgTVd43kbrEQtuOiaUpK') + } +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py new file mode 100644 index 00000000000..661cc8a7ba1 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,119 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +import logging + +import voluptuous as vol +import async_timeout + +from homeassistant.components.http import HomeAssistantView + +from . import cloud_api +from .const import DOMAIN, REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudAccountView) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str, + }) + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Login with invalid JSON') + return self.json_message('Invalid JSON.', 400) + + try: + self.schema(data) + except vol.Invalid as err: + _LOGGER.error('Login with invalid formatted data') + return self.json_message( + 'Message format incorrect: {}'.format(err), 400) + + hass = request.app['hass'] + phase = 1 + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + cloud = yield from cloud_api.async_login( + hass, data['username'], data['password']) + + phase += 1 + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from cloud.async_refresh_account_info() + + except cloud_api.Unauthenticated: + return self.json_message( + 'Authentication failed (phase {}).'.format(phase), 401) + except cloud_api.UnknownError: + return self.json_message( + 'Unknown error occurred (phase {}).'.format(phase), 500) + except asyncio.TimeoutError: + return self.json_message( + 'Unable to reach Home Assistant cloud ' + '(phase {}).'.format(phase), 502) + + hass.data[DOMAIN]['cloud'] = cloud + return self.json(cloud.account) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from \ + hass.data[DOMAIN]['cloud'].async_revoke_access_token() + + hass.data[DOMAIN].pop('cloud') + + return self.json({ + 'result': 'ok', + }) + except asyncio.TimeoutError: + return self.json_message("Could not reach the server.", 502) + except cloud_api.UnknownError as err: + return self.json_message( + "Error communicating with the server ({}).".format(err.status), + 502) + + +class CloudAccountView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + + if 'cloud' not in hass.data[DOMAIN]: + return self.json_message('Not logged in', 400) + + return self.json(hass.data[DOMAIN]['cloud'].account) diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 00000000000..ec5445f0638 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/tests/common.py b/tests/common.py index 5fdec2fc411..f0d6a5bd057 100644 --- a/tests/common.py +++ b/tests/common.py @@ -119,7 +119,7 @@ def async_test_home_assistant(loop): def async_add_job(target, *args): """Add a magic mock.""" if isinstance(target, Mock): - return mock_coro(target()) + return mock_coro(target(*args)) return orig_async_add_job(target, *args) hass.async_add_job = async_add_job diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py new file mode 100644 index 00000000000..707e49f670f --- /dev/null +++ b/tests/components/cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the cloud component.""" diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 00000000000..11c396daf05 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,352 @@ +"""Tests for the tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch +from urllib.parse import urljoin + +import aiohttp +import pytest + +from homeassistant.components.cloud import DOMAIN, cloud_api, const +import homeassistant.util.dt as dt_util + +from tests.common import mock_coro + + +MOCK_AUTH = { + "access_token": "jvCHxpTu2nfORLBRgQY78bIAoK4RPa", + "expires_at": "2017-08-29T05:33:28.266048+00:00", + "expires_in": 86400, + "refresh_token": "C4wR1mgb03cs69EeiFgGOBC8mMQC5Q", + "scope": "", + "token_type": "Bearer" +} + + +def url(path): + """Create a url.""" + return urljoin(const.SERVERS['development']['host'], path) + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(cloud_api, '_write_auth') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(cloud_api, '_read_auth') as mock: + yield mock + + +@asyncio.coroutine +def test_async_login_invalid_auth(cloud_hass, aioclient_mock, mock_write): + """Test trying to login with invalid credentials.""" + aioclient_mock.post(url('o/token/'), status=401) + with pytest.raises(cloud_api.Unauthenticated): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_cloud_error(cloud_hass, aioclient_mock, mock_write): + """Test exception in cloud while logging in.""" + aioclient_mock.post(url('o/token/'), status=500) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_client_error(cloud_hass, aioclient_mock, mock_write): + """Test client error while logging in.""" + aioclient_mock.post(url('o/token/'), exc=aiohttp.ClientError) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login(cloud_hass, aioclient_mock, mock_write): + """Test logging in.""" + aioclient_mock.post(url('o/token/'), json={ + 'expires_in': 10 + }) + now = dt_util.utcnow() + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 1 + result_hass, result_data = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_data == { + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + + +@asyncio.coroutine +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_timeout_during_verification(cloud_hass, mock_read): + """Test loading authentication with timeout during verification.""" + mock_read.return_value = MOCK_AUTH + + with patch.object(cloud_api.Cloud, 'async_refresh_account_info', + side_effect=asyncio.TimeoutError): + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_verification_failed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with verify request getting 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_401(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 401.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=401) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_timeout(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), exc=asyncio.TimeoutError) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_succeeds(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh: + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_load_auth_token(cloud_hass, mock_read, aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is not None + assert result.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + assert result.auth == MOCK_AUTH + + +def test_cloud_properties(): + """Test Cloud class properties.""" + cloud = cloud_api.Cloud(None, MOCK_AUTH) + assert cloud.access_token == MOCK_AUTH['access_token'] + assert cloud.refresh_token == MOCK_AUTH['refresh_token'] + + +@asyncio.coroutine +def test_cloud_refresh_account_info(cloud_hass, aioclient_mock): + """Test refreshing account info.""" + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert result + assert cloud.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + + +@asyncio.coroutine +def test_cloud_refresh_account_info_500(cloud_hass, aioclient_mock): + """Test refreshing account info and getting 500.""" + aioclient_mock.get(url('account.json'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert not result + assert cloud.account is None + + +@asyncio.coroutine +def test_cloud_refresh_token(cloud_hass, aioclient_mock, mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), json={ + 'access_token': 'refreshed', + 'expires_in': 10 + }) + now = dt_util.utcnow() + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + result = yield from cloud.async_refresh_access_token() + assert result + assert cloud.auth == { + 'access_token': 'refreshed', + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data == cloud.auth + + +@asyncio.coroutine +def test_cloud_refresh_token_unknown_error(cloud_hass, aioclient_mock, + mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + result = yield from cloud.async_refresh_access_token() + assert not result + assert cloud.auth == MOCK_AUTH + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token(cloud_hass, aioclient_mock, mock_write): + """Test revoking access token.""" + aioclient_mock.post(url('o/revoke_token/')) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + yield from cloud.async_revoke_access_token() + assert cloud.auth is None + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data is None + + +@asyncio.coroutine +def test_cloud_revoke_token_invalid_client_creds(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), status=401) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token_request_error(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), exc=aiohttp.ClientError) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_request(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), json={'hello': 'world'}) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 200 + data = yield from request.json() + assert data == {'hello': 'world'} + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_fail(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(False)) as mock_refresh: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_success(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh, \ + patch.object(cloud_api.Cloud, 'async_refresh_account_info', + return_value=mock_coro()) as mock_account_info: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + assert len(mock_account_info.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py new file mode 100644 index 00000000000..99e73461bc1 --- /dev/null +++ b/tests/components/cloud/test_http_api.py @@ -0,0 +1,157 @@ +"""Tests for the HTTP API for the cloud component.""" +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.cloud import DOMAIN, cloud_api + +from tests.common import mock_coro + + +@pytest.fixture +def cloud_client(hass, test_client): + """Fixture that can fetch from the cloud client.""" + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { + 'cloud': { + 'mode': 'development' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_account_view_no_account(cloud_client): + """Test fetching account if no account available.""" + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 400 + + +@asyncio.coroutine +def test_account_view(hass, cloud_client): + """Test fetching account if no account available.""" + cloud = MagicMock(account={'test': 'account'}) + hass.data[DOMAIN]['cloud'] = cloud + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 200 + result = yield from req.json() + assert result == {'test': 'account'} + + +@asyncio.coroutine +def test_login_view(hass, cloud_client): + """Test logging in.""" + cloud = MagicMock(account={'test': 'account'}) + cloud.async_refresh_account_info.return_value = mock_coro(None) + + with patch.object(cloud_api, 'async_login', + MagicMock(return_value=mock_coro(cloud))): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 200 + + result = yield from req.json() + assert result == {'test': 'account'} + assert hass.data[DOMAIN]['cloud'] is cloud + + +@asyncio.coroutine +def test_login_view_invalid_json(hass, cloud_client): + """Try logging in with invalid JSON.""" + req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_schema(hass, cloud_client): + """Try logging in with invalid schema.""" + req = yield from cloud_client.post('/api/cloud/login', json={ + 'invalid': 'schema' + }) + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_request_timeout(hass, cloud_client): + """Test request timeout while trying to log in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=asyncio.TimeoutError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 502 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_credentials(hass, cloud_client): + """Test logging in with invalid credentials.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.Unauthenticated)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 401 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_unknown_error(hass, cloud_client): + """Test unknown error while logging in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.UnknownError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 500 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view(hass, cloud_client): + """Test logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.return_value = mock_coro(None) + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 200 + data = yield from req.json() + assert data == {'result': 'ok'} + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_request_timeout(hass, cloud_client): + """Test timeout while logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = asyncio.TimeoutError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_unknown_error(hass, cloud_client): + """Test unknown error while loggin out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = cloud_api.UnknownError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] diff --git a/tests/test_config.py b/tests/test_config.py index d1b9a052b72..1cb5e00bee9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,8 @@ from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) +from homeassistant.components.config.script import ( + CONFIG_PATH as SCRIPTS_CONFIG_PATH) from homeassistant.components.config.customize import ( CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) @@ -33,6 +35,7 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) +SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH) CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -68,6 +71,9 @@ class TestConfig(unittest.TestCase): if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) + if os.path.isfile(SCRIPTS_PATH): + os.remove(SCRIPTS_PATH) + if os.path.isfile(CUSTOMIZE_PATH): os.remove(CUSTOMIZE_PATH) diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0af5321c65f..ccd71e55d16 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -201,6 +201,7 @@ def mock_aiohttp_client(): with mock.patch('aiohttp.ClientSession') as mock_session: instance = mock_session() + instance.request = mocker.match_request for method in ('get', 'post', 'put', 'options', 'delete'): setattr(instance, method, From 7de73e9ef723a7cc3a47ac507632153fb9eb2962 Mon Sep 17 00:00:00 2001 From: Jeff McGehee Date: Tue, 29 Aug 2017 17:53:41 -0400 Subject: [PATCH 041/108] Bayesian Binary Sensor (#8810) * Bayesian Binary Sensor Why: * It would be beneficial to leverage various sensor outputs in a Bayesian manner in order to sense more complex events. This change addresses the need by: * `BayesianBinarySensor` class in `./homeassistant/components/binary_sensor/bayesian.py` * Tests in `./tests/components/binary_sensor/test_bayesian.py` Caveats: This is my first time in this code-base. I did try to follow conventions that I was able to find, but I'm sure there will be some issues to straighten out. * minor cleanup * Address reviewer's comments This change addresses the need by: * Removing `CONF_SENSOR_CLASS` and its usage in `get_deprecated`. * Make probability update function a static method, and use single `_` to match project conventions. * Address linter failures * fix `device_class` declaration * Address Comments Why: * Not validating config schema enough. * Not following common practices for async initialization. * Naive implementation of Bayes' rule. This change addresses the need by: * Improving config validation for observations. * Moving initialization logic into `async_added_to_hass`. * Re-configuring Bayesian updates to allow true P|Q usage. * address linting issues * Improve DRYness by adding `_update_current_obs` method * update doc strings and ensure functions are set up properly for async * Make only 1 state change handle * fix style * fix style part 2 * fix lint --- .../components/binary_sensor/bayesian.py | 211 ++++++++++++++++++ .../components/binary_sensor/test_bayesian.py | 176 +++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 homeassistant/components/binary_sensor/bayesian.py create mode 100644 tests/components/binary_sensor/test_bayesian.py diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py new file mode 100644 index 00000000000..4c62735a6f9 --- /dev/null +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -0,0 +1,211 @@ +""" +Use Bayesian Inference to trigger a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bayesian/ +""" +import asyncio +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +CONF_OBSERVATIONS = 'observations' +CONF_PRIOR = 'prior' +CONF_PROBABILITY_THRESHOLD = 'probability_threshold' +CONF_P_GIVEN_F = 'prob_given_false' +CONF_P_GIVEN_T = 'prob_given_true' +CONF_TO_STATE = 'to_state' + +DEFAULT_NAME = 'BayesianBinary' + +NUMERIC_STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): + cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Required(CONF_OBSERVATIONS): vol.Schema( + vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, + STATE_SCHEMA)]) + ), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD): + vol.Coerce(float), +}) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + + probability = numerator / denominator + return probability + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Threshold sensor.""" + name = config.get(CONF_NAME) + observations = config.get(CONF_OBSERVATIONS) + prior = config.get(CONF_PRIOR) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([ + BayesianBinarySensor(name, prior, observations, probability_threshold, + device_class) + ], True) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, + device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + + self.watchers = { + 'numeric_state': self._process_numeric_state, + 'state': self._process_state + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + @callback + # pylint: disable=invalid-name + def async_threshold_sensor_state_listener(entity, old_state, + new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs = self.entity_obs[entity] + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) + + prior = self.prior + print(self.current_obs.values()) + for obs in self.current_obs.values(): + prior = update_probability(prior, obs['prob_true'], + obs['prob_false']) + + self.probability = prior + + self.hass.async_add_job(self.async_update_ha_state, True) + + entities = [obs['entity_id'] for obs in self._observations] + async_track_state_change( + self.hass, entities, async_threshold_sensor_state_listener) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + entity = entity_observation['entity_id'] + + if should_trigger: + prob_true = entity_observation['prob_given_true'] + prob_false = entity_observation.get( + 'prob_given_false', 1 - prob_true) + + self.current_obs[entity] = { + 'prob_true': prob_true, + 'prob_false': prob_false + } + + else: + self.current_obs.pop(entity, None) + + def _process_numeric_state(self, entity_observation): + """Add entity to current_obs if numeric state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.async_numeric_state( + self.hass, entity, + entity_observation.get('below'), + entity_observation.get('above'), None, entity_observation) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get('to_state')) + + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'observations': [val for val in self.current_obs.values()], + 'probability': self.probability, + 'probability_threshold': self._probability_threshold + } + + @asyncio.coroutine + def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability > self._probability_threshold) diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py new file mode 100644 index 00000000000..f86047f3a3d --- /dev/null +++ b/tests/components/binary_sensor/test_bayesian.py @@ -0,0 +1,176 @@ +"""The test for the bayesian sensor platform.""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.components.binary_sensor import bayesian + +from tests.common import get_test_home_assistant + + +class TestBayesianBinarySensor(unittest.TestCase): + """Test the threshold sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_sensor_numeric_state(self): + """Test sensor on numeric state platform observations.""" + config = { + 'binary_sensor': { + 'platform': + 'bayesian', + 'name': + 'Test_Binary', + 'observations': [{ + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored', + 'below': 10, + 'above': 5, + 'prob_given_true': 0.6 + }, { + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored1', + 'below': 7, + 'above': 5, + 'prob_given_true': 0.9, + 'prob_given_false': 0.1 + }], + 'prior': + 0.2, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 6) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_false': 0.4, + 'prob_true': 0.6 + }, { + 'prob_false': 0.1, + 'prob_true': 0.9 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.7714285714285715, + state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 0) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 15) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + assert state.state == 'off' + + def test_sensor_state(self): + """Test sensor on state platform observations.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'off', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'on') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33333333, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + def test_probability_updates(self): + """Test probability update function.""" + prob_true = [0.3, 0.6, 0.8] + prob_false = [0.7, 0.4, 0.2] + prior = 0.5 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.720000, prior) + + prob_true = [0.8, 0.3, 0.9] + prob_false = [0.6, 0.4, 0.2] + prior = 0.7 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.9130434782608695, prior) From ebc7ade59198b2ac97cd3ff2c877bcef5d022a5f Mon Sep 17 00:00:00 2001 From: Nicholas Sielicki Date: Tue, 29 Aug 2017 17:08:56 -0500 Subject: [PATCH 042/108] directv: extended discovery via REST api, bug fix (#8800) * fix not providing device for discovered directvs This fixes a bug introduced at 6884965c80 Discovered directv boxes would not be instantiated with a DEVICE parameter. Signed-off-by: Nicholas Sielicki * directv: add discovery of RVU clients If discovery is used with directv, also try to further discover and configure RVU client set-top boxes by requesting information from a REST service running on the main directv box/RVU-server. This commit also disables discovery if any directv configuration is supplied by the user. Signed-off-by: Nicholas Sielicki * components/media_player/directv.py: use hass.data Use hass.data instead of a global to remember state. Signed-off-by: Nicholas Sielicki * unconditionally import requests in directv.py Requests is a core requirement, so we're okay to import at the top of the file rather than conditionally / in a function. Signed-off-by: Nicholas Sielicki --- .../components/media_player/directv.py | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 599b8fbbd71..a334dc7caa4 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.directv/ """ import voluptuous as vol +import requests from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, @@ -25,7 +26,7 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY -KNOWN_HOSTS = [] +DATA_DIRECTV = "data_directv" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -37,32 +38,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DirecTV platform.""" + known_devices = hass.data.get(DATA_DIRECTV) + if not known_devices: + known_devices = [] hosts = [] - if discovery_info: - host = discovery_info.get('host') - - if host in KNOWN_HOSTS: - return - - hosts.append([ - 'DirecTV_' + discovery_info.get('serial', ''), - host, DEFAULT_PORT - ]) - - elif CONF_HOST in config: + if CONF_HOST in config: hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) ]) + elif discovery_info: + host = discovery_info.get('host') + name = 'DirecTV_' + discovery_info.get('serial', '') + + # attempt to discover additional RVU units + try: + resp = requests.get( + 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() + if "locations" in resp: + for loc in resp["locations"]: + if("locationName" in loc and "clientAddr" in loc + and loc["clientAddr"] not in known_devices): + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) + + except requests.exceptions.RequestException: + # bail out and just go forward with uPnP data + if DEFAULT_DEVICE not in known_devices: + hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - KNOWN_HOSTS.append(host) + known_devices.append(host[-1]) add_devices(dtvs) + hass.data[DATA_DIRECTV] = known_devices return True From 8673e539406336caaac834c8f277bb187124b62e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 30 Aug 2017 06:06:18 +0200 Subject: [PATCH 043/108] Upgrade pyasn1 to 0.3.3 and pyasn1-modules to 0.1.1 (#9216) --- homeassistant/components/notify/xmpp.py | 4 ++-- requirements_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index d04eb91b6c4..42c7a3953b9 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,8 +15,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.2', - 'pyasn1-modules==0.0.11'] + 'pyasn1==0.3.3', + 'pyasn1-modules==0.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b357f9ffc53..d0ab3cbae48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -545,10 +545,10 @@ pyalarmdotcom==0.3.0 pyarlo==0.0.4 # homeassistant.components.notify.xmpp -pyasn1-modules==0.0.11 +pyasn1-modules==0.1.1 # homeassistant.components.notify.xmpp -pyasn1==0.3.2 +pyasn1==0.3.3 # homeassistant.components.apple_tv pyatv==0.3.4 From 4aafcfa478418ae65efcaeb80a8d8ee1be4a331e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 30 Aug 2017 06:06:31 +0200 Subject: [PATCH 044/108] Upgrade sendgrid to 5.0.1 (#9215) --- homeassistant/components/notify/sendgrid.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index f67eae6c611..545cddfadea 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.0.0'] +REQUIREMENTS = ['sendgrid==5.0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d0ab3cbae48..374c903d81a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -878,7 +878,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.0.0 +sendgrid==5.0.1 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat From f76436f3266badbb382a49bec22cd915572c5c09 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Wed, 30 Aug 2017 04:01:01 -0400 Subject: [PATCH 045/108] Fix fitbit error when trying to access token after upgrade. (#9183) * - Fixes Fitbit error when trying to refresh oauth token The 3rd python-fitbit module requires an extra kwarg on the FitBit constructor called refresh_cb. The value should be a function that accepts one argument token. This value will be a dictionary with the keys: 'access_token', 'refresh_token', 'expires_at' This implements a lambda refresh_cb as required by the Fitbit module to work, however the new token will always be save manually on every update() call. * Simplified by calling expires_at instead reading again from dict --- homeassistant/components/sensor/fitbit.py | 37 ++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 605805c028d..5876a059672 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -260,13 +260,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): access_token = config_file.get(ATTR_ACCESS_TOKEN) refresh_token = config_file.get(ATTR_REFRESH_TOKEN) + expires_at = config_file.get(ATTR_LAST_SAVED_AT) if None not in (access_token, refresh_token): authd_client = fitbit.Fitbit(config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET), access_token=access_token, - refresh_token=refresh_token) + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=lambda x: None) - if int(time.time()) - config_file.get(ATTR_LAST_SAVED_AT, 0) > 3600: + if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() authd_client.system = authd_client.user_profile_get()["user"]["locale"] @@ -338,12 +341,14 @@ class FitbitAuthCallbackView(HomeAssistantView): response_message = """Fitbit has been successfully authorized! You can close this window now!""" + result = None if data.get('code') is not None: redirect_uri = '{}{}'.format( hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) try: - self.oauth.fetch_access_token(data.get('code'), redirect_uri) + result = self.oauth.fetch_access_token(data.get('code'), + redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) response_message = """Something went wrong when @@ -361,15 +366,23 @@ class FitbitAuthCallbackView(HomeAssistantView): An unknown error occurred. Please try again! """ + if result is None: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + html_response = """Fitbit Auth

{}

""".format(response_message) - config_contents = { - ATTR_ACCESS_TOKEN: self.oauth.token['access_token'], - ATTR_REFRESH_TOKEN: self.oauth.token['refresh_token'], - ATTR_CLIENT_ID: self.oauth.client_id, - ATTR_CLIENT_SECRET: self.oauth.client_secret - } + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get('access_token'), + ATTR_REFRESH_TOKEN: result.get('refresh_token'), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret + } if not config_from_file(hass.config.path(FITBIT_CONFIG_FILE), config_contents): _LOGGER.error("Failed to save config file") @@ -490,9 +503,11 @@ class FitbitSensor(Entity): if self.resource_type == 'activities/heart': self._state = response[container][-1]. \ get('value').get('restingHeartRate') + + token = self.client.client.session.token config_contents = { - ATTR_ACCESS_TOKEN: self.client.client.token['access_token'], - ATTR_REFRESH_TOKEN: self.client.client.token['refresh_token'], + ATTR_ACCESS_TOKEN: token.get('access_token'), + ATTR_REFRESH_TOKEN: token.get('refresh_token'), ATTR_CLIENT_ID: self.client.client.client_id, ATTR_CLIENT_SECRET: self.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()) From 56f9ccb877a386f97eb17220271d5397dc246787 Mon Sep 17 00:00:00 2001 From: Riccardo Canta Date: Wed, 30 Aug 2017 15:10:02 +0200 Subject: [PATCH 046/108] Allow sonos to select album as a source (#9221) Importing the fix in the PR https://github.com/home-assistant/home-assistant/pull/8258 I noticed that the same error is present also for Spotify album so I have extended the code and tested it. It works fine on my setup --- homeassistant/components/media_player/sonos.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 63d27299aa7..03be42d07ff 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -897,7 +897,8 @@ class SonosDevice(MediaPlayerDevice): src = fav.pop() self._source_name = src['title'] - if 'object.container.playlistContainer' in src['meta']: + if ('object.container.playlistContainer' in src['meta'] or + 'object.container.album.musicAlbum' in src['meta']): self._replace_queue_with_playlist(src) self._player.play_from_queue(0) else: From 3a0e38aa738072b9a153b54b49b5a21fba9eea1b Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Wed, 30 Aug 2017 17:13:36 +0200 Subject: [PATCH 047/108] Add max_age to statistics sensor (#8790) * Add max_age to statistics sensor * Allow only non-zero sampling sizes * Fix long line * Fix style --- homeassistant/components/sensor/statistics.py | 39 ++++++++++++++----- tests/components/sensor/test_statistics.py | 35 +++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 2d7b74e8791..34d3cabf26b 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,8 @@ ATTR_SAMPLING_SIZE = 'sampling_size' ATTR_TOTAL = 'total' CONF_SAMPLING_SIZE = 'sampling_size' +CONF_MAX_AGE = 'max_age' + DEFAULT_NAME = 'Stats' DEFAULT_SIZE = 20 ICON = 'mdi:calculator' @@ -41,7 +44,9 @@ ICON = 'mdi:calculator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): cv.positive_int, + vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_MAX_AGE): cv.time_period }) @@ -51,16 +56,18 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) + max_age = config.get(CONF_MAX_AGE, None) async_add_devices( - [StatisticsSensor(hass, entity_id, name, sampling_size)], True) + [StatisticsSensor(hass, entity_id, name, sampling_size, max_age)], + True) return True class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size): + def __init__(self, hass, entity_id, name, sampling_size, max_age): """Initialize the Statistics sensor.""" self._hass = hass self._entity_id = entity_id @@ -71,11 +78,12 @@ class StatisticsSensor(Entity): else: self._name = '{} {}'.format(name, ATTR_COUNT) self._sampling_size = sampling_size + self._max_age = max_age self._unit_of_measurement = None - if self._sampling_size == 0: - self.states = deque() - else: - self.states = deque(maxlen=self._sampling_size) + self.states = deque(maxlen=self._sampling_size) + if self._max_age is not None: + self.ages = deque(maxlen=self._sampling_size) + self.median = self.mean = self.variance = self.stdev = 0 self.min = self.max = self.total = self.count = 0 self.average_change = self.change = 0 @@ -89,6 +97,9 @@ class StatisticsSensor(Entity): try: self.states.append(float(new_state.state)) + if self._max_age is not None: + now = dt_util.utcnow() + self.ages.append(now) self.count = self.count + 1 except ValueError: self.count = self.count + 1 @@ -128,8 +139,7 @@ class StatisticsSensor(Entity): ATTR_MAX_VALUE: self.max, ATTR_MEDIAN: self.median, ATTR_MIN_VALUE: self.min, - ATTR_SAMPLING_SIZE: 'unlimited' if self._sampling_size is - 0 else self._sampling_size, + ATTR_SAMPLING_SIZE: self._sampling_size, ATTR_STANDARD_DEVIATION: self.stdev, ATTR_TOTAL: self.total, ATTR_VARIANCE: self.variance, @@ -142,9 +152,20 @@ class StatisticsSensor(Entity): """Return the icon to use in the frontend, if any.""" return ICON + def _purge_old(self): + """Remove states which are older than self._max_age.""" + now = dt_util.utcnow() + + while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + self.ages.popleft() + self.states.popleft() + @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" + if self._max_age is not None: + self._purge_old() + if not self.is_binary: try: self.mean = round(statistics.mean(self.states), 2) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 753b18f137f..ba71c6e3993 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -4,7 +4,10 @@ import statistics from homeassistant.setup import setup_component from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant +from unittest.mock import patch +from datetime import datetime, timedelta class TestStatisticsSensor(unittest.TestCase): @@ -100,3 +103,35 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(3.8, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + + def test_max_age(self): + """Test value deprecation.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'max_age': {'minutes': 3} + } + }) + + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value one minute later + mock_data['return_time'] += timedelta(minutes=1) + + state = self.hass.states.get('sensor.test_mean') + + self.assertEqual(6, state.attributes.get('min_value')) + self.assertEqual(14, state.attributes.get('max_value')) From f2551c08af5d1d1b8e51d31987fcf0d2978eb13d Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Wed, 30 Aug 2017 20:11:45 +0200 Subject: [PATCH 048/108] Egardia package to .19 and change in port number for egardiaserver (#9225) --- homeassistant/components/alarm_control_panel/egardia.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index fe7db95651b..1ef5e5d64d8 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.18'] +REQUIREMENTS = ['pythonegardia==1.0.19'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ CONF_REPORT_SERVER_PORT = 'report_server_port' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 85 +DEFAULT_REPORT_SERVER_PORT = 52010 DOMAIN = 'egardia' NOTIFICATION_ID = 'egardia_notification' diff --git a/requirements_all.txt b/requirements_all.txt index 374c903d81a..363bc1b3111 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -797,7 +797,7 @@ python-wink==1.5.1 python_openzwave==0.4.0.31 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.18 +pythonegardia==1.0.19 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 214c92d787e11cf9ef86ede5b2fe099f3eab5586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 30 Aug 2017 21:42:27 +0200 Subject: [PATCH 049/108] pushbullet, send a file from url (#9189) * pushbullet, send a file from url * pushbullet, send a file from url * Simplify --- homeassistant/components/notify/pushbullet.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index e52348c3446..69e2cc4298a 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.pushbullet/ """ import logging +import mimetypes import voluptuous as vol @@ -20,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_URL = 'url' ATTR_FILE = 'file' +ATTR_FILE_URL = 'file_url' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,16 +82,11 @@ class PushBulletNotificationService(BaseNotificationService): targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) - url = None - filepath = None - if data: - url = data.get(ATTR_URL, None) - filepath = data.get(ATTR_FILE, None) refreshed = False if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, url, self.pushbullet) + self._push_data(message, title, data, self.pushbullet) _LOGGER.info("Sent notification to self") return @@ -104,8 +101,7 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - self._push_data(filepath, message, title, url, - self.pushbullet, tname) + self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -124,16 +120,19 @@ class PushBulletNotificationService(BaseNotificationService): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - self._push_data(filepath, message, title, url, + self._push_data(message, title, data, self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, filepath, message, title, url, pusher, tname=None): + def _push_data(self, title, message, data, pusher, tname=None): from pushbullet import PushError from pushbullet import Device + url = data.get(ATTR_URL) + filepath = data.get(ATTR_FILE) + file_url = data.get(ATTR_FILE_URL) try: if url: if isinstance(pusher, Device): @@ -144,9 +143,16 @@ class PushBulletNotificationService(BaseNotificationService): with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) if filedata.get('file_type') == 'application/x-empty': - _LOGGER.error("Failed to send an empty file.") + _LOGGER.error("Can not send an empty file.") return pusher.push_file(title=title, body=message, **filedata) + elif file_url: + if not file_url.startswith('http'): + _LOGGER.error("Url should start with http or https.") + return + pusher.push_file(title=title, body=message, file_name=file_url, + file_url=file_url, + file_type=mimetypes.guess_type(file_url)[0]) else: if isinstance(pusher, Device): pusher.push_note(title, message) From 76c7eef7d8fadee84f32ed4f6752f56d443a5fb1 Mon Sep 17 00:00:00 2001 From: Kris Molendyke Date: Wed, 30 Aug 2017 16:21:54 -0400 Subject: [PATCH 050/108] Add Tank Utility sensor (#9132) * Add Tank Utility sensor * Fix, disable Pylint errors * Move coverage omission to single platform section * Do not catch unknown exceptions * Check for invalid credentials in setup * Update tank_utility.py --- .coveragerc | 1 + .../components/sensor/tank_utility.py | 138 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 142 insertions(+) create mode 100644 homeassistant/components/sensor/tank_utility.py diff --git a/.coveragerc b/.coveragerc index 93a422d6d5b..37d4fd831dc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -520,6 +520,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py new file mode 100644 index 00000000000..01ace415159 --- /dev/null +++ b/homeassistant/components/sensor/tank_utility.py @@ -0,0 +1,138 @@ +""" +Support for the Tank Utility propane monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tank_utility/ +""" + +import datetime +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, + STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + + +REQUIREMENTS = [ + "tank_utility==1.4.0" +] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, vol.Length(min=1)) +}) + +SENSOR_TYPE = "tank" +SENSOR_ROUNDING_PRECISION = 1 +SENSOR_UNIT_OF_MEASUREMENT = "%" +SENSOR_ATTRS = [ + "name", + "address", + "capacity", + "fuelType", + "orientation", + "status", + "time", + "time_iso" +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tank Utility sensor.""" + from tank_utility import auth + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICES) + + try: + token = auth.get_token(email, password) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.error("Invalid credentials") + return + + all_sensors = [] + for device in devices: + sensor = TankUtilitySensor(email, password, token, device) + all_sensors.append(sensor) + add_devices(all_sensors, True) + + +class TankUtilitySensor(Entity): + """Representation of a Tank Utility sensor.""" + + def __init__(self, email, password, token, device): + """Initialize the sensor.""" + self._email = email + self._password = password + self._token = token + self._device = device + self._state = STATE_UNKNOWN + self._name = "Tank Utility " + self.device + self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT + self._attributes = {} + + @property + def device(self): + """Return the device identifier.""" + return self._device + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the device.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + return self._attributes + + def get_data(self): + """Get data from the device. + + Flatten dictionary to map device to map of device data. + + """ + from tank_utility import auth, device + data = {} + try: + data = device.get_device_data(self._token, self.device) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.info("Getting new token") + self._token = auth.get_token(self._email, self._password, + force=True) + data = device.get_device_data(self._token, self.device) + else: + raise http_error + data.update(data.pop("device", {})) + data.update(data.pop("lastReading", {})) + return data + + def update(self): + """Set the device state and attributes.""" + data = self.get_data() + self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} diff --git a/requirements_all.txt b/requirements_all.txt index 363bc1b3111..d9444d511a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,6 +930,9 @@ steamodd==4.21 # homeassistant.components.camera.onvif suds-py3==1.3.3.0 +# homeassistant.components.sensor.tank_utility +tank_utility==1.4.0 + # homeassistant.components.binary_sensor.tapsaff tapsaff==0.1.3 From 10e8aea46b107b7904fd0c6e7892189be2abeb4c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 30 Aug 2017 22:23:28 +0200 Subject: [PATCH 051/108] Upgrade shodan to 1.7.5 (#9228) --- 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 c95d975ec47..3d86d940f4d 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.4'] +REQUIREMENTS = ['shodan==1.7.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d9444d511a4..a2bc1260aaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -888,7 +888,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.4 +shodan==1.7.5 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 From 5f445b4a13ca5284d525acc977589d299eb0ce9c Mon Sep 17 00:00:00 2001 From: Sergey Isachenko Date: Thu, 31 Aug 2017 07:13:02 +0300 Subject: [PATCH 052/108] Tesla platform (#9211) * Tesla support implemetation * requirements_all.txt fix * .coveragerc fix * logging-too-many-args fix * logging-too-many-args attempt 2 * Post-review fixes. * requirements version fix * requirements * Lint fix * Hot fix * requirements_all.txt fix * Review preparation. * 1. Linting fix. 2. Minimal value for SCAN_INTERVAL hardcoded to 300 sec (to prevent possible ban form Tesla) * Removed redundant whitespace. * Fixed components according to @MartinHjelmare proposals and remarks. * .coveragerc as @MartinHjelmare suggested. * Minor changes * Fix docstrings * Update ordering * Update quotes * Minor changes * Update quotes --- .coveragerc | 3 + .../components/binary_sensor/tesla.py | 57 +++++++++++ homeassistant/components/climate/tesla.py | 93 ++++++++++++++++++ .../components/device_tracker/tesla.py | 57 +++++++++++ homeassistant/components/lock/tesla.py | 57 +++++++++++ homeassistant/components/sensor/tesla.py | 82 ++++++++++++++++ homeassistant/components/tesla.py | 95 +++++++++++++++++++ requirements_all.txt | 3 + 8 files changed, 447 insertions(+) create mode 100644 homeassistant/components/binary_sensor/tesla.py create mode 100644 homeassistant/components/climate/tesla.py create mode 100644 homeassistant/components/device_tracker/tesla.py create mode 100644 homeassistant/components/lock/tesla.py create mode 100644 homeassistant/components/sensor/tesla.py create mode 100644 homeassistant/components/tesla.py diff --git a/.coveragerc b/.coveragerc index 37d4fd831dc..b43688aa281 100644 --- a/.coveragerc +++ b/.coveragerc @@ -170,6 +170,9 @@ omit = homeassistant/components/tellstick.py homeassistant/components/*/tellstick.py + homeassistant/components/tesla.py + homeassistant/components/*/tesla.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py new file mode 100644 index 00000000000..af7e394b50e --- /dev/null +++ b/homeassistant/components/binary_sensor/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tesla/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla binary sensor.""" + devices = [ + TeslaBinarySensor( + device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') + for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] + add_devices(devices, True) + + +class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): + """Implement an Tesla binary sensor for parking and charger.""" + + def __init__(self, tesla_device, controller, sensor_type): + """Initialisation of binary sensor.""" + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self._state = False + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._sensor_type = sensor_type + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py new file mode 100644 index 00000000000..39d002e72d9 --- /dev/null +++ b/homeassistant/components/climate/tesla.py @@ -0,0 +1,93 @@ +""" +Support for Tesla HVAC system. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.tesla/ +""" +import logging + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import ( + TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +OPERATION_LIST = [STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla climate platform.""" + devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['climate']] + add_devices(devices, True) + + +class TeslaThermostat(TeslaDevice, ClimateDevice): + """Representation of a Tesla climate.""" + + def __init__(self, tesla_device, controller): + """Initialize the Tesla device.""" + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._target_temperature = None + self._temperature = None + self._name = self.tesla_device.name + + @property + def current_operation(self): + """Return current operation ie. On or Off.""" + mode = self.tesla_device.is_hvac_enabled() + if mode: + return OPERATION_LIST[0] # On + else: + return OPERATION_LIST[1] # Off + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + def update(self): + """Called by the Tesla device callback to update state.""" + _LOGGER.debug("Updating: %s", self._name) + self.tesla_device.update() + self._target_temperature = self.tesla_device.get_goal_temp() + self._temperature = self.tesla_device.get_current_temp() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + _LOGGER.debug("Setting temperature for: %s", self._name) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.tesla_device.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, cool, heat, off).""" + _LOGGER.debug("Setting mode for: %s", self._name) + if operation_mode == OPERATION_LIST[1]: # off + self.tesla_device.set_status(False) + elif operation_mode == OPERATION_LIST[0]: # heat + self.tesla_device.set_status(True) diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py new file mode 100644 index 00000000000..4945e98a94d --- /dev/null +++ b/homeassistant/components/device_tracker/tesla.py @@ -0,0 +1,57 @@ +""" +Support for the Tesla platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tesla/ +""" +import logging + +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Tesla tracker.""" + TeslaDeviceTracker( + hass, config, see, + hass.data[TESLA_DOMAIN]['devices']['devices_tracker']) + return True + + +class TeslaDeviceTracker(object): + """A class representing a Tesla device.""" + + def __init__(self, hass, config, see, tesla_devices): + """Initialize the Tesla device scanner.""" + self.hass = hass + self.see = see + self.devices = tesla_devices + self._update_info() + + track_utc_time_change( + self.hass, self._update_info, second=range(0, 60, 30)) + + def _update_info(self, now=None): + """Update the device info.""" + for device in self.devices: + device.update() + name = device.name + _LOGGER.debug("Updating device position: %s", name) + dev_id = slugify(device.uniq_name) + location = device.get_location() + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py new file mode 100644 index 00000000000..3e93e4787a0 --- /dev/null +++ b/homeassistant/components/lock/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla door locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.tesla/ +""" +import logging + +from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla lock platform.""" + devices = [TeslaLock(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['lock']] + add_devices(devices, True) + + +class TeslaLock(TeslaDevice, LockDevice): + """Representation of a Tesla door lock.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the lock.""" + self._state = None + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def lock(self, **kwargs): + """Send the lock command.""" + _LOGGER.debug("Locking doors for: %s", self._name) + self.tesla_device.lock() + self._state = STATE_LOCKED + + def unlock(self, **kwargs): + """Send the unlock command.""" + _LOGGER.debug("Unlocking doors for: %s", self._name) + self.tesla_device.unlock() + self._state = STATE_UNLOCKED + + @property + def is_locked(self): + """Get whether the lock is in locked state.""" + return self._state == STATE_LOCKED + + def update(self): + """Updating state of the lock.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_LOCKED if self.tesla_device.is_locked() \ + else STATE_UNLOCKED diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py new file mode 100644 index 00000000000..fc31a5543e2 --- /dev/null +++ b/homeassistant/components/sensor/tesla.py @@ -0,0 +1,82 @@ +""" +Sensors for the Tesla sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tesla/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +SCAN_INTERVAL = timedelta(minutes=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla sensor platform.""" + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + + for device in hass.data[TESLA_DOMAIN]['devices']['sensor']: + if device.bin_type == 0x4: + devices.append(TeslaSensor(device, controller, 'inside')) + devices.append(TeslaSensor(device, controller, 'outside')) + else: + devices.append(TeslaSensor(device, controller)) + add_devices(devices, True) + + +class TeslaSensor(TeslaDevice, Entity): + """Representation of Tesla sensors.""" + + def __init__(self, tesla_device, controller, sensor_type=None): + """Initialisation of the sensor.""" + self.current_value = None + self._temperature_units = None + self.last_changed_time = None + self.type = sensor_type + super().__init__(tesla_device, controller) + + if self.type: + self._name = '{} ({})'.format(self.tesla_device.name, self.type) + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(self.tesla_id, self.type)) + else: + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._temperature_units + + def update(self): + """Update the state from the sensor.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + if self.tesla_device.bin_type == 0x4: + if self.type == 'outside': + self.current_value = self.tesla_device.get_outside_temp() + else: + self.current_value = self.tesla_device.get_inside_temp() + + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + self._temperature_units = TEMP_FAHRENHEIT + else: + self._temperature_units = TEMP_CELSIUS + else: + self.current_value = self.tesla_device.battery_level() diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py new file mode 100644 index 00000000000..e48d805abab --- /dev/null +++ b/homeassistant/components/tesla.py @@ -0,0 +1,95 @@ +""" +Support for Tesla cars. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tesla/ +""" +from collections import defaultdict + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +REQUIREMENTS = ['teslajsonpy==0.0.11'] + +DOMAIN = 'tesla' + +TESLA_ID_FORMAT = '{}_{}' +TESLA_ID_LIST_SCHEMA = vol.Schema([int]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=300): + vol.All(cv.positive_int, vol.Clamp(min=300)), + }), +}, extra=vol.ALLOW_EXTRA) + +TESLA_COMPONENTS = [ + 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' +] + + +def setup(hass, base_config): + """Set up of Tesla platform.""" + from teslajsonpy.controller import Controller as teslaApi + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + update_interval = config.get(CONF_SCAN_INTERVAL) + if hass.data.get(DOMAIN) is None: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + + all_devices = hass.data[DOMAIN]['controller'].list_vehicles() + + if not all_devices: + return False + + for device in all_devices: + hass.data[DOMAIN]['devices'][device.hass_type].append(device) + + for component in TESLA_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + + return True + + +class TeslaDevice(Entity): + """Representation of a Tesla device.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the Tesla device.""" + self.tesla_device = tesla_device + self.controller = controller + self._name = self.tesla_device.name + self.tesla_id = slugify(self.tesla_device.uniq_name) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from tesla device.""" + return self.tesla_device.should_poll + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + if self.tesla_device.has_battery(): + attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() + return attr diff --git a/requirements_all.txt b/requirements_all.txt index a2bc1260aaa..01b38661af7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,6 +946,9 @@ tellduslive==0.3.4 # homeassistant.components.sensor.temper temperusb==1.5.3 +# homeassistant.components.tesla +teslajsonpy==0.0.11 + # homeassistant.components.thingspeak thingspeak==0.4.1 From de4a4fe71a3d416b608e887138e91055e14dfdc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Soko=C5=82owski?= Date: Thu, 31 Aug 2017 06:19:06 +0200 Subject: [PATCH 053/108] [light.tradfri] Full range of white spectrum lightbulbs support (#9224) * [light.tradfri] Support for pytradfri version supporting full white spectrum * [light.tradfri] Checkout pytradfri master * Developer docker image adjusted * [light.tradfri] pytradfri 2.2 support for white spectrum bulbs * Removed fix already included in dev * Style adjusted * pylint false positive overriden * Review remarks applied (#1) * make pylint happy * Review remarks --- homeassistant/components/light/tradfri.py | 99 ++++++++++++----------- homeassistant/components/tradfri.py | 15 ++-- requirements_all.txt | 2 +- 3 files changed, 63 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index b04640d7a8a..fa21af996cb 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -9,9 +9,10 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) -from homeassistant.components.light import \ - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS +from homeassistant.components.light import ( + PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA) +from homeassistant.components.tradfri import ( + KEY_GATEWAY, KEY_TRADFRI_GROUPS, KEY_API) from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -19,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' -ALLOWED_TEMPERATURES = { - IKEA: {2200: 'efd275', 2700: 'f1e0b5', 4000: 'f5faf6'} -} +ALLOWED_TEMPERATURES = {IKEA} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -30,24 +29,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return gateway_id = discovery_info['gateway'] + api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = gateway.get_devices() - lights = [dev for dev in devices if dev.has_light_control] - add_devices(Tradfri(light) for light in lights) + devices = api(gateway.get_devices()) + lights = [dev for dev in devices if api(dev).has_light_control] + add_devices(Tradfri(light, api) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = gateway.get_groups() - add_devices(TradfriGroup(group) for group in groups) + groups = api(gateway.get_groups()) + add_devices(TradfriGroup(group, api) for group in groups) class TradfriGroup(Light): """The platform class required by hass.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Group.""" - self._group = light - self._name = light.name + self._group = api(light) + self._api = api + self._name = self._group.name @property def supported_features(self): @@ -71,20 +72,20 @@ class TradfriGroup(Light): def turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._group.set_state(0) + self._api(self._group.set_state(0)) def turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" if ATTR_BRIGHTNESS in kwargs: - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._group.set_state(1) + self._api(self._group.set_state(1)) def update(self): """Fetch new state data for this group.""" from pytradfri import RequestTimeout try: - self._group.update() + self._api(self._group.update()) except RequestTimeout: _LOGGER.warning("Tradfri update request timed out") @@ -92,14 +93,15 @@ class TradfriGroup(Light): class Tradfri(Light): """The platform class required by Home Asisstant.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Light.""" - self._light = light + self._light = api(light) + self._api = api # Caching of LightControl and light object - self._light_control = light.light_control - self._light_data = light.light_control.lights[0] - self._name = light.name + self._light_control = self._light.light_control + self._light_data = self._light_control.lights[0] + self._name = self._light.name self._rgb_color = None self._features = SUPPORT_BRIGHTNESS @@ -109,8 +111,20 @@ class Tradfri(Light): else: self._features |= SUPPORT_RGB_COLOR - self._ok_temps = ALLOWED_TEMPERATURES.get( - self._light.device_info.manufacturer) + self._ok_temps = \ + self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + from pytradfri.color import MAX_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + from pytradfri.color import MIN_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) @property def supported_features(self): @@ -135,20 +149,13 @@ class Tradfri(Light): @property def color_temp(self): """Return the CT color value in mireds.""" - if (self._light_data.hex_color is None or + if (self._light_data.kelvin_color is None or self.supported_features & SUPPORT_COLOR_TEMP == 0 or not self._ok_temps): return None - - kelvin = next(( - kelvin for kelvin, hex_color in self._ok_temps.items() - if hex_color == self._light_data.hex_color), None) - if kelvin is None: - _LOGGER.error( - "Unexpected color temperature found for %s: %s", - self.name, self._light_data.hex_color) - return - return color_util.color_temperature_kelvin_to_mired(kelvin) + return color_util.color_temperature_kelvin_to_mired( + self._light_data.kelvin_color + ) @property def rgb_color(self): @@ -157,7 +164,7 @@ class Tradfri(Light): def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._light_control.set_state(False) + self._api(self._light_control.set_state(False)) def turn_on(self, **kwargs): """ @@ -167,29 +174,27 @@ class Tradfri(Light): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_BRIGHTNESS in kwargs: - self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._light_control.set_state(True) + self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])) + self._api(self._light.light_control.set_hex_color( + color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - # find closest allowed kelvin temp from user input - kelvin = min(self._ok_temps.keys(), key=lambda x: abs(x - kelvin)) - self._light_control.set_hex_color(self._ok_temps[kelvin]) + self._api(self._light_control.set_kelvin_color(kelvin)) def update(self): """Fetch new state data for this light.""" from pytradfri import RequestTimeout try: - self._light.update() - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + self._api(self._light.update()) + except RequestTimeout as exception: + _LOGGER.warning("Tradfri update request timed out: %s", exception) # Handle Hue lights paired with the gateway # hex_color is 0 when bulb is unreachable diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 31938cd15ff..34422819743 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -16,12 +16,13 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==1.1'] +REQUIREMENTS = ['pytradfri==2.2'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' +KEY_API = 'tradfri_api' KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' DEFAULT_ALLOW_TRADFRI_GROUPS = True @@ -109,17 +110,21 @@ def async_setup(hass, config): @asyncio.coroutine def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" - from pytradfri import cli_api_factory, Gateway, RequestError, retry_timeout + from pytradfri import Gateway, RequestError + from pytradfri.api.libcoap_api import api_factory try: - api = retry_timeout(cli_api_factory(host, key)) + api = api_factory(host, key) except RequestError: return False - gateway = Gateway(api) - gateway_id = gateway.get_gateway_info().id + gateway = Gateway() + # pylint: disable=no-member + gateway_id = api(gateway.get_gateway_info()).id + hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] + hass.data[KEY_API][gateway_id] = api hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] diff --git a/requirements_all.txt b/requirements_all.txt index 01b38661af7..c3722ec1307 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -803,7 +803,7 @@ pythonegardia==1.0.19 pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==1.1 +pytradfri==2.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 From bb372940476903d157337e245914b6b0197968f0 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 31 Aug 2017 07:21:24 +0300 Subject: [PATCH 054/108] Allow panels with external URL (#9214) * Allow panels with external URL * Update comment --- homeassistant/components/frontend/__init__.py | 21 ++++++++++--------- tests/components/test_frontend.py | 19 ++++++++++++++++- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2f84abc745b..112c93403b0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -109,14 +109,13 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, component_name: name of the web component path: path to the HTML of the web component + (required unless url is provided) md5: the md5 hash of the web component (for versioning, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (for dev environment, optional) + url: for the web component (optional) config: config to be passed into the web component - - Warning: this API will probably change. Use at own risk. """ panels = hass.data.get(DATA_PANELS) if panels is None: @@ -127,14 +126,16 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, if url_path in panels: _LOGGER.warning("Overwriting component %s", url_path) - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if url is None: + if not os.path.isfile(path): + _LOGGER.error( + "Panel %s component does not exist: %s", component_name, path) + return + + if md5 is None: + with open(path) as fil: + md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { 'url_path': url_path, diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index d99732fefd9..fdd33b99d2b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -7,7 +7,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( - DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL) + DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL, DATA_PANELS, register_panel) @pytest.fixture @@ -163,3 +163,20 @@ def test_extra_urls(mock_http_client_with_urls): assert resp.status == 200 text = yield from resp.text() assert text.find('href=\'https://domain.com/my_extra_url.html\'') >= 0 + + +@asyncio.coroutine +def test_panel_without_path(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', 'nonexistant_file') + assert hass.data[DATA_PANELS] == {} + + +@asyncio.coroutine +def test_panel_with_url(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', None, url='some_url') + assert hass.data[DATA_PANELS] == { + 'test_component': {'component_name': 'test_component', + 'url': 'some_url', + 'url_path': 'test_component'}} From e22ec28bce83f73c1fd5065cf782f3dc685ec1f8 Mon Sep 17 00:00:00 2001 From: "John K. Luebs" Date: Thu, 31 Aug 2017 01:18:01 -0400 Subject: [PATCH 055/108] Use ZCL mandatory attribute to determine ZHA light capabilities (#9232) The manadatory ColorCapabilities attribute should indicate whether a light is capable of XY color changes and/or color temperature. --- homeassistant/components/light/zha.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 2a3ce18d74e..e7ba394a977 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -27,8 +27,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): endpoint = discovery_info['endpoint'] try: - primaries = yield from endpoint.light_color['num_primaries'] - discovery_info['num_primaries'] = primaries + discovery_info['color_capabilities'] \ + = yield from endpoint.light_color['color_capabilities'] except (AttributeError, KeyError): pass @@ -54,11 +54,11 @@ class Light(zha.Entity, light.Light): self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - # Not sure all color lights necessarily support this directly - # Should we emulate it? - self._supported_features |= light.SUPPORT_COLOR_TEMP - # Silly heuristic, not sure if it works widely - if kwargs.get('num_primaries', 1) >= 3: + color_capabilities = kwargs.get('color_capabilities', 0x10) + if color_capabilities & 0x10: + self._supported_features |= light.SUPPORT_COLOR_TEMP + + if color_capabilities & 0x08: self._supported_features |= light.SUPPORT_XY_COLOR self._supported_features |= light.SUPPORT_RGB_COLOR self._xy_color = (1.0, 1.0) From d816ff26adae5164597dbae91fcd124e6de25741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 31 Aug 2017 14:19:33 +0200 Subject: [PATCH 056/108] A bugfix for pushbullet (#9237) * Bug fix for pushbullet --- homeassistant/components/notify/pushbullet.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 69e2cc4298a..9b83184047f 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -129,17 +129,21 @@ class PushBulletNotificationService(BaseNotificationService): def _push_data(self, title, message, data, pusher, tname=None): from pushbullet import PushError - from pushbullet import Device + if data is None: + data = {} url = data.get(ATTR_URL) filepath = data.get(ATTR_FILE) file_url = data.get(ATTR_FILE_URL) try: if url: - if isinstance(pusher, Device): - pusher.push_link(title, url, body=message) - else: + if tname: pusher.push_link(title, url, body=message, email=tname) - elif filepath and self.hass.config.is_allowed_path(filepath): + else: + pusher.push_link(title, url, body=message) + elif filepath: + if not self.hass.config.is_allowed_path(filepath): + _LOGGER.error("Filepath is not valid or allowed.") + return with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) if filedata.get('file_type') == 'application/x-empty': @@ -154,9 +158,9 @@ class PushBulletNotificationService(BaseNotificationService): file_url=file_url, file_type=mimetypes.guess_type(file_url)[0]) else: - if isinstance(pusher, Device): - pusher.push_note(title, message) - else: + if tname: pusher.push_note(title, message, email=tname) + else: + pusher.push_note(title, message) except PushError as err: _LOGGER.error("Notify failed: %s", err) From 99c1c9472a4448f12f7302bc1b290e4b47c5c1c3 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Thu, 31 Aug 2017 10:26:33 -0400 Subject: [PATCH 057/108] mopar sensor (#9136) * mopar sensor * fix doc url * mopar review comments * remove unneeded hass.data handling * fix lint --- .coveragerc | 1 + homeassistant/components/sensor/mopar.py | 165 +++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 169 insertions(+) create mode 100644 homeassistant/components/sensor/mopar.py diff --git a/.coveragerc b/.coveragerc index b43688aa281..5e27aed0182 100644 --- a/.coveragerc +++ b/.coveragerc @@ -486,6 +486,7 @@ omit = homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py + homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py homeassistant/components/sensor/netdata.py diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py new file mode 100644 index 00000000000..0184cb2afdf --- /dev/null +++ b/homeassistant/components/sensor/mopar.py @@ -0,0 +1,165 @@ +""" +Sensor for Mopar vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mopar/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PIN, + ATTR_ATTRIBUTION, ATTR_COMMAND, + LENGTH_KILOMETERS) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['motorparts==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(days=7) +DOMAIN = 'mopar' +ATTR_VEHICLE_INDEX = 'vehicle_index' +SERVICE_REMOTE_COMMAND = 'remote_command' +COOKIE_FILE = 'mopar_cookies.pickle' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PIN): cv.positive_int +}) + +REMOTE_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Mopar platform.""" + import motorparts + cookie = hass.config.path(COOKIE_FILE) + try: + session = motorparts.get_session(config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PIN), + cookie_path=cookie) + except motorparts.MoparError: + _LOGGER.error("failed to login") + return False + + def _handle_service(service): + """Handle service call.""" + index = service.data.get(ATTR_VEHICLE_INDEX) + command = service.data.get(ATTR_COMMAND) + try: + motorparts.remote_command(session, command, index) + except motorparts.MoparError as error: + _LOGGER.error(str(error)) + + hass.services.register(DOMAIN, SERVICE_REMOTE_COMMAND, _handle_service, + schema=REMOTE_COMMAND_SCHEMA) + + data = MoparData(session) + add_devices([MoparSensor(data, index) + for index, _ in enumerate(data.vehicles)], + True) + return True + + +# pylint: disable=too-few-public-methods +class MoparData(object): + """Container for Mopar vehicle data. + + Prevents session expiry re-login race condition. + """ + + def __init__(self, session): + """Initialize data.""" + self._session = session + self.vehicles = [] + self.vhrs = {} + self.tow_guides = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Update data.""" + import motorparts + _LOGGER.info("updating vehicle data") + try: + self.vehicles = motorparts.get_summary(self._session)['vehicles'] + except motorparts.MoparError: + _LOGGER.exception("failed to get summary") + return + for index, _ in enumerate(self.vehicles): + try: + self.vhrs[index] = motorparts.get_report(self._session, index) + self.tow_guides[index] = motorparts.get_tow_guide( + self._session, index) + except motorparts.MoparError: + _LOGGER.warning("failed to update for vehicle index %s", index) + + +class MoparSensor(Entity): + """Mopar vehicle sensor.""" + + def __init__(self, data, index): + """Initialize the sensor.""" + self._index = index + self._vehicle = {} + self._vhr = {} + self._tow_guide = {} + self._odometer = None + self._data = data + + def update(self): + """Update device state.""" + self._data.update() + self._vehicle = self._data.vehicles[self._index] + self._vhr = self._data.vhrs.get(self._index, {}) + self._tow_guide = self._data.tow_guides.get(self._index, {}) + if 'odometer' in self._vhr: + odo = float(self._vhr['odometer']) + self._odometer = int(self.hass.config.units.length( + odo, LENGTH_KILOMETERS)) + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {} {}'.format(self._vehicle['year'], + self._vehicle['make'], + self._vehicle['model']) + + @property + def state(self): + """Return the state of the sensor.""" + return self._odometer + + @property + def device_state_attributes(self): + """Return the state attributes.""" + import motorparts + attributes = { + ATTR_VEHICLE_INDEX: self._index, + ATTR_ATTRIBUTION: motorparts.ATTRIBUTION + } + attributes.update(self._vehicle) + attributes.update(self._vhr) + attributes.update(self._tow_guide) + return attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.length_unit + + @property + def icon(self): + """Return the icon.""" + return 'mdi:car' diff --git a/requirements_all.txt b/requirements_all.txt index c3722ec1307..2df6562cf58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,6 +415,9 @@ miflora==0.1.16 # homeassistant.components.upnp miniupnpc==1.9 +# homeassistant.components.sensor.mopar +motorparts==1.0.0 + # homeassistant.components.tts mutagen==1.38 From 60342b47383a4a8fd1c5c198f763812e34d77315 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 31 Aug 2017 16:26:52 +0200 Subject: [PATCH 058/108] Upgrade discord.py to 0.16.11 (#9239) --- homeassistant/components/notify/discord.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index a4ce304167f..90212bca025 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.10'] +REQUIREMENTS = ['discord.py==0.16.11'] CONF_TOKEN = 'token' diff --git a/requirements_all.txt b/requirements_all.txt index 2df6562cf58..823de83c610 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,7 +172,7 @@ denonavr==0.5.2 directpy==0.1 # homeassistant.components.notify.discord -discord.py==0.16.10 +discord.py==0.16.11 # homeassistant.components.updater distro==1.0.4 From 7d281fd22447e3d27fa45930ae355d8ae2b6e124 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 31 Aug 2017 10:29:18 -0400 Subject: [PATCH 059/108] Skip automatic events older than latest data (#9230) * Skip automatic events older than latest data * Update test --- .../components/device_tracker/automatic.py | 30 ++++++++++++++----- .../device_tracker/test_automatic.py | 3 ++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index a4495926f82..6ae038fd41c 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -205,6 +205,7 @@ class AutomaticData(object): self.hass = hass self.devices = devices self.vehicle_info = {} + self.vehicle_seen = {} self.client = client self.session = session self.async_see = async_see @@ -236,6 +237,14 @@ class AutomaticData(object): return yield from self.get_vehicle_info(vehicle) + if event.created_at < self.vehicle_seen[event.vehicle.id]: + # Skip events received out of order + _LOGGER.debug("Skipping out of order event. Event Created %s. " + "Last seen event: %s.", event.created_at, + self.vehicle_seen[event.vehicle.id]) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + kwargs = self.vehicle_info[event.vehicle.id] if kwargs is None: # Ignored device @@ -323,15 +332,17 @@ class AutomaticData(object): if self.devices is not None and name not in self.devices: self.vehicle_info[vehicle.id] = None return - else: - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: { + ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, } + } + self.vehicle_seen[vehicle.id] = \ + vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -352,4 +363,7 @@ class AutomaticData(object): kwargs[ATTR_GPS] = (location.lat, location.lon) kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + return kwargs diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d572791168c..d40c1518ffa 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -1,5 +1,6 @@ """Test the automatic device tracker platform.""" import asyncio +from datetime import datetime import logging from unittest.mock import patch, MagicMock import aioautomatic @@ -71,10 +72,12 @@ def test_valid_credentials( vehicle.display_name = 'mock_display_name' vehicle.fuel_level_percent = 45.6 vehicle.latest_location = None + vehicle.updated_at = datetime(2017, 8, 13, 1, 2, 3) trip.end_location.lat = 45.567 trip.end_location.lon = 34.345 trip.end_location.accuracy_m = 5.6 + trip.ended_at = datetime(2017, 8, 13, 1, 2, 4) @asyncio.coroutine def get_session(*args, **kwargs): From acb6b7c68dfbc8599ce17d3dfcd483ef33481284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 31 Aug 2017 20:41:22 +0200 Subject: [PATCH 060/108] title and message was swapped in pushbullet (#9241) --- homeassistant/components/notify/pushbullet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 9b83184047f..d8b67413528 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -127,7 +127,7 @@ class PushBulletNotificationService(BaseNotificationService): _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, title, message, data, pusher, tname=None): + def _push_data(self, message, title, data, pusher, tname=None): from pushbullet import PushError if data is None: data = {} From 274e4449ea81abf56326d14f1b627090f15f495d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 31 Aug 2017 21:00:09 +0200 Subject: [PATCH 061/108] Fix possible KeyError (#9242) * Multiple devices per child per platform would lead to KeyError. --- homeassistant/components/mysensors.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 62fecddb8c4..c37116fb32d 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -485,12 +485,14 @@ def gw_callback_factory(hass): validated = validate_child(msg.gateway, msg.node_id, child) for platform, dev_ids in validated.items(): devices = get_mysensors_devices(hass, platform) - for idx, dev_id in enumerate(list(dev_ids)): + new_dev_ids = [] + for dev_id in dev_ids: if dev_id in devices: - dev_ids.pop(idx) signals.append(SIGNAL_CALLBACK.format(*dev_id)) - if dev_ids: - discover_mysensors_platform(hass, platform, dev_ids) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform(hass, platform, new_dev_ids) for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. From 836b528bd3ca6f6ad5a8b13000b03a5f8f6a756f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 31 Aug 2017 21:16:44 +0200 Subject: [PATCH 062/108] WIP: Homematic improvments with new hass interfaces (#9058) * Remove hass to init hack and use official interfaces * fix lint * Fix lint * change style --- .../components/binary_sensor/homematic.py | 3 +- homeassistant/components/climate/homematic.py | 3 +- homeassistant/components/cover/homematic.py | 3 +- homeassistant/components/homematic.py | 57 ++++++++++--------- homeassistant/components/light/homematic.py | 3 +- homeassistant/components/sensor/homematic.py | 3 +- homeassistant/components/switch/homematic.py | 3 +- 7 files changed, 35 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index a82431a5ab8..2f464bc73cc 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(hass, conf) - new_device.link_homematic() + new_device = HMBinarySensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 60cda24eef9..ce6e9580e54 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(hass, conf) - new_device.link_homematic() + new_device = HMThermostat(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index e8372b84ce4..9e3d675cabe 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(hass, conf) - new_device.link_homematic() + new_device = HMCover(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index f9583d9be7a..dc5e641cbba 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -4,8 +4,8 @@ Support for HomeMatic devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ +import asyncio import os -import time import logging from datetime import timedelta from functools import partial @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['pyhomematic==0.1.30'] @@ -121,7 +121,6 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DELAY = 'homematic_delay' DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' @@ -134,7 +133,6 @@ CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' CONF_VARIABLES = 'variables' CONF_DEVICES = 'devices' -CONF_DELAY = 'delay' CONF_PRIMARY = 'primary' DEFAULT_LOCAL_IP = '0.0.0.0' @@ -145,7 +143,6 @@ DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' DEFAULT_VARIABLES = False DEFAULT_DEVICES = True -DEFAULT_DELAY = 0.5 DEFAULT_PRIMARY = False @@ -177,7 +174,6 @@ CONFIG_SCHEMA = vol.Schema({ }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): vol.Coerce(float), }), }, extra=vol.ALLOW_EXTRA) @@ -249,7 +245,6 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DELAY] = config[DOMAIN].get(CONF_DELAY) hass.data[DATA_DEVINIT] = {} hass.data[DATA_STORE] = set() @@ -277,7 +272,7 @@ def setup(hass, config): # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) - hass.data[DATA_HOMEMATIC] = HMConnection( + hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), localport=config[DOMAIN].get(CONF_LOCAL_PORT), remotes=remotes, @@ -286,7 +281,7 @@ def setup(hass, config): ) # Start server thread, connect to hosts, initialize to receive events - hass.data[DATA_HOMEMATIC].start() + homematic.start() # Stops server when HASS is shutting down hass.bus.listen_once( @@ -296,7 +291,7 @@ def setup(hass, config): entity_hubs = [] for _, hub_data in hosts.items(): entity_hubs.append(HMHub( - hass, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -359,7 +354,7 @@ def setup(hass, config): def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" - hass.data[DATA_HOMEMATIC].reconnect() + homematic.reconnect() hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, @@ -575,24 +570,27 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, name, use_variables): + def __init__(self, homematic, name, use_variables): """Initialize HomeMatic hub.""" - self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) - self._homematic = hass.data[DATA_HOMEMATIC] + self._homematic = homematic self._variables = {} self._name = name self._state = STATE_UNKNOWN self._use_variables = use_variables + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" # Load data - track_time_interval(hass, self._update_hub, SCAN_INTERVAL_HUB) - self._update_hub(None) + async_track_time_interval( + self.hass, self._update_hub, SCAN_INTERVAL_HUB) + yield from self.hass.async_add_job(self._update_hub, None) if self._use_variables: - track_time_interval( - hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self._update_variables(None) + async_track_time_interval( + self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) + yield from self.hass.async_add_job(self._update_variables, None) @property def name(self): @@ -624,7 +622,9 @@ class HMHub(Entity): """Retrieve latest state.""" state = self._homematic.getServiceMessages(self._name) self._state = STATE_UNKNOWN if state is None else len(state) - self.schedule_update_ha_state() + + if now: + self.schedule_update_ha_state() def _update_variables(self, now): """Retrive all variable data and update hmvariable states.""" @@ -640,7 +640,7 @@ class HMHub(Entity): state_change = True self._variables.update({key: value}) - if state_change: + if state_change and now: self.schedule_update_ha_state() def hm_set_variable(self, name, value): @@ -662,16 +662,15 @@ class HMHub(Entity): class HMDevice(Entity): """The HomeMatic device base object.""" - def __init__(self, hass, config): + def __init__(self, config): """Initialize a generic HomeMatic device.""" - self.hass = hass - self._homematic = hass.data[DATA_HOMEMATIC] self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) self._proxy = config.get(ATTR_PROXY) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} + self._homematic = None self._hmdevice = None self._connected = False self._available = False @@ -680,6 +679,11 @@ class HMDevice(Entity): if self._state: self._state = self._state.upper() + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" + yield from self.hass.async_add_job(self.link_homematic) + @property def should_poll(self): """Return false. HomeMatic states are pushed by the XML-RPC Server.""" @@ -728,16 +732,13 @@ class HMDevice(Entity): return True # Initialize + self._homematic = self.hass.data[DATA_HOMEMATIC] self._hmdevice = self._homematic.devices[self._proxy][self._address] self._connected = True try: # Initialize datapoints of this object self._init_data() - if self.hass.data[DATA_DELAY]: - # We optionally delay / pause loading of data to avoid - # overloading of CCU / Homegear - time.sleep(self.hass.data[DATA_DELAY]) self._load_data_from_hm() # Link events from pyhomematic diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 60865dd223e..807c19fffdb 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -24,8 +24,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMLight(hass, conf) - new_device.link_homematic() + new_device = HMLight(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 771b4a94bd4..061fd27ca69 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -57,8 +57,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(hass, conf) - new_device.link_homematic() + new_device = HMSensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index 566eff99828..487947598bb 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSwitch(hass, conf) - new_device.link_homematic() + new_device = HMSwitch(conf) devices.append(new_device) add_devices(devices) From 0af4f8903d3457d8fc528676f0a9221a09a886ba Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 1 Sep 2017 00:23:11 +0200 Subject: [PATCH 063/108] Add available to sonos (#9243) * Readd sonos available flag / fix polling state * cleanup --- homeassistant/components/media_player/sonos.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 03be42d07ff..a5ef91ecc87 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -322,6 +322,7 @@ class SonosDevice(MediaPlayerDevice): self._media_title = None self._media_radio_show = None self._media_next_title = None + self._available = True self._support_previous_track = False self._support_next_track = False self._support_play = False @@ -386,6 +387,11 @@ class SonosDevice(MediaPlayerDevice): """Return coordinator of this player.""" return self._coordinator + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def _is_available(self): try: sock = socket.create_connection( @@ -416,11 +422,11 @@ class SonosDevice(MediaPlayerDevice): self._player.get_sonos_favorites()['favorites'] if self._last_avtransport_event: - is_available = True + self._available = True else: - is_available = self._is_available() + self._available = self._is_available() - if not is_available: + if not self._available: self._player_volume = None self._player_volume_muted = None self._status = 'OFF' From a55895b66248afa3d9ee2e496279a061b31b2125 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Fri, 1 Sep 2017 03:14:16 -0400 Subject: [PATCH 064/108] Make sure Ring binary_sensor state will update only if device_id matches (#9247) --- homeassistant/components/binary_sensor/ring.py | 5 +++-- tests/fixtures/ring_ding_active.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 429e92afa7f..5c9a644f6b7 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -103,7 +103,8 @@ class RingBinarySensor(BinarySensorDevice): self._data.check_alerts() if self._data.alert: - self._state = (self._sensor_type == - self._data.alert.get('kind')) + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True else: self._state = False diff --git a/tests/fixtures/ring_ding_active.json b/tests/fixtures/ring_ding_active.json index 6bbcc0ee3f9..7c9e0b07405 100644 --- a/tests/fixtures/ring_ding_active.json +++ b/tests/fixtures/ring_ding_active.json @@ -2,7 +2,7 @@ "audio_jitter_buffer_ms": 0, "device_kind": "lpd_v1", "doorbot_description": "Front Door", - "doorbot_id": 12345, + "doorbot_id": 987652, "expires_in": 180, "id": 123456789, "id_str": "123456789", From 8d5f6723ce66deb41dc507e8838d681cf5ae0f43 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 1 Sep 2017 09:15:47 +0200 Subject: [PATCH 065/108] =?UTF-8?q?Added=20configurable=20timeout=20for=20?= =?UTF-8?q?receiver=20HTTP=20requests=20|=20Additional=20AV=E2=80=A6=20(#9?= =?UTF-8?q?244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added configurable timeout for receiver HTTP requests | Additional AVR-X detection based on CommApiVers | Treat Marantz SR6007 - SR6010 as AVR-X device * timeout value not passed correctly --- .../components/media_player/denonavr.py | 18 ++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 06f95a7d3a7..94339514712 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -17,15 +17,16 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, - CONF_NAME, STATE_ON, CONF_ZONE) + CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.2'] +REQUIREMENTS = ['denonavr==0.5.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' CONF_ZONES = 'zones' CONF_VALID_ZONES = ['Zone2', 'Zone3'] @@ -51,7 +52,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): - vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]) + vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) NewHost = namedtuple('NewHost', ['host', 'name']) @@ -69,8 +71,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if cache is None: cache = hass.data[KEY_DENON_CACHE] = set() - # Get config option for show_all_sources + # Get config option for show_all_sources and timeout show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + timeout = config.get(CONF_TIMEOUT) # Get config option for additional zones zones = config.get(CONF_ZONES) @@ -103,14 +106,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - new_hosts.append(NewHost(host=host, name=name)) + 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 # starting if entry.host not in cache: new_device = denonavr.DenonAVR( - entry.host, entry.name, show_all_sources, add_zones) + host=entry.host, name=entry.name, + show_all_inputs=show_all_sources, timeout=timeout, + add_zones=add_zones) for new_zone in new_device.zones.values(): receivers.append(DenonDevice(new_zone)) cache.add(host) diff --git a/requirements_all.txt b/requirements_all.txt index 823de83c610..08be6f5f556 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -166,7 +166,7 @@ datapoint==0.4.3 # decora_wifi==1.3 # homeassistant.components.media_player.denonavr -denonavr==0.5.2 +denonavr==0.5.3 # homeassistant.components.media_player.directv directpy==0.1 From 4cd5173ac8ac332b1640a649ac282c3f2be53695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 1 Sep 2017 11:58:26 +0200 Subject: [PATCH 066/108] upgrade xiaomi lib (#9250) --- homeassistant/components/xiaomi.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py index ce1149d0ece..1d14a76d251 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi.py @@ -9,7 +9,7 @@ from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - '0.3.1.zip#PyXiaomiGateway==0.3.1'] + '0.3.2.zip#PyXiaomiGateway==0.3.2'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' diff --git a/requirements_all.txt b/requirements_all.txt index 08be6f5f556..de8a73b3bc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -299,7 +299,7 @@ holidays==0.8.1 http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a # homeassistant.components.xiaomi -https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.1.zip#PyXiaomiGateway==0.3.1 +https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.2.zip#PyXiaomiGateway==0.3.2 # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 From 713f7fa2a1300c637c5ec3340f9d03cb63bef6d8 Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Fri, 1 Sep 2017 12:02:22 +0200 Subject: [PATCH 067/108] Fix nello.io login (#9251) --- homeassistant/components/lock/nello.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 47a8e3146aa..04030c92425 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) -REQUIREMENTS = ['pynello==1.5'] +REQUIREMENTS = ['pynello==1.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index de8a73b3bc0..a80b9de6d3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -667,7 +667,7 @@ pymyq==0.0.8 pymysensors==0.11.1 # homeassistant.components.lock.nello -pynello==1.5 +pynello==1.5.1 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 From 185d838803fe7e781e3817e5ed77bc7dcddf4dad Mon Sep 17 00:00:00 2001 From: snjoetw Date: Fri, 1 Sep 2017 03:08:30 -0700 Subject: [PATCH 068/108] This is to fix #6386: Manual Alarm not re-arm after 2nd trigger (#9249) --- .../components/alarm_control_panel/manual.py | 4 +- .../alarm_control_panel/test_manual.py | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 97820ab4b2b..d9cd6d6a9ac 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -101,7 +101,9 @@ class ManualAlarm(alarm.AlarmControlPanel): self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state return self._state diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index e5d819bc815..328ae4acd57 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -364,6 +364,97 @@ class TestAlarmControlPanelManual(unittest.TestCase): 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( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + '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, entity_id) + 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() + + 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_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_back_to_back_trigger_with_no_disarm_after_trigger(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, + '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, entity_id) + 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() + + 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_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() + + 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_ARMED_AWAY, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(setup_component( From 4defd96cd62eecedad0e0c73aa96a8c33166bbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Br=C3=A6dstrup?= Date: Fri, 1 Sep 2017 15:27:43 +0200 Subject: [PATCH 069/108] Version bump of DLink switch to v0.6.0 (#9252) --- homeassistant/components/switch/dlink.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b24693da616..e3d426f83a6 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN -REQUIREMENTS = ['pyW215==0.5.1'] +REQUIREMENTS = ['pyW215==0.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a80b9de6d3d..f77390e83d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ pyHS100==0.2.4.2 pyRFXtrx==0.20.0 # homeassistant.components.switch.dlink -pyW215==0.5.1 +pyW215==0.6.0 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 From 8d1f6d399504fd3a581091d834bffbf0fbe463a7 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 1 Sep 2017 18:05:37 +0200 Subject: [PATCH 070/108] Upgrade sendgrid to 5.2.0 (#9254) --- homeassistant/components/notify/sendgrid.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 545cddfadea..b7f192ff983 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.0.1'] +REQUIREMENTS = ['sendgrid==5.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f77390e83d2..41e3c2fba0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -881,7 +881,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.0.1 +sendgrid==5.2.0 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat From 8797932f8046ef727bb2f43a14d402d95a5f17a4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 1 Sep 2017 18:05:53 +0200 Subject: [PATCH 071/108] Upgrade psutil to 5.3.0 (#9253) --- homeassistant/components/sensor/systemmonitor.py | 13 ++++++++----- requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 42229351fde..69a82fb0fac 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,10 +16,12 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.2.2'] +REQUIREMENTS = ['psutil==5.3.0'] _LOGGER = logging.getLogger(__name__) +CONF_ARG = 'arg' + SENSOR_TYPES = { 'disk_free': ['Disk Free', 'GiB', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -49,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional('arg'): cv.string, + vol.Optional(CONF_ARG): cv.string, })]) }) @@ -71,9 +73,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the system monitor sensors.""" dev = [] for resource in config[CONF_RESOURCES]: - if 'arg' not in resource: - resource['arg'] = '' - dev.append(SystemMonitorSensor(resource[CONF_TYPE], resource['arg'])) + if CONF_ARG not in resource: + resource[CONF_ARG] = '' + dev.append(SystemMonitorSensor( + resource[CONF_TYPE], resource[CONF_ARG])) add_devices(dev, True) diff --git a/requirements_all.txt b/requirements_all.txt index 41e3c2fba0c..cd66d4d8710 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.2.2 +psutil==5.3.0 # homeassistant.components.wink pubnubsub-handler==1.0.2 From 639eb81aefc5656fc73f64138e27a50b7820e82e Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Fri, 1 Sep 2017 15:41:35 -0400 Subject: [PATCH 072/108] Adding ZWave CentralScene activation handler. (#9178) * Adding ZWave CentralScene activation handler. * Migrated CentralScene logic to node_entity.py Removed extraneous logging Modified scene_activated event to send the scene_id and scene_data separately * Adding unit test for ZWave central scene activation * Removed return to allow node statistics to update after central scene message is received --- homeassistant/components/zwave/const.py | 1 + homeassistant/components/zwave/node_entity.py | 25 +++++++-- tests/components/zwave/test_node_entity.py | 54 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b72d9eb0cff..a238d01d520 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -10,6 +10,7 @@ ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" ATTR_NAME = "name" ATTR_SCENE_ID = "scene_id" +ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 3a810d00d2d..44a30cdc529 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -7,8 +7,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from .const import ( - ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_BASIC_LEVEL, - EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN) + ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, + ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN, + COMMAND_CLASS_CENTRAL_SCENE) from .util import node_name _LOGGER = logging.getLogger(__name__) @@ -107,13 +108,19 @@ class ZWaveNodeEntity(ZWaveBaseEntity): dispatcher.connect( self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT) - def network_node_changed(self, node=None, args=None): + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: return if args is not None and 'nodeId' in args and \ args['nodeId'] != self.node_id: return + + # Process central scene activation + if (value is not None and + value.command_class == COMMAND_CLASS_CENTRAL_SCENE): + self.central_scene_activated(value.index, value.data) + self.node_changed() def get_node_statistics(self): @@ -177,6 +184,18 @@ class ZWaveNodeEntity(ZWaveBaseEntity): ATTR_SCENE_ID: scene_id }) + def central_scene_activated(self, scene_id, scene_data): + """Handle an activated central scene for this node.""" + if self.hass is None: + return + + self.hass.bus.fire(EVENT_SCENE_ACTIVATED, { + ATTR_ENTITY_ID: self.entity_id, + ATTR_NODE_ID: self.node_id, + ATTR_SCENE_ID: scene_id, + ATTR_SCENE_DATA: scene_data + }) + @property def state(self): """Return the state.""" diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index b7148dd982e..32351234ad3 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -117,6 +117,60 @@ def test_scene_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_SCENE_ID] == scene_id +@asyncio.coroutine +def test_central_scene_activated(hass, mock_openzwave): + """Test central scene activated event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED: + mock_receivers.append(receiver) + + node = mock_zwave.MockNode(node_id=11) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + entity = node_entity.ZWaveNodeEntity(node, mock_openzwave, True) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener) + + # Test event before entity added to hass + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + assert len(events) == 0 + + # Add entity to hass + entity.hass = hass + entity.entity_id = 'zwave.mock_node' + + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" + assert events[0].data[const.ATTR_NODE_ID] == 11 + assert events[0].data[const.ATTR_SCENE_ID] == scene_id + assert events[0].data[const.ATTR_SCENE_DATA] == scene_data + + @pytest.mark.usefixtures('mock_openzwave') class TestZWaveNodeEntity(unittest.TestCase): """Class to test ZWaveNodeEntity.""" From f51163f803e408fab0bf7aaab541752d9e66af63 Mon Sep 17 00:00:00 2001 From: Gunnar Helgason Date: Fri, 1 Sep 2017 23:56:59 +0200 Subject: [PATCH 073/108] Add Geofency device tracker (#9106) * Added Geofency device tracker Added Geofency device tracker * fix pylint error * review fixes * merge coroutines --- .../components/device_tracker/geofency.py | 127 ++++++++++ .../device_tracker/test_geofency.py | 230 ++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100755 homeassistant/components/device_tracker/geofency.py create mode 100644 tests/components/device_tracker/test_geofency.py diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py new file mode 100755 index 00000000000..d4e576bad74 --- /dev/null +++ b/homeassistant/components/device_tracker/geofency.py @@ -0,0 +1,127 @@ +""" +Support for the Geofency platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.geofency/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +BEACON_DEV_PREFIX = 'beacon' +CONF_MOBILE_BEACONS = 'mobile_beacons' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MOBILE_BEACONS): vol.All( + cv.ensure_list, [cv.string]), +}) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up an endpoint for the Geofency application.""" + mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] + + hass.http.register_view(GeofencyView(see, mobile_beacons)) + + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, see, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.see = see + self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] + + @asyncio.coroutine + def post(self, request): + """Handle Geofency requests.""" + data = yield from request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) + + if self._is_mobile_beacon(data): + return (yield from self._set_location(hass, data, None)) + else: + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + + return (yield from self._set_location(hass, data, location_name)) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return False + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) + data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + else: + return data['device'] + + @asyncio.coroutine + def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name=location_name, + attributes=data)) + + return "Setting location for {}".format(device) diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/device_tracker/test_geofency.py new file mode 100644 index 00000000000..e8aa44cb0e5 --- /dev/null +++ b/tests/components/device_tracker/test_geofency.py @@ -0,0 +1,230 @@ +"""The tests for the Geofency device tracker platform.""" +# pylint: disable=redefined-outer-name +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components import zone +import homeassistant.components.device_tracker as device_tracker +from homeassistant.components.device_tracker.geofency import ( + CONF_MOBILE_BEACONS, URL) +from homeassistant.const import ( + CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + STATE_NOT_HOME) +from homeassistant.setup import async_setup_component +from homeassistant.util import slugify + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + +NOT_HOME_LATITUDE = 37.239394 +NOT_HOME_LONGITUDE = -115.763283 + +GPS_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +GPS_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + + +@pytest.fixture +def geofency_client(loop, hass, test_client): + """Geofency mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'geofency', + CONF_MOBILE_BEACONS: ['Car 1'] + }})) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture(autouse=True) +def setup_zones(loop, hass): + """Setup Zone config in HA.""" + assert loop.run_until_complete(async_setup_component( + hass, zone.DOMAIN, { + 'zone': { + 'name': 'Home', + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'radius': 100, + }})) + + +@asyncio.coroutine +def test_data_validation(geofency_client): + """Test data validation.""" + # No data + req = yield from geofency_client.post(URL) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + missing_attributes = ['address', 'device', + 'entry', 'latitude', 'longitude', 'name'] + + # missing attributes + for attribute in missing_attributes: + copy = GPS_ENTER_HOME.copy() + del copy[attribute] + req = yield from geofency_client.post(URL, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +@asyncio.coroutine +def test_gps_enter_and_exit_home(hass, geofency_client): + """Test GPS based zone enter and exit.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_ENTER_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_EXIT_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_home(hass, geofency_client): + """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_car(hass, geofency_client): + """Test use of mobile iBeacon.""" + # Enter the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Exit the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Enter the Car in the Home zone + data = BEACON_ENTER_CAR.copy() + data['latitude'] = HOME_LATITUDE + data['longitude'] = HOME_LONGITUDE + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Car in the Home zone + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name From 0889e38cb182c92ec7e880cf1fecdf115cb493b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 2 Sep 2017 17:02:11 +0100 Subject: [PATCH 074/108] flux: fix for when stop_time is after midnight (#8932) * flux: fix for when stop_time is after midnight * flux: fix imports * flux: add missing check when now is after midnight * flux: one more try; should fix all use cases now * flux switch: fix lint * flux switch: add new tests * flux switch: fix tests lint * flux switch: fix tests docstrings --- homeassistant/components/switch/flux.py | 38 +++- tests/components/switch/test_flux.py | 255 ++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 5613bcbb19e..e8bd592cee8 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -6,8 +6,9 @@ The idea was taken from https://github.com/KpaBap/hue-flux/ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.flux/ """ -from datetime import time +import datetime import logging + import voluptuous as vol from homeassistant.components.light import is_on, turn_on @@ -46,7 +47,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_LIGHTS): cv.entity_ids, vol.Optional(CONF_NAME, default="Flux"): cv.string, vol.Optional(CONF_START_TIME): cv.time, - vol.Optional(CONF_STOP_TIME, default=time(22, 0)): cv.time, + vol.Optional(CONF_STOP_TIME, default=datetime.time(22, 0)): cv.time, vol.Optional(CONF_START_CT, default=4000): vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_SUNSET_CT, default=3000): @@ -171,12 +172,22 @@ class FluxSwitch(SwitchDevice): """Update all the lights using flux.""" if now is None: now = dt_now() + sunset = get_astral_event_date(self.hass, 'sunset', now.date()) start_time = self.find_start_time(now) stop_time = now.replace( hour=self._stop_time.hour, minute=self._stop_time.minute, second=0) + if stop_time <= start_time: + # stop_time does not happen in the same day as start_time + if start_time < now: + # stop time is tomorrow + stop_time += datetime.timedelta(days=1) + elif now < start_time: + # stop_time was yesterday since the new start_time is not reached + stop_time -= datetime.timedelta(days=1) + if start_time < now < sunset: # Daytime time_state = 'day' @@ -192,15 +203,24 @@ class FluxSwitch(SwitchDevice): else: # Nightime time_state = 'night' - if now < stop_time and now > start_time: - now_time = now + + if now < stop_time: + if stop_time < start_time and stop_time.day == sunset.day: + # we need to use yesterday's sunset time + sunset_time = sunset - datetime.timedelta(days=1) + else: + sunset_time = sunset + + # pylint: disable=no-member + night_length = int(stop_time.timestamp() - + sunset_time.timestamp()) + seconds_from_sunset = int(now.timestamp() - + sunset_time.timestamp()) + percentage_complete = seconds_from_sunset / night_length else: - now_time = stop_time + percentage_complete = 1 + temp_range = abs(self._sunset_colortemp - self._stop_colortemp) - night_length = int(stop_time.timestamp() - sunset.timestamp()) - seconds_from_sunset = int(now_time.timestamp() - - sunset.timestamp()) - percentage_complete = seconds_from_sunset / night_length temp_offset = temp_range * percentage_complete if self._sunset_colortemp > self._stop_colortemp: temp = self._sunset_colortemp - temp_offset diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index d529e8c3f56..0d2a486cb4f 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -347,6 +347,261 @@ class TestSwitchFlux(unittest.TestCase): self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) + def test_flux_before_sunrise_stop_next_day(self): + """Test the flux switch before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + + # pylint: disable=invalid-name + def test_flux_after_sunrise_before_sunset_stop_next_day(self): + """ + Test the flux switch after sunrise and before sunset. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_before_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 126) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.574, 0.401]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_after_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=00, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 122) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.586, 0.397]) + + # pylint: disable=invalid-name + def test_flux_after_stop_before_sunrise_stop_next_day(self): + """Test the flux switch after stop and before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + # pylint: disable=invalid-name def test_flux_with_custom_colortemps(self): """Test the flux with custom start and stop colortemps.""" From a78f5e0970953bf14978f3558f1ee0aaacd1420c Mon Sep 17 00:00:00 2001 From: pavoni Date: Sun, 3 Sep 2017 11:31:55 +0100 Subject: [PATCH 075/108] Bump pywemo, handle more ports. --- homeassistant/components/wemo.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 3d7226e3c8b..0592ad4c124 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.19'] +REQUIREMENTS = ['pywemo==0.4.20'] DOMAIN = 'wemo' diff --git a/requirements_all.txt b/requirements_all.txt index cd66d4d8710..df92881ccb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -827,7 +827,7 @@ pyvlx==0.1.3 pywebpush==1.0.6 # homeassistant.components.wemo -pywemo==0.4.19 +pywemo==0.4.20 # homeassistant.components.zabbix pyzabbix==0.7.4 From 7694c31814dd4286fd808efb25d04fc61bb4506c Mon Sep 17 00:00:00 2001 From: emlt Date: Sun, 3 Sep 2017 16:07:12 +0200 Subject: [PATCH 076/108] Change attribute names (#9277) Remove spaces and capitals in attribute names to be consistent with sensors and other switches. --- homeassistant/components/switch/dlink.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index e3d426f83a6..f6ed6dac018 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -23,9 +23,9 @@ DEFAULT_PASSWORD = '' DEFAULT_USERNAME = 'admin' CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' -ATTR_CURRENT_CONSUMPTION = 'Current Consumption' -ATTR_TOTAL_CONSUMPTION = 'Total Consumption' -ATTR_TEMPERATURE = 'Temperature' +ATTR_CURRENT_CONSUMPTION = 'power_consumption' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TEMPERATURE = 'temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, From 68343ac81f4ab62cf8000ecf56592172f70bf756 Mon Sep 17 00:00:00 2001 From: Dan Ports Date: Sun, 3 Sep 2017 12:42:05 -0700 Subject: [PATCH 077/108] insteon_plm: fix typo in attributes (#9284) --- homeassistant/components/insteon_plm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 92807bf9b1c..94b70e47cba 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -102,7 +102,7 @@ def common_attributes(entity): 'address': 'INSTEON Address', 'description': 'Description', 'model': 'Model', - 'cat': 'Cagegory', + 'cat': 'Category', 'subcat': 'Subcategory', 'firmware': 'Firmware', 'product_key': 'Product Key' From 38e1b81ff67be6f9178e6e403431389e4dd17ee3 Mon Sep 17 00:00:00 2001 From: Paul Sokolovsky Date: Sun, 3 Sep 2017 23:27:13 +0300 Subject: [PATCH 078/108] discovery: If unknown NetDisco service discovered, log about it. (#9280) Otherwise, known services are logged, ignored are logged, but unknown - not. Logging them is quite helpful for someone working on adding new discovery service to NetDisco/HA, and would help to decouple NetDisco library further: another project may use a generic NetDisco library, and contribute new service to it, which won't be automatically supported by HA. But logging about it would be a good hint to HA users that they can look into supporting it. --- homeassistant/components/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 06e6f0b989a..c757d9d1ce3 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -100,6 +100,7 @@ def async_setup(hass, config): # We do not know how to handle this service. if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) return discovery_hash = json.dumps([service, info], sort_keys=True) From 5dfd60a029f96e8643deb201090657b92497f8fa Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 3 Sep 2017 17:21:35 -0400 Subject: [PATCH 079/108] Upgrade youtube_dl to 2017.9.2 (#9279) --- 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 f1d6139ffb1..1ecb09ac022 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,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.8.18'] +REQUIREMENTS = ['youtube_dl==2017.9.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index df92881ccb6..742d2f3cc83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,7 +1029,7 @@ yeelight==0.3.2 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.8.18 +youtube_dl==2017.9.2 # homeassistant.components.light.zengge zengge==0.2 From 7c7a5a4a15e28c2ce34cd914562149929daabc14 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 3 Sep 2017 17:21:51 -0400 Subject: [PATCH 080/108] Upgrade python-telegram-bot to 8.0.0 (#9282) --- homeassistant/components/telegram_bot/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 38669ff4ee6..de9c0f4ede3 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==7.0.1'] +REQUIREMENTS = ['python-telegram-bot==8.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 742d2f3cc83..ae91099165b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -782,7 +782,7 @@ python-synology==0.1.0 python-tado==0.2.2 # homeassistant.components.telegram_bot -python-telegram-bot==7.0.1 +python-telegram-bot==8.0.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 From e6207684bf3fb0fbe94bc2455a5c7b6f53e3ff8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 4 Sep 2017 10:19:58 +0200 Subject: [PATCH 081/108] rfxtrx lib upgrade (#9288) --- homeassistant/components/rfxtrx.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 259f8fa8ac6..0c5acd3f7fa 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.20.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.1'] DOMAIN = 'rfxtrx' diff --git a/requirements_all.txt b/requirements_all.txt index ae91099165b..89f42d89979 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -536,7 +536,7 @@ pyCEC==0.4.13 pyHS100==0.2.4.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.20.0 +pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink pyW215==0.6.0 From 1b5e574a76a6e0d7dc55bfa6a58b43ee01159fcc Mon Sep 17 00:00:00 2001 From: Jeroen ter Heerdt Date: Mon, 4 Sep 2017 13:34:56 +0200 Subject: [PATCH 082/108] Fixing bug when using egardiaserver - package requirement updated to 1.0.20. (#9294) * Bumping pythonegardia package requirement up to .18 * Updating requirements_all to reflect updated pythonegardia package .18 * Catching up with reality and updating egardia.py Requirements_all reflects updated package requirement for python-egardia of 1.0.20 --- homeassistant/components/alarm_control_panel/egardia.py | 7 ++++--- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 1ef5e5d64d8..fbafe061334 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.19'] +REQUIREMENTS = ['pythonegardia==1.0.20'] _LOGGER = logging.getLogger(__name__) @@ -154,8 +154,9 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def update(self): """Update the alarm status.""" - status = self._egardiasystem.getstate() - self.parsestatus(status) + if not self._rs_enabled: + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/requirements_all.txt b/requirements_all.txt index 89f42d89979..664ca45a82b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -800,7 +800,7 @@ python-wink==1.5.1 python_openzwave==0.4.0.31 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.19 +pythonegardia==1.0.20 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 From 54de3d89d1f7987707215ccdc5f8262d5f035b2b Mon Sep 17 00:00:00 2001 From: Andreas Jacobsen Date: Mon, 4 Sep 2017 13:40:08 +0200 Subject: [PATCH 083/108] Added intent_type to exception log (#9289) --- homeassistant/components/snips.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 6243de0b2d6..1f64f78e9c8 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -66,7 +66,7 @@ def async_setup(hass, config): yield from intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) except intent.IntentError: - _LOGGER.exception("Error while handling intent.") + _LOGGER.exception("Error while handling intent: %s.", intent_type) yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) From 67828cb7a2c4995eefb0b6ee51269ba322b9dbf8 Mon Sep 17 00:00:00 2001 From: Tom Matheussen Date: Mon, 4 Sep 2017 20:47:40 +0200 Subject: [PATCH 084/108] Handle spotify failing to refresh access_token (#9295) * Handle spotify failing to refresh access_token * Remove whitespace --- homeassistant/components/media_player/spotify.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 239b13a6292..734285d918a 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -148,6 +148,10 @@ class SpotifyMediaPlayer(MediaPlayerDevice): new_token = \ self._oauth.refresh_access_token( self._token_info['refresh_token']) + # skip when refresh failed + if new_token is None: + return + self._token_info = new_token token_refreshed = True if self._player is None or token_refreshed: @@ -158,6 +162,12 @@ class SpotifyMediaPlayer(MediaPlayerDevice): def update(self): """Update state and attributes.""" self.refresh_spotify_instance() + + # Don't true update when token is expired + if self._oauth.is_token_expired(self._token_info): + _LOGGER.warning("Spotify failed to update, token expired.") + return + # Available devices player_devices = self._player.devices() if player_devices is not None: From ed699896cb5965afb4e56cc40b6f3a0ef873967f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 5 Sep 2017 02:01:01 +0200 Subject: [PATCH 085/108] Core track same state for a period / Allow on platforms (#9273) * Core track state period / Allow on platforms * Add tests * fix lint * fix tests * add new tracker to automation state * update schema * fix bug * revert validate string * Fix bug * Set arguments to async_check_funct * add logic into numeric_state * fix numeric_state * Add tests * fix retrigger state * cleanup * Add delay function to template binary_sensor * Fix tests & lint * add more tests * fix lint * Address comments * fix test & lint --- .../components/automation/numeric_state.py | 66 +++++++-- homeassistant/components/automation/state.py | 74 +++------ .../components/binary_sensor/template.py | 59 ++++++-- homeassistant/const.py | 1 + homeassistant/helpers/event.py | 56 +++++++ .../automation/test_numeric_state.py | 132 ++++++++++++++++- .../components/binary_sensor/test_template.py | 140 +++++++++++++++++- tests/helpers/test_event.py | 108 +++++++++++++- 8 files changed, 548 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3657724f679..51b2ea89f0f 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -12,16 +12,18 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - CONF_BELOW, CONF_ABOVE) -from homeassistant.helpers.event import async_track_state_change + CONF_BELOW, CONF_ABOVE, CONF_FOR) +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'numeric_state', vol.Required(CONF_ENTITY_ID): cv.entity_ids, - CONF_BELOW: vol.Coerce(float), - CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) _LOGGER = logging.getLogger(__name__) @@ -33,15 +35,18 @@ def async_trigger(hass, config, action): entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) + async_remove_track_same = None + if value_template is not None: value_template.hass = hass @callback - def state_automation_listener(entity, from_s, to_s): - """Listen for state changes and calls action.""" + def check_numeric_state(entity, from_s, to_s): + """Return True if they should trigger.""" if to_s is None: - return + return False variables = { 'trigger': { @@ -55,17 +60,56 @@ def async_trigger(hass, config, action): # If new one doesn't match, nothing to do if not condition.async_numeric_state( hass, to_s, below, above, value_template, variables): + return False + + return True + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + nonlocal async_remove_track_same + + if not check_numeric_state(entity, from_s, to_s): return + variables = { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + } + # Only match if old didn't exist or existed but didn't match # Written as: skip if old one did exist and matched if from_s is not None and condition.async_numeric_state( hass, from_s, below, above, value_template, variables): return - variables['trigger']['from_state'] = from_s - variables['trigger']['to_state'] = to_s + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, variables) - hass.async_run_job(action, variables) + if not time_delta: + call_action() + return - return async_track_state_change(hass, entity_id, state_automation_listener) + async_remove_track_same = async_track_same_state( + hass, True, time_delta, call_action, entity_ids=entity_id, + async_check_func=check_numeric_state) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable + + return async_remove diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 8ad5c40bb80..e7a01cb7115 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -8,28 +8,23 @@ import asyncio import voluptuous as vol from homeassistant.core import callback -import homeassistant.util.dt as dt_util -from homeassistant.const import MATCH_ALL, CONF_PLATFORM +from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR from homeassistant.helpers.event import ( - async_track_state_change, async_track_point_in_utc_time) + async_track_state_change, async_track_same_state) import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' CONF_FROM = 'from' CONF_TO = 'to' -CONF_FOR = 'for' -TRIGGER_SCHEMA = vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - # These are str on purpose. Want to catch YAML conversions - CONF_FROM: str, - CONF_TO: str, - CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), - }), - cv.key_dependency(CONF_FOR, CONF_TO), -) +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): str, + vol.Optional(CONF_TO): str, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.key_dependency(CONF_FOR, CONF_TO)) @asyncio.coroutine @@ -39,28 +34,15 @@ def async_trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) - async_remove_state_for_cancel = None - async_remove_state_for_listener = None match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) - - @callback - def clear_listener(): - """Clear all unsub listener.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - - # pylint: disable=not-callable - if async_remove_state_for_listener is not None: - async_remove_state_for_listener() - async_remove_state_for_listener = None - if async_remove_state_for_cancel is not None: - async_remove_state_for_cancel() - async_remove_state_for_cancel = None + async_remove_track_same = None @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + nonlocal async_remove_track_same + @callback def call_action(): """Call action with right context.""" hass.async_run_job(action, { @@ -78,33 +60,12 @@ def async_trigger(hass, config, action): from_s.last_changed == to_s.last_changed): return - if time_delta is None: + if not time_delta: call_action() return - @callback - def state_for_listener(now): - """Fire on state changes after a delay and calls action.""" - nonlocal async_remove_state_for_listener - async_remove_state_for_listener = None - clear_listener() - call_action() - - @callback - def state_for_cancel_listener(entity, inner_from_s, inner_to_s): - """Fire on changes and cancel for listener if changed.""" - if inner_to_s.state == to_s.state: - return - clear_listener() - - # cleanup previous listener - clear_listener() - - async_remove_state_for_listener = async_track_point_in_utc_time( - hass, state_for_listener, dt_util.utcnow() + time_delta) - - async_remove_state_for_cancel = async_track_state_change( - hass, entity, state_for_cancel_listener) + async_remove_track_same = async_track_same_state( + hass, to_s.state, time_delta, call_action, entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) @@ -113,6 +74,7 @@ def async_trigger(hass, config, action): def async_remove(): """Remove state listeners async.""" unsub() - clear_listener() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable return async_remove diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 330e8eaea9d..413804f0856 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -19,16 +19,24 @@ from homeassistant.const import ( from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) if value_template is not None: value_template.hass = hass @@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids) + entity_ids, delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids): + value_template, entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -78,6 +88,8 @@ class BinarySensorTemplate(BinarySensorDevice): self._template = value_template self._state = None self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off @asyncio.coroutine def async_added_to_hass(self): @@ -89,7 +101,7 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_check_state() @callback def template_bsensor_startup(event): @@ -97,7 +109,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_state_change( self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_check_state) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -122,11 +134,11 @@ class BinarySensorTemplate(BinarySensorDevice): """No polling needed.""" return False - @asyncio.coroutine - def async_update(self): - """Update the state from the template.""" + @callback + def _async_render(self, *args): + """Get the state of template.""" try: - self._state = self._template.async_render().lower() == 'true' + return self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -135,4 +147,29 @@ class BinarySensorTemplate(BinarySensorDevice): "the state is unknown", self._name) return _LOGGER.error("Could not render template %s: %s", self._name, ex) - self._state = False + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.hass.async_add_job(self.async_update_ha_state()) + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, state, period, set_state, entity_ids=self._entities, + async_check_func=self._async_render) diff --git a/homeassistant/const.py b/homeassistant/const.py index dd8a579b033..88ab58201f8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -101,6 +101,7 @@ CONF_EVENT = 'event' CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FOR = 'for' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9b64c08af18..5db4ece5ef5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -113,6 +113,62 @@ def async_track_template(hass, template, action, variables=None): track_template = threaded_listener_factory(async_track_template) +@callback +def async_track_same_state(hass, orig_value, period, action, + async_check_func=None, entity_ids=MATCH_ALL): + """Track the state of entities for a period and run a action. + + If async_check_func is None it use the state of orig_value. + Without entity_ids we track all state changes. + """ + async_remove_state_for_cancel = None + async_remove_state_for_listener = None + + @callback + def clear_listener(): + """Clear all unsub listener.""" + nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + + # pylint: disable=not-callable + if async_remove_state_for_listener is not None: + async_remove_state_for_listener() + async_remove_state_for_listener = None + if async_remove_state_for_cancel is not None: + async_remove_state_for_cancel() + async_remove_state_for_cancel = None + + @callback + def state_for_listener(now): + """Fire on state changes after a delay and calls action.""" + nonlocal async_remove_state_for_listener + async_remove_state_for_listener = None + clear_listener() + hass.async_run_job(action) + + @callback + def state_for_cancel_listener(entity, from_state, to_state): + """Fire on changes and cancel for listener if changed.""" + if async_check_func: + value = async_check_func(entity, from_state, to_state) + else: + value = to_state.state + + if orig_value == value: + return + clear_listener() + + async_remove_state_for_listener = async_track_point_in_utc_time( + hass, state_for_listener, dt_util.utcnow() + period) + + async_remove_state_for_cancel = async_track_state_change( + hass, entity_ids, state_for_cancel_listener) + + return clear_listener + + +track_same_state = threaded_listener_factory(async_track_same_state) + + @callback def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in time.""" diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 355e26abf9b..0a7db4a122d 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -1,11 +1,16 @@ """The tests for numeric state automation.""" +from datetime import timedelta import unittest +from unittest.mock import patch +import homeassistant.components.automation as automation from homeassistant.core import callback from homeassistant.setup import setup_component -import homeassistant.components.automation as automation +import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant, mock_component +from tests.common import ( + get_test_home_assistant, mock_component, fire_time_changed, + assert_setup_component) # pylint: disable=invalid-name @@ -576,3 +581,126 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(self.calls)) + + def test_if_fails_setup_bad_for(self): + """Test for setup failure for bad for.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'invalid': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_fails_setup_for_without_above_below(self): + """Test for setup failures for missing above or below.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_not_fires_on_entity_change_with_for(self): + """Test for not firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + self.hass.states.set('test.entity', 15) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_with_for_attribute_change(self): + """Test for firing on entity change with for and attribute change.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + utcnow = dt_util.utcnow() + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = utcnow + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.states.set('test.entity', 9, + attributes={"mock_attr": "attr_change"}) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_for(self): + """Test for firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 4e829b42fe3..11163d42ab5 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,5 +1,6 @@ """The tests for the Template Binary sensor platform.""" import asyncio +from datetime import timedelta import unittest from unittest import mock @@ -10,10 +11,12 @@ from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr from homeassistant.util.async import run_callback_threadsafe +import homeassistant.util.dt as dt_util from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) + get_test_home_assistant, assert_setup_component, mock_component, + async_fire_time_changed) class TestBinarySensorTemplate(unittest.TestCase): @@ -103,19 +106,20 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() self.assertFalse(vs.should_poll) self.assertEqual('motion', vs.device_class) self.assertEqual('Parent', vs.name) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertFalse(vs.is_on) # pylint: disable=protected-access vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertTrue(vs.is_on) def test_event(self): @@ -155,13 +159,14 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() mock_render.side_effect = TemplateError('foo') - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() mock_render.side_effect = TemplateError( "UndefinedError: 'None' has no attribute") - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() @asyncio.coroutine @@ -197,3 +202,124 @@ def test_restore_state(hass): state = hass.states.get('binary_sensor.test') assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_on(hass): + """Test binary sensor template delay on.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_on': 5 + }, + }, + }, + } + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + # check with time changes + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_off(hass): + """Test binary sensor template delay off.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_off': 5 + }, + }, + }, + } + hass.states.async_set('sensor.test_state', 'on') + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + # check with time changes + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 37ff8ba297e..9c325df181e 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -17,6 +17,7 @@ from homeassistant.helpers.event import ( track_state_change, track_time_interval, track_template, + track_same_state, track_sunrise, track_sunset, ) @@ -24,7 +25,7 @@ from homeassistant.helpers.template import Template from homeassistant.components import sun import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, fire_time_changed from unittest.mock import patch @@ -262,6 +263,111 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(wildcard_runs)) self.assertEqual(2, len(wildercard_runs)) + def test_track_same_state_simple_trigger(self): + """Test track_same_change with trigger simple.""" + thread_runs = [] + callback_runs = [] + coroutine_runs = [] + period = timedelta(minutes=1) + + def thread_run_callback(): + thread_runs.append(1) + + track_same_state( + self.hass, 'on', period, thread_run_callback, + entity_ids='light.Bowl') + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + @asyncio.coroutine + def coroutine_run_callback(): + coroutine_runs.append(1) + + track_same_state( + self.hass, 'on', period, coroutine_run_callback) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(thread_runs)) + self.assertEqual(0, len(callback_runs)) + self.assertEqual(0, len(coroutine_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(thread_runs)) + self.assertEqual(1, len(callback_runs)) + self.assertEqual(1, len(coroutine_runs)) + + def test_track_same_state_simple_no_trigger(self): + """Test track_same_change with no trigger.""" + callback_runs = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # Change state on state machine + self.hass.states.set("light.Bowl", "off") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + def test_track_same_state_simple_trigger_check_funct(self): + """Test track_same_change with trigger and check funct.""" + callback_runs = [] + check_func = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + @ha.callback + def async_check_func(entity, from_s, to_s): + check_func.append((entity, from_s, to_s)) + return 'on' + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl', async_check_func=async_check_func) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + self.assertEqual('on', check_func[-1][2].state) + self.assertEqual('light.bowl', check_func[-1][0]) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(callback_runs)) + def test_track_time_interval(self): """Test tracking time interval.""" specific_runs = [] From c3a91000ac6e659f63343ee5cace204429a7c5b5 Mon Sep 17 00:00:00 2001 From: upsert <30200174+upsert@users.noreply.github.com> Date: Tue, 5 Sep 2017 05:30:36 -0400 Subject: [PATCH 086/108] Improved Lutron Caseta shade support (#9302) --- .../components/cover/lutron_caseta.py | 29 ++++++++++--------- .../components/light/lutron_caseta.py | 4 +-- homeassistant/components/lutron_caseta.py | 2 +- .../components/switch/lutron_caseta.py | 4 +-- requirements_all.txt | 2 +- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 648dba98ca6..31e4f1e3cf2 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -1,14 +1,14 @@ """ -Support for Lutron Caseta SerenaRollerShade. +Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ import logging - from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION, DOMAIN) from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) @@ -19,11 +19,10 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Lutron Caseta Serena shades as a cover device.""" + """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", - "SerenaHoneycombShade"]) + cover_devices = bridge.get_devices_by_domain(DOMAIN) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) @@ -32,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class LutronCasetaCover(LutronCasetaDevice, CoverDevice): - """Representation of a Lutron Serena shade.""" + """Representation of a Lutron shade.""" @property def supported_features(self): @@ -42,24 +41,26 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - return self._state["current_state"] < 1 + return self._state['current_state'] < 1 @property def current_cover_position(self): """Return the current position of cover.""" - return self._state["current_state"] + return self._state['current_state'] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self._smartbridge.set_value(self._device_id, position) + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._smartbridge.set_value(self._device_id, position) def update(self): """Call when forcing a refresh of the device.""" diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index 8e4e9d7450e..c11b3da6f75 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/light.lutron_caseta/ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, DOMAIN) from homeassistant.components.light.lutron import ( to_hass_level, to_lutron_level) from homeassistant.components.lutron_caseta import ( @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - light_devices = bridge.get_devices_by_types(["WallDimmer", "PlugInDimmer"]) + light_devices = bridge.get_devices_by_domain(DOMAIN) for light_device in light_devices: dev = LutronCasetaLight(light_device, bridge) devs.append(dev) diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index dcb3347e919..8660546c910 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.7'] +REQUIREMENTS = ['pylutron-caseta==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index 585dc043315..daaba68dc5e 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -8,7 +8,7 @@ import logging from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchDevice, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - switch_devices = bridge.get_devices_by_type("WallSwitch") + switch_devices = bridge.get_devices_by_domain(DOMAIN) for switch_device in switch_devices: dev = LutronCasetaLight(switch_device, bridge) diff --git a/requirements_all.txt b/requirements_all.txt index 664ca45a82b..40b58ac130e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ pylitejet==0.1 pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta -pylutron-caseta==0.2.7 +pylutron-caseta==0.2.8 # homeassistant.components.lutron pylutron==0.1.0 From 984cae531000df2bdfa1298f0750b27d5662fa10 Mon Sep 17 00:00:00 2001 From: Brian Hopkins Date: Tue, 5 Sep 2017 07:05:31 -0400 Subject: [PATCH 087/108] Upgrade mycroftapi to 2.0 (#9309) * updating mycroftapi version * updating mycroftapi version --- homeassistant/components/mycroft.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mycroft.py b/homeassistant/components/mycroft.py index c8179c280c8..834572bc551 100644 --- a/homeassistant/components/mycroft.py +++ b/homeassistant/components/mycroft.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['mycroftapi==0.1.2'] +REQUIREMENTS = ['mycroftapi==2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 40b58ac130e..2862aadecc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ motorparts==1.0.0 mutagen==1.38 # homeassistant.components.mycroft -mycroftapi==0.1.2 +mycroftapi==2.0 # homeassistant.components.usps myusps==1.1.3 From 5ba39c849eef9a58a1828ab1e130defb1a15e0fc Mon Sep 17 00:00:00 2001 From: Dan Sarginson Date: Tue, 5 Sep 2017 12:06:28 +0100 Subject: [PATCH 088/108] Fix for Honeywell Round thermostats (#9308) This fixes an issue (#8554) whereby the Honeywell thermostats stopped working after a period of hours or days. We do this by forgetting the authorisation token that was sent back to us when we first logged in, which causes the underlying evohomeclient library to perform the full login procedure again. --- homeassistant/components/climate/honeywell.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 4ff87aa67ab..0b2df903e17 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -196,6 +196,11 @@ class RoundThermostat(ClimateDevice): if val['id'] == self._id: data = val + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + except StopIteration: _LOGGER.error("Did not receive any temperature data from the " "evohomeclient API") From a28ac37a912133e3ee31727808404af8c91c6951 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 5 Sep 2017 17:03:24 +0200 Subject: [PATCH 089/108] Update jinja to 2.9.6 (#9306) * Update jinja 2.10 * Update requirements_all.txt * Update package_constraints.txt * Update package_constraints.txt * Update requirements_all.txt * Update setup.py --- 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 932ed076d3b..43de2a54dbb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 diff --git a/requirements_all.txt b/requirements_all.txt index 2862aadecc0..a8353b431fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,7 +3,7 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 diff --git a/setup.py b/setup.py index d5a6294e3d2..63f77820ca7 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2017.02', 'pip>=8.0.3', - 'jinja2>=2.9.5', + 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', From 968ed6ef5bf2ef35fb2b4236e7f5e286889fd9e6 Mon Sep 17 00:00:00 2001 From: Sean Gollschewsky Date: Tue, 5 Sep 2017 16:11:02 +0100 Subject: [PATCH 090/108] Ensure display-name does not exceed 12 characters for CecAdapter. (#9268) * Ensure display-name does not exceed 12 characters for CecAdapter. * Miscalculated offset. --- homeassistant/components/hdmi_cec.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 9989b2799cd..b4233f1ac82 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -31,7 +31,7 @@ DOMAIN = 'hdmi_cec' _LOGGER = logging.getLogger(__name__) -DEFAULT_DISPLAY_NAME = "HomeAssistant" +DEFAULT_DISPLAY_NAME = "HA" CONF_TYPES = 'types' ICON_UNKNOWN = 'mdi:help' @@ -181,7 +181,7 @@ def setup(hass: HomeAssistant, base_config): if host: adapter = TcpAdapter(host, name=display_name, activate_source=False) else: - adapter = CecAdapter(name=display_name, activate_source=False) + adapter = CecAdapter(name=display_name[:12], activate_source=False) hdmi_network = HDMINetwork(adapter, loop=loop) def _volume(call): From 0b1677de6d14f81ab947eed930f7aa8126dc778d Mon Sep 17 00:00:00 2001 From: Phil Cole Date: Tue, 5 Sep 2017 16:38:12 +0100 Subject: [PATCH 091/108] Expose hue group 0 (#8663) * Tado Fix #8606 Handle case where 'mode' and 'fanSpeed' are missing JSON. Based on changes in commit https://github.com/wmalgadey/tado_component/commit/adfb608f86b8bf4c1c43e71b4067cbfe1de9ba85 * Expose hue group 0 to HA #8652 If allow_hue_groups is set expose "All Hue Lights" group for "special group 0". This does add an additional Hue API call for every refresh (approx 30 secs) to get the status of the special group 0 because it's not included in the full API pull that currently occurs. * Revert "Expose hue group 0 to HA #8652" This reverts commit db7fe47ec72a4907f8a59ebfb47bc4a6dfa41e89. * Expose hue group 0 to HA #8652 If allow_hue_groups is set expose "All Hue Lights" group for "special group 0". This does add an additional Hue API call for every refresh (approx 30 secs) to get the status of the special group 0 because it's not included in the full API pull that currently occurs. * Changes per review by balloob 1) Use all_lights instead of all_lamps 2) Fix line lengths and trailing whitespace 3) Move "All Hue Lights" to GROUP_NAME_ALL_HUE_LIGHTS constant * Make "All Hue Lights" a constant * Fix trailing whitespace --- homeassistant/components/light/hue.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 746c6489c9e..79d80d2b8a0 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -83,6 +83,7 @@ SCENE_SCHEMA = vol.Schema({ }) ATTR_IS_HUE_GROUP = "is_hue_group" +GROUP_NAME_ALL_HUE_LIGHTS = "All Hue Lights" def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): @@ -203,6 +204,21 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, _LOGGER.error("Got unexpected result from Hue API") return + if not skip_groups: + # Group ID 0 is a special group in the hub for all lights, but it + # is not returned by get_api() so explicity get it and include it. + # See https://developers.meethue.com/documentation/ + # groups-api#21_get_all_groups + _LOGGER.debug("Getting group 0 from bridge") + all_lights = bridge.get_group(0) + if not isinstance(all_lights, dict): + _LOGGER.error("Got unexpected result from Hue API for group 0") + return + # Hue hub returns name of group 0 as "Group 0", so rename + # for ease of use in HA. + all_lights['name'] = GROUP_NAME_ALL_HUE_LIGHTS + api_groups["0"] = all_lights + new_lights = [] api_name = api.get('config').get('name') From 9ede0f57e601b341890fd0a92237a47f41e85b7f Mon Sep 17 00:00:00 2001 From: runningman84 Date: Tue, 5 Sep 2017 17:40:47 +0200 Subject: [PATCH 092/108] Added DWD WarnApp Sensor (#8657) * Added DWD WarnApp Sensor * Fixed some idents and spaces * Removed unused imports * Removed comment * Some fixes * Added throttle * Renamed sensor to dwd weather warnings * Renamed test file * shorten lines * shorten lines * Implemented changes requested by fabaff * added ATTRIBUTION * move ATTRIBUTION to existing method * fixed lint tests * Fix linter issues * Fix linter issues * Fix linter * Fixed linter --- .coveragerc | 1 + .../components/sensor/dwd_weather_warnings.py | 243 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 homeassistant/components/sensor/dwd_weather_warnings.py diff --git a/.coveragerc b/.coveragerc index 5e27aed0182..ecf35b8030d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -451,6 +451,7 @@ omit = homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py new file mode 100644 index 00000000000..0eeaa9424e8 --- /dev/null +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -0,0 +1,243 @@ +""" +Support for getting statistical data from a DWD Weather Warnings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dwd_weather_warnings/ + +Data is fetched from DWD: +https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html + +Warnungen vor extremem Unwetter (Stufe 4) +Unwetterwarnungen (Stufe 3) +Warnungen vor markantem Wetter (Stufe 2) +Wetterwarnungen (Stufe 1) +""" +import logging +import json +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor.rest import RestData + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by DWD" + +DEFAULT_NAME = 'DWD-Weather-Warnings' + +CONF_REGION_NAME = 'region_name' + +SCAN_INTERVAL = timedelta(minutes=15) + +MONITORED_CONDITIONS = { + 'current_warning_level': ['Current Warning Level', + None, 'mdi:close-octagon-outline'], + 'advance_warning_level': ['Advance Warning Level', + None, 'mdi:close-octagon-outline'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DWD-Weather-Warnings sensor.""" + name = config.get(CONF_NAME) + region_name = config.get(CONF_REGION_NAME) + + api = DwdWeatherWarningsAPI(region_name) + + sensors = [DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS]] + + add_devices(sensors, True) + + +class DwdWeatherWarningsSensor(Entity): + """Representation of a DWD-Weather-Warnings sensor.""" + + def __init__(self, api, name, variable): + """Initialize a DWD-Weather-Warnings sensor.""" + self._api = api + self._name = name + self._var_id = variable + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable_info[0] + self._var_units = variable_info[1] + self._var_icon = variable_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._name, self._var_name) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._var_icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._var_units + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] + + # pylint: disable=no-member + @property + def device_state_attributes(self): + """Return the state attributes of the DWD-Weather-Warnings.""" + data = { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'region_name': self._api.region_name + } + + if self._api.region_id is not None: + data['region_id'] = self._api.region_id + + if self._api.region_state is not None: + data['region_state'] = self._api.region_state + + if self._api.data['time'] is not None: + data['last_update'] = dt_util.as_local( + dt_util.utc_from_timestamp(self._api.data['time'] / 1000)) + + if self._var_id == 'current_warning_level': + prefix = 'current' + elif self._var_id == 'advance_warning_level': + prefix = 'advance' + else: + raise Exception('Unknown warning type') + + data['warning_count'] = self._api.data[prefix + '_warning_count'] + i = 0 + for event in self._api.data[prefix + '_warnings']: + i = i + 1 + + data['warning_{}_name'.format(i)] = event['event'] + data['warning_{}_level'.format(i)] = event['level'] + data['warning_{}_type'.format(i)] = event['type'] + if len(event['headline']) > 0: + data['warning_{}_headline'.format(i)] = event['headline'] + if len(event['description']) > 0: + data['warning_{}_description'.format(i)] = event['description'] + if len(event['instruction']) > 0: + data['warning_{}_instruction'.format(i)] = event['instruction'] + + if event['start'] is not None: + data['warning_{}_start'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['start'] / 1000)) + + if event['end'] is not None: + data['warning_{}_end'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['end'] / 1000)) + + return data + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the DWD-Weather-Warnings API.""" + self._api.update() + + +class DwdWeatherWarningsAPI(object): + """Get the latest data and update the states.""" + + def __init__(self, region_name): + """Initialize the data object.""" + resource = "{}{}{}?{}".format( + 'https://', + 'www.dwd.de', + '/DWD/warnungen/warnapp_landkreise/json/warnings.json', + 'jsonp=loadWarnings' + ) + + self._rest = RestData('GET', resource, None, None, None, True) + self.region_name = region_name + self.region_id = None + self.region_state = None + self.data = None + self.available = True + self.update() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from the DWD-Weather-Warnings.""" + try: + self._rest.update() + + json_string = self._rest.data[24:len(self._rest.data) - 2] + json_obj = json.loads(json_string) + + data = {'time': json_obj['time']} + + for mykey, myvalue in { + 'current': 'warnings', + 'advance': 'vorabInformation' + }.items(): + + _LOGGER.debug("Found %d %s global DWD warnings", + len(json_obj[myvalue]), mykey) + + data['{}_warning_level'.format(mykey)] = 0 + my_warnings = [] + + if self.region_id is not None: + # get a specific region_id + if self.region_id in json_obj[myvalue]: + my_warnings = json_obj[myvalue][self.region_id] + + else: + # loop through all items to find warnings, region_id + # and region_state for region_name + for key in json_obj[myvalue]: + my_region = json_obj[myvalue][key][0]['regionName'] + if my_region != self.region_name: + continue + my_warnings = json_obj[myvalue][key] + my_state = json_obj[myvalue][key][0]['stateShort'] + self.region_id = key + self.region_state = my_state + break + + # Get max warning level + maxlevel = data['{}_warning_level'.format(mykey)] + for event in my_warnings: + if event['level'] >= maxlevel: + data['{}_warning_level'.format(mykey)] = event['level'] + + data['{}_warning_count'.format(mykey)] = len(my_warnings) + data['{}_warnings'.format(mykey)] = my_warnings + + _LOGGER.debug("Found %d %s local DWD warnings", + len(my_warnings), mykey) + + self.data = data + self.available = True + except TypeError: + _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings") + self.available = False From 552abf7da5ca6fff526e21a31baa64a36d0578d6 Mon Sep 17 00:00:00 2001 From: BioSehnsucht Date: Tue, 5 Sep 2017 11:04:07 -0500 Subject: [PATCH 093/108] Add input_text component (#9112) --- homeassistant/components/input_text.py | 202 +++++++++++++++++++++++++ tests/components/test_input_text.py | 147 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100755 homeassistant/components/input_text.py create mode 100755 tests/components/test_input_text.py diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py new file mode 100755 index 00000000000..d17837b0ced --- /dev/null +++ b/homeassistant/components/input_text.py @@ -0,0 +1,202 @@ +""" +Component to offer a way to enter a value into a text box. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_text/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_text' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_MIN = 'min' +CONF_MAX = 'max' +CONF_DISABLED = 'disabled' + +ATTR_VALUE = 'value' +ATTR_MIN = 'min' +ATTR_MAX = 'max' +ATTR_PATTERN = 'pattern' +ATTR_DISABLED = 'disabled' + +SERVICE_SELECT_VALUE = 'select_value' + +SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): cv.string, +}) + + +def _cv_input_text(cfg): + """Configure validation helper for input box (voluptuous).""" + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + if minimum > maximum: + raise vol.Invalid('Max len ({}) is not greater than min len ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (len(state) < minimum or len(state) > maximum): + raise vol.Invalid('Initial value {} length not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=0): vol.Coerce(int), + vol.Optional(CONF_MAX, default=100): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ''): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_PATTERN): cv.string, + vol.Optional(CONF_DISABLED, default=False): cv.boolean, + }, _cv_input_text) + }) +}, required=True, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def select_value(hass, entity_id, value): + """Set input_text to value.""" + hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input text box.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + pattern = cfg.get(ATTR_PATTERN) + disabled = cfg.get(CONF_DISABLED) + + entities.append(InputText( + object_id, name, initial, minimum, maximum, icon, unit, + pattern, disabled)) + + if not entities: + return False + + @asyncio.coroutine + def async_select_value_service(call): + """Handle a calls to the input box services.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [input_text.async_select_value(call.data[ATTR_VALUE]) + for input_text in target_inputs] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, + schema=SERVICE_SELECT_VALUE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputText(Entity): + """Represent a text box.""" + + def __init__(self, object_id, name, initial, minimum, maximum, icon, + unit, pattern, disabled): + """Initialize a select input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._minimum = minimum + self._maximum = maximum + self._icon = icon + self._unit = unit + self._pattern = pattern + self._disabled = disabled + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input box.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def disabled(self): + """Return the disabled flag.""" + return self._disabled + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_PATTERN: self._pattern, + ATTR_DISABLED: self._disabled, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + if self._current_value is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + value = state and state.state + + # Check against None because value can be 0 + if value is not None and self._minimum <= len(value) <= self._maximum: + self._current_value = value + + @asyncio.coroutine + def async_select_value(self, value): + """Select new value.""" + if len(value) < self._minimum or len(value) > self._maximum: + _LOGGER.warning("Invalid value: %s (length range %s - %s)", + value, self._minimum, self._maximum) + return + self._current_value = value + yield from self.async_update_ha_state() diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py new file mode 100755 index 00000000000..81b1f58aa87 --- /dev/null +++ b/tests/components/test_input_text.py @@ -0,0 +1,147 @@ +"""The tests for the Input text component.""" +# pylint: disable=protected-access +import asyncio +import unittest + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.input_text import (DOMAIN, select_value) + +from tests.common import get_test_home_assistant, mock_restore_cache + + +class TestInputText(unittest.TestCase): + """Test the input slider component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_select_value(self): + """Test select_value method.""" + self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 'test', + 'min': 3, + 'max': 10, + }, + }})) + entity_id = 'input_text.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual('test', str(state.state)) + + select_value(self.hass, entity_id, 'testing') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + select_value(self.hass, entity_id, 'testing too long') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'test'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 10, + }, + 'b2': { + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'unknown' + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'testing'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + 'b2': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'test' + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 100, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'unknown' From e4bb8b044443a38d52414eb98d45571a3677428d Mon Sep 17 00:00:00 2001 From: Jan Almeroth Date: Tue, 5 Sep 2017 18:07:58 +0200 Subject: [PATCH 094/108] Introducing a media_player component for Yamaha Multicast devices (#9258) * Introducing media_player yamaha_multicast * Fix pep8_max_line_length * Revert "Fix pep8_max_line_length" This reverts commit 664c25d6571e2f49f635aea332a848655f220c36. * Revert "Introducing media_player yamaha_multicast" This reverts commit a4fb64b53a79f68966d4af80fe9304d357bcd832. * Introducing media_player for Yamaha MultiCast Devices * Add missing Docstrings * Adding Requirements * Add Geofency device tracker (#9106) * Added Geofency device tracker Added Geofency device tracker * fix pylint error * review fixes * merge coroutines * Version bump * Version bump * D210: No whitespaces allowed surrounding docstring text * Fix linting * Version bump * Revert "Add Geofency device tracker (#9106)" This reverts commit c240d907d2f1fadecf831b3d5bb4e026ce3f892d. * Fix Invalid method names * Fix update_status timer * Fix Invalid class name "mcDevice" * Fix Access to a protected members * Introducing source_list setter * Fix logging * Version bump * D400: First line should end with a period (not 'e') * Removed unnecessary logging * Minor changes Thanks to comments from @andrey-git --- .coveragerc | 1 + .../media_player/yamaha_musiccast.py | 233 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 237 insertions(+) create mode 100644 homeassistant/components/media_player/yamaha_musiccast.py diff --git a/.coveragerc b/.coveragerc index ecf35b8030d..2fc424e91f6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -384,6 +384,7 @@ omit = homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/yamaha_musiccast.py homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py new file mode 100644 index 00000000000..88d17b4d627 --- /dev/null +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -0,0 +1,233 @@ +"""Example for configuration.yaml. + +media_player: + - platform: yamaha_musiccast + name: "Living Room" + host: 192.168.xxx.xx + port: 5005 + +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, + STATE_UNKNOWN, STATE_ON +) +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP +) +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = ( + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | + SUPPORT_SELECT_SOURCE +) + +REQUIREMENTS = ['pymusiccast==0.1.0'] + +DEFAULT_NAME = "Yamaha Receiver" +DEFAULT_PORT = 5005 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Yamaha MusicCast platform.""" + import pymusiccast + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + receiver = pymusiccast.McDevice(host, udp_port=port) + _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + + add_devices([YamahaDevice(receiver, name)], True) + + +class YamahaDevice(MediaPlayerDevice): + """Representation of a Yamaha MusicCast device.""" + + def __init__(self, receiver, name): + """Initialize the Yamaha MusicCast device.""" + self._receiver = receiver + self._name = name + self.power = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self.mute = False + self._source = None + self._source_list = [] + self.status = STATE_UNKNOWN + self.media_status = None + self._receiver.set_yamaha_device(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.power == STATE_ON and self.status is not STATE_UNKNOWN: + return self.status + return self.power + + @property + def should_poll(self): + """Push an update after each command.""" + return True + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.mute + + @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 SUPPORTED_FEATURES + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @source_list.setter + def source_list(self, value): + """Set source_list attribute.""" + self._source_list = value + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.media_status.media_duration \ + if self.media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self.media_status.media_image_url \ + if self.media_status else None + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self.media_status.media_artist if self.media_status else None + + @property + def media_album(self): + """Album of current playing media, music track only.""" + return self.media_status.media_album if self.media_status else None + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self.media_status.media_track if self.media_status else None + + @property + def media_title(self): + """Title of current playing media.""" + return self.media_status.media_title if self.media_status else None + + def update(self): + """Get the latest details from the device.""" + _LOGGER.debug("update: %s", self.entity_id) + + # call from constructor setup_platform() + if not self.entity_id: + _LOGGER.debug("First run") + self._receiver.update_status(push=False) + # call from regular polling + else: + # update_status_timer was set before + if self._receiver.update_status_timer: + _LOGGER.debug( + "is_alive: %s", + self._receiver.update_status_timer.is_alive()) + # e.g. computer was suspended, while hass was running + if not self._receiver.update_status_timer.is_alive(): + _LOGGER.debug("Reinitializing") + self._receiver.update_status() + + def turn_on(self): + """Turn on specified media player or all.""" + _LOGGER.debug("Turn device: on") + self._receiver.set_power(True) + + def turn_off(self): + """Turn off specified media player or all.""" + _LOGGER.debug("Turn device: off") + self._receiver.set_power(False) + + def media_play(self): + """Send the media player the command for play/pause.""" + _LOGGER.debug("Play") + self._receiver.set_playback("play") + + def media_pause(self): + """Send the media player the command for pause.""" + _LOGGER.debug("Pause") + self._receiver.set_playback("pause") + + def media_stop(self): + """Send the media player the stop command.""" + _LOGGER.debug("Stop") + self._receiver.set_playback("stop") + + def media_previous_track(self): + """Send the media player the command for prev track.""" + _LOGGER.debug("Previous") + self._receiver.set_playback("previous") + + def media_next_track(self): + """Send the media player the command for next track.""" + _LOGGER.debug("Next") + self._receiver.set_playback("next") + + def mute_volume(self, mute): + """Send mute command.""" + _LOGGER.debug("Mute volume: %s", mute) + self._receiver.set_mute(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + _LOGGER.debug("Volume level: %.2f / %d", + volume, volume * self.volume_max) + self._receiver.set_volume(volume * self.volume_max) + + def select_source(self, source): + """Send the media player the command to select input source.""" + _LOGGER.debug("select_source: %s", source) + self.status = STATE_UNKNOWN + self._receiver.set_input(source) diff --git a/requirements_all.txt b/requirements_all.txt index a8353b431fd..cad3e01fd70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -660,6 +660,9 @@ pymochad==0.1.1 # homeassistant.components.modbus pymodbus==1.3.1 +# homeassistant.components.media_player.yamaha_musiccast +pymusiccast==0.1.0 + # homeassistant.components.cover.myq pymyq==0.0.8 From 418ccc820a4a4f7338d47226178afd489f827f1d Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Tue, 5 Sep 2017 18:10:01 +0200 Subject: [PATCH 095/108] Handle the case where no registration number is available (instead display VIN (vehicle identification number)). (#9073) --- .../components/device_tracker/volvooncall.py | 5 ++- homeassistant/components/volvooncall.py | 45 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 4312c5dd54a..7872f8f1f1c 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -20,11 +20,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return vin, _ = discovery_info - vehicle = hass.data[DATA_KEY].vehicles[vin] + voc = hass.data[DATA_KEY] + vehicle = voc.vehicles[vin] def see_vehicle(vehicle): """Handle the reporting of the vehicle position.""" - host_name = vehicle.registration_number + host_name = voc.vehicle_name(vehicle) dev_id = 'volvo_{}'.format(slugify(host_name)) see(dev_id=dev_id, host_name=host_name, diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 5903bed1fc7..9c8366e7f7e 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -73,14 +73,7 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - class state: # pylint:disable=invalid-name - """Namespace to hold state for each vehicle.""" - - entities = {} - vehicles = {} - names = config[DOMAIN].get(CONF_NAME) - - hass.data[DATA_KEY] = state + state = hass.data[DATA_KEY] = VolvoData(config) def discover_vehicle(vehicle): """Load relevant platforms.""" @@ -120,6 +113,31 @@ def setup(hass, config): return update(utcnow()) +class VolvoData: + """Hold component state.""" + + def __init__(self, config): + """Initialize the component state.""" + self.entities = {} + self.vehicles = {} + self.names = config[DOMAIN].get(CONF_NAME) + + def vehicle_name(self, vehicle): + """Provide a friendly name for a vehicle.""" + if (vehicle.registration_number and + vehicle.registration_number.lower()) in self.names: + return self.names[vehicle.registration_number.lower()] + elif (vehicle.vin and + vehicle.vin.lower() in self.names): + return self.names[vehicle.vin.lower()] + elif vehicle.registration_number: + return vehicle.registration_number + elif vehicle.vin: + return vehicle.vin + else: + return '' + + class VolvoEntity(Entity): """Base class for all VOC entities.""" @@ -139,17 +157,14 @@ class VolvoEntity(Entity): """Return vehicle.""" return self._state.vehicles[self._vin] - @property - def _vehicle_name(self): - return (self._state.names.get(self._vin.lower()) or - self._state.names.get( - self.vehicle.registration_number.lower()) or - self.vehicle.registration_number) - @property def _entity_name(self): return RESOURCES[self._attribute][1] + @property + def _vehicle_name(self): + return self._state.vehicle_name(self.vehicle) + @property def name(self): """Return full name of the entity.""" From 788275da329c4c62c91602d4f4f9036e4fd29325 Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Tue, 5 Sep 2017 11:26:59 -0700 Subject: [PATCH 096/108] Add post_pending_state attribute to manual alarm_control_panel (#9291) Add post_pending_state attribute to manual alarm_control_panel --- .../components/alarm_control_panel/manual.py | 12 +++++++++++ .../alarm_control_panel/test_manual.py | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index d9cd6d6a9ac..f345ccc4dcd 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -24,6 +24,8 @@ DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +ATTR_POST_PENDING_STATE = 'post_pending_state' + PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, @@ -185,3 +187,13 @@ class ManualAlarm(alarm.AlarmControlPanel): if not check: _LOGGER.warning("Invalid code given for %s", state) return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index 328ae4acd57..063f3361148 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -72,6 +72,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_HOME)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -150,6 +155,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_AWAY)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -228,6 +238,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_NIGHT)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -314,6 +329,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_TRIGGERED)) + future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): From 9ade8002ac2fde685fe647772835621b92a36fb0 Mon Sep 17 00:00:00 2001 From: Konstantin Belyalov Date: Tue, 5 Sep 2017 16:01:03 -0700 Subject: [PATCH 097/108] Add new config variable to MQTT light (#9304) * Add new config variable to MQTT light * Address reviewer's issues: refactor template render part. * Update mqtt.py --- homeassistant/components/light/mqtt.py | 14 ++++++-- tests/components/light/test_mqtt.py | 46 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 038cacd300e..ac72a7052f1 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -39,6 +39,7 @@ CONF_EFFECT_COMMAND_TOPIC = 'effect_command_topic' CONF_EFFECT_LIST = 'effect_list' CONF_EFFECT_STATE_TOPIC = 'effect_state_topic' CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template' +CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template' CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic' CONF_RGB_STATE_TOPIC = 'rgb_state_topic' CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template' @@ -75,6 +76,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template, @@ -125,6 +127,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE), + CONF_RGB_COMMAND_TEMPLATE: config.get(CONF_RGB_COMMAND_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), @@ -397,10 +400,17 @@ class MqttLight(Light): if ATTR_RGB_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] + if tpl: + colors = {'red', 'green', 'blue'} + variables = {key: val for key, val in + zip(colors, kwargs[ATTR_RGB_COLOR])} + rgb_color_str = tpl.async_render(variables) + else: + rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]), self._qos, - self._retain) + rgb_color_str, self._qos, self._retain) if self._optimistic_rgb: self._rgb = kwargs[ATTR_RGB_COLOR] diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 97375aa6b13..e111fc3aa49 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -123,6 +123,20 @@ light: payload_on: "on" payload_off: "off" +config for RGB Version with RGB command template: + +light: + platform: mqtt + name: "Office Light RGB" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + rgb_state_topic: "office/rgb1/rgb/status" + rgb_command_topic: "office/rgb1/rgb/set" + rgb_command_template: "{{ '#%02x%02x%02x' | format(red, green, blue)}}" + qos: 0 + payload_on: "on" + payload_off: "off" + """ import unittest from unittest import mock @@ -512,6 +526,38 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(80, state.attributes['white_value']) self.assertEqual((0.123, 0.123), state.attributes['xy_color']) + def test_sending_mqtt_rgb_command_with_template(self): + """Test the sending of RGB command with template.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'rgb_command_topic': 'test_light_rgb/rgb/set', + 'rgb_command_template': '{{ "#%02x%02x%02x" | ' + 'format(red, green, blue)}}', + 'payload_on': 'on', + 'payload_off': 'off', + 'qos': 0 + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', rgb_color=[255, 255, 255]) + self.hass.block_till_done() + + self.mock_publish().async_publish.assert_has_calls([ + mock.call('test_light_rgb/set', 'on', 0, False), + mock.call('test_light_rgb/rgb/set', '#ffffff', 0, False), + ], any_order=True) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((255, 255, 255), state.attributes['rgb_color']) + def test_show_brightness_if_only_command_topic(self): """Test the brightness if only a command topic is present.""" config = {light.DOMAIN: { From e7a5f7bcdff16eca38727d690c437b6a8d8cffbc Mon Sep 17 00:00:00 2001 From: Mike Christianson Date: Tue, 5 Sep 2017 18:49:40 -0700 Subject: [PATCH 098/108] Follow Twitter guidelines for media upload by conforming to the "STATUS" phase, when required, and by providing "media_category" information. These will, for example, allow users to upload videos that exceed the basic 30 second limit. (#9261) See: - https://twittercommunity.com/t/media-category-values/64781/7 - https://twittercommunity.com/t/duration-too-long-maximim-30000/68760 - https://dev.twitter.com/rest/reference/get/media/upload-status.html --- homeassistant/components/notify/twitter.py | 111 ++++++++++++++++----- 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9d2a8c07932..25e6fc00a2f 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -8,6 +8,8 @@ import json import logging import mimetypes import os +from datetime import timedelta, datetime +from functools import partial import voluptuous as vol @@ -15,6 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +from homeassistant.helpers.event import async_track_point_in_time REQUIREMENTS = ['TwitterAPI==2.4.6'] @@ -68,49 +71,67 @@ class TwitterNotificationService(BaseNotificationService): _LOGGER.warning("'%s' is not a whitelisted directory", media) return - media_id = self.upload_media(media) + callback = partial(self.send_message_callback, message) + self.upload_media_then_callback(callback, media) + + def send_message_callback(self, message, media_id): + """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', - {'text': message, 'user': self.user, + {'user': self.user, + 'text': message, 'media_ids': media_id}) else: resp = self.api.request('statuses/update', - {'status': message, 'media_ids': media_id}) + {'status': message, + 'media_ids': media_id}) if resp.status_code != 200: self.log_error_resp(resp) + else: + _LOGGER.debug("Message posted: %s", resp.json()) - def upload_media(self, media_path=None): + def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: return None + with open(media_path, 'rb') as file: + total_bytes = os.path.getsize(media_path) + (media_category, media_type) = self.media_info(media_path) + resp = self.upload_media_init( + media_type, media_category, total_bytes + ) + + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + media_id = resp.json()['media_id'] + media_id = self.upload_media_chunked(file, total_bytes, media_id) + + resp = self.upload_media_finalize(media_id) + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + self.check_status_until_done(media_id, callback) + + def media_info(self, media_path): + """Determine mime type and Twitter media category for given media.""" (media_type, _) = mimetypes.guess_type(media_path) - total_bytes = os.path.getsize(media_path) + media_category = self.media_category_for_type(media_type) + _LOGGER.debug("media %s is mime type %s and translates to %s", + media_path, media_type, media_category) + return media_category, media_type - file = open(media_path, 'rb') - resp = self.upload_media_init(media_type, total_bytes) - - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - return None - - media_id = resp.json()['media_id'] - media_id = self.upload_media_chunked(file, total_bytes, media_id) - - resp = self.upload_media_finalize(media_id) - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - - return media_id - - def upload_media_init(self, media_type, total_bytes): + def upload_media_init(self, media_type, media_category, total_bytes): """Upload media, INIT phase.""" - resp = self.api.request('media/upload', + return self.api.request('media/upload', {'command': 'INIT', 'media_type': media_type, + 'media_category': media_category, 'total_bytes': total_bytes}) - return resp def upload_media_chunked(self, file, total_bytes, media_id): """Upload media, chunked append.""" @@ -128,17 +149,55 @@ class TwitterNotificationService(BaseNotificationService): return media_id def upload_media_append(self, chunk, media_id, segment_id): - """Upload media, append phase.""" + """Upload media, APPEND phase.""" return self.api.request('media/upload', {'command': 'APPEND', 'media_id': media_id, 'segment_index': segment_id}, {'media': chunk}) def upload_media_finalize(self, media_id): - """Upload media, finalize phase.""" + """Upload media, FINALIZE phase.""" return self.api.request('media/upload', {'command': 'FINALIZE', 'media_id': media_id}) + def check_status_until_done(self, media_id, callback, *args): + """Upload media, STATUS phase.""" + resp = self.api.request('media/upload', + {'command': 'STATUS', 'media_id': media_id}, + method_override='GET') + if resp.status_code != 200: + _LOGGER.error("media processing error: %s", resp.json()) + processing_info = resp.json()['processing_info'] + + _LOGGER.debug("media processing %s status: %s", media_id, + processing_info) + + if processing_info['state'] in {u'succeeded', u'failed'}: + return callback(media_id) + + check_after_secs = processing_info['check_after_secs'] + _LOGGER.debug("media processing waiting %s seconds to check status", + str(check_after_secs)) + + when = datetime.now() + timedelta(seconds=check_after_secs) + myself = partial(self.check_status_until_done, media_id, callback) + async_track_point_in_time(self.hass, myself, when) + + @staticmethod + def media_category_for_type(media_type): + """Determine Twitter media category by mime type.""" + if media_type is None: + return None + + if media_type.startswith('image/gif'): + return 'tweet_gif' + elif media_type.startswith('video/'): + return 'tweet_video' + elif media_type.startswith('image/'): + return 'tweet_image' + + return None + @staticmethod def log_bytes_sent(bytes_sent, total_bytes): """Log upload progress.""" From 5971a7c009b4b7f742ccd2e16d7f1699c7d951b0 Mon Sep 17 00:00:00 2001 From: ohmer1 <1868995+ohmer1@users.noreply.github.com> Date: Wed, 6 Sep 2017 01:58:13 -0400 Subject: [PATCH 099/108] Optionally disable ssl certificate validity check. (#9181) * Optionally disable ssl certificate validity check. * Fix lines too long. * Fix formatting. * Force build CI * Fix "Method could be a function (no-self-use)" --- homeassistant/components/notify/xmpp.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 42c7a3953b9..f93e1b8f426 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -21,12 +21,14 @@ REQUIREMENTS = ['sleekxmpp==1.3.2', _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' +CONF_VERIFY = 'verify' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, + vol.Optional(CONF_VERIFY, default=True): cv.boolean, }) @@ -34,18 +36,20 @@ def get_service(hass, config, discovery_info=None): """Get the Jabber (XMPP) notification service.""" return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), config.get(CONF_TLS)) + config.get(CONF_RECIPIENT), config.get(CONF_TLS), + config.get(CONF_VERIFY)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls): + def __init__(self, sender, password, recipient, tls, verify): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls + self._verify = verify def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -53,10 +57,11 @@ class XmppNotificationService(BaseNotificationService): data = '{}: {}'.format(title, message) if title else message send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, data) + self._recipient, self._tls, self._verify, data) -def send_message(sender, password, recipient, use_tls, message): +def send_message(sender, password, recipient, use_tls, + verify_certificate, message): """Send a message over XMPP.""" import sleekxmpp @@ -73,6 +78,10 @@ def send_message(sender, password, recipient, use_tls, message): self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) self.add_event_handler('session_start', self.start) + if not verify_certificate: + self.add_event_handler('ssl_invalid_cert', + self.discard_ssl_invalid_cert) + self.connect(use_tls=self.use_tls, use_ssl=False) self.process() @@ -87,4 +96,10 @@ def send_message(sender, password, recipient, use_tls, message): """Disconnect from the server if credentials are invalid.""" self.disconnect() + @staticmethod + def discard_ssl_invalid_cert(event): + """Do nothing if ssl certificate is invalid.""" + _LOGGER.info('Ignoring invalid ssl certificate as requested.') + return + SendNotificationBot() From fad914de8c5cae24288513b7c596625a49ed7df3 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Wed, 6 Sep 2017 20:05:34 +0530 Subject: [PATCH 100/108] Version bump dlib to 1.0.0 (#9316) --- homeassistant/components/image_processing/dlib_face_detect.py | 2 +- homeassistant/components/image_processing/dlib_face_identify.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index 6de85f022f3..65705feb7f7 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing import ( from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) -REQUIREMENTS = ['face_recognition==0.2.2'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index 50a7bc846c4..22594aa2547 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['face_recognition==0.2.2'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cad3e01fd70..9114d774234 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ evohomeclient==0.2.5 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify -# face_recognition==0.2.2 +# face_recognition==1.0.0 # homeassistant.components.sensor.fastdotcom fastdotcom==0.0.1 From 894200d87dc0502864c869453ab2769350ef519c Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Wed, 6 Sep 2017 09:11:32 -0700 Subject: [PATCH 101/108] Fixed bug with devices not being discovered correctly. (#9311) --- homeassistant/components/abode.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index c8d4ee67d49..f3283eff748 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -52,7 +52,8 @@ def setup(hass, config): password = conf.get(CONF_PASSWORD) try: - hass.data[DATA_ABODE] = abode = abodepy.Abode(username, password) + hass.data[DATA_ABODE] = abode = abodepy.Abode( + username, password, auto_login=True, get_devices=True) except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) From 9a7089bad30f3fe0b8a05569f723e9ce77d40a60 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 7 Sep 2017 09:01:59 +0200 Subject: [PATCH 102/108] Platform not ready behavior fixed. (#9325) Expose the device model as sensor attribute. Device initialized log message added. Provides device model, firmware and hardware version. --- .../components/light/xiaomi_philipslight.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py index 96d2d7ff9d2..8df25153a73 100644 --- a/homeassistant/components/light/xiaomi_philipslight.py +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -35,6 +35,7 @@ CCT_MIN = 1 CCT_MAX = 100 SUCCESS = ['ok'] +ATTR_MODEL = 'model' # pylint: disable=unused-argument @@ -53,8 +54,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): try: light = Ceil(host, token) + device_info = light.info() + _LOGGER.info("%s %s %s initialized", + device_info.raw['model'], + device_info.raw['fw_ver'], + device_info.raw['hw_ver']) - philips_light = XiaomiPhilipsLight(name, light) + philips_light = XiaomiPhilipsLight(name, light, device_info) hass.data[PLATFORM][host] = philips_light except DeviceException: raise PlatformNotReady @@ -65,15 +71,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiPhilipsLight(Light): """Representation of a Xiaomi Philips Light.""" - def __init__(self, name, light): + def __init__(self, name, light, device_info): """Initialize the light device.""" self._name = name + self._device_info = device_info self._brightness = None self._color_temp = None self._light = light self._state = None + self._state_attrs = { + ATTR_MODEL: self._device_info.raw['model'], + } @property def should_poll(self): @@ -90,6 +100,11 @@ class XiaomiPhilipsLight(Light): """Return true when state is known.""" return self._state is not None + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + @property def is_on(self): """Return true if light is on.""" From 77d0ad1797e39ac694576a2de779dd4eadebe305 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 7 Sep 2017 09:11:55 +0200 Subject: [PATCH 103/108] Stable and asynchronous KNX library. (#8725) * First draft of XKNX module for Home-Assistant * XKNX does now take path of xknx.yaml as parameter * small fix, telegram_received_callback has different signature * changed method of registering callbacks of devices * removed non async command lines from xknx * telegram_received_cb not needed within HASS module * updated requirements * Configuration if XKNX should connect via Routing or Tunneling * bumping version to 0.6.1 * small fix within xknx plugin * bumped version * XKNX-Switches are now BinarySensors and Logic from Sensor was moved to BinarySensor * renamed Outlet to Switch * pylint * configuration of KNX lights via HASS config, yay! * changed name of attribute * Added configuration for xknx to switch component * added support for sensors within hass configuration * added support for climate within hass configuration * Thermostat -> Climate * added configuration support for binary_sensors * renamed Shutter to Cover * added configuration support for cover * restructured file structure according to HASS requirements * pylint * pylint * pylint * pylint * pylint * pylint * updated version * pylint * pylint * pylint * added setpoint support for climate devices * devices are now in a different module * more asyncio :-) * pydocstyle * pydocstyle * added actions to binary_sensor * allow more than one automation * readded requirement * Modifications suggested by hound * Modifications suggested by hound * Modifications suggested by hound * Modifications suggested by hound * xknx now imported as local import * hound *sigh* * lint * 'fixed' coverage. * next try for getting gen_requirements_all.py working * removed blank line * XKNX 0.7.1 with logging functionality, replaced some print() calls with _LOGGER * updated requirements_all.txt * Fixes issue https://github.com/XKNX/xknx/issues/51 * https://github.com/XKNX/xknx/issues/52 added raw access to KNX bus from HASS component. * bumped version - 0.7.3 contains some bugfixes * bumped version - 0.7.3 contains some bugfixes * setting setpoint within climate device has to be async * bumped version to 0.7.4 * bumped version * https://github.com/XKNX/xknx/issues/48 Adding HVAC support. * pylint suggestions * Made target temperature and set point required attributes * renamed value_type to type within sensor configuration * Issue https://github.com/XKNX/xknx/issues/52 : added filter functionality for not flooding the event bus. * suggestions by pylint * Added notify support for knx platform. * logging error if discovery_info is None. * review suggestions by @armills * line too long * Using discovery_info to notifiy component which devices should be added. * moved XKNX automation to main level. * renamed xknx component to knx. * reverted change within .coveragerc * changed dependency * updated docstrings. * updated version of xknx within requirements_all.txt * moved requirement to correct position * renamed configuration attribute * added @callback-decorator and async_prefix. * added @callback decorator and async_ prefix to register_callbacks functions * fixed typo * pylint suggestions * added angle position and invert_position and invert_angle to cover.knx * typo * bumped version within requirements_all.txt * bumped version * Added support for HVAC controller status --- homeassistant/components/binary_sensor/knx.py | 142 +++- homeassistant/components/climate/knx.py | 182 +++-- homeassistant/components/cover/knx.py | 320 +++++---- homeassistant/components/knx.py | 642 ++++++------------ homeassistant/components/light/knx.py | 192 ++++-- homeassistant/components/notify/__init__.py | 13 +- homeassistant/components/notify/knx.py | 99 +++ homeassistant/components/sensor/knx.py | 227 +++---- homeassistant/components/switch/knx.py | 105 ++- requirements_all.txt | 6 +- 10 files changed, 1041 insertions(+), 887 deletions(-) create mode 100644 homeassistant/components/notify/knx.py diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 87f8a30d78c..2b11c3fe172 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -1,21 +1,145 @@ """ -Contains functionality to use a KNX group address as a binary. +Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +import asyncio +import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ + KNXAutomation +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ + BinarySensorDevice +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_DEVICE_CLASS = 'device_class' +CONF_SIGNIFICANT_BIT = 'significant_bit' +CONF_DEFAULT_SIGNIFICANT_BIT = 1 +CONF_AUTOMATION = 'automation' +CONF_HOOK = 'hook' +CONF_DEFAULT_HOOK = 'on' +CONF_COUNTER = 'counter' +CONF_DEFAULT_COUNTER = 1 +CONF_ACTION = 'action' + +CONF__ACTION = 'turn_off_action' + +DEFAULT_NAME = 'KNX Binary Sensor' DEPENDENCIES = ['knx'] +AUTOMATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, + vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, + vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA +}) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX binary sensor platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +AUTOMATIONS_SCHEMA = vol.All( + cv.ensure_list, + [AUTOMATION_SCHEMA] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): + cv.positive_int, + vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, +}) -class KNXSwitch(KNXGroupAddress, BinarySensorDevice): - """Representation of a KNX binary sensor device.""" +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up binary sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False - pass + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up binary sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXBinarySensor(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up binary senor for KNX platform configured within plattform.""" + name = config.get(CONF_NAME) + import xknx + binary_sensor = xknx.devices.BinarySensor( + hass.data[DATA_KNX].xknx, + name=name, + group_address=config.get(CONF_ADDRESS), + device_class=config.get(CONF_DEVICE_CLASS), + significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + hass.data[DATA_KNX].xknx.devices.add(binary_sensor) + + entity = KNXBinarySensor(hass, binary_sensor) + automations = config.get(CONF_AUTOMATION) + if automations is not None: + for automation in automations: + counter = automation.get(CONF_COUNTER) + hook = automation.get(CONF_HOOK) + action = automation.get(CONF_ACTION) + entity.automations.append(KNXAutomation( + hass=hass, device=binary_sensor, hook=hook, + action=action, counter=counter)) + add_devices([entity]) + + +class KNXBinarySensor(BinarySensorDevice): + """Representation of a KNX binary sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXBinarySensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + self.automations = [] + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return self.device.device_class + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.device.is_on() diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e399e2f3dca..688ded5e7c4 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -1,68 +1,136 @@ """ -Support for KNX thermostats. +Support for KNX/IP climate devices. -For more details about this platform, please refer to the documentation +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import logging - +import asyncio import voluptuous as vol -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_ADDRESS = 'address' CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' +CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' +CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' +CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' +CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' +CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ + 'operation_mode_frost_protection_address' +CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' +CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' -DEFAULT_NAME = 'KNX Thermostat' +DEFAULT_NAME = 'KNX Climate' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXThermostat(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up climate(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): - """Representation of a KNX thermostat. +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up climates for KNX platform configured within plattform.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXClimate(hass, device)) + add_devices(entities) - A KNX thermostat will has the following parameters: - - temperature (current temperature) - - setpoint (target temperature in HASS terms) - - operation mode selection (comfort/night/frost protection) - This version supports only polling. Messages from the KNX bus do not - automatically update the state of the thermostat (to be implemented - in future releases) - """ +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up climate for KNX platform configured within plattform.""" + import xknx + climate = xknx.devices.Climate( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_temperature=config.get( + CONF_TEMPERATURE_ADDRESS), + group_address_target_temperature=config.get( + CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_setpoint=config.get( + CONF_SETPOINT_ADDRESS), + group_address_operation_mode=config.get( + CONF_OPERATION_MODE_ADDRESS), + group_address_operation_mode_state=config.get( + CONF_OPERATION_MODE_STATE_ADDRESS), + group_address_controller_status=config.get( + CONF_CONTROLLER_STATUS_ADDRESS), + group_address_controller_status_state=config.get( + CONF_CONTROLLER_STATUS_STATE_ADDRESS), + group_address_operation_mode_protection=config.get( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), + group_address_operation_mode_night=config.get( + CONF_OPERATION_MODE_NIGHT_ADDRESS), + group_address_operation_mode_comfort=config.get( + CONF_OPERATION_MODE_COMFORT_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(climate) + add_devices([KNXClimate(hass, climate)]) - def __init__(self, hass, config): - """Initialize the thermostat based on the given configuration.""" - KNXMultiAddressDevice.__init__( - self, hass, config, ['temperature', 'setpoint'], ['mode']) - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius +class KNXClimate(ClimateDevice): + """Representation of a KNX climate.""" + + def __init__(self, hass, device): + """Initialization of KNXClimate.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + self._unit_of_measurement = TEMP_CELSIUS self._away = False # not yet supported self._is_fan_on = False # not yet supported - self._current_temp = None - self._target_temp = None + + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Return the polling state, is needed for the KNX thermostat.""" - return True + """No polling needed within KNX.""" + return False @property def temperature_unit(self): @@ -72,32 +140,42 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.device.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + if self.device.supports_target_temperature: + return self.device.target_temperature + return None - def set_temperature(self, **kwargs): + @asyncio.coroutine + def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - from knxip.conversion import float_to_knx2 + if self.device.supports_target_temperature: + yield from self.device.set_target_temperature(temperature) - self.set_value('setpoint', float_to_knx2(temperature)) - _LOGGER.debug("Set target temperature to %s", temperature) + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.supports_operation_mode: + return self.device.operation_mode.value + return None - def set_operation_mode(self, operation_mode): + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [operation_mode.value for + operation_mode in + self.device.get_supported_operation_modes()] + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - raise NotImplementedError() - - def update(self): - """Update KNX climate.""" - from knxip.conversion import knx2_to_float - - super().update() - - self._current_temp = knx2_to_float(self.value('temperature')) - self._target_temp = knx2_to_float(self.value('setpoint')) + if self.device.supports_operation_mode: + from xknx.knx import HVACOperationMode + knx_operation_mode = HVACOperationMode(operation_mode) + yield from self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 4883cfe3648..e4c2931983d 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -1,185 +1,239 @@ """ -Support for KNX covers. +Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import logging - +import asyncio import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, - SUPPORT_SET_TILT_POSITION -) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.core import callback +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_GETPOSITION_ADDRESS = 'getposition_address' -CONF_SETPOSITION_ADDRESS = 'setposition_address' -CONF_GETANGLE_ADDRESS = 'getangle_address' -CONF_SETANGLE_ADDRESS = 'setangle_address' -CONF_STOP = 'stop_address' -CONF_UPDOWN = 'updown_address' +CONF_MOVE_LONG_ADDRESS = 'move_long_address' +CONF_MOVE_SHORT_ADDRESS = 'move_short_address' +CONF_POSITION_ADDRESS = 'position_address' +CONF_POSITION_STATE_ADDRESS = 'position_state_address' +CONF_ANGLE_ADDRESS = 'angle_address' +CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' +CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' +CONF_TRAVELLING_TIME_UP = 'travelling_time_up' CONF_INVERT_POSITION = 'invert_position' CONF_INVERT_ANGLE = 'invert_angle' +DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = 'KNX Cover' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_UPDOWN): cv.string, - vol.Required(CONF_STOP): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, + vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, - vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXCover(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up cover(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXCover(KNXMultiAddressDevice, CoverDevice): - """Representation of a KNX cover. e.g. a rollershutter.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up covers for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXCover(hass, device)) + add_devices(entities) - def __init__(self, hass, config): + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up cover for KNX platform configured within plattform.""" + import xknx + cover = xknx.devices.Cover( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), + group_address_position_state=config.get( + CONF_POSITION_STATE_ADDRESS), + group_address_angle=config.get(CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), + group_address_position=config.get(CONF_POSITION_ADDRESS), + travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + + invert_position = config.get(CONF_INVERT_POSITION) + invert_angle = config.get(CONF_INVERT_ANGLE) + hass.data[DATA_KNX].xknx.devices.add(cover) + add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + + +class KNXCover(CoverDevice): + """Representation of a KNX cover.""" + + def __init__(self, hass, device, invert_position=False, + invert_angle=False): """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - ['updown', 'stop'], # required - optional=['setposition', 'getposition', - 'getangle', 'setangle'] - ) - self._device_class = config.config.get(CONF_DEVICE_CLASS) - self._invert_position = config.config.get(CONF_INVERT_POSITION) - self._invert_angle = config.config.get(CONF_INVERT_ANGLE) - self._hass = hass - self._current_pos = None - self._target_pos = None - self._current_tilt = None - self._target_tilt = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP + self.device = device + self.invert_position = invert_position + self.invert_angle = invert_angle + self.hass = hass + self.async_register_callbacks() - # Tilt is only supported, if there is a angle get and set address - if CONF_SETANGLE_ADDRESS in config.config: - _LOGGER.debug("%s: Tilt supported at addresses %s, %s", - self.name, config.config.get(CONF_SETANGLE_ADDRESS), - config.config.get(CONF_GETANGLE_ADDRESS)) - self._supported_features = self._supported_features | \ - SUPPORT_SET_TILT_POSITION + self._unsubscribe_auto_updater = None + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Polling is needed for the KNX cover.""" - return True + """No polling needed within KNX.""" + return False @property def supported_features(self): """Flag supported features.""" - return self._supported_features + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + if self.device.supports_angle: + supported_features |= SUPPORT_SET_TILT_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return int(self.from_knx_position( + self.device.current_position(), + self.invert_position)) @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + return self.device.is_closed() - @property - def current_cover_position(self): - """Return current position of cover. + @asyncio.coroutine + def async_close_cover(self, **kwargs): + """Close the cover.""" + if not self.device.is_closed(): + yield from self.device.set_down() + self.start_auto_updater() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_pos + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Open the cover.""" + if not self.device.is_open(): + yield from self.device.set_up() + self.start_auto_updater() - @property - def target_position(self): - """Return the position we are trying to reach: 0 - 100.""" - return self._target_pos + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + knx_position = self.to_knx_position(position, self.invert_position) + yield from self.device.set_position(knx_position) + self.start_auto_updater() + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Stop the cover.""" + yield from self.device.stop() + self.stop_auto_updater() @property def current_cover_tilt_position(self): - """Return current position of cover. + """Return current tilt position of cover.""" + if not self.device.supports_angle: + return None + return int(self.from_knx_position( + self.device.angle, + self.invert_angle)) - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_tilt + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + position = kwargs[ATTR_TILT_POSITION] + knx_position = self.to_knx_position(position, self.invert_angle) + yield from self.device.set_angle(knx_position) - @property - def target_tilt(self): - """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" - return self._target_tilt + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + if self._unsubscribe_auto_updater is None: + self._unsubscribe_auto_updater = async_track_utc_time_change( + self.hass, self.auto_updater_hook) - def set_cover_position(self, **kwargs): - """Set new target position.""" - position = kwargs.get(ATTR_POSITION) - if position is None: - return + def stop_auto_updater(self): + """Stop the autoupdater.""" + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None - if self._invert_position: - position = 100-position + @callback + def auto_updater_hook(self, now): + """Callback for autoupdater.""" + # pylint: disable=unused-argument + self.hass.async_add_job(self.async_update_ha_state()) + if self.device.position_reached(): + self.stop_auto_updater() - self._target_pos = position - self.set_percentage('setposition', position) - _LOGGER.debug("%s: Set target position to %d", self.name, position) + self.hass.add_job(self.device.auto_stop_if_necessary()) - def update(self): - """Update device state.""" - super().update() - value = self.get_percentage('getposition') - if value is not None: - self._current_pos = value - if self._invert_position: - self._current_pos = 100-value - _LOGGER.debug("%s: position = %d", self.name, value) + @staticmethod + def from_knx_position(raw, invert): + """Convert KNX position [0...255] to hass position [100...0].""" + position = round((raw/256)*100) + if not invert: + position = 100 - position + return position - if self._supported_features & SUPPORT_SET_TILT_POSITION: - value = self.get_percentage('getangle') - if value is not None: - self._current_tilt = value - if self._invert_angle: - self._current_tilt = 100-value - _LOGGER.debug("%s: tilt = %d", self.name, value) - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("%s: open: updown = 0", self.name) - self.set_int_value('updown', 0) - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("%s: open: updown = 1", self.name) - self.set_int_value('updown', 1) - - def stop_cover(self, **kwargs): - """Stop the cover movement.""" - _LOGGER.debug("%s: stop: stop = 1", self.name) - self.set_int_value('stop', 1) - - def set_cover_tilt_position(self, tilt_position, **kwargs): - """Move the cover til to a specific position.""" - if self._invert_angle: - tilt_position = 100-tilt_position - - self._target_tilt = round(tilt_position, -1) - self.set_percentage('setangle', tilt_position) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class + @staticmethod + def to_knx_position(value, invert): + """Convert hass position [100...0] to KNX position [0...255].""" + knx_position = round(value/100*255.4) + if not invert: + knx_position = 255-knx_position + print(value, " -> ", knx_position) + return knx_position diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 9530becb6ce..a5015ff9454 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -1,495 +1,255 @@ """ -Support for KNX components. -For more details about this component, please refer to the documentation at +Connects to KNX platform. + +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ + """ import logging -import os +import asyncio import voluptuous as vol +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) -from homeassistant.helpers.entity import Entity -from homeassistant.config import load_yaml_config_file +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, \ + CONF_HOST, CONF_PORT +from homeassistant.helpers.script import Script -REQUIREMENTS = ['knxip==0.5'] +DOMAIN = "knx" +DATA_KNX = "data_knx" +CONF_KNX_CONFIG = "config_file" + +CONF_KNX_ROUTING = "routing" +CONF_KNX_TUNNELING = "tunneling" +CONF_KNX_LOCAL_IP = "local_ip" +CONF_KNX_FIRE_EVENT = "fire_event" +CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" + +SERVICE_KNX_SEND = "send" +SERVICE_KNX_ATTR_ADDRESS = "address" +SERVICE_KNX_ATTR_PAYLOAD = "payload" + +ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = '0.0.0.0' -DEFAULT_PORT = 3671 -DOMAIN = 'knx' +REQUIREMENTS = ['xknx==0.7.13'] -EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' -EVENT_KNX_FRAME_SEND = 'knx_frame_send' +TUNNELING_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) -KNXTUNNEL = None -KNX_ADDRESS = "address" -KNX_DATA = "data" -KNX_GROUP_WRITE = "group_write" -CONF_LISTEN = "listen" +ROUTING_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_LISTEN, default=[]): - vol.All(cv.ensure_list, [cv.string]), - }), + vol.Optional(CONF_KNX_CONFIG): cv.string, + vol.Exclusive(CONF_KNX_ROUTING, 'connection_type'): ROUTING_SCHEMA, + vol.Exclusive(CONF_KNX_TUNNELING, 'connection_type'): + TUNNELING_SCHEMA, + vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'): + cv.boolean, + vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): + vol.All( + cv.ensure_list, + [cv.string]) + }) }, extra=vol.ALLOW_EXTRA) -KNX_WRITE_SCHEMA = vol.Schema({ - vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), - vol.Required(KNX_DATA): vol.All(cv.ensure_list, [cv.byte]) +SERVICE_KNX_SEND_SCHEMA = vol.Schema({ + vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int]), }) -def setup(hass, config): - """Set up the connection to the KNX IP interface.""" - global KNXTUNNEL - - from knxip.ip import KNXIPTunnel - from knxip.core import KNXException, parse_group_address - - host = config[DOMAIN].get(CONF_HOST) - port = config[DOMAIN].get(CONF_PORT) - - if host == '0.0.0.0': - _LOGGER.debug("Will try to auto-detect KNX/IP gateway") - - KNXTUNNEL = KNXIPTunnel(host, port) +@asyncio.coroutine +def async_setup(hass, config): + """Set up knx component.""" + from xknx.exceptions import XKNXException try: - res = KNXTUNNEL.connect() - _LOGGER.debug("Res = %s", res) - if not res: - _LOGGER.error("Could not connect to KNX/IP interface %s", host) - return False + hass.data[DATA_KNX] = KNXModule(hass, config) + yield from hass.data[DATA_KNX].start() - except KNXException as ex: - _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex) - KNXTUNNEL = None + except XKNXException as ex: + _LOGGER.exception("Can't connect to KNX interface: %s", ex) return False - _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) + for component, discovery_type in ( + ('switch', 'Switch'), + ('climate', 'Climate'), + ('cover', 'Cover'), + ('light', 'Light'), + ('sensor', 'Sensor'), + ('binary_sensor', 'BinarySensor'), + ('notify', 'Notification')): + found_devices = _get_devices(hass, discovery_type) + hass.async_add_job( + discovery.async_load_platform(hass, component, DOMAIN, { + ATTR_DISCOVER_DEVICES: found_devices + }, config)) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - def received_knx_event(address, data): - """Process received KNX message.""" - if len(data) == 1: - data = data[0] - hass.bus.fire('knx_event', { - 'address': address, - 'data': data - }) - - for listen in config[DOMAIN].get(CONF_LISTEN): - _LOGGER.debug("Registering listener for %s", listen) - try: - KNXTUNNEL.register_listener(parse_group_address(listen), - received_knx_event) - except KNXException as knxexception: - _LOGGER.error("Can't register KNX listener for address %s (%s)", - listen, knxexception) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) - - # Listen to KNX events and send them to the bus - def handle_group_write(call): - """Bridge knx_frame_send events to the KNX bus.""" - # parameters are pre-validated using KNX_WRITE_SCHEMA - addrlist = call.data.get("address") - knxdata = call.data.get("data") - - knxaddrlist = [] - for addr in addrlist: - try: - _LOGGER.debug("Found %s", addr) - knxaddr = int(addr) - except ValueError: - knxaddr = None - - if knxaddr is None: - try: - knxaddr = parse_group_address(addr) - except KNXException: - _LOGGER.error("KNX address format incorrect: %s", addr) - - knxaddrlist.append(knxaddr) - - for addr in knxaddrlist: - KNXTUNNEL.group_write(addr, knxdata) - - # Listen for when knx_frame_send event is fired - hass.services.register(DOMAIN, - KNX_GROUP_WRITE, - handle_group_write, - descriptions[DOMAIN][KNX_GROUP_WRITE], - schema=KNX_WRITE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_KNX_SEND, + hass.data[DATA_KNX].service_send_to_knx_bus, + schema=SERVICE_KNX_SEND_SCHEMA) return True -def close_tunnel(_data): - """Close the NKX tunnel connection on shutdown.""" - global KNXTUNNEL - - KNXTUNNEL.disconnect() - KNXTUNNEL = None +def _get_devices(hass, discovery_type): + return list( + map(lambda device: device.name, + filter( + lambda device: type(device).__name__ == discovery_type, + hass.data[DATA_KNX].xknx.devices))) -class KNXConfig(object): - """Handle the fetching of configuration from the config file.""" - - def __init__(self, config): - """Initialize the configuration.""" - from knxip.core import parse_group_address - - self.config = config - self.should_poll = config.get('poll', True) - if config.get('address'): - self._address = parse_group_address(config.get('address')) - else: - self._address = None - if self.config.get('state_address'): - self._state_address = parse_group_address( - self.config.get('state_address')) - else: - self._state_address = None - - @property - def name(self): - """Return the name given to the entity.""" - return self.config['name'] - - @property - def address(self): - """Return the address of the device as an integer value. - - 3 types of addresses are supported: - integer - 0-65535 - 2 level - a/b - 3 level - a/b/c - """ - return self._address - - @property - def state_address(self): - """Return the group address the device sends its current state to. - - Some KNX devices can send the current state to a seperate - group address. This makes send e.g. when an actuator can - be switched but also have a timer functionality. - """ - return self._state_address - - -class KNXGroupAddress(Entity): - """Representation of devices connected to a KNX group address.""" +class KNXModule(object): + """Representation of KNX Object.""" def __init__(self, hass, config): - """Initialize the device.""" - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "Initalizing KNX group address for %s (%s)", - self.name, self.address - ) + """Initialization of KNXModule.""" + self.hass = hass + self.config = config + self.initialized = False + self.init_xknx() + self.register_callbacks() - def handle_knx_message(addr, data): - """Handle an incoming KNX frame. + def init_xknx(self): + """Initialization of KNX object.""" + from xknx import XKNX + self.xknx = XKNX( + config=self.config_file(), + loop=self.hass.loop) - Handle an incoming frame and update our status if it contains - information relating to this device. - """ - if (addr == self.state_address) or (addr == self.address): - self._state = data[0] - self.schedule_update_ha_state() + @asyncio.coroutine + def start(self): + """Start KNX object. Connect to tunneling or Routing device.""" + connection_config = self.connection_config() + yield from self.xknx.start( + state_updater=True, + connection_config=connection_config) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + self.initialized = True - KNXTUNNEL.register_listener(self.address, handle_knx_message) - if self.state_address: - KNXTUNNEL.register_listener(self.state_address, handle_knx_message) + @asyncio.coroutine + def stop(self, event): + """Stop KNX object. Disconnect from tunneling or Routing device.""" + yield from self.xknx.stop() - @property - def name(self): - """Return the entity's display name.""" - return self._config.name + def config_file(self): + """Resolve and return the full path of xknx.yaml if configured.""" + config_file = self.config[DOMAIN].get(CONF_KNX_CONFIG) + if not config_file: + return None + if not config_file.startswith("/"): + return self.hass.config.path(config_file) + return config_file - @property - def config(self): - """Return the entity's configuration.""" - return self._config + def connection_config(self): + """Return the connection_config.""" + if CONF_KNX_TUNNELING in self.config[DOMAIN]: + return self.connection_config_tunneling() + elif CONF_KNX_ROUTING in self.config[DOMAIN]: + return self.connection_config_routing() + return self.connection_config_auto() - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll + def connection_config_routing(self): + """Return the connection_config if routing is configured.""" + from xknx.io import ConnectionConfig, ConnectionType + local_ip = \ + self.config[DOMAIN][CONF_KNX_ROUTING].get(CONF_KNX_LOCAL_IP) + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + local_ip=local_ip) - @property - def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + def connection_config_tunneling(self): + """Return the connection_config if tunneling is configured.""" + from xknx.io import ConnectionConfig, ConnectionType, \ + DEFAULT_MCAST_PORT + gateway_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST) + gateway_port = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT) + local_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP) + if gateway_port is None: + gateway_port = DEFAULT_MCAST_PORT + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=gateway_ip, + gateway_port=gateway_port, + local_ip=local_ip) - @property - def address(self): - """Return the KNX group address.""" - return self._config.address + def connection_config_auto(self): + """Return the connection_config if auto is configured.""" + # pylint: disable=no-self-use + from xknx.io import ConnectionConfig + return ConnectionConfig() - @property - def state_address(self): - """Return the KNX group address.""" - return self._config.state_address + def register_callbacks(self): + """Register callbacks within XKNX object.""" + if CONF_KNX_FIRE_EVENT in self.config[DOMAIN] and \ + self.config[DOMAIN][CONF_KNX_FIRE_EVENT]: + from xknx.knx import AddressFilter + address_filters = list(map( + AddressFilter, + self.config[DOMAIN][CONF_KNX_FIRE_EVENT_FILTER])) + self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, address_filters) - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def group_write(self, value): - """Write to the group address.""" - KNXTUNNEL.group_write(self.address, [value]) - - def update(self): - """Get the state from KNX bus or cache.""" - from knxip.core import KNXException - - try: - if self.state_address: - res = KNXTUNNEL.group_read( - self.state_address, use_cache=self.cache) - else: - res = KNXTUNNEL.group_read(self.address, use_cache=self.cache) - - if res: - self._state = res[0] - self._data = res - else: - _LOGGER.debug( - "%s: unable to read from KNX address: %s (None)", - self.name, self.address - ) - - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, self.address - ) - return False - - -class KNXMultiAddressDevice(Entity): - """Representation of devices connected to a multiple KNX group address. - - This is needed for devices like dimmers or shutter actuators as they have - to be controlled by multiple group addresses. - """ - - def __init__(self, hass, config, required, optional=None): - """Initialize the device. - - The namelist argument lists the required addresses. E.g. for a dimming - actuators, the namelist might look like: - onoff_address: 0/0/1 - brightness_address: 0/0/2 - """ - from knxip.core import parse_group_address, KNXException - - self.names = {} - self.values = {} - - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "%s: initalizing KNX multi address device", - self.name - ) - - settings = self._config.config - if config.address: - _LOGGER.debug( - "%s: base address: address=%s", - self.name, settings.get('address') - ) - self.names[config.address] = 'base' - if config.state_address: - _LOGGER.debug( - "%s, state address: state_address=%s", - self.name, settings.get('state_address') - ) - self.names[config.state_address] = 'state' - - # parse required addresses - for name in required: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - if addr is None: - _LOGGER.error( - "%s: Required KNX group address %s missing", - self.name, paramname - ) - raise KNXException( - "%s: Group address for {} missing in " - "configuration for {}".format( - self.name, paramname - ) - ) - _LOGGER.debug( - "%s: (required parameter) %s=%s", - self.name, paramname, addr - ) - addr = parse_group_address(addr) - self.names[addr] = name - - # parse optional addresses - for name in optional: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - _LOGGER.debug( - "%s: (optional parameter) %s=%s", - self.name, paramname, addr - ) - if addr: - try: - addr = parse_group_address(addr) - except KNXException: - _LOGGER.exception( - "%s: cannot parse group address %s", - self.name, addr - ) - self.names[addr] = name - - @property - def name(self): - """Return the entity's display name.""" - return self._config.name - - @property - def config(self): - """Return the entity's configuration.""" - return self._config - - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll - - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def has_attribute(self, name): - """Check if the attribute with the given name is defined. - - This is mostly important for optional addresses. - """ - for attributename in self.names.values(): - if attributename == name: - return True + @asyncio.coroutine + def telegram_received_cb(self, telegram): + """Callback invoked after a KNX telegram was received.""" + self.hass.bus.fire('knx_event', { + 'address': telegram.group_address.str(), + 'data': telegram.payload.value + }) + # False signals XKNX to proceed with processing telegrams. return False - def set_percentage(self, name, percentage): - """Set a percentage in knx for a given attribute. + @asyncio.coroutine + def service_send_to_knx_bus(self, call): + """Service for sending an arbitray KNX message to the KNX bus.""" + from xknx.knx import Telegram, Address, DPTBinary, DPTArray + attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) + attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - percentage = abs(percentage) # only accept positive values - scaled_value = percentage * 255 / 100 - value = min(255, scaled_value) - return self.set_int_value(name, value) + def calculate_payload(attr_payload): + """Calculate payload depending on type of attribute.""" + if isinstance(attr_payload, int): + return DPTBinary(attr_payload) + return DPTArray(attr_payload) + payload = calculate_payload(attr_payload) + address = Address(attr_address) - def get_percentage(self, name): - """Get a percentage from knx for a given attribute. + telegram = Telegram() + telegram.payload = payload + telegram.group_address = address + yield from self.xknx.telegrams.put(telegram) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - value = self.get_int_value(name) - percentage = round(value * 100 / 255) - return percentage - def set_int_value(self, name, value, num_bytes=1): - """Set an integer value for a given attribute.""" - # KNX packets are big endian - value = round(value) # only accept integers - b_value = value.to_bytes(num_bytes, byteorder='big') - return self.set_value(name, list(b_value)) +class KNXAutomation(): + """Wrapper around xknx.devices.ActionCallback object..""" - def get_int_value(self, name): - """Get an integer value for a given attribute.""" - # KNX packets are big endian - summed_value = 0 - raw_value = self.value(name) - try: - # convert raw value in bytes - for val in raw_value: - summed_value *= 256 - summed_value += val - except TypeError: - # pknx returns a non-iterable type for unsuccessful reads - pass + def __init__(self, hass, device, hook, action, counter=1): + """Initialize Automation class.""" + self.hass = hass + self.device = device + script_name = "{} turn ON script".format(device.get_name()) + self.script = Script(hass, action, script_name) - return summed_value - - def value(self, name): - """Return the value to a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - res = KNXTUNNEL.group_read(addr, use_cache=self.cache) - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, addr - ) - return False - - return res - - def set_value(self, name, value): - """Set the value of a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - KNXTUNNEL.group_write(addr, value) - except KNXException: - _LOGGER.exception( - "%s: unable to write to KNX address: %s", - self.name, addr - ) - return False - - return True + import xknx + self.action = xknx.devices.ActionCallback( + hass.data[DATA_KNX].xknx, + self.script.async_run, + hook=hook, + counter=counter) + device.actions.append(self.action) diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index d89d45e99a7..62261944feb 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -1,17 +1,17 @@ """ -Support KNX Lighting actuators. +Support for KNX/IP lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/Light.knx/ +https://home-assistant.io/components/light.knx/ """ -import logging +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.components.light import (Light, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, - ATTR_BRIGHTNESS) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.light import PLATFORM_SCHEMA, Light, \ + SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -19,8 +19,6 @@ CONF_STATE_ADDRESS = 'state_address' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -33,84 +31,136 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX light platform.""" - add_devices([KNXLight(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up light(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXLight(KNXMultiAddressDevice, Light): - """Representation of a KNX Light device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up lights for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXLight(hass, device)) + add_devices(entities) - def __init__(self, hass, config): - """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - [], # required - optional=['state', 'brightness', 'brightness_state'] - ) - self._hass = hass - self._supported_features = 0 - if CONF_BRIGHTNESS_ADDRESS in config.config: - _LOGGER.debug("%s is dimmable", self.name) - self._supported_features = self._supported_features | \ - SUPPORT_BRIGHTNESS - self._brightness = None +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up light for KNX platform configured within plattform.""" + import xknx + light = xknx.devices.Light( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_switch=config.get(CONF_ADDRESS), + group_address_switch_state=config.get(CONF_STATE_ADDRESS), + group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), + group_address_brightness_state=config.get( + CONF_BRIGHTNESS_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(light) + add_devices([KNXLight(hass, light)]) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn on", self.name) - self.set_value('base', [1]) - self._state = 1 +class KNXLight(Light): + """Representation of a KNX light.""" - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", - self.name, self._brightness) - assert self._brightness <= 255 - self.set_value("brightness", [self._brightness]) + def __init__(self, hass, device): + """Initialization of KNXLight.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - if not self.should_poll: - self.schedule_update_ha_state() + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) - def turn_off(self, **kwargs): - """Turn the switch off. + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn off", self.name) - self.set_value('base', [0]) - self._state = 0 - if not self.should_poll: - self.schedule_update_ha_state() + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self.device.brightness \ + if self.device.supports_dimming else \ + None + + @property + def xy_color(self): + """Return the XY color value [float, float].""" + return None + + @property + def rgb_color(self): + """Return the RBG color value.""" + return None + + @property + def color_temp(self): + """Return the CT color temperature.""" + return None + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return None + + @property + def effect_list(self): + """Return the list of supported effects.""" + return None + + @property + def effect(self): + """Return the current effect.""" + return None @property def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + """Return true if light is on.""" + return self.device.state @property def supported_features(self): """Flag supported features.""" - return self._supported_features + flags = 0 + if self.device.supports_dimming: + flags |= SUPPORT_BRIGHTNESS + return flags - def update(self): - """Update device state.""" - super().update() - if self.has_attribute('brightness_state'): - value = self.value('brightness_state') - if value is not None: - self._brightness = int.from_bytes(value, byteorder='little') - _LOGGER.debug("%s: brightness = %d", - self.name, self._brightness) + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: + yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + else: + yield from self.device.set_on() - if self.has_attribute('state'): - self._state = self.value("state")[0] - _LOGGER.debug("%s: state = %d", self.name, self._state) - - def should_poll(self): - """No polling needed for a KNX light.""" - return False + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 1c17d1a795a..9496ff1d596 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -82,8 +82,6 @@ def async_setup(hass, config): """Set up a notify platform.""" if p_config is None: p_config = {} - if discovery_info is None: - discovery_info = {} platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -105,8 +103,12 @@ def async_setup(hass, config): raise HomeAssistantError("Invalid notify platform.") if notify_service is None: - _LOGGER.error( - "Failed to initialize notification service %s", p_type) + # Platforms can decide not to create a service based + # on discovery data. + if discovery_info is None: + _LOGGER.error( + "Failed to initialize notification service %s", + p_type) return except Exception: # pylint: disable=broad-except @@ -115,6 +117,9 @@ def async_setup(hass, config): notify_service.hass = hass + if discovery_info is None: + discovery_info = {} + @asyncio.coroutine def async_notify_message(service): """Handle sending notification message service calls.""" diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py new file mode 100644 index 00000000000..c5dbcb0d4ad --- /dev/null +++ b/homeassistant/components/notify/knx.py @@ -0,0 +1,99 @@ +""" +KNX/IP notification service. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/notify.knx/ +""" +import asyncio +import voluptuous as vol + +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.notify import PLATFORM_SCHEMA, \ + BaseNotificationService +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +DEFAULT_NAME = 'KNX Notify' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the KNX notification service.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + return async_get_service_discovery(hass, discovery_info) \ + if discovery_info is not None else \ + async_get_service_config(hass, config) + + +@callback +def async_get_service_discovery(hass, discovery_info): + """Set up notifications for KNX platform configured via xknx.yaml.""" + notification_devices = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + notification_devices.append(device) + return \ + KNXNotificationService(hass, notification_devices) \ + if notification_devices else \ + None + + +@callback +def async_get_service_config(hass, config): + """Set up notification for KNX platform configured within plattform.""" + import xknx + notification = xknx.devices.Notification( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(notification) + return KNXNotificationService(hass, [notification, ]) + + +class KNXNotificationService(BaseNotificationService): + """Implement demo notification service.""" + + def __init__(self, hass, devices): + """Initialize the service.""" + self.hass = hass + self.devices = devices + + @property + def targets(self): + """Return a dictionary of registered targets.""" + ret = {} + for device in self.devices: + ret[device.name] = device.name + return ret + + @asyncio.coroutine + def async_send_message(self, message="", **kwargs): + """Send a notification to knx bus.""" + if "target" in kwargs: + yield from self._async_send_to_device(message, kwargs["target"]) + else: + yield from self._async_send_to_all_devices(message) + + @asyncio.coroutine + def _async_send_to_all_devices(self, message): + """Send a notification to knx bus to all connected devices.""" + for device in self.devices: + yield from device.set(message) + + @asyncio.coroutine + def _async_send_to_device(self, message, names): + """Send a notification to knx bus to device with given names.""" + for device in self.devices: + if device.name in names: + yield from device.set(message) diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 80a88ca925a..60f11d76e79 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -1,184 +1,111 @@ """ -Sensors of a KNX Device. +Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/knx/ +https://home-assistant.io/components/sensor.knx/ """ -from enum import Enum - -import logging +import asyncio import voluptuous as vol -from homeassistant.const import ( - CONF_NAME, CONF_MAXIMUM, CONF_MINIMUM, - CONF_TYPE, TEMP_CELSIUS -) -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +CONF_ADDRESS = 'address' +CONF_TYPE = 'type' +DEFAULT_NAME = 'KNX Sensor' DEPENDENCIES = ['knx'] -DEFAULT_NAME = "KNX sensor" - -CONF_TEMPERATURE = 'temperature' -CONF_ADDRESS = 'address' -CONF_ILLUMINANCE = 'illuminance' -CONF_PERCENTAGE = 'percentage' -CONF_SPEED_MS = 'speed_ms' - - -class KNXAddressType(Enum): - """Enum to indicate conversion type for the KNX address.""" - - FLOAT = 1 - PERCENT = 2 - - -# define the fixed settings required for each sensor type -FIXED_SETTINGS_MAP = { - # Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp - CONF_TEMPERATURE: { - 'unit': TEMP_CELSIUS, - 'default_minimum': -273, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp - CONF_SPEED_MS: { - 'unit': 'm/s', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Luminance(LUX) as defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux - CONF_ILLUMINANCE: { - 'unit': 'lx', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Percentage(%) as defined in KNX Standard 3.10 - 5.001 DPT_Scaling - CONF_PERCENTAGE: { - 'unit': '%', - 'default_minimum': 0, - 'default_maximum': 100, - 'address_type': KNXAddressType.PERCENT - } -} - -SENSOR_TYPES = set(FIXED_SETTINGS_MAP.keys()) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MINIMUM): vol.Coerce(float), - vol.Optional(CONF_MAXIMUM): vol.Coerce(float) + vol.Optional(CONF_TYPE): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX Sensor platform.""" - add_devices([KNXSensor(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXSensor(KNXGroupAddress): - """Representation of a KNX Sensor device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSensor(hass, device)) + add_devices(entities) - def __init__(self, hass, config): - """Initialize a KNX Float Sensor.""" - # set up the KNX Group address - KNXGroupAddress.__init__(self, hass, config) - device_type = config.config.get(CONF_TYPE) - sensor_config = FIXED_SETTINGS_MAP.get(device_type) +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up sensor for KNX platform configured within plattform.""" + import xknx + sensor = xknx.devices.Sensor( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + value_type=config.get(CONF_TYPE)) + hass.data[DATA_KNX].xknx.devices.add(sensor) + add_devices([KNXSensor(hass, sensor)]) - if not sensor_config: - raise NotImplementedError() - # set up the conversion function based on the address type - address_type = sensor_config.get('address_type') - if address_type == KNXAddressType.FLOAT: - self.convert = convert_float - elif address_type == KNXAddressType.PERCENT: - self.convert = convert_percent - else: - raise NotImplementedError() +class KNXSensor(Entity): + """Representation of a KNX sensor.""" - # other settings - self._unit_of_measurement = sensor_config.get('unit') - default_min = float(sensor_config.get('default_minimum')) - default_max = float(sensor_config.get('default_maximum')) - self._minimum_value = config.config.get(CONF_MINIMUM, default_min) - self._maximum_value = config.config.get(CONF_MAXIMUM, default_max) - _LOGGER.debug( - "%s: configured additional settings: unit=%s, " - "min=%f, max=%f, type=%s", - self.name, self._unit_of_measurement, - self._minimum_value, self._maximum_value, str(address_type) - ) + def __init__(self, hass, device): + """Initialization of KNXSensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - self._value = None + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False @property def state(self): - """Return the Value of the KNX Sensor.""" - return self._value + """Return the state of the sensor.""" + return self.device.resolve_state() @property def unit_of_measurement(self): - """Return the defined Unit of Measurement for the KNX Sensor.""" - return self._unit_of_measurement - - def update(self): - """Update KNX sensor.""" - super().update() - - self._value = None - - if self._data: - if self._data == 0: - value = 0 - else: - value = self.convert(self._data) - if self._minimum_value <= value <= self._maximum_value: - self._value = value + """Return the unit this state is expressed in.""" + return self.device.unit_of_measurement() @property - def cache(self): - """We don't want to cache any Sensor Value.""" - return False - - -def convert_float(raw_value): - """Conversion for 2 byte floating point values. - - 2byte Floating Point KNX Telegram. - Defined in KNX 3.7.2 - 3.10 - """ - from knxip.conversion import knx2_to_float - from knxip.core import KNXException - - try: - return knx2_to_float(raw_value) - except KNXException as exception: - _LOGGER.error("Can't convert %s to float (%s)", raw_value, exception) - - -def convert_percent(raw_value): - """Conversion for scaled byte values. - - 1byte percentage scaled KNX Telegram. - Defined in KNX 3.7.2 - 3.10. - """ - value = 0 - try: - value = raw_value[0] - except (IndexError, ValueError): - # pknx returns a non-iterable type for unsuccessful reads - _LOGGER.error("Can't convert %s to percent value", raw_value) - - return round(value * 100 / 255) + def device_state_attributes(self): + """Return the state attributes.""" + return None diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index d07df08ed5c..90b04239086 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -1,14 +1,16 @@ """ -Support KNX switching actuators. +Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -24,30 +26,85 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX switch platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up switch(es) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXSwitch(KNXGroupAddress, SwitchDevice): - """Representation of a KNX switch device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up switches for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSwitch(hass, device)) + add_devices(entities) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 0 to the group address of the device - """ - self.group_write(1) - self._state = [1] - if not self.should_poll: - self.schedule_update_ha_state() +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up switch for KNX platform configured within plattform.""" + import xknx + switch = xknx.devices.Switch( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + group_address_state=config.get(CONF_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(switch) + add_devices([KNXSwitch(hass, switch)]) - def turn_off(self, **kwargs): - """Turn the switch off. - This sends a value 1 to the group address of the device - """ - self.group_write(0) - self._state = [0] - if not self.should_poll: - self.schedule_update_ha_state() +class KNXSwitch(SwitchDevice): + """Representation of a KNX switch.""" + + def __init__(self, hass, device): + """Initialization of KNXSwitch.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self.device.state + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + yield from self.device.set_on() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + yield from self.device.set_off() diff --git a/requirements_all.txt b/requirements_all.txt index 9114d774234..1bafef96fba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -362,9 +362,6 @@ jsonrpc-websocket==0.5 # homeassistant.scripts.keyring keyring>=9.3,<10.0 -# homeassistant.components.knx -knxip==0.5 - # homeassistant.components.device_tracker.owntracks libnacl==1.5.2 @@ -1012,6 +1009,9 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.knx +xknx==0.7.13 + # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 From 7036a7845c97b122a48000cbd9982c5ec082f40b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Sep 2017 23:08:38 -0700 Subject: [PATCH 104/108] Update frontend --- homeassistant/components/frontend/version.py | 4 ++-- .../frontend/www_static/frontend.html | 8 ++++---- .../frontend/www_static/frontend.html.gz | Bin 167216 -> 167890 bytes .../www_static/home-assistant-polymer | 2 +- .../www_static/panels/ha-panel-config.html | 2 +- .../www_static/panels/ha-panel-config.html.gz | Bin 32839 -> 32428 bytes .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 5139 -> 5136 bytes 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 54d9ffda6c5..21215e14d23 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "3ce24a1e0bc1c6620373f38a2d11b359", + "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "37803526cb203a8f1eaacd528fb2c7b3", + "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 02063e4df3e..d6a15a0d610 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;} \ No newline at end of file + ha-script-editor{height:100%;} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 335c067e4b5e797fe65c72dd74463b0875e0733b..08a7f5002cd0f9d15d61a5e673e4c0d16885f183 100644 GIT binary patch delta 5781 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 delta 837 zcmV-L1G@Z>D3d6#fdqd;dm@F+EpKGcxIM8$&vkvlCsPs3+|ZYYAA10V*%L~e!zE8- zoWn$SeWjzm7+Id2a)0bjg%`?+VsTg&?S|a7#c0eYBRR3gJ`W8gE~mno z_;xT0T-WkK;Rcg_1z~?3_?GR?EXyB_@T(vAGZ8o)f;M3 zN?b5e=+9=M?f5c4_HjeYf`uS7g4}jS-^2BX6wzu?A`5?|?aGloMYMW>6C(5{V;FQg zo%%uo_xF%p$@wbgS&5AYZaDQkSOBp;w!KNneS6Fi2XZ!Y?VyP*Lfw%MLzJrcrEh!A zJ^mjolR25x_B_k=k*6Fm#NmK53ljR$vuxX*j`!jj;18^)7#SnqPeE0ZSQ4qmmyRDy zyh#wYR1_;MXv-f4JQJT5P!0ctxPOq@Cx2dY!`+cHvxSYk2KRR1f|HR0Ry@~|@FTMX P2m}lWfbbiX12q5uxuS Date: Thu, 7 Sep 2017 19:20:27 +0300 Subject: [PATCH 105/108] Fix for potential issue with tesla initialization (#9307) Fix for potential issue with tesla initialization --- homeassistant/components/tesla.py | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py index e48d805abab..08006310dc7 100644 --- a/homeassistant/components/tesla.py +++ b/homeassistant/components/tesla.py @@ -5,7 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tesla/ """ from collections import defaultdict +import logging +from urllib.error import HTTPError import voluptuous as vol from homeassistant.const import ( @@ -19,6 +21,8 @@ REQUIREMENTS = ['teslajsonpy==0.0.11'] DOMAIN = 'tesla' +_LOGGER = logging.getLogger(__name__) + TESLA_ID_FORMAT = '{}_{}' TESLA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -31,6 +35,9 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +NOTIFICATION_ID = 'tesla_integration_notification' +NOTIFICATION_TITLE = 'Tesla integration setup' + TESLA_COMPONENTS = [ 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' ] @@ -46,10 +53,31 @@ def setup(hass, base_config): password = config.get(CONF_PASSWORD) update_interval = config.get(CONF_SCAN_INTERVAL) if hass.data.get(DOMAIN) is None: - hass.data[DOMAIN] = { - 'controller': teslaApi(email, password, update_interval), - 'devices': defaultdict(list) - } + try: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + _LOGGER.debug("Connected to the Tesla API.") + except HTTPError as ex: + if ex.code == 401: + hass.components.persistent_notification.create( + "Error:
Please check username and password." + "You will need to restart Home Assistant after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + else: + hass.components.persistent_notification.create( + "Error:
Can't communicate with Tesla API.
" + "Error code: {} Reason: {}" + "You will need to restart Home Assistant after fixing." + "".format(ex.code, ex.reason), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + _LOGGER.error("Unable to communicate with Tesla API: %s", + ex.reason) + + return False all_devices = hass.data[DOMAIN]['controller'].list_vehicles() From c539b5c12bfb3f1513a3d1e26962b7a61b50eb0e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 8 Sep 2017 08:05:51 -0600 Subject: [PATCH 106/108] Adds the AirVisual air quality sensor platform (#9320) * Adds the AirVisual air quality sensor platform * Updated .coveragerc * Removed some un-needed code * Adding strangely-necessary pylint disable * Removing a Python3.5-specific dict combiner method * Restarting stuck coverage test * Added units to AQI sensor (to get nice graph) * Making collaborator-requested changes * Removing unnecessary parameter from data object --- .coveragerc | 1 + homeassistant/components/sensor/airvisual.py | 289 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 293 insertions(+) create mode 100644 homeassistant/components/sensor/airvisual.py diff --git a/.coveragerc b/.coveragerc index 2fc424e91f6..d5eb32e670c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -427,6 +427,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 00000000000..7b077aa38ee --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,289 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_STATE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==0.1.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_TIMESTAMP = 'timestamp' + +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + api_key = config.get(CONF_API_KEY) + _LOGGER.debug('AirVisual API Key: %s', api_key) + + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + _LOGGER.debug('Monitored Conditions: %s', monitored_locales) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + _LOGGER.debug('AirVisual Latitude: %s', latitude) + + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + _LOGGER.debug('AirVisual Longitude: %s', longitude) + + radius = config.get(CONF_RADIUS) + _LOGGER.debug('AirVisual Radius: %s', radius) + + data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_STATE: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, latitude, longitude, radius): + """Initialize.""" + self.city = None + self._client = client + self.country = None + self.latitude = latitude + self.longitude = longitude + self.pollution_info = None + self.radius = radius + self.state = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current').get('pollution') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update sensor data') + _LOGGER.debug(exc_info) diff --git a/requirements_all.txt b/requirements_all.txt index 1bafef96fba..80401ed3733 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -538,6 +538,9 @@ pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.sensor.airvisual +pyairvisual==0.1.0 + # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 From 74bfcde814a465b4be0061d4bb674ce63064e106 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Sep 2017 21:19:49 -0700 Subject: [PATCH 107/108] Cleanup input_text (#9326) --- homeassistant/components/input_text.py | 37 +++++++++----------------- tests/components/test_input_text.py | 10 +++---- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index d17837b0ced..583181fe453 100755 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -25,17 +25,15 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_INITIAL = 'initial' CONF_MIN = 'min' CONF_MAX = 'max' -CONF_DISABLED = 'disabled' ATTR_VALUE = 'value' ATTR_MIN = 'min' ATTR_MAX = 'max' ATTR_PATTERN = 'pattern' -ATTR_DISABLED = 'disabled' -SERVICE_SELECT_VALUE = 'select_value' +SERVICE_SET_VALUE = 'set_value' -SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_VALUE): cv.string, }) @@ -65,16 +63,15 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_ICON): cv.icon, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_PATTERN): cv.string, - vol.Optional(CONF_DISABLED, default=False): cv.boolean, }, _cv_input_text) }) }, required=True, extra=vol.ALLOW_EXTRA) @bind_hass -def select_value(hass, entity_id, value): +def set_value(hass, entity_id, value): """Set input_text to value.""" - hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, }) @@ -95,28 +92,27 @@ def async_setup(hass, config): icon = cfg.get(CONF_ICON) unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) pattern = cfg.get(ATTR_PATTERN) - disabled = cfg.get(CONF_DISABLED) entities.append(InputText( object_id, name, initial, minimum, maximum, icon, unit, - pattern, disabled)) + pattern)) if not entities: return False @asyncio.coroutine - def async_select_value_service(call): + def async_set_value_service(call): """Handle a calls to the input box services.""" target_inputs = component.async_extract_from_service(call) - tasks = [input_text.async_select_value(call.data[ATTR_VALUE]) + tasks = [input_text.async_set_value(call.data[ATTR_VALUE]) for input_text in target_inputs] if tasks: yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, - schema=SERVICE_SELECT_VALUE_SCHEMA) + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) yield from component.async_add_entities(entities) return True @@ -126,8 +122,8 @@ class InputText(Entity): """Represent a text box.""" def __init__(self, object_id, name, initial, minimum, maximum, icon, - unit, pattern, disabled): - """Initialize a select input.""" + unit, pattern): + """Initialize a text input.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name self._current_value = initial @@ -136,7 +132,6 @@ class InputText(Entity): self._icon = icon self._unit = unit self._pattern = pattern - self._disabled = disabled @property def should_poll(self): @@ -145,7 +140,7 @@ class InputText(Entity): @property def name(self): - """Return the name of the select input box.""" + """Return the name of the text input entity.""" return self._name @property @@ -163,11 +158,6 @@ class InputText(Entity): """Return the unit the value is expressed in.""" return self._unit - @property - def disabled(self): - """Return the disabled flag.""" - return self._disabled - @property def state_attributes(self): """Return the state attributes.""" @@ -175,7 +165,6 @@ class InputText(Entity): ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, ATTR_PATTERN: self._pattern, - ATTR_DISABLED: self._disabled, } @asyncio.coroutine @@ -192,7 +181,7 @@ class InputText(Entity): self._current_value = value @asyncio.coroutine - def async_select_value(self, value): + def async_set_value(self, value): """Select new value.""" if len(value) < self._minimum or len(value) > self._maximum: _LOGGER.warning("Invalid value: %s (length range %s - %s)", diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 81b1f58aa87..be22e1122ea 100755 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -5,7 +5,7 @@ import unittest from homeassistant.core import CoreState, State from homeassistant.setup import setup_component, async_setup_component -from homeassistant.components.input_text import (DOMAIN, select_value) +from homeassistant.components.input_text import (DOMAIN, set_value) from tests.common import get_test_home_assistant, mock_restore_cache @@ -38,8 +38,8 @@ class TestInputText(unittest.TestCase): self.assertFalse( setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) - def test_select_value(self): - """Test select_value method.""" + def test_set_value(self): + """Test set_value method.""" self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { 'test_1': { 'initial': 'test', @@ -52,13 +52,13 @@ class TestInputText(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual('test', str(state.state)) - select_value(self.hass, entity_id, 'testing') + set_value(self.hass, entity_id, 'testing') self.hass.block_till_done() state = self.hass.states.get(entity_id) self.assertEqual('testing', str(state.state)) - select_value(self.hass, entity_id, 'testing too long') + set_value(self.hass, entity_id, 'testing too long') self.hass.block_till_done() state = self.hass.states.get(entity_id) From 3065575777f4e74059119c321356fe8fa404ae9a Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Sat, 9 Sep 2017 03:06:06 -0400 Subject: [PATCH 108/108] Bump pyHik version to add IO support (#9341) --- homeassistant/components/binary_sensor/hikvision.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5..df488cc0ed6 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ DEVICE_CLASS_MAP = { 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index 80401ed3733..703bbd6b184 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -609,7 +609,7 @@ pyfttt==0.3 pyharmony==1.0.16 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.3 +pyhik==0.1.4 # homeassistant.components.homematic pyhomematic==0.1.30