From 85487612d5d0ca510e09324604c72d4c958487b0 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 6 Apr 2018 16:20:59 +0200 Subject: [PATCH 001/155] Update Homekit to 1.1.9 (#13716) * Version bump to HAP-python==1.1.9 * Updated types and tests --- homeassistant/components/homekit/__init__.py | 2 +- .../components/homekit/type_covers.py | 1 - .../components/homekit/type_lights.py | 18 +++++---------- .../homekit/type_security_systems.py | 7 ++---- .../components/homekit/type_sensors.py | 4 ++-- .../components/homekit/type_switches.py | 3 +-- .../components/homekit/type_thermostats.py | 15 ++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_type_covers.py | 4 ++-- tests/components/homekit/test_type_lights.py | 22 +++++++++---------- .../homekit/test_type_security_systems.py | 10 ++++----- .../components/homekit/test_type_switches.py | 8 +++---- .../homekit/test_type_thermostats.py | 14 ++++++------ 14 files changed, 46 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 948e26be29176..8a38c01026ef1 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -28,7 +28,7 @@ TYPES = Registry() _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['HAP-python==1.1.8'] +REQUIREMENTS = ['HAP-python==1.1.9'] CONFIG_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 3650a948f5dc6..781f52941fcac 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -46,7 +46,6 @@ def __init__(self, hass, entity_id, display_name, **kwargs): def move_cover(self, value): """Move cover to value if call came from HomeKit.""" - self.char_target_position.set_value(value, should_callback=False) if value != self.current_position: _LOGGER.debug('%s: Set position to %d', self.entity_id, value) self.homekit_target = value diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 018d3cd2e74a5..1110981fe1061 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -87,7 +87,6 @@ def set_state(self, value): _LOGGER.debug('%s: Set state to %d', self.entity_id, value) self._flag[CHAR_ON] = True - self.char_on.set_value(value, should_callback=False) if value == 1: self.hass.components.light.turn_on(self.entity_id) @@ -98,7 +97,6 @@ def set_brightness(self, value): """Set brightness if call came from HomeKit.""" _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value) self._flag[CHAR_BRIGHTNESS] = True - self.char_brightness.set_value(value, should_callback=False) if value != 0: self.hass.components.light.turn_on( self.entity_id, brightness_pct=value) @@ -109,14 +107,12 @@ def set_color_temperature(self, value): """Set color temperature if call came from HomeKit.""" _LOGGER.debug('%s: Set color temp to %s', self.entity_id, value) self._flag[CHAR_COLOR_TEMPERATURE] = True - self.char_color_temperature.set_value(value, should_callback=False) self.hass.components.light.turn_on(self.entity_id, color_temp=value) def set_saturation(self, value): """Set saturation if call came from HomeKit.""" _LOGGER.debug('%s: Set saturation to %d', self.entity_id, value) self._flag[CHAR_SATURATION] = True - self.char_saturation.set_value(value, should_callback=False) self._saturation = value self.set_color() @@ -124,7 +120,6 @@ def set_hue(self, value): """Set hue if call came from HomeKit.""" _LOGGER.debug('%s: Set hue to %d', self.entity_id, value) self._flag[CHAR_HUE] = True - self.char_hue.set_value(value, should_callback=False) self._hue = value self.set_color() @@ -150,7 +145,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): if state in (STATE_ON, STATE_OFF): self._state = 1 if state == STATE_ON else 0 if not self._flag[CHAR_ON] and self.char_on.value != self._state: - self.char_on.set_value(self._state, should_callback=False) + self.char_on.set_value(self._state) self._flag[CHAR_ON] = False # Handle Brightness @@ -159,8 +154,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int): brightness = round(brightness / 255 * 100, 0) if self.char_brightness.value != brightness: - self.char_brightness.set_value(brightness, - should_callback=False) + self.char_brightness.set_value(brightness) self._flag[CHAR_BRIGHTNESS] = False # Handle color temperature @@ -168,8 +162,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) if not self._flag[CHAR_COLOR_TEMPERATURE] \ and isinstance(color_temperature, int): - self.char_color_temperature.set_value(color_temperature, - should_callback=False) + self.char_color_temperature.set_value(color_temperature) self._flag[CHAR_COLOR_TEMPERATURE] = False # Handle Color @@ -180,8 +173,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): hue != self._hue or saturation != self._saturation) and \ isinstance(hue, (int, float)) and \ isinstance(saturation, (int, float)): - self.char_hue.set_value(hue, should_callback=False) - self.char_saturation.set_value(saturation, - should_callback=False) + self.char_hue.set_value(hue) + self.char_saturation.set_value(saturation) self._hue, self._saturation = (hue, saturation) self._flag[RGB_COLOR] = False diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 2cce6653db394..235a8b22e7c3b 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -53,7 +53,6 @@ def set_security_state(self, value): _LOGGER.debug('%s: Set security state to %d', self.entity_id, value) self.flag_target_state = True - self.char_target_state.set_value(value, should_callback=False) hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] @@ -72,13 +71,11 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): return current_security_state = HASS_TO_HOMEKIT[hass_state] - self.char_current_state.set_value(current_security_state, - should_callback=False) + self.char_current_state.set_value(current_security_state) _LOGGER.debug('%s: Updated current state to %s (%d)', self.entity_id, hass_state, current_security_state) if not self.flag_target_state: - self.char_target_state.set_value(current_security_state, - should_callback=False) + self.char_target_state.set_value(current_security_state) if self.char_target_state.value == self.char_current_state.value: self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 80521df599115..393962eac21ba 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -44,7 +44,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): temperature = convert_to_float(new_state.state) if temperature: temperature = temperature_to_homekit(temperature, unit) - self.char_temp.set_value(temperature, should_callback=False) + self.char_temp.set_value(temperature) _LOGGER.debug('%s: Current temperature set to %d°C', self.entity_id, temperature) @@ -72,6 +72,6 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): humidity = convert_to_float(new_state.state) if humidity: - self.char_humidity.set_value(humidity, should_callback=False) + self.char_humidity.set_value(humidity) _LOGGER.debug('%s: Percent set to %d%%', self.entity_id, humidity) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 689edde6f37f1..854cb49d1819c 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -36,7 +36,6 @@ def set_state(self, value): _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) self.flag_target_state = True - self.char_on.set_value(value, should_callback=False) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF self.hass.services.call(self._domain, service, {ATTR_ENTITY_ID: self.entity_id}) @@ -50,6 +49,6 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): if not self.flag_target_state: _LOGGER.debug('%s: Set current state to %s', self.entity_id, current_state) - self.char_on.set_value(current_state, should_callback=False) + self.char_on.set_value(current_state) self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 69b6106279171..de8ecbdfe3ed8 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -97,7 +97,6 @@ def __init__(self, hass, entity_id, display_name, support_auto, **kwargs): def set_heat_cool(self, value): """Move operation mode to value if call came from HomeKit.""" - self.char_target_heat_cool.set_value(value, should_callback=False) if value in HC_HOMEKIT_TO_HASS: _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value) self.heat_cool_flag_target_state = True @@ -110,7 +109,6 @@ def set_cooling_threshold(self, value): _LOGGER.debug('%s: Set cooling threshold temperature to %.2f°C', self.entity_id, value) self.coolingthresh_flag_target_state = True - self.char_cooling_thresh_temp.set_value(value, should_callback=False) low = self.char_heating_thresh_temp.value low = temperature_to_states(low, self._unit) value = temperature_to_states(value, self._unit) @@ -123,7 +121,6 @@ def set_heating_threshold(self, value): _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', self.entity_id, value) self.heatingthresh_flag_target_state = True - self.char_heating_thresh_temp.set_value(value, should_callback=False) # Home assistant always wants to set low and high at the same time high = self.char_cooling_thresh_temp.value high = temperature_to_states(high, self._unit) @@ -137,7 +134,6 @@ def set_target_temperature(self, value): _LOGGER.debug('%s: Set target temperature to %.2f°C', self.entity_id, value) self.temperature_flag_target_state = True - self.char_target_temp.set_value(value, should_callback=False) value = temperature_to_states(value, self._unit) self.hass.components.climate.set_temperature( temperature=value, entity_id=self.entity_id) @@ -161,8 +157,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): if isinstance(target_temp, (int, float)): target_temp = temperature_to_homekit(target_temp, self._unit) if not self.temperature_flag_target_state: - self.char_target_temp.set_value(target_temp, - should_callback=False) + self.char_target_temp.set_value(target_temp) self.temperature_flag_target_state = False # Update cooling threshold temperature if characteristic exists @@ -172,8 +167,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): cooling_thresh = temperature_to_homekit(cooling_thresh, self._unit) if not self.coolingthresh_flag_target_state: - self.char_cooling_thresh_temp.set_value( - cooling_thresh, should_callback=False) + self.char_cooling_thresh_temp.set_value(cooling_thresh) self.coolingthresh_flag_target_state = False # Update heating threshold temperature if characteristic exists @@ -183,8 +177,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): heating_thresh = temperature_to_homekit(heating_thresh, self._unit) if not self.heatingthresh_flag_target_state: - self.char_heating_thresh_temp.set_value( - heating_thresh, should_callback=False) + self.char_heating_thresh_temp.set_value(heating_thresh) self.heatingthresh_flag_target_state = False # Update display units @@ -197,7 +190,7 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): and operation_mode in HC_HASS_TO_HOMEKIT: if not self.heat_cool_flag_target_state: self.char_target_heat_cool.set_value( - HC_HASS_TO_HOMEKIT[operation_mode], should_callback=False) + HC_HASS_TO_HOMEKIT[operation_mode]) self.heat_cool_flag_target_state = False # Set current operation mode based on temperatures and target mode diff --git a/requirements_all.txt b/requirements_all.txt index da2373443cb26..7af7bdb95ec11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ attrs==17.4.0 DoorBirdPy==0.1.3 # homeassistant.components.homekit -HAP-python==1.1.8 +HAP-python==1.1.9 # homeassistant.components.notify.mastodon Mastodon.py==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c5467f76087b..645b56b9e62df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ requests_mock==1.4 # homeassistant.components.homekit -HAP-python==1.1.8 +HAP-python==1.1.9 # homeassistant.components.notify.html5 PyJWT==1.6.0 diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 45631a76c98ac..1fa1ef1728e04 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -62,7 +62,7 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_position_state.value, 2) # Set from HomeKit - acc.char_target_position.set_value(25) + acc.char_target_position.client_update_value(25) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_cover_position') @@ -74,7 +74,7 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_position_state.value, 0) # Set from HomeKit - acc.char_target_position.set_value(75) + acc.char_target_position.client_update_value(75) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_cover_position') diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 1cfb926c4ceb1..1d18235d4a13b 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -57,7 +57,7 @@ def test_light_basic(self): self.assertEqual(acc.char_on.value, 0) # Set from HomeKit - acc.char_on.set_value(1) + acc.char_on.client_update_value(1) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -65,7 +65,7 @@ def test_light_basic(self): self.hass.states.set(entity_id, STATE_ON) self.hass.block_till_done() - acc.char_on.set_value(0) + acc.char_on.client_update_value(0) self.hass.block_till_done() self.assertEqual(self.events[1].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[1].data[ATTR_SERVICE], SERVICE_TURN_OFF) @@ -94,8 +94,8 @@ def test_light_brightness(self): self.assertEqual(acc.char_brightness.value, 40) # Set from HomeKit - acc.char_brightness.set_value(20) - acc.char_on.set_value(1) + acc.char_brightness.client_update_value(20) + acc.char_on.client_update_value(1) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -103,8 +103,8 @@ def test_light_brightness(self): self.events[0].data[ATTR_SERVICE_DATA], { ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 20}) - acc.char_on.set_value(1) - acc.char_brightness.set_value(40) + acc.char_on.client_update_value(1) + acc.char_brightness.client_update_value(40) self.hass.block_till_done() self.assertEqual(self.events[1].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[1].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -112,8 +112,8 @@ def test_light_brightness(self): self.events[1].data[ATTR_SERVICE_DATA], { ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 40}) - acc.char_on.set_value(1) - acc.char_brightness.set_value(0) + acc.char_on.client_update_value(1) + acc.char_brightness.client_update_value(0) self.hass.block_till_done() self.assertEqual(self.events[2].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[2].data[ATTR_SERVICE], SERVICE_TURN_OFF) @@ -132,7 +132,7 @@ def test_light_color_temperature(self): self.assertEqual(acc.char_color_temperature.value, 190) # Set from HomeKit - acc.char_color_temperature.set_value(250) + acc.char_color_temperature.client_update_value(250) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -156,8 +156,8 @@ def test_light_rgb_color(self): self.assertEqual(acc.char_saturation.value, 90) # Set from HomeKit - acc.char_hue.set_value(145) - acc.char_saturation.set_value(75) + acc.char_hue.client_update_value(145) + acc.char_saturation.client_update_value(75) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index c689a73bac229..46f886c4d35b9 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -71,7 +71,7 @@ def test_switch_set_state(self): self.assertEqual(acc.char_current_state.value, 3) # Set from HomeKit - acc.char_target_state.set_value(0) + acc.char_target_state.client_update_value(0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') @@ -79,7 +79,7 @@ def test_switch_set_state(self): self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') self.assertEqual(acc.char_target_state.value, 0) - acc.char_target_state.set_value(1) + acc.char_target_state.client_update_value(1) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_SERVICE], 'alarm_arm_away') @@ -87,7 +87,7 @@ def test_switch_set_state(self): self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') self.assertEqual(acc.char_target_state.value, 1) - acc.char_target_state.set_value(2) + acc.char_target_state.client_update_value(2) self.hass.block_till_done() self.assertEqual( self.events[2].data[ATTR_SERVICE], 'alarm_arm_night') @@ -95,7 +95,7 @@ def test_switch_set_state(self): self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') self.assertEqual(acc.char_target_state.value, 2) - acc.char_target_state.set_value(3) + acc.char_target_state.client_update_value(3) self.hass.block_till_done() self.assertEqual( self.events[3].data[ATTR_SERVICE], 'alarm_disarm') @@ -112,7 +112,7 @@ def test_no_alarm_code(self): acc.run() # Set from HomeKit - acc.char_target_state.set_value(0) + acc.char_target_state.client_update_value(0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 21d7583152e8e..7f30e45730869 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -51,14 +51,14 @@ def test_switch_set_state(self): self.assertEqual(acc.char_on.value, False) # Set from HomeKit - acc.char_on.set_value(True) + acc.char_on.client_update_value(True) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_DOMAIN], domain) self.assertEqual( self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - acc.char_on.set_value(False) + acc.char_on.client_update_value(False) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_DOMAIN], domain) @@ -76,7 +76,7 @@ def test_remote_set_state(self): self.assertEqual(acc.char_on.value, False) # Set from HomeKit - acc.char_on.set_value(True) + acc.char_on.client_update_value(True) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_DOMAIN], domain) @@ -95,7 +95,7 @@ def test_input_boolean_set_state(self): self.assertEqual(acc.char_on.value, False) # Set from HomeKit - acc.char_on.set_value(True) + acc.char_on.client_update_value(True) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_DOMAIN], domain) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e1511163f2fdf..d363e26d71296 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -151,7 +151,7 @@ def test_default_thermostat(self): self.assertEqual(acc.char_display_units.value, 0) # Set from HomeKit - acc.char_target_temp.set_value(19.0) + acc.char_target_temp.client_update_value(19.0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_temperature') @@ -159,7 +159,7 @@ def test_default_thermostat(self): self.events[0].data[ATTR_SERVICE_DATA][ATTR_TEMPERATURE], 19.0) self.assertEqual(acc.char_target_temp.value, 19.0) - acc.char_target_heat_cool.set_value(1) + acc.char_target_heat_cool.client_update_value(1) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_SERVICE], 'set_operation_mode') @@ -221,7 +221,7 @@ def test_auto_thermostat(self): self.assertEqual(acc.char_display_units.value, 0) # Set from HomeKit - acc.char_heating_thresh_temp.set_value(20.0) + acc.char_heating_thresh_temp.client_update_value(20.0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_temperature') @@ -229,7 +229,7 @@ def test_auto_thermostat(self): self.events[0].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_LOW], 20.0) self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) - acc.char_cooling_thresh_temp.set_value(25.0) + acc.char_cooling_thresh_temp.client_update_value(25.0) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_SERVICE], 'set_temperature') @@ -260,19 +260,19 @@ def test_thermostat_fahrenheit(self): self.assertEqual(acc.char_display_units.value, 1) # Set from HomeKit - acc.char_cooling_thresh_temp.set_value(23) + acc.char_cooling_thresh_temp.client_update_value(23) self.hass.block_till_done() service_data = self.events[-1].data[ATTR_SERVICE_DATA] self.assertEqual(service_data[ATTR_TARGET_TEMP_HIGH], 73.4) self.assertEqual(service_data[ATTR_TARGET_TEMP_LOW], 68) - acc.char_heating_thresh_temp.set_value(22) + acc.char_heating_thresh_temp.client_update_value(22) self.hass.block_till_done() service_data = self.events[-1].data[ATTR_SERVICE_DATA] self.assertEqual(service_data[ATTR_TARGET_TEMP_HIGH], 73.4) self.assertEqual(service_data[ATTR_TARGET_TEMP_LOW], 71.6) - acc.char_target_temp.set_value(24.0) + acc.char_target_temp.client_update_value(24.0) self.hass.block_till_done() service_data = self.events[-1].data[ATTR_SERVICE_DATA] self.assertEqual(service_data[ATTR_TEMPERATURE], 75.2) From 3394916a68122913c7b066b50d217cfe298914eb Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 6 Apr 2018 18:06:47 +0200 Subject: [PATCH 002/155] Update docstrings (#13720) --- homeassistant/components/cover/opengarage.py | 47 ++++++++++--------- homeassistant/components/cover/tahoma.py | 2 +- homeassistant/components/ihc/__init__.py | 29 ++++++------ homeassistant/components/ihc/ihcdevice.py | 10 ++-- homeassistant/components/light/aurora.py | 38 +++++++-------- .../sensor/trafikverket_weatherstation.py | 20 ++++---- 6 files changed, 72 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index d68021d7db388..028a7a0c9fc8a 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -18,30 +18,31 @@ _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE_SENSOR = "distance_sensor" -ATTR_DOOR_STATE = "door_state" -ATTR_SIGNAL_STRENGTH = "wifi_signal" +ATTR_DISTANCE_SENSOR = 'distance_sensor' +ATTR_DOOR_STATE = 'door_state' +ATTR_SIGNAL_STRENGTH = 'wifi_signal' -CONF_DEVICEKEY = "device_key" +CONF_DEVICE_ID = 'device_id' +CONF_DEVICE_KEY = 'device_key' DEFAULT_NAME = 'OpenGarage' DEFAULT_PORT = 80 -STATE_CLOSING = "closing" -STATE_OFFLINE = "offline" -STATE_OPENING = "opening" -STATE_STOPPED = "stopped" +STATE_CLOSING = 'closing' +STATE_OFFLINE = 'offline' +STATE_OPENING = 'opening' +STATE_STOPPED = 'stopped' STATES_MAP = { 0: STATE_CLOSED, - 1: STATE_OPEN + 1: STATE_OPEN, } COVER_SCHEMA = vol.Schema({ - vol.Required(CONF_DEVICEKEY): cv.string, + vol.Required(CONF_DEVICE_KEY): cv.string, vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME): cv.string }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -50,7 +51,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up OpenGarage covers.""" + """Set up the OpenGarage covers.""" covers = [] devices = config.get(CONF_COVERS) @@ -59,8 +60,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_NAME: device_config.get(CONF_NAME), CONF_HOST: device_config.get(CONF_HOST), CONF_PORT: device_config.get(CONF_PORT), - "device_id": device_config.get(CONF_DEVICE, device_id), - CONF_DEVICEKEY: device_config.get(CONF_DEVICEKEY) + CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id), + CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY) } covers.append(OpenGarageCover(hass, args)) @@ -79,8 +80,8 @@ def __init__(self, hass, args): self.hass = hass self._name = args[CONF_NAME] self.device_id = args['device_id'] - self._devicekey = args[CONF_DEVICEKEY] - self._state = STATE_UNKNOWN + self._device_key = args[CONF_DEVICE_KEY] + self._state = None self._state_before_move = None self.dist = None self.signal = None @@ -138,8 +139,8 @@ def update(self): try: status = self._get_status() if self._name is None: - if status["name"] is not None: - self._name = status["name"] + if status['name'] is not None: + self._name = status['name'] state = STATES_MAP.get(status.get('door'), STATE_UNKNOWN) if self._state_before_move is not None: if self._state_before_move != state: @@ -152,7 +153,7 @@ def update(self): self.signal = status.get('rssi') self.dist = status.get('dist') self._available = True - except (requests.exceptions.RequestException) as ex: + except requests.exceptions.RequestException as ex: _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex)) self._state = STATE_OFFLINE @@ -166,15 +167,15 @@ def _get_status(self): def _push_button(self): """Send commands to API.""" url = '{}/cc?dkey={}&click=1'.format( - self.opengarage_url, self._devicekey) + self.opengarage_url, self._device_key) try: response = requests.get(url, timeout=10).json() - if response["result"] == 2: - _LOGGER.error("Unable to control %s: device_key is incorrect.", + if response['result'] == 2: + _LOGGER.error("Unable to control %s: Device key is incorrect", self._name) self._state = self._state_before_move self._state_before_move = None - except (requests.exceptions.RequestException) as ex: + except requests.exceptions.RequestException as ex: _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex)) self._state = self._state_before_move diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 6fb8e92e05192..c99076de851c2 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Tahoma covers.""" + """Set up the Tahoma covers.""" controller = hass.data[TAHOMA_DOMAIN]['controller'] devices = [] for device in hass.data[TAHOMA_DOMAIN]['devices']['cover']: diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 031fa263e5a40..0c0100bc9f595 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -1,4 +1,5 @@ -"""IHC component. +""" +Support for IHC devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ihc/ @@ -6,18 +7,18 @@ import logging import os.path import xml.etree.ElementTree + import voluptuous as vol from homeassistant.components.ihc.const import ( - ATTR_IHC_ID, ATTR_VALUE, CONF_INFO, CONF_AUTOSETUP, - CONF_BINARY_SENSOR, CONF_LIGHT, CONF_SENSOR, CONF_SWITCH, - CONF_XPATH, CONF_NODE, CONF_DIMMABLE, CONF_INVERTING, - SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_INT, - SERVICE_SET_RUNTIME_VALUE_FLOAT) + ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_SENSOR, CONF_SWITCH, + CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, + SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - CONF_URL, CONF_USERNAME, CONF_PASSWORD, CONF_ID, CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, CONF_TYPE, TEMP_CELSIUS) + CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, + CONF_URL, CONF_USERNAME, TEMP_CELSIUS) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType @@ -36,7 +37,7 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, - vol.Optional(CONF_INFO, default=True): cv.boolean + vol.Optional(CONF_INFO, default=True): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -97,7 +98,7 @@ def setup(hass, config): - """Setup the IHC component.""" + """Set up the IHC component.""" from ihcsdk.ihccontroller import IHCController conf = config[DOMAIN] url = conf[CONF_URL] @@ -106,7 +107,7 @@ def setup(hass, config): ihc_controller = IHCController(url, username, password) if not ihc_controller.authenticate(): - _LOGGER.error("Unable to authenticate on ihc controller.") + _LOGGER.error("Unable to authenticate on IHC controller") return False if (conf[CONF_AUTOSETUP] and @@ -125,7 +126,7 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): """Auto setup of IHC products from the ihc project file.""" project_xml = ihc_controller.get_project() if not project_xml: - _LOGGER.error("Unable to read project from ihc controller.") + _LOGGER.error("Unable to read project from ICH controller") return False project = xml.etree.ElementTree.fromstring(project_xml) @@ -150,7 +151,7 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): def get_discovery_info(component_setup, groups): - """Get discovery info for specified component.""" + """Get discovery info for specified IHC component.""" discovery_data = {} for group in groups: groupname = group.attrib['name'] @@ -173,7 +174,7 @@ def get_discovery_info(component_setup, groups): def setup_service_functions(hass: HomeAssistantType, ihc_controller): - """Setup the ihc service functions.""" + """Setup the IHC service functions.""" def set_runtime_value_bool(call): """Set a IHC runtime bool value service function.""" ihc_id = call.data[ATTR_IHC_ID] diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 59f4d95f0a1f2..de6db875def00 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -1,4 +1,4 @@ -"""Implements a base class for all IHC devices.""" +"""Implementation of a base class for all IHC devices.""" import asyncio from xml.etree.ElementTree import Element @@ -6,7 +6,7 @@ class IHCDevice(Entity): - """Base class for all ihc devices. + """Base class for all IHC devices. All IHC devices have an associated IHC resource. IHCDevice handled the registration of the IHC controller callback when the IHC resource changes. @@ -31,13 +31,13 @@ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, @asyncio.coroutine def async_added_to_hass(self): - """Add callback for ihc changes.""" + """Add callback for IHC changes.""" self.ihc_controller.add_notify_event( self.ihc_id, self.on_ihc_change, True) @property def should_poll(self) -> bool: - """No polling needed for ihc devices.""" + """No polling needed for IHC devices.""" return False @property @@ -58,7 +58,7 @@ def device_state_attributes(self): } def on_ihc_change(self, ihc_id, value): - """Callback when ihc resource changes. + """Callback when IHC resource changes. Derived classes must overwrite this to do device specific stuff. """ diff --git a/homeassistant/components/light/aurora.py b/homeassistant/components/light/aurora.py index 2a9066bd55fde..99c07166037e4 100644 --- a/homeassistant/components/light/aurora.py +++ b/homeassistant/components/light/aurora.py @@ -1,11 +1,6 @@ """ Support for Nanoleaf Aurora platform. -Based in large parts upon Software-2's ha-aurora and fully -reliant on Software-2's nanoleaf-aurora Python Library, see -https://github.com/software-2/ha-aurora as well as -https://github.com/software-2/nanoleaf - For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.nanoleaf_aurora/ """ @@ -15,9 +10,9 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, - SUPPORT_EFFECT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, - SUPPORT_COLOR, PLATFORM_SCHEMA, Light) -from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_NAME + PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, Light) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.util import color as color_util from homeassistant.util.color import \ @@ -25,20 +20,24 @@ REQUIREMENTS = ['nanoleaf==0.4.1'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Aurora' + +ICON = 'mdi:triangle-outline' + SUPPORT_AURORA = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | SUPPORT_COLOR) -_LOGGER = logging.getLogger(__name__) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): cv.string, - vol.Optional(CONF_NAME, default='Aurora'): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup Nanoleaf Aurora device.""" + """Set up the Nanoleaf Aurora device.""" import nanoleaf host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -47,8 +46,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): aurora_light.hass_name = name if aurora_light.on is None: - _LOGGER.error("Could not connect to \ - Nanoleaf Aurora: %s on %s", name, host) + _LOGGER.error( + "Could not connect to Nanoleaf Aurora: %s on %s", name, host) + return + add_devices([AuroraLight(aurora_light)], True) @@ -56,7 +57,7 @@ class AuroraLight(Light): """Representation of a Nanoleaf Aurora.""" def __init__(self, light): - """Initialize an Aurora.""" + """Initialize an Aurora light.""" self._brightness = None self._color_temp = None self._effect = None @@ -99,7 +100,7 @@ def name(self): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return "mdi:triangle-outline" + return ICON @property def is_on(self): @@ -141,10 +142,7 @@ def turn_off(self, **kwargs): self._light.on = False def update(self): - """Fetch new state data for this light. - - This is the only method that should fetch new data for Home Assistant. - """ + """Fetch new state data for this light.""" self._brightness = self._light.brightness self._color_temp = self._light.color_temperature self._effect = self._light.effect diff --git a/homeassistant/components/sensor/trafikverket_weatherstation.py b/homeassistant/components/sensor/trafikverket_weatherstation.py index fba16c27c7e98..77a2b0e7338e9 100644 --- a/homeassistant/components/sensor/trafikverket_weatherstation.py +++ b/homeassistant/components/sensor/trafikverket_weatherstation.py @@ -4,17 +4,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.trafikverket_weatherstation/ """ +from datetime import timedelta import json import logging -from datetime import timedelta 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_NAME, ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_API_KEY, CONF_TYPE) + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -25,6 +25,7 @@ CONF_STATION = 'station' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + SCAN_INTERVAL = timedelta(seconds=300) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -36,7 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the sensor platform.""" + """Set up the Trafikverket sensor platform.""" sensor_name = config.get(CONF_NAME) sensor_api = config.get(CONF_API_KEY) sensor_station = config.get(CONF_STATION) @@ -47,10 +48,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class TrafikverketWeatherStation(Entity): - """Representation of a Sensor.""" + """Representation of a Trafikverket sensor.""" def __init__(self, sensor_name, sensor_api, sensor_station, sensor_type): - """Initialize the sensor.""" + """Initialize the Trafikverket sensor.""" self._name = sensor_name self._api = sensor_api self._station = sensor_station @@ -82,10 +83,7 @@ def device_state_attributes(self): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ + """Fetch new state data for the sensor.""" url = 'http://api.trafikinfo.trafikverket.se/v1.3/data.json' if self._type == 'road': @@ -117,7 +115,7 @@ def update(self): result = data["RESPONSE"]["RESULT"][0] final = result["WeatherStation"][0]["Measurement"] except KeyError: - _LOGGER.error("Incorrect weather station or API key.") + _LOGGER.error("Incorrect weather station or API key") return # air_vs_road contains "Air" or "Road" depending on user input. From 48fe2d18e8c752b36a9a5e40bad77c540da2665f Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 6 Apr 2018 21:48:50 +0200 Subject: [PATCH 003/155] Add option to ignore availability in google calendar events (#13714) --- homeassistant/components/calendar/google.py | 25 +++++++++++++++++---- homeassistant/components/google.py | 4 +++- tests/components/test_google.py | 1 + 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index a8763e8ca9ea0..6c26c65ebe77f 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -11,6 +11,7 @@ from homeassistant.components.calendar import CalendarEventDevice from homeassistant.components.google import ( CONF_CAL_ID, CONF_ENTITIES, CONF_TRACK, TOKEN_FILE, + CONF_IGNORE_AVAILABILITY, CONF_SEARCH, GoogleCalendarService) from homeassistant.util import Throttle, dt @@ -18,7 +19,7 @@ DEFAULT_GOOGLE_SEARCH_PARAMS = { 'orderBy': 'startTime', - 'maxResults': 1, + 'maxResults': 5, 'singleEvents': True, } @@ -45,18 +46,22 @@ class GoogleCalendarEventDevice(CalendarEventDevice): def __init__(self, hass, calendar_service, calendar, data): """Create the Calendar event device.""" self.data = GoogleCalendarData(calendar_service, calendar, - data.get('search', None)) + data.get(CONF_SEARCH), + data.get(CONF_IGNORE_AVAILABILITY)) + super().__init__(hass, data) class GoogleCalendarData(object): """Class to utilize calendar service object to get next event.""" - def __init__(self, calendar_service, calendar_id, search=None): + def __init__(self, calendar_service, calendar_id, search, + ignore_availability): """Set up how we are going to search the google calendar.""" self.calendar_service = calendar_service self.calendar_id = calendar_id self.search = search + self.ignore_availability = ignore_availability self.event = None @Throttle(MIN_TIME_BETWEEN_UPDATES) @@ -80,5 +85,17 @@ def update(self): result = events.list(**params).execute() items = result.get('items', []) - self.event = items[0] if len(items) == 1 else None + + new_event = None + for item in items: + if (not self.ignore_availability + and 'transparency' in item.keys()): + if item['transparency'] == 'opaque': + new_event = item + break + else: + new_event = item + break + + self.event = new_event return True diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 30151ee1a56b5..b41d4ea33a20b 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -44,6 +44,7 @@ CONF_TRACK = 'track' CONF_SEARCH = 'search' CONF_OFFSET = 'offset' +CONF_IGNORE_AVAILABILITY = 'ignore_availability' DEFAULT_CONF_TRACK_NEW = True DEFAULT_CONF_OFFSET = '!!' @@ -74,8 +75,9 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_DEVICE_ID): cv.string, vol.Optional(CONF_TRACK): cv.boolean, - vol.Optional(CONF_SEARCH): vol.Any(cv.string, None), + vol.Optional(CONF_SEARCH): cv.string, vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, }) DEVICE_SCHEMA = vol.Schema({ diff --git a/tests/components/test_google.py b/tests/components/test_google.py index fd45cfc59a9e3..0ee066fcfeeca 100644 --- a/tests/components/test_google.py +++ b/tests/components/test_google.py @@ -58,6 +58,7 @@ def test_get_calendar_info(self): 'device_id': 'we_are_we_are_a_test_calendar', 'name': 'We are, we are, a... Test Calendar', 'track': True, + 'ignore_availability': True, }] }) From c77d013f430b25b131843d7a72c75d6fb8aa8831 Mon Sep 17 00:00:00 2001 From: Juggels Date: Fri, 6 Apr 2018 22:23:40 +0200 Subject: [PATCH 004/155] Allow use of date_string in service call (#13256) * Allow use of date_string in service call * Add stricter validation, fix descriptions --- .../components/calendar/services.yaml | 43 +++++++++++-------- homeassistant/components/calendar/todoist.py | 20 ++++++++- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 61ff4345fbec3..ebf0c7b1591ab 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,21 +1,26 @@ # Describes the format for available calendar services -todoist: - new_task: - description: Create a new task and add it to a project. - fields: - content: - description: The name of the task (Required). - example: Pick up the mail - project: - description: The name of the project this task should belong to. Defaults to Inbox (Optional). - example: Errands - labels: - description: Any labels that you want to apply to this task, separated by a comma (Optional). - example: Chores,Deliveries - priority: - description: The priority of this task, from 1 (normal) to 4 (urgent) (Optional). - example: 2 - due_date: - description: The day this task is due, in format YYYY-MM-DD (Optional). - example: "2018-04-01" +todoist_new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). + example: 2 + due_date_string: + description: The day this task is due, in natural language. + example: "tomorrow" + due_date_lang: + description: The language of due_date_string. + example: "en" + due_date: + description: The day this task is due, in format YYYY-MM-DD. + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index 02840c7d0ee80..b70e44456db82 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -41,6 +41,14 @@ DESCRIPTION = 'description' # Calendar Platform: Used in the '_get_date()' method DATETIME = 'dateTime' +# Service Call: When is this task due (in natural language)? +DUE_DATE_STRING = 'due_date_string' +# Service Call: The language of DUE_DATE_STRING +DUE_DATE_LANG = 'due_date_lang' +# Service Call: The available options of DUE_DATE_LANG +DUE_DATE_VALID_LANGS = ['en', 'da', 'pl', 'zh', 'ko', 'de', + 'pt', 'ja', 'it', 'fr', 'sv', 'ru', + 'es', 'nl'] # Attribute: When is this task due? # Service Call: When is this task due? DUE_DATE = 'due_date' @@ -83,7 +91,11 @@ vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), vol.Optional(LABELS): cv.ensure_list_csv, vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), - vol.Optional(DUE_DATE): cv.string, + + vol.Exclusive(DUE_DATE_STRING, 'due_date'): cv.string, + vol.Optional(DUE_DATE_LANG): + vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)), + vol.Exclusive(DUE_DATE, 'due_date'): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -186,6 +198,12 @@ def handle_new_task(call): if PRIORITY in call.data: item.update(priority=call.data[PRIORITY]) + if DUE_DATE_STRING in call.data: + item.update(date_string=call.data[DUE_DATE_STRING]) + + if DUE_DATE_LANG in call.data: + item.update(date_lang=call.data[DUE_DATE_LANG]) + if DUE_DATE in call.data: due_date = dt.parse_datetime(call.data[DUE_DATE]) if due_date is None: From 262ea14e5a9bc63ba9b8f915874df0cf9517f412 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 6 Apr 2018 23:11:53 +0200 Subject: [PATCH 005/155] Add timeout / debounce (for brightness and others) (#13534) * Add async timeout feature * Decorator for setter methods to limit service calls to HA * Changed to async * Use async_call_later * Use lastargs, async_add_job * Use dict for lastargs * Updated tests to stop patch --- .../components/homekit/accessories.py | 51 +++++++++++++++++-- homeassistant/components/homekit/const.py | 1 + .../components/homekit/type_lights.py | 3 +- .../components/homekit/type_thermostats.py | 5 +- tests/components/homekit/test_accessories.py | 48 ++++++++++++++++- tests/components/homekit/test_homekit.py | 12 +++++ tests/components/homekit/test_type_lights.py | 24 +++++++-- .../homekit/test_type_thermostats.py | 23 +++++++-- 8 files changed, 151 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index da45bee9e903f..ec2c49f5e4399 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,21 +1,64 @@ """Extend the basic Accessory and Bridge functions.""" +from datetime import timedelta +from functools import wraps +from inspect import getmodule import logging from pyhap.accessory import Accessory, Bridge, Category from pyhap.accessory_driver import AccessoryDriver -from homeassistant.helpers.event import async_track_state_change +from homeassistant.core import callback +from homeassistant.helpers.event import ( + async_track_state_change, track_point_in_utc_time) +from homeassistant.util import dt as dt_util from .const import ( - ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, BRIDGE_NAME, - MANUFACTURER, SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, CHAR_MODEL, - CHAR_NAME, CHAR_SERIAL_NUMBER) + DEBOUNCE_TIMEOUT, ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, + BRIDGE_NAME, MANUFACTURER, SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, + CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER) from .util import ( show_setup_message, dismiss_setup_message) _LOGGER = logging.getLogger(__name__) +def debounce(func): + """Decorator function. Debounce callbacks form HomeKit.""" + @callback + def call_later_listener(*args): + """Callback listener called from call_later.""" + # pylint: disable=unsubscriptable-object + nonlocal lastargs, remove_listener + hass = lastargs['hass'] + hass.async_add_job(func, *lastargs['args']) + lastargs = remove_listener = None + + @wraps(func) + def wrapper(*args): + """Wrapper starts async timer. + + The accessory must have 'self.hass' and 'self.entity_id' as attributes. + """ + # pylint: disable=not-callable + hass = args[0].hass + nonlocal lastargs, remove_listener + if remove_listener: + remove_listener() + lastargs = remove_listener = None + lastargs = {'hass': hass, 'args': [*args]} + remove_listener = track_point_in_utc_time( + hass, call_later_listener, + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)) + logger.debug('%s: Start %s timeout', args[0].entity_id, + func.__name__.replace('set_', '')) + + remove_listener = None + lastargs = None + name = getmodule(func).__name__ + logger = logging.getLogger(name) + return wrapper + + def add_preload_service(acc, service, chars=None): """Define and return a service to be available for the accessory.""" from pyhap.loader import get_serv_loader, get_char_loader diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d1c3d84b5177b..18d02a89e18d0 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,5 +1,6 @@ """Constants used be the HomeKit component.""" # #### MISC #### +DEBOUNCE_TIMEOUT = 0.5 DOMAIN = 'homekit' HOMEKIT_FILE = '.homekit.state' HOMEKIT_NOTIFY_ID = 4663548 diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 1110981fe1061..4fbfb99585956 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, debounce from .const import ( CATEGORY_LIGHT, SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE, CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION) @@ -93,6 +93,7 @@ def set_state(self, value): elif value == 0: self.hass.components.light.turn_off(self.entity_id) + @debounce def set_brightness(self, value): """Set brightness if call came from HomeKit.""" _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index de8ecbdfe3ed8..daf81c51c4d93 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -10,7 +10,7 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, debounce from .const import ( CATEGORY_THERMOSTAT, SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, @@ -104,6 +104,7 @@ def set_heat_cool(self, value): self.hass.components.climate.set_operation_mode( operation_mode=hass_value, entity_id=self.entity_id) + @debounce def set_cooling_threshold(self, value): """Set cooling threshold temp to value if call came from HomeKit.""" _LOGGER.debug('%s: Set cooling threshold temperature to %.2f°C', @@ -116,6 +117,7 @@ def set_cooling_threshold(self, value): entity_id=self.entity_id, target_temp_high=value, target_temp_low=low) + @debounce def set_heating_threshold(self, value): """Set heating threshold temp to value if call came from HomeKit.""" _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', @@ -129,6 +131,7 @@ def set_heating_threshold(self, value): entity_id=self.entity_id, target_temp_high=high, target_temp_low=value) + @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" _LOGGER.debug('%s: Set target temperature to %.2f°C', diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index a2facd826e46d..b7bf625a2d645 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -2,21 +2,67 @@ This includes tests for all mock object types. """ +from datetime import datetime, timedelta import unittest from unittest.mock import call, patch, Mock from homeassistant.components.homekit.accessories import ( add_preload_service, set_accessory_info, - HomeAccessory, HomeBridge, HomeDriver) + debounce, HomeAccessory, HomeBridge, HomeDriver) from homeassistant.components.homekit.const import ( ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, BRIDGE_NAME, SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER) +from homeassistant.const import ATTR_NOW, EVENT_TIME_CHANGED +import homeassistant.util.dt as dt_util + +from tests.common import get_test_home_assistant + + +def patch_debounce(): + """Return patch for debounce method.""" + return patch('homeassistant.components.homekit.accessories.debounce', + lambda f: lambda *args, **kwargs: f(*args, **kwargs)) class TestAccessories(unittest.TestCase): """Test pyhap adapter methods.""" + def test_debounce(self): + """Test add_timeout decorator function.""" + def demo_func(*args): + nonlocal arguments, counter + counter += 1 + arguments = args + + arguments = None + counter = 0 + hass = get_test_home_assistant() + mock = Mock(hass=hass) + + debounce_demo = debounce(demo_func) + self.assertEqual(debounce_demo.__name__, 'demo_func') + now = datetime(2018, 1, 1, 20, 0, 0, tzinfo=dt_util.UTC) + + with patch('homeassistant.util.dt.utcnow', return_value=now): + debounce_demo(mock, 'value') + hass.bus.fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + hass.block_till_done() + assert counter == 1 + assert len(arguments) == 2 + + with patch('homeassistant.util.dt.utcnow', return_value=now): + debounce_demo(mock, 'value') + debounce_demo(mock, 'value') + + hass.bus.fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + hass.block_till_done() + assert counter == 2 + + hass.stop() + def test_add_preload_service(self): """Test add_preload_service without additional characteristics.""" acc = Mock() diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index c6d7954548735..51a965b581714 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -14,6 +14,7 @@ CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from tests.common import get_test_home_assistant +from tests.components.homekit.test_accessories import patch_debounce IP_ADDRESS = '127.0.0.1' PATH_HOMEKIT = 'homeassistant.components.homekit' @@ -22,6 +23,17 @@ class TestHomeKit(unittest.TestCase): """Test setup of HomeKit component and HomeKit class.""" + @classmethod + def setUpClass(cls): + """Setup debounce patcher.""" + cls.patcher = patch_debounce() + cls.patcher.start() + + @classmethod + def tearDownClass(cls): + """Stop debounce patcher.""" + cls.patcher.stop() + def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 1d18235d4a13b..af8676dfd742b 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -2,7 +2,6 @@ import unittest from homeassistant.core import callback -from homeassistant.components.homekit.type_lights import Light from homeassistant.components.light import ( DOMAIN, ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR) @@ -12,11 +11,26 @@ SERVICE_TURN_OFF, STATE_ON, STATE_OFF, STATE_UNKNOWN) from tests.common import get_test_home_assistant +from tests.components.homekit.test_accessories import patch_debounce class TestHomekitLights(unittest.TestCase): """Test class for all accessory types regarding lights.""" + @classmethod + def setUpClass(cls): + """Setup Light class import and debounce patcher.""" + cls.patcher = patch_debounce() + cls.patcher.start() + _import = __import__('homeassistant.components.homekit.type_lights', + fromlist=['Light']) + cls.light_cls = _import.Light + + @classmethod + def tearDownClass(cls): + """Stop debounce patcher.""" + cls.patcher.stop() + def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() @@ -38,7 +52,7 @@ def test_light_basic(self): entity_id = 'light.demo' self.hass.states.set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) self.assertEqual(acc.aid, 2) self.assertEqual(acc.category, 5) # Lightbulb self.assertEqual(acc.char_on.value, 0) @@ -82,7 +96,7 @@ def test_light_brightness(self): entity_id = 'light.demo' self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) self.assertEqual(acc.char_brightness.value, 0) acc.run() @@ -124,7 +138,7 @@ def test_light_color_temperature(self): self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP: 190}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) self.assertEqual(acc.char_color_temperature.value, 153) acc.run() @@ -146,7 +160,7 @@ def test_light_rgb_color(self): self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, ATTR_HS_COLOR: (260, 90)}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) self.assertEqual(acc.char_hue.value, 0) self.assertEqual(acc.char_saturation.value, 75) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index d363e26d71296..feea5c0d01a88 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -6,17 +6,32 @@ ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, STATE_COOL, STATE_HEAT, STATE_AUTO) -from homeassistant.components.homekit.type_thermostats import Thermostat from homeassistant.const import ( ATTR_SERVICE, EVENT_CALL_SERVICE, ATTR_SERVICE_DATA, ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant +from tests.components.homekit.test_accessories import patch_debounce class TestHomekitThermostats(unittest.TestCase): """Test class for all accessory types regarding thermostats.""" + @classmethod + def setUpClass(cls): + """Setup Thermostat class import and debounce patcher.""" + cls.patcher = patch_debounce() + cls.patcher.start() + _import = __import__( + 'homeassistant.components.homekit.type_thermostats', + fromlist=['Thermostat']) + cls.thermostat_cls = _import.Thermostat + + @classmethod + def tearDownClass(cls): + """Stop debounce patcher.""" + cls.patcher.stop() + def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() @@ -37,7 +52,7 @@ def test_default_thermostat(self): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = Thermostat(self.hass, climate, 'Climate', False, aid=2) + acc = self.thermostat_cls(self.hass, climate, 'Climate', False, aid=2) acc.run() self.assertEqual(acc.aid, 2) @@ -172,7 +187,7 @@ def test_auto_thermostat(self): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = Thermostat(self.hass, climate, 'Climate', True) + acc = self.thermostat_cls(self.hass, climate, 'Climate', True) acc.run() self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) @@ -242,7 +257,7 @@ def test_thermostat_fahrenheit(self): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = Thermostat(self.hass, climate, 'Climate', True) + acc = self.thermostat_cls(self.hass, climate, 'Climate', True) acc.run() self.hass.states.set(climate, STATE_AUTO, From fdf93d18298c89714e438dc5dfbba858f29de18e Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Fri, 6 Apr 2018 23:14:31 +0200 Subject: [PATCH 006/155] added support for smappee water sensors (#12831) * added support for smappee water sensors * fixed lint error and wrong location_id * fixed lint error * Use string formatting --- homeassistant/components/sensor/smappee.py | 76 ++++++++++++++++------ homeassistant/components/smappee.py | 21 ++++-- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py index c59798d16d724..5b84962144d5b 100644 --- a/homeassistant/components/sensor/smappee.py +++ b/homeassistant/components/sensor/smappee.py @@ -31,7 +31,19 @@ 'solar_today': ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kWh', 'solar'], 'power_today': - ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'] + ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'], + 'water_sensor_1': + ['Water Sensor 1', 'mdi:water', 'water', 'm3', 'value1'], + 'water_sensor_2': + ['Water Sensor 2', 'mdi:water', 'water', 'm3', 'value2'], + 'water_sensor_temperature': + ['Water Sensor Temperature', 'mdi:temperature-celsius', + 'water', '°', 'temperature'], + 'water_sensor_humidity': + ['Water Sensor Humidity', 'mdi:water-percent', 'water', + '%', 'humidity'], + 'water_sensor_battery': + ['Water Sensor Battery', 'mdi:battery', 'water', '%', 'battery'], } SCAN_INTERVAL = timedelta(seconds=30) @@ -43,36 +55,50 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] if smappee.is_remote_active: - for sensor in SENSOR_TYPES: - if 'remote' in SENSOR_TYPES[sensor]: - for location_id in smappee.locations.keys(): - dev.append(SmappeeSensor(smappee, location_id, sensor)) + for location_id in smappee.locations.keys(): + for sensor in SENSOR_TYPES: + if 'remote' in SENSOR_TYPES[sensor]: + dev.append(SmappeeSensor(smappee, location_id, + sensor, + SENSOR_TYPES[sensor])) + elif 'water' in SENSOR_TYPES[sensor]: + for items in smappee.info[location_id].get('sensors'): + dev.append(SmappeeSensor( + smappee, + location_id, + '{}:{}'.format(sensor, items.get('id')), + SENSOR_TYPES[sensor])) if smappee.is_local_active: - for sensor in SENSOR_TYPES: - if 'local' in SENSOR_TYPES[sensor]: - if smappee.is_remote_active: - for location_id in smappee.locations.keys(): - dev.append(SmappeeSensor(smappee, location_id, sensor)) - else: - dev.append(SmappeeSensor(smappee, None, sensor)) + for location_id in smappee.locations.keys(): + for sensor in SENSOR_TYPES: + if 'local' in SENSOR_TYPES[sensor]: + if smappee.is_remote_active: + dev.append(SmappeeSensor(smappee, location_id, sensor, + SENSOR_TYPES[sensor])) + else: + dev.append(SmappeeSensor(smappee, None, sensor, + SENSOR_TYPES[sensor])) + add_devices(dev, True) class SmappeeSensor(Entity): """Implementation of a Smappee sensor.""" - def __init__(self, smappee, location_id, sensor): - """Initialize the sensor.""" + def __init__(self, smappee, location_id, sensor, attributes): + """Initialize the Smappee sensor.""" self._smappee = smappee self._location_id = location_id + self._attributes = attributes self._sensor = sensor self.data = None self._state = None - self._name = SENSOR_TYPES[self._sensor][0] - self._icon = SENSOR_TYPES[self._sensor][1] - self._unit_of_measurement = SENSOR_TYPES[self._sensor][3] - self._smappe_name = SENSOR_TYPES[self._sensor][4] + self._name = self._attributes[0] + self._icon = self._attributes[1] + self._type = self._attributes[2] + self._unit_of_measurement = self._attributes[3] + self._smappe_name = self._attributes[4] @property def name(self): @@ -82,9 +108,7 @@ def name(self): else: location_name = 'Local' - return "{} {} {}".format(SENSOR_PREFIX, - location_name, - self._name) + return "{} {} {}".format(SENSOR_PREFIX, location_name, self._name) @property def icon(self): @@ -160,3 +184,13 @@ def update(self): if i['key'].endswith('phase5ActivePower')] power = sum(value1 + value2 + value3) / 1000 self._state = round(power, 2) + elif self._type == 'water': + sensor_name, sensor_id = self._sensor.split(":") + data = self._smappee.sensor_consumption[self._location_id]\ + .get(int(sensor_id)) + if data: + consumption = data.get('records')[-1] + _LOGGER.debug("%s (%s) %s", + sensor_name, sensor_id, consumption) + value = consumption.get(self._smappe_name) + self._state = value diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py index 1241679770b3d..b35cd8cf5a8ce 100644 --- a/homeassistant/components/smappee.py +++ b/homeassistant/components/smappee.py @@ -110,6 +110,7 @@ def __init__(self, client_id, client_secret, username, self.locations = {} self.info = {} self.consumption = {} + self.sensor_consumption = {} self.instantaneous = {} if self._remote_active or self._local_active: @@ -124,11 +125,22 @@ def update(self): for location in service_locations: location_id = location.get('serviceLocationId') if location_id is not None: + self.sensor_consumption[location_id] = {} self.locations[location_id] = location.get('name') self.info[location_id] = self._smappy \ .get_service_location_info(location_id) _LOGGER.debug("Remote info %s %s", - self.locations, self.info) + self.locations, self.info[location_id]) + + for sensors in self.info[location_id].get('sensors'): + sensor_id = sensors.get('id') + self.sensor_consumption[location_id]\ + .update({sensor_id: self.get_sensor_consumption( + location_id, sensor_id, + aggregation=3, delta=1440)}) + _LOGGER.debug("Remote sensors %s %s", + self.locations, + self.sensor_consumption[location_id]) self.consumption[location_id] = self.get_consumption( location_id, aggregation=3, delta=1440) @@ -190,7 +202,8 @@ def get_consumption(self, location_id, aggregation, delta): "Error getting comsumption from Smappee cloud. (%s)", error) - def get_sensor_consumption(self, location_id, sensor_id): + def get_sensor_consumption(self, location_id, sensor_id, + aggregation, delta): """Update data from Smappee.""" # Start & End accept epoch (in milliseconds), # datetime and pandas timestamps @@ -203,13 +216,13 @@ def get_sensor_consumption(self, location_id, sensor_id): if not self.is_remote_active: return - start = datetime.utcnow() - timedelta(minutes=30) end = datetime.utcnow() + start = end - timedelta(minutes=delta) try: return self._smappy.get_sensor_consumption(location_id, sensor_id, start, - end, 1) + end, aggregation) except RequestException as error: _LOGGER.error( "Error getting comsumption from Smappee cloud. (%s)", From 286476f0d61ab0b038b1ef767fcea700a2d0d470 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 7 Apr 2018 02:59:55 +0100 Subject: [PATCH 007/155] Initialise filter_sensor with historical values (#13075) * Initialise filter with historical values Added get_last_state_changes() * fix test * Major changes to accommodate history + time_SMA # Conflicts: # homeassistant/components/sensor/filter.py * hail the hound! * lint fixed * less debug * ups * get state from the proper entity * sensible default * No defaults in get_last_state_changes * list_reverseiterator instead of list * prev_state to state * Initialise filter with historical values Added get_last_state_changes() * fix test * Major changes to accommodate history + time_SMA # Conflicts: # homeassistant/components/sensor/filter.py * hail the hound! * lint fixed * less debug * ups * get state from the proper entity * sensible default * No defaults in get_last_state_changes * list_reverseiterator instead of list * prev_state to state * update * added window_unit * replace isinstance with window_unit --- homeassistant/components/history.py | 24 ++++ homeassistant/components/sensor/filter.py | 158 +++++++++++++++++----- tests/components/sensor/test_filter.py | 68 +++++++--- tests/components/test_history.py | 33 +++++ 4 files changed, 229 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 8ab91b08a3dcd..b5ac37b1451b2 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -118,6 +118,30 @@ def state_changes_during_period(hass, start_time, end_time=None, return states_to_json(hass, states, start_time, entity_ids) +def get_last_state_changes(hass, number_of_states, entity_id): + """Return the last number_of_states.""" + from homeassistant.components.recorder.models import States + + start_time = dt_util.utcnow() + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated)) + + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) + + entity_ids = [entity_id] if entity_id is not None else None + + states = execute( + query.order_by(States.last_updated.desc()).limit(number_of_states)) + + return states_to_json(hass, reversed(states), + start_time, + entity_ids, + include_start_time_state=False) + + def get_states(hass, utc_point_in_time, entity_ids=None, run=None, filters=None): """Return the states at a specific point in time.""" diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 3faf51a5f47ee..27730a8f63e4e 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -8,6 +8,9 @@ import statistics from collections import deque, Counter from numbers import Number +from functools import partial +from copy import copy +from datetime import timedelta import voluptuous as vol @@ -20,6 +23,7 @@ from homeassistant.util.decorator import Registry from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +import homeassistant.components.history as history import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -40,6 +44,9 @@ TIME_SMA_LAST = 'last' +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + DEFAULT_WINDOW_SIZE = 1 DEFAULT_PRECISION = 2 DEFAULT_FILTER_RADIUS = 2.0 @@ -123,21 +130,22 @@ def __init__(self, name, entity_id, filters): async def async_added_to_hass(self): """Register callbacks.""" @callback - def filter_sensor_state_listener(entity, old_state, new_state): + def filter_sensor_state_listener(entity, old_state, new_state, + update_ha=True): """Handle device state changes.""" if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return - temp_state = new_state.state + temp_state = new_state try: for filt in self._filters: - filtered_state = filt.filter_state(temp_state) + filtered_state = filt.filter_state(copy(temp_state)) _LOGGER.debug("%s(%s=%s) -> %s", filt.name, self._entity, - temp_state, + temp_state.state, "skip" if filt.skip_processing else - filtered_state) + filtered_state.state) if filt.skip_processing: return temp_state = filtered_state @@ -146,7 +154,7 @@ def filter_sensor_state_listener(entity, old_state, new_state): self._state) return - self._state = temp_state + self._state = temp_state.state if self._icon is None: self._icon = new_state.attributes.get( @@ -156,7 +164,50 @@ def filter_sensor_state_listener(entity, old_state, new_state): self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT) - self.async_schedule_update_ha_state() + if update_ha: + self.async_schedule_update_ha_state() + + if 'recorder' in self.hass.config.components: + history_list = [] + largest_window_items = 0 + largest_window_time = timedelta(0) + + # Determine the largest window_size by type + for filt in self._filters: + if filt.window_unit == WINDOW_SIZE_UNIT_NUMBER_EVENTS\ + and largest_window_items < filt.window_size: + largest_window_items = filt.window_size + elif filt.window_unit == WINDOW_SIZE_UNIT_TIME\ + and largest_window_time < filt.window_size: + largest_window_time = filt.window_size + + # Retrieve the largest window_size of each type + if largest_window_items > 0: + filter_history = await self.hass.async_add_job(partial( + history.get_last_state_changes, self.hass, + largest_window_items, entity_id=self._entity)) + history_list.extend( + [state for state in filter_history[self._entity]]) + if largest_window_time > timedelta(seconds=0): + start = dt_util.utcnow() - largest_window_time + filter_history = await self.hass.async_add_job(partial( + history.state_changes_during_period, self.hass, + start, entity_id=self._entity)) + history_list.extend( + [state for state in filter_history[self._entity] + if state not in history_list]) + + # Sort the window states + history_list = sorted(history_list, key=lambda s: s.last_updated) + _LOGGER.debug("Loading from history: %s", + [(s.state, s.last_updated) for s in history_list]) + + # Replay history through the filter chain + prev_state = None + for state in history_list: + filter_sensor_state_listener( + self._entity, prev_state, state, False) + prev_state = state async_track_state_change( self.hass, self._entity, filter_sensor_state_listener) @@ -195,6 +246,31 @@ def device_state_attributes(self): return state_attr +class FilterState(object): + """State abstraction for filter usage.""" + + def __init__(self, state): + """Initialize with HA State object.""" + self.timestamp = state.last_updated + try: + self.state = float(state.state) + except ValueError: + self.state = state.state + + def set_precision(self, precision): + """Set precision of Number based states.""" + if isinstance(self.state, Number): + self.state = round(float(self.state), precision) + + def __str__(self): + """Return state as the string representation of FilterState.""" + return str(self.state) + + def __repr__(self): + """Return timestamp and state as the representation of FilterState.""" + return "{} : {}".format(self.timestamp, self.state) + + class Filter(object): """Filter skeleton. @@ -207,11 +283,22 @@ class Filter(object): def __init__(self, name, window_size=1, precision=None, entity=None): """Initialize common attributes.""" - self.states = deque(maxlen=window_size) + if isinstance(window_size, int): + self.states = deque(maxlen=window_size) + self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS + else: + self.states = deque(maxlen=0) + self.window_unit = WINDOW_SIZE_UNIT_TIME self.precision = precision self._name = name self._entity = entity self._skip_processing = False + self._window_size = window_size + + @property + def window_size(self): + """Return window size.""" + return self._window_size @property def name(self): @@ -229,11 +316,11 @@ def _filter_state(self, new_state): def filter_state(self, new_state): """Implement a common interface for filters.""" - filtered = self._filter_state(new_state) - if isinstance(filtered, Number): - filtered = round(float(filtered), self.precision) - self.states.append(filtered) - return filtered + filtered = self._filter_state(FilterState(new_state)) + filtered.set_precision(self.precision) + self.states.append(copy(filtered)) + new_state.state = filtered.state + return new_state @FILTERS.register(FILTER_NAME_OUTLIER) @@ -254,11 +341,10 @@ def __init__(self, window_size, precision, entity, radius): def _filter_state(self, new_state): """Implement the outlier filter.""" - new_state = float(new_state) - if (self.states and - abs(new_state - statistics.median(self.states)) - > self._radius): + abs(new_state.state - + statistics.median([s.state for s in self.states])) > + self._radius): self._stats_internal['erasures'] += 1 @@ -284,16 +370,15 @@ def __init__(self, window_size, precision, entity, time_constant): def _filter_state(self, new_state): """Implement the low pass filter.""" - new_state = float(new_state) - if not self.states: return new_state new_weight = 1.0 / self._time_constant prev_weight = 1.0 - new_weight - filtered = prev_weight * self.states[-1] + new_weight * new_state + new_state.state = prev_weight * self.states[-1].state +\ + new_weight * new_state.state - return filtered + return new_state @FILTERS.register(FILTER_NAME_TIME_SMA) @@ -308,35 +393,36 @@ class TimeSMAFilter(Filter): def __init__(self, window_size, precision, entity, type): """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_SMA, 0, precision, entity) - self._time_window = int(window_size.total_seconds()) + super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + self._time_window = window_size self.last_leak = None self.queue = deque() - def _leak(self, now): + def _leak(self, left_boundary): """Remove timeouted elements.""" while self.queue: - timestamp, _ = self.queue[0] - if timestamp + self._time_window <= now: + if self.queue[0].timestamp + self._time_window <= left_boundary: self.last_leak = self.queue.popleft() else: return def _filter_state(self, new_state): - now = int(dt_util.utcnow().timestamp()) + """Implement the Simple Moving Average filter.""" + self._leak(new_state.timestamp) + self.queue.append(copy(new_state)) - self._leak(now) - self.queue.append((now, float(new_state))) moving_sum = 0 - start = now - self._time_window - _, prev_val = self.last_leak or (0, float(new_state)) + start = new_state.timestamp - self._time_window + prev_state = self.last_leak or self.queue[0] + for state in self.queue: + moving_sum += (state.timestamp-start).total_seconds()\ + * prev_state.state + start = state.timestamp + prev_state = state - for timestamp, val in self.queue: - moving_sum += (timestamp-start)*prev_val - start, prev_val = timestamp, val - moving_sum += (now-start)*prev_val + new_state.state = moving_sum / self._time_window.total_seconds() - return moving_sum/self._time_window + return new_state @FILTERS.register(FILTER_NAME_THROTTLE) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 0d4082731ab38..8b8e7607b0776 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -7,7 +7,9 @@ LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, assert_setup_component +import homeassistant.core as ha +from tests.common import (get_test_home_assistant, assert_setup_component, + init_recorder_component) class TestFilterSensor(unittest.TestCase): @@ -16,12 +18,24 @@ class TestFilterSensor(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.values = [20, 19, 18, 21, 22, 0] + raw_values = [20, 19, 18, 21, 22, 0] + self.values = [] + + timestamp = dt_util.utcnow() + for val in raw_values: + self.values.append(ha.State('sensor.test_monitored', + val, last_updated=timestamp)) + timestamp += timedelta(minutes=1) def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() + def init_recorder(self): + """Initialize the recorder.""" + init_recorder_component(self.hass) + self.hass.start() + def test_setup_fail(self): """Test if filter doesn't exist.""" config = { @@ -36,31 +50,52 @@ def test_setup_fail(self): def test_chain(self): """Test if filter chaining works.""" + self.init_recorder() config = { + 'history': { + }, 'sensor': { 'platform': 'filter', 'name': 'test', 'entity_id': 'sensor.test_monitored', + 'history_period': '00:05', 'filters': [{ 'filter': 'outlier', + 'window_size': 10, 'radius': 4.0 }, { 'filter': 'lowpass', - 'window_size': 4, 'time_constant': 10, 'precision': 2 }] } } - with assert_setup_component(1): - assert setup_component(self.hass, 'sensor', config) + t_0 = dt_util.utcnow() - timedelta(minutes=1) + t_1 = dt_util.utcnow() - timedelta(minutes=2) + t_2 = dt_util.utcnow() - timedelta(minutes=3) - for value in self.values: - self.hass.states.set(config['sensor']['entity_id'], value) - self.hass.block_till_done() + fake_states = { + 'sensor.test_monitored': [ + ha.State('sensor.test_monitored', 18.0, last_changed=t_0), + ha.State('sensor.test_monitored', 19.0, last_changed=t_1), + ha.State('sensor.test_monitored', 18.2, last_changed=t_2), + ] + } - state = self.hass.states.get('sensor.test') - self.assertEqual('20.25', state.state) + with patch('homeassistant.components.history.' + 'state_changes_during_period', return_value=fake_states): + with patch('homeassistant.components.history.' + 'get_last_state_changes', return_value=fake_states): + with assert_setup_component(1, 'sensor'): + assert setup_component(self.hass, 'sensor', config) + + for value in self.values: + self.hass.states.set( + config['sensor']['entity_id'], value.state) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual('19.25', state.state) def test_outlier(self): """Test if outlier filter works.""" @@ -70,7 +105,7 @@ def test_outlier(self): radius=4.0) for state in self.values: filtered = filt.filter_state(state) - self.assertEqual(22, filtered) + self.assertEqual(22, filtered.state) def test_lowpass(self): """Test if lowpass filter works.""" @@ -80,7 +115,7 @@ def test_lowpass(self): time_constant=10) for state in self.values: filtered = filt.filter_state(state) - self.assertEqual(18.05, filtered) + self.assertEqual(18.05, filtered.state) def test_throttle(self): """Test if lowpass filter works.""" @@ -92,7 +127,7 @@ def test_throttle(self): new_state = filt.filter_state(state) if not filt.skip_processing: filtered.append(new_state) - self.assertEqual([20, 21], filtered) + self.assertEqual([20, 21], [f.state for f in filtered]) def test_time_sma(self): """Test if time_sma filter works.""" @@ -100,9 +135,6 @@ def test_time_sma(self): precision=2, entity=None, type='last') - past = dt_util.utcnow() - timedelta(minutes=5) for state in self.values: - with patch('homeassistant.util.dt.utcnow', return_value=past): - filtered = filt.filter_state(state) - past += timedelta(minutes=1) - self.assertEqual(21.5, filtered) + filtered = filt.filter_state(state) + self.assertEqual(21.5, filtered.state) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index bea2af396cbc5..5d909492380c1 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -131,6 +131,39 @@ def set_state(state): self.assertEqual(states, hist[entity_id]) + def test_get_last_state_changes(self): + """Test number of state changes.""" + self.init_recorder() + entity_id = 'sensor.test' + + def set_state(state): + """Set the state.""" + self.hass.states.set(entity_id, state) + self.wait_recording_done() + return self.hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1) + + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=start): + set_state('1') + + states = [] + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=point): + states.append(set_state('2')) + + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=point2): + states.append(set_state('3')) + + hist = history.get_last_state_changes( + self.hass, 2, entity_id) + + self.assertEqual(states, hist[entity_id]) + def test_get_significant_states(self): """Test that only significant states are returned. From 58f3690ef6b8664501b8f74b8e1684a03b205009 Mon Sep 17 00:00:00 2001 From: David Broadfoot Date: Sat, 7 Apr 2018 13:48:53 +1000 Subject: [PATCH 008/155] Fix Gogogate2 'available' attribute (#13728) * Fixed bug - unable to set base readaonly property * PR fixes * Added line --- homeassistant/components/cover/gogogate2.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/cover/gogogate2.py b/homeassistant/components/cover/gogogate2.py index c2bdc9c5472fd..99da248b09456 100644 --- a/homeassistant/components/cover/gogogate2.py +++ b/homeassistant/components/cover/gogogate2.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, STATE_UNKNOWN, + CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, CONF_IP_ADDRESS, CONF_NAME) import homeassistant.helpers.config_validation as cv @@ -50,7 +50,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(MyGogogate2Device( mygogogate2, door, name) for door in devices) - return except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) @@ -60,7 +59,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ''.format(ex), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) - return class MyGogogate2Device(CoverDevice): @@ -72,7 +70,7 @@ def __init__(self, mygogogate2, device, name): self.device_id = device['door'] self._name = name or device['name'] self._status = device['status'] - self.available = None + self._available = None @property def name(self): @@ -97,24 +95,22 @@ def supported_features(self): @property def available(self): """Could the device be accessed during the last update call.""" - return self.available + return self._available def close_cover(self, **kwargs): """Issue close command to cover.""" self.mygogogate2.close_device(self.device_id) - self.schedule_update_ha_state(True) def open_cover(self, **kwargs): """Issue open command to cover.""" self.mygogogate2.open_device(self.device_id) - self.schedule_update_ha_state(True) def update(self): """Update status of cover.""" try: self._status = self.mygogogate2.get_status(self.device_id) - self.available = True + self._available = True except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) - self._status = STATE_UNKNOWN - self.available = False + self._status = None + self._available = False From b0fd2342dbdf812bb1a8ec0cab07ec0f0e990f03 Mon Sep 17 00:00:00 2001 From: thrawnarn Date: Sat, 7 Apr 2018 10:09:09 +0200 Subject: [PATCH 009/155] Bluesound bugfix status 595 and await (#13727) * 595 fix * Await fixes and last 595 fix * Lint * Made internal exception class * Fix lint issue --- .../components/media_player/bluesound.py | 125 +++++++++--------- 1 file changed, 66 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index 1b6310d4cab91..283c4af032e63 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -37,30 +37,30 @@ _LOGGER = logging.getLogger(__name__) -STATE_GROUPED = 'grouped' - ATTR_MASTER = 'master' -SERVICE_JOIN = 'bluesound_join' -SERVICE_UNJOIN = 'bluesound_unjoin' -SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' -SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' - DATA_BLUESOUND = 'bluesound' DEFAULT_PORT = 11000 +NODE_OFFLINE_CHECK_TIMEOUT = 180 +NODE_RETRY_INITIATION = timedelta(minutes=3) + +SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' +SERVICE_JOIN = 'bluesound_join' +SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' +SERVICE_UNJOIN = 'bluesound_unjoin' +STATE_GROUPED = 'grouped' SYNC_STATUS_INTERVAL = timedelta(minutes=5) + UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) -UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) -NODE_OFFLINE_CHECK_TIMEOUT = 180 -NODE_RETRY_INITIATION = timedelta(minutes=3) +UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }]) }) @@ -131,8 +131,8 @@ def _add_player_cb(): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) -async def async_setup_platform(hass, config, async_add_devices, - discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Bluesound platforms.""" if DATA_BLUESOUND not in hass.data: hass.data[DATA_BLUESOUND] = [] @@ -202,6 +202,9 @@ def __init__(self, hass, host, port=None, name=None, init_callback=None): if self.port is None: self.port = DEFAULT_PORT + class _TimeoutException(Exception): + pass + @staticmethod def _try_get_index(string, search_string): """Get the index.""" @@ -258,7 +261,8 @@ async def _start_poll_command(self): while True: await self.async_update_status() - except (asyncio.TimeoutError, ClientError): + except (asyncio.TimeoutError, ClientError, + BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s is offline, retrying later", self._name) await asyncio.sleep( NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) @@ -293,8 +297,8 @@ async def async_init(self, triggered=None): self._retry_remove = async_track_time_interval( self._hass, self.async_init, NODE_RETRY_INITIATION) except Exception: - _LOGGER.exception("Unexpected when initiating error in %s", - self.host) + _LOGGER.exception( + "Unexpected when initiating error in %s", self.host) raise async def async_update(self): @@ -307,8 +311,8 @@ async def async_update(self): await self.async_update_captures() await self.async_update_services() - async def send_bluesound_command(self, method, raise_timeout=False, - allow_offline=False): + async def send_bluesound_command( + self, method, raise_timeout=False, allow_offline=False): """Send command to the player.""" import xmltodict @@ -321,6 +325,7 @@ async def send_bluesound_command(self, method, raise_timeout=False, _LOGGER.debug("Calling URL: %s", url) response = None + try: websession = async_get_clientsession(self._hass) with async_timeout.timeout(10, loop=self._hass.loop): @@ -332,6 +337,9 @@ async def send_bluesound_command(self, method, raise_timeout=False, data = None else: data = xmltodict.parse(result) + elif response.status == 595: + _LOGGER.info("Status 595 returned, treating as timeout") + raise BluesoundPlayer._TimeoutException() else: _LOGGER.error("Error %s on %s", response.status, url) return None @@ -366,13 +374,9 @@ async def async_update_status(self): with async_timeout.timeout(125, loop=self._hass.loop): response = await self._polling_session.get( - url, - headers={CONNECTION: KEEP_ALIVE}) + url, headers={CONNECTION: KEEP_ALIVE}) - if response.status != 200: - _LOGGER.error("Error %s on %s. Trying one more time.", - response.status, url) - else: + if response.status == 200: result = await response.text() self._is_online = True self._last_status_update = dt_util.utcnow() @@ -380,8 +384,8 @@ async def async_update_status(self): group_name = self._status.get('groupName', None) if group_name != self._group_name: - _LOGGER.debug('Group name change detected on device: %s', - self.host) + _LOGGER.debug( + "Group name change detected on device: %s", self.host) self._group_name = group_name # the sleep is needed to make sure that the # devices is synced @@ -398,14 +402,20 @@ async def async_update_status(self): await self.force_update_sync_status() self.async_schedule_update_ha_state() + elif response.status == 595: + _LOGGER.info("Status 595 returned, treating as timeout") + raise BluesoundPlayer._TimeoutException() + else: + _LOGGER.error("Error %s on %s. Trying one more time", + response.status, url) except (asyncio.TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None self.async_schedule_update_ha_state() - _LOGGER.info("Client connection error, marking %s as offline", - self._name) + _LOGGER.info( + "Client connection error, marking %s as offline", self._name) raise async def async_trigger_sync_on_all(self): @@ -416,8 +426,8 @@ async def async_trigger_sync_on_all(self): await player.force_update_sync_status() @Throttle(SYNC_STATUS_INTERVAL) - async def async_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + async def async_update_sync_status( + self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" await self.force_update_sync_status( on_updated_cb, raise_timeout=False) @@ -465,7 +475,7 @@ def _create_preset_item(item): 'image': item.get('@image', ''), 'is_raw_url': True, 'url2': item.get('@url', ''), - 'url': 'Preset?id=' + item.get('@id', '') + 'url': 'Preset?id={}'.format(item.get('@id', '')) }) if 'presets' in resp and 'preset' in resp['presets']: @@ -503,11 +513,6 @@ def _create_service_item(item): return self._services_items - @property - def should_poll(self): - """No need to poll information.""" - return True - @property def media_content_type(self): """Content type of current playing media.""" @@ -803,22 +808,22 @@ async def async_unjoin(self): async def async_add_slave(self, slave_device): """Add slave to master.""" - return self.send_bluesound_command('/AddSlave?slave={}&port={}' - .format(slave_device.host, - slave_device.port)) + return await self.send_bluesound_command( + '/AddSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) async def async_remove_slave(self, slave_device): """Remove slave to master.""" - return self.send_bluesound_command('/RemoveSlave?slave={}&port={}' - .format(slave_device.host, - slave_device.port)) + return await self.send_bluesound_command( + '/RemoveSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) async def async_increase_timer(self): """Increase sleep time on player.""" sleep_time = await self.send_bluesound_command('/Sleep') if sleep_time is None: - _LOGGER.error('Error while increasing sleep time on player: %s', - self.host) + _LOGGER.error( + "Error while increasing sleep time on player: %s", self.host) return 0 return int(sleep_time.get('sleep', '0')) @@ -831,8 +836,9 @@ async def async_clear_timer(self): async def async_set_shuffle(self, shuffle): """Enable or disable shuffle mode.""" - return self.send_bluesound_command('/Shuffle?state={}' - .format('1' if shuffle else '0')) + value = '1' if shuffle else '0' + return await self.send_bluesound_command( + '/Shuffle?state={}'.format(value)) async def async_select_source(self, source): """Select input source.""" @@ -856,14 +862,14 @@ async def async_select_source(self, source): if 'is_raw_url' in selected_source and selected_source['is_raw_url']: url = selected_source['url'] - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) async def async_clear_playlist(self): """Clear players playlist.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Clear') + return await self.send_bluesound_command('Clear') async def async_media_next_track(self): """Send media_next command to media player.""" @@ -877,7 +883,7 @@ async def async_media_next_track(self): action['@name'] == 'skip'): cmd = action['@url'] - return self.send_bluesound_command(cmd) + return await self.send_bluesound_command(cmd) async def async_media_previous_track(self): """Send media_previous command to media player.""" @@ -891,35 +897,36 @@ async def async_media_previous_track(self): action['@name'] == 'back'): cmd = action['@url'] - return self.send_bluesound_command(cmd) + return await self.send_bluesound_command(cmd) async def async_media_play(self): """Send media_play command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Play') + return await self.send_bluesound_command('Play') async def async_media_pause(self): """Send media_pause command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Pause') + return await self.send_bluesound_command('Pause') async def async_media_stop(self): """Send stop command.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Pause') + return await self.send_bluesound_command('Pause') async def async_media_seek(self, position): """Send media_seek command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Play?seek=' + str(float(position))) + return await self.send_bluesound_command( + 'Play?seek={}'.format(float(position))) async def async_play_media(self, media_type, media_id, **kwargs): """ @@ -933,9 +940,9 @@ async def async_play_media(self, media_type, media_id, **kwargs): url = 'Play?url={}'.format(media_id) if kwargs.get(ATTR_MEDIA_ENQUEUE): - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) async def async_volume_up(self): """Volume up the media player.""" @@ -957,7 +964,7 @@ async def async_set_volume_level(self, volume): volume = 0 elif volume > 1: volume = 1 - return self.send_bluesound_command( + return await self.send_bluesound_command( 'Volume?level=' + str(float(volume) * 100)) async def async_mute_volume(self, mute): @@ -966,7 +973,7 @@ async def async_mute_volume(self, mute): volume = self.volume_level if volume > 0: self._lastvol = volume - return self.send_bluesound_command('Volume?level=0') + return await self.send_bluesound_command('Volume?level=0') else: - return self.send_bluesound_command( + return await self.send_bluesound_command( 'Volume?level=' + str(float(self._lastvol) * 100)) From fbb8a54c391336ea791be1374cc8ca6e3a0d5f4c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 7 Apr 2018 10:40:34 +0200 Subject: [PATCH 010/155] Upgrade aiohttp to 3.1.2 (#13732) --- 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 85f8d5dcf12e7..9e21055f0c105 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==3.1.1 +aiohttp==3.1.2 async_timeout==2.0.1 astral==1.6 certifi>=2017.4.17 diff --git a/requirements_all.txt b/requirements_all.txt index 7af7bdb95ec11..b2cb5155aec00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==3.1.1 +aiohttp==3.1.2 async_timeout==2.0.1 astral==1.6 certifi>=2017.4.17 diff --git a/setup.py b/setup.py index db4b1f8df92fa..602c1d19cbd3c 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ 'jinja2>=2.10', 'voluptuous==0.11.1', 'typing>=3,<4', - 'aiohttp==3.1.1', + 'aiohttp==3.1.2', 'async_timeout==2.0.1', 'astral==1.6', 'certifi>=2017.4.17', From ca3cc27e400b5791702f622198526bdcf45dc1cc Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 7 Apr 2018 10:41:35 +0200 Subject: [PATCH 011/155] Upgrade sqlalchemy to 1.2.6 (#13733) --- homeassistant/components/recorder/__init__.py | 7 +++---- homeassistant/components/sensor/sql.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index f10e0fc75d7e3..64e2b85f611e5 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,7 +35,7 @@ from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.5'] +REQUIREMENTS = ['sqlalchemy==1.2.6'] _LOGGER = logging.getLogger(__name__) @@ -47,9 +47,8 @@ ATTR_REPACK = 'repack' SERVICE_PURGE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_KEEP_DAYS): - vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(ATTR_REPACK, default=False): cv.boolean + vol.Optional(ATTR_KEEP_DAYS): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(ATTR_REPACK, default=False): cv.boolean, }) DEFAULT_URL = 'sqlite:///{hass_config_path}' diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index af9fa233d4081..eeca31fa36bf7 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.5'] +REQUIREMENTS = ['sqlalchemy==1.2.6'] CONF_QUERIES = 'queries' CONF_QUERY = 'query' diff --git a/requirements_all.txt b/requirements_all.txt index b2cb5155aec00..e71616d6ddf52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1196,7 +1196,7 @@ spotcrime==1.0.3 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.5 +sqlalchemy==1.2.6 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 645b56b9e62df..ce20ecfbfc6aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -179,7 +179,7 @@ somecomfort==0.5.0 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.5 +sqlalchemy==1.2.6 # homeassistant.components.statsd statsd==3.2.1 From 2bf17cba8e3e653394f87d94b615e7555a15d1c2 Mon Sep 17 00:00:00 2001 From: shred86 <32663154+shred86@users.noreply.github.com> Date: Sat, 7 Apr 2018 01:15:35 -0800 Subject: [PATCH 012/155] Brightness conversion for Abode dimmers (#13711) With AbodePy 0.12.3, dimmers will now work but a conversion of the brightness is required. Additionally, when a brightness value of 100 is sent to Abode, 99 is returned causing AbodePy to throw an error so this component will send 99 instead of 100. Keeps the brightness value sent and returned from the device response consistent. However, during initialization and when a device refresh is received, Abode can return 100 thus we'll convert that case back to 99. --- homeassistant/components/light/abode.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) mode change 100644 => 100755 homeassistant/components/light/abode.py diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py old mode 100644 new mode 100755 index bfea19fc3faca..8b7e09d86bceb --- a/homeassistant/components/light/abode.py +++ b/homeassistant/components/light/abode.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/light.abode/ """ import logging - +from math import ceil from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -51,7 +51,9 @@ def turn_on(self, **kwargs): *kwargs[ATTR_HS_COLOR])) if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: - self._device.set_level(kwargs[ATTR_BRIGHTNESS]) + # Convert HASS brightness (0-255) to Abode brightness (0-99) + # If 100 is sent to Abode, response is 99 causing an error + self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0)) else: self._device.switch_on() @@ -68,7 +70,12 @@ def is_on(self): def brightness(self): """Return the brightness of the light.""" if self._device.is_dimmable and self._device.has_brightness: - return self._device.brightness + brightness = int(self._device.brightness) + # Abode returns 100 during device initialization and device refresh + if brightness == 100: + return 255 + # Convert Abode brightness (0-99) to HASS brightness (0-255) + return ceil(brightness * 255 / 99.0) @property def hs_color(self): From 3084ac16258a3ee856a8891bd33f10bb8da5d53c Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 7 Apr 2018 12:44:08 +0100 Subject: [PATCH 013/155] Update CODEOWNERS (sensor.filter, sensor.upnp) (#13736) --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 67aef6a248f91..528716e174d6e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -63,6 +63,7 @@ homeassistant/components/media_player/xiaomi_tv.py @fattdev homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel @@ -72,6 +73,7 @@ homeassistant/components/sensor/sma.py @kellerza homeassistant/components/sensor/sql.py @dgomes homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen +homeassistant/components/sensor/upnp.py @dgomes homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/switch/rainmachine.py @bachya homeassistant/components/switch/tplink.py @rytilahti From 435b49fb96c0acd3aa86c9a7dfdbc1974a719e87 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 7 Apr 2018 19:11:48 +0200 Subject: [PATCH 014/155] Reset permission (#13743) --- homeassistant/components/light/abode.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 homeassistant/components/light/abode.py diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py old mode 100755 new mode 100644 From 6cd599b7dfc8382b13325b350d17ecac4475bedb Mon Sep 17 00:00:00 2001 From: dangyuluo Date: Sat, 7 Apr 2018 15:47:56 -0400 Subject: [PATCH 015/155] Throw an error when invalid device_mode is given (#13739) * Throw an error when invalid device_mode is given * Fix lint issue, typo and error msg * Fix error msg --- homeassistant/components/climate/nest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index d11f6890a7b31..0a5344fdf9899 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -187,6 +187,11 @@ def set_operation_mode(self, operation_mode): device_mode = operation_mode elif operation_mode == STATE_AUTO: device_mode = NEST_MODE_HEAT_COOL + else: + device_mode = STATE_OFF + _LOGGER.error( + "An error occurred while setting device mode. " + "Invalid operation mode: %s", operation_mode) self.device.mode = device_mode @property From f915a1c80982164775203bcd84f37335465ea61c Mon Sep 17 00:00:00 2001 From: Kane610 Date: Sat, 7 Apr 2018 23:18:49 +0200 Subject: [PATCH 016/155] Fix so it is possible to ignore discovered config entry handlers (#13741) * Fix so it is possible to ignore discovered config entry handlers * Improve efficiency --- homeassistant/components/discovery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index b2aa5b890a8de..01ef36b778b29 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -84,7 +84,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(DOMAIN): vol.Schema({ vol.Optional(CONF_IGNORE, default=[]): - vol.All(cv.ensure_list, [vol.In(SERVICE_HANDLERS)]) + vol.All(cv.ensure_list, [ + vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]) }), }, extra=vol.ALLOW_EXTRA) From 99f4509c2b4872e04b18e6e2d2ea836941efbfc0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 7 Apr 2018 23:19:55 +0200 Subject: [PATCH 017/155] Upgrade netdisco to 1.3.1 (#13744) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 01ef36b778b29..677a13d6a9d09 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.3.0'] +REQUIREMENTS = ['netdisco==1.3.1'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index e71616d6ddf52..532c723365b0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ nad_receiver==0.0.9 nanoleaf==0.4.1 # homeassistant.components.discovery -netdisco==1.3.0 +netdisco==1.3.1 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From 81b1d08d3580257f29c74a8110421fc3d9f4f6ee Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 8 Apr 2018 04:32:09 +0200 Subject: [PATCH 018/155] Add MQTT Sensor unique_id (#13318) * Add MQTT Sensor unique_id * Add test * Update comment --- homeassistant/components/sensor/mqtt.py | 15 ++++++++++++++- tests/components/sensor/test_mqtt.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index d191b9a22e88f..c4f64e9e01527 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -8,6 +8,7 @@ import logging import json from datetime import timedelta +from typing import Optional import voluptuous as vol @@ -28,6 +29,7 @@ CONF_EXPIRE_AFTER = 'expire_after' CONF_JSON_ATTRS = 'json_attributes' +CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Sensor' DEFAULT_FORCE_UPDATE = False @@ -40,6 +42,9 @@ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + # Integrations shouldn't never expose unique_id through configuration + # this here is an exception because MQTT is a msg transport, not a protocol + vol.Optional(CONF_UNIQUE_ID): cv.string, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -63,6 +68,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_ICON), value_template, config.get(CONF_JSON_ATTRS), + config.get(CONF_UNIQUE_ID), config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), @@ -74,7 +80,8 @@ class MqttSensor(MqttAvailability, Entity): def __init__(self, name, state_topic, qos, unit_of_measurement, force_update, expire_after, icon, value_template, - json_attributes, availability_topic, payload_available, + json_attributes, unique_id: Optional[str], + availability_topic, payload_available, payload_not_available): """Initialize the sensor.""" super().__init__(availability_topic, qos, payload_available, @@ -90,6 +97,7 @@ def __init__(self, name, state_topic, qos, unit_of_measurement, self._icon = icon self._expiration_trigger = None self._json_attributes = set(json_attributes) + self._unique_id = unique_id self._attributes = None @asyncio.coroutine @@ -174,6 +182,11 @@ def device_state_attributes(self): """Return the state attributes.""" return self._attributes + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def icon(self): """Return the icon.""" diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index b23d89e305784..88e74e1100825 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -329,3 +329,24 @@ def test_update_with_json_attrs_and_template(self): self.assertEqual('100', state.attributes.get('val')) self.assertEqual('100', state.state) + + def test_unique_id(self): + """Test unique id option only creates one sensor per unique_id.""" + assert setup_component(self.hass, sensor.DOMAIN, { + sensor.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + fire_mqtt_message(self.hass, 'test-topic', 'payload') + self.hass.block_till_done() + + assert len(self.hass.states.all()) == 1 From 40d7857f3b7a8b2b1522e4d1a7f59f7ac3617b06 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 7 Apr 2018 23:04:50 -0400 Subject: [PATCH 019/155] Prepare entity component for config entries (#13730) * Prepare entity component for config entries * Return in time --- homeassistant/helpers/entity_component.py | 55 ++++++++++------------- homeassistant/helpers/entity_platform.py | 30 ++++++++++--- tests/common.py | 4 +- 3 files changed, 49 insertions(+), 40 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index f086437c10dc9..6ff9b6f657139 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -40,16 +40,7 @@ def __init__(self, logger, domain, hass, self.config = None self._platforms = { - domain: EntityPlatform( - hass=hass, - logger=logger, - domain=domain, - platform_name=domain, - scan_interval=self.scan_interval, - parallel_updates=0, - entity_namespace=None, - async_entities_added_callback=self._async_update_group, - ) + domain: self._async_init_entity_platform(domain, None) } self.async_add_entities = self._platforms[domain].async_add_entities self.add_entities = self._platforms[domain].add_entities @@ -127,34 +118,19 @@ async def _async_setup_platform(self, platform_type, platform_config, if platform is None: return - # Config > Platform > Component - scan_interval = ( - platform_config.get(CONF_SCAN_INTERVAL) or - getattr(platform, 'SCAN_INTERVAL', None) or self.scan_interval) - parallel_updates = getattr( - platform, 'PARALLEL_UPDATES', - int(not hasattr(platform, 'async_setup_platform'))) - + # Use config scan interval, fallback to platform if none set + scan_interval = platform_config.get( + CONF_SCAN_INTERVAL, getattr(platform, 'SCAN_INTERVAL', None)) entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE) key = (platform_type, scan_interval, entity_namespace) if key not in self._platforms: - entity_platform = self._platforms[key] = EntityPlatform( - hass=self.hass, - logger=self.logger, - domain=self.domain, - platform_name=platform_type, - scan_interval=scan_interval, - parallel_updates=parallel_updates, - entity_namespace=entity_namespace, - async_entities_added_callback=self._async_update_group, + self._platforms[key] = self._async_init_entity_platform( + platform_type, platform, scan_interval, entity_namespace ) - else: - entity_platform = self._platforms[key] - await entity_platform.async_setup( - platform, platform_config, discovery_info) + await self._platforms[key].async_setup(platform_config, discovery_info) @callback def _async_update_group(self): @@ -219,3 +195,20 @@ async def async_prepare_reload(self): await self._async_reset() return conf + + def _async_init_entity_platform(self, platform_type, platform, + scan_interval=None, entity_namespace=None): + """Helper to initialize an entity platform.""" + if scan_interval is None: + scan_interval = self.scan_interval + + return EntityPlatform( + hass=self.hass, + logger=self.logger, + domain=self.domain, + platform_name=platform_type, + platform=platform, + scan_interval=scan_interval, + entity_namespace=entity_namespace, + async_entities_added_callback=self._async_update_group, + ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 501ab5057a368..3c6deaba94af9 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -20,8 +20,8 @@ class EntityPlatform(object): """Manage the entities for a single platform.""" - def __init__(self, *, hass, logger, domain, platform_name, scan_interval, - parallel_updates, entity_namespace, + def __init__(self, *, hass, logger, domain, platform_name, platform, + scan_interval, entity_namespace, async_entities_added_callback): """Initialize the entity platform. @@ -38,8 +38,8 @@ def __init__(self, *, hass, logger, domain, platform_name, scan_interval, self.logger = logger self.domain = domain self.platform_name = platform_name + self.platform = platform self.scan_interval = scan_interval - self.parallel_updates = None self.entity_namespace = entity_namespace self.async_entities_added_callback = async_entities_added_callback self.entities = {} @@ -47,13 +47,30 @@ def __init__(self, *, hass, logger, domain, platform_name, scan_interval, self._async_unsub_polling = None self._process_updates = asyncio.Lock(loop=hass.loop) + # Platform is None for the EntityComponent "catch-all" EntityPlatform + # which powers entity_component.add_entities + if platform is None: + self.parallel_updates = None + return + + # Async platforms do all updates in parallel by default + if hasattr(platform, 'async_setup_platform'): + default_parallel_updates = 0 + else: + default_parallel_updates = 1 + + parallel_updates = getattr(platform, 'PARALLEL_UPDATES', + default_parallel_updates) + if parallel_updates: self.parallel_updates = asyncio.Semaphore( parallel_updates, loop=hass.loop) + else: + self.parallel_updates = None - async def async_setup(self, platform, platform_config, discovery_info=None, - tries=0): + async def async_setup(self, platform_config, discovery_info=None, tries=0): """Setup the platform.""" + platform = self.platform logger = self.logger hass = self.hass full_name = '{}.{}'.format(self.domain, self.platform_name) @@ -98,8 +115,7 @@ async def async_setup(self, platform, platform_config, discovery_info=None, 'Platform %s not ready yet. Retrying in %d seconds.', self.platform_name, wait_time) async_track_point_in_time( - hass, self.async_setup( - platform, platform_config, discovery_info, tries), + hass, self.async_setup(platform_config, discovery_info, tries), dt_util.utcnow() + timedelta(seconds=wait_time)) except asyncio.TimeoutError: logger.error( diff --git a/tests/common.py b/tests/common.py index bc84b3493a8ec..388898e702436 100644 --- a/tests/common.py +++ b/tests/common.py @@ -370,8 +370,8 @@ def __init__( logger=None, domain='test_domain', platform_name='test_platform', + platform=None, scan_interval=timedelta(seconds=15), - parallel_updates=0, entity_namespace=None, async_entities_added_callback=lambda: None ): @@ -381,8 +381,8 @@ def __init__( logger=logger, domain=domain, platform_name=platform_name, + platform=platform, scan_interval=scan_interval, - parallel_updates=parallel_updates, entity_namespace=entity_namespace, async_entities_added_callback=async_entities_added_callback, ) From ef16c53e4650f6001f7289ec07a18e1764c77b09 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 8 Apr 2018 10:32:49 +0100 Subject: [PATCH 020/155] Check valid file on get_size (#13756) Addresses https://github.com/home-assistant/home-assistant/issues/13754 --- homeassistant/components/sensor/folder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py index a185cd1e82554..2b5f3dd430923 100644 --- a/homeassistant/components/sensor/folder.py +++ b/homeassistant/components/sensor/folder.py @@ -38,7 +38,7 @@ def get_files_list(folder_path, filter_term): def get_size(files_list): """Return the sum of the size in bytes of files in the list.""" - size_list = [os.stat(f).st_size for f in files_list] + size_list = [os.stat(f).st_size for f in files_list if os.path.isfile(f)] return sum(size_list) From b01dceaff2c6188e69ee9706a9fa6c547a9f131a Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 8 Apr 2018 21:59:19 +0200 Subject: [PATCH 021/155] Qwikswitch sensors (#13622) --- .coveragerc | 4 +- homeassistant/components/light/qwikswitch.py | 4 +- homeassistant/components/qwikswitch.py | 116 +++++++++--------- homeassistant/components/sensor/qwikswitch.py | 54 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_qwikswitch.py | 90 ++++++++++++++ 8 files changed, 182 insertions(+), 92 deletions(-) create mode 100644 tests/components/sensor/test_qwikswitch.py diff --git a/.coveragerc b/.coveragerc index e9c69d137e2a0..6b1ca91a574b3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -190,8 +190,8 @@ omit = homeassistant/components/pilight.py homeassistant/components/*/pilight.py - homeassistant/components/qwikswitch.py - homeassistant/components/*/qwikswitch.py + homeassistant/components/switch/qwikswitch.py + homeassistant/components/light/qwikswitch.py homeassistant/components/rachio.py homeassistant/components/*/rachio.py diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index 26741525b8fc5..528f4f73c53d5 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -27,9 +27,9 @@ class QSLight(QSToggleEntity, Light): @property def brightness(self): """Return the brightness of this light (0-255).""" - return self._qsusb[self.qsid, 1] if self._dim else None + return self.device.value if self.device.is_dimmer else None @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS if self._dim else 0 + return SUPPORT_BRIGHTNESS if self.device.is_dimmer else 0 diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 708eff7cf118a..36bd726fa2dc2 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyqwikswitch==0.6'] +REQUIREMENTS = ['pyqwikswitch==0.7'] _LOGGER = logging.getLogger(__name__) @@ -34,17 +34,48 @@ vol.Coerce(str), vol.Optional(CONF_DIMMER_ADJUST, default=1): CV_DIM_VALUE, vol.Optional(CONF_BUTTON_EVENTS, default=[]): cv.ensure_list_csv, - vol.Optional(CONF_SENSORS, default={}): vol.Schema({cv.slug: str}), + vol.Optional(CONF_SENSORS, default=[]): vol.All( + cv.ensure_list, [vol.Schema({ + vol.Required('id'): str, + vol.Optional('channel', default=1): int, + vol.Required('name'): str, + vol.Required('type'): str, + })]), vol.Optional(CONF_SWITCHES, default=[]): vol.All( cv.ensure_list, [str]) })}, extra=vol.ALLOW_EXTRA) -class QSToggleEntity(Entity): - """Representation of a Qwikswitch Entity. +class QSEntity(Entity): + """Qwikswitch Entity base.""" + + def __init__(self, qsid, name): + """Initialize the QSEntity.""" + self._name = name + self.qsid = qsid + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def poll(self): + """QS sensors gets packets in update_packet.""" + return False + + def update_packet(self, packet): + """Receive update packet from QSUSB. Match dispather_send signature.""" + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Listen for updates from QSUSb via dispatcher.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + self.qsid, self.update_packet) - Implement base QS methods. Modeled around HA ToggleEntity[1] & should only - be used in a class that extends both QSToggleEntity *and* ToggleEntity. + +class QSToggleEntity(QSEntity): + """Representation of a Qwikswitch Toggle Entity. Implemented: - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) @@ -57,52 +88,28 @@ class QSToggleEntity(Entity): def __init__(self, qsid, qsusb): """Initialize the ToggleEntity.""" - from pyqwikswitch import (QS_NAME, QSDATA, QS_TYPE, QSType) - self.qsid = qsid - self._qsusb = qsusb.devices - dev = qsusb.devices[qsid] - self._dim = dev[QS_TYPE] == QSType.dimmer - self._name = dev[QSDATA][QS_NAME] - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the light.""" - return self._name + self.device = qsusb.devices[qsid] + super().__init__(qsid, self.device.name) @property def is_on(self): """Check if device is on (non-zero).""" - return self._qsusb[self.qsid, 1] > 0 + return self.device.value > 0 async def async_turn_on(self, **kwargs): """Turn the device on.""" new = kwargs.get(ATTR_BRIGHTNESS, 255) - self._qsusb.set_value(self.qsid, new) + self.hass.data[DOMAIN].devices.set_value(self.qsid, new) async def async_turn_off(self, **_): """Turn the device off.""" - self._qsusb.set_value(self.qsid, 0) - - def _update(self, _packet=None): - """Schedule an update - match dispather_send signature.""" - self.async_schedule_update_ha_state() - - async def async_added_to_hass(self): - """Listen for updates from QSUSb via dispatcher.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - self.qsid, self._update) + self.hass.data[DOMAIN].devices.set_value(self.qsid, 0) async def async_setup(hass, config): """Qwiskswitch component setup.""" from pyqwikswitch.async_ import QSUsb - from pyqwikswitch import ( - CMD_BUTTONS, QS_CMD, QS_ID, QS_TYPE, QSType) + from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType # Add cmd's to in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] @@ -112,8 +119,8 @@ async def async_setup(hass, config): url = config[DOMAIN][CONF_URL] dimmer_adjust = config[DOMAIN][CONF_DIMMER_ADJUST] - sensors = config[DOMAIN]['sensors'] - switches = config[DOMAIN]['switches'] + sensors = config[DOMAIN][CONF_SENSORS] + switches = config[DOMAIN][CONF_SWITCHES] def callback_value_changed(_qsd, qsid, _val): """Update entity values based on device change.""" @@ -131,17 +138,17 @@ def callback_value_changed(_qsd, qsid, _val): hass.data[DOMAIN] = qsusb _new = {'switch': [], 'light': [], 'sensor': sensors} - for _id, item in qsusb.devices: - if _id in switches: - if item[QS_TYPE] != QSType.relay: + for qsid, dev in qsusb.devices.items(): + if qsid in switches: + if dev.qstype != QSType.relay: _LOGGER.warning( - "You specified a switch that is not a relay %s", _id) + "You specified a switch that is not a relay %s", qsid) continue - _new['switch'].append(_id) - elif item[QS_TYPE] in [QSType.relay, QSType.dimmer]: - _new['light'].append(_id) + _new['switch'].append(qsid) + elif dev.qstype in (QSType.relay, QSType.dimmer): + _new['light'].append(qsid) else: - _LOGGER.warning("Ignored unknown QSUSB device: %s", item) + _LOGGER.warning("Ignored unknown QSUSB device: %s", dev) continue # Load platforms @@ -149,24 +156,21 @@ def callback_value_changed(_qsd, qsid, _val): if comp_conf: load_platform(hass, comp_name, DOMAIN, {DOMAIN: comp_conf}, config) - def callback_qs_listen(item): + def callback_qs_listen(qspacket): """Typically a button press or update signal.""" # If button pressed, fire a hass event - if QS_ID in item: - if item.get(QS_CMD, '') in cmd_buttons: + if QS_ID in qspacket: + if qspacket.get(QS_CMD, '') in cmd_buttons: hass.bus.async_fire( - 'qwikswitch.button.{}'.format(item[QS_ID]), item) + 'qwikswitch.button.{}'.format(qspacket[QS_ID]), qspacket) return - # Private method due to bad __iter__ design in qsusb - # qsusb.devices returns a list of tuples - if item[QS_ID] not in \ - qsusb.devices._data: # pylint: disable=protected-access + if qspacket[QS_ID] not in qsusb.devices: # Not a standard device in, component can handle packet # i.e. sensors - _LOGGER.debug("Dispatch %s ((%s))", item[QS_ID], item) + _LOGGER.debug("Dispatch %s ((%s))", qspacket[QS_ID], qspacket) hass.helpers.dispatcher.async_dispatcher_send( - item[QS_ID], item) + qspacket[QS_ID], qspacket) # Update all ha_objects hass.async_add_job(qsusb.update_from_devices) diff --git a/homeassistant/components/sensor/qwikswitch.py b/homeassistant/components/sensor/qwikswitch.py index 19b32e936708b..98c67b7a21c98 100644 --- a/homeassistant/components/sensor/qwikswitch.py +++ b/homeassistant/components/sensor/qwikswitch.py @@ -6,8 +6,7 @@ """ import logging -from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH -from homeassistant.helpers.entity import Entity +from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH, QSEntity DEPENDENCIES = [QWIKSWITCH] @@ -15,55 +14,48 @@ async def async_setup_platform(hass, _, add_devices, discovery_info=None): - """Add lights from the main Qwikswitch component.""" + """Add sensor from the main Qwikswitch component.""" if discovery_info is None: return qsusb = hass.data[QWIKSWITCH] _LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info) - devs = [QSSensor(name, qsid) - for name, qsid in discovery_info[QWIKSWITCH].items()] + devs = [QSSensor(sensor) for sensor in discovery_info[QWIKSWITCH]] add_devices(devs) -class QSSensor(Entity): +class QSSensor(QSEntity): """Sensor based on a Qwikswitch relay/dimmer module.""" - _val = {} + _val = None - def __init__(self, sensor_name, sensor_id): + def __init__(self, sensor): """Initialize the sensor.""" - self._name = sensor_name - self.qsid = sensor_id + from pyqwikswitch import SENSORS + + super().__init__(sensor['id'], sensor['name']) + self.channel = sensor['channel'] + self.sensor_type = sensor['type'] + + self._decode, self.unit = SENSORS[self.sensor_type] + if isinstance(self.unit, type): + self.unit = "{}:{}".format(self.sensor_type, self.channel) def update_packet(self, packet): """Receive update packet from QSUSB.""" - _LOGGER.debug("Update %s (%s): %s", self.entity_id, self.qsid, packet) - self._val = packet - self.async_schedule_update_ha_state() + val = self._decode(packet.get('data'), channel=self.channel) + _LOGGER.debug("Update %s (%s) decoded as %s: %s: %s", + self.entity_id, self.qsid, val, self.channel, packet) + if val is not None: + self._val = val + self.async_schedule_update_ha_state() @property def state(self): """Return the value of the sensor.""" - return self._val.get('data', 0) - - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - return self._val + return str(self._val) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return None - - @property - def poll(self): - """QS sensors gets packets in update_packet.""" - return False - - async def async_added_to_hass(self): - """Listen for updates from QSUSb via dispatcher.""" - # Part of Entity/ToggleEntity - self.hass.helpers.dispatcher.async_dispatcher_connect( - self.qsid, self.update_packet) + return self.unit diff --git a/requirements_all.txt b/requirements_all.txt index 532c723365b0f..a747b5c3090be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -885,7 +885,7 @@ pyowm==2.8.0 pypollencom==1.1.1 # homeassistant.components.qwikswitch -pyqwikswitch==0.6 +pyqwikswitch==0.7 # homeassistant.components.rainbird pyrainbird==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce20ecfbfc6aa..484fd1c39f52d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -145,6 +145,9 @@ pymonoprice==0.3 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.components.qwikswitch +pyqwikswitch==0.7 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky python-forecastio==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d5bb2701e9bdb..708d9dbd30b77 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -73,6 +73,7 @@ 'pylitejet', 'pymonoprice', 'pynx584', + 'pyqwikswitch', 'python-forecastio', 'pyunifi', 'pywebpush', diff --git a/tests/components/sensor/test_qwikswitch.py b/tests/components/sensor/test_qwikswitch.py new file mode 100644 index 0000000000000..d9799b8530e72 --- /dev/null +++ b/tests/components/sensor/test_qwikswitch.py @@ -0,0 +1,90 @@ +"""Test qwikswitch sensors.""" +import asyncio +import logging + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH +from homeassistant.bootstrap import async_setup_component +from tests.test_util.aiohttp import mock_aiohttp_client + + +_LOGGER = logging.getLogger(__name__) + + +class AiohttpClientMockResponseList(list): + """List that fires an event on empty pop, for aiohttp Mocker.""" + + def decode(self, _): + """Return next item from list.""" + try: + res = list.pop(self) + _LOGGER.debug("MockResponseList popped %s: %s", res, self) + return res + except IndexError: + _LOGGER.debug("MockResponseList empty") + return "" + + async def wait_till_empty(self, hass): + """Wait until empty.""" + while self: + await asyncio.sleep(1) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +LISTEN = AiohttpClientMockResponseList() + + +@pytest.fixture +def aioclient_mock(): + """HTTP client listen and devices.""" + devices = """[ + {"id":"@000001","name":"Switch 1","type":"rel","val":"OFF", + "time":"1522777506","rssi":"51%"}, + {"id":"@000002","name":"Light 2","type":"rel","val":"ON", + "time":"1522777507","rssi":"45%"}, + {"id":"@000003","name":"Dim 3","type":"dim","val":"280c00", + "time":"1522777544","rssi":"62%"}]""" + + with mock_aiohttp_client() as mock_session: + mock_session.get("http://127.0.0.1:2020/&listen", content=LISTEN) + mock_session.get("http://127.0.0.1:2020/&device", text=devices) + yield mock_session + + +# @asyncio.coroutine +async def test_sensor_device(hass, aioclient_mock): + """Test a sensor device.""" + config = { + 'qwikswitch': { + 'sensors': { + 'name': 's1', + 'id': '@a00001', + 'channel': 1, + 'type': 'imod', + } + } + } + await async_setup_component(hass, QWIKSWITCH, config) + await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.s1') + assert state_obj + assert state_obj.state == 'None' + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + LISTEN.append( # Close + """{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}""") + await hass.async_block_till_done() + state_obj = hass.states.get('sensor.s1') + assert state_obj.state == 'True' + + # Causes a 30second delay: can be uncommented when upstream library + # allows cancellation of asyncio.sleep(30) on failed packet ("") + # LISTEN.append( # Open + # """{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}""") + # await LISTEN.wait_till_empty(hass) + # state_obj = hass.states.get('sensor.s1') + # assert state_obj.state == 'False' From 70649dfe22c898a0efe6754465d6924b6cdef59e Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sun, 8 Apr 2018 22:00:47 +0200 Subject: [PATCH 022/155] Device type mapping introduced to avoid breaking change (#13765) --- homeassistant/components/light/yeelight.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 7061c24aac622..d6d860cbd9e5a 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -24,6 +24,14 @@ _LOGGER = logging.getLogger(__name__) +LEGACY_DEVICE_TYPE_MAP = { + 'color1': 'rgb', + 'mono1': 'white', + 'strip1': 'strip', + 'bslamp1': 'bedside', + 'ceiling1': 'ceiling', +} + CONF_TRANSITION = 'transition' DEFAULT_TRANSITION = 350 @@ -122,8 +130,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is not None: _LOGGER.debug("Adding autodetected %s", discovery_info['hostname']) + device_type = discovery_info['device_type'] + device_type = LEGACY_DEVICE_TYPE_MAP.get(device_type, device_type) + # Not using hostname, as it seems to vary. - name = "yeelight_%s_%s" % (discovery_info['device_type'], + name = "yeelight_%s_%s" % (device_type, discovery_info['properties']['mac']) device = {'name': name, 'ipaddr': discovery_info['host']} From 8beb9c2b2890f829d51d91d97d0a639aab2e8d88 Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Mon, 9 Apr 2018 06:12:46 +0200 Subject: [PATCH 023/155] Only flag media position as updated when it really has (#13737) --- homeassistant/components/media_player/squeezebox.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 86b4087ca816d..371ad89036414 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -266,6 +266,8 @@ def async_update(self): if response is False: return + last_media_position = self.media_position + self._status = {} try: @@ -278,7 +280,11 @@ def async_update(self): pass self._status.update(response) - self._last_update = utcnow() + + if self.media_position != last_media_position: + _LOGGER.debug('Media position updated for %s: %s', + self, self.media_position) + self._last_update = utcnow() @property def volume_level(self): From cb51553c2dd2bd6cc38beeb411f9b693f9ce4bac Mon Sep 17 00:00:00 2001 From: Yonsm Date: Mon, 9 Apr 2018 21:32:29 +0800 Subject: [PATCH 024/155] Support binary_sensor and device_tracker in HomeKit (#13735) * Support binary_sensor and device_tracker for HomeKit * Add test for get_accessory and binary sensor * Test service.display_name and char_detected.display_name * Split test to improve speed --- homeassistant/components/homekit/__init__.py | 5 ++ homeassistant/components/homekit/const.py | 23 ++++++++ .../components/homekit/type_sensors.py | 57 ++++++++++++++++++- .../homekit/test_get_accessories.py | 15 ++++- tests/components/homekit/test_type_sensors.py | 57 ++++++++++++++++++- 5 files changed, 152 insertions(+), 5 deletions(-) mode change 100644 => 100755 homeassistant/components/homekit/type_sensors.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 8a38c01026ef1..06258bcc97a04 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -92,6 +92,11 @@ def get_accessory(hass, state, aid, config): return TYPES['HumiditySensor'](hass, state.entity_id, state.name, aid=aid) + elif state.domain == 'binary_sensor' or state.domain == 'device_tracker': + _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'BinarySensor') + return TYPES['BinarySensor'](hass, state.entity_id, + state.name, aid=aid) + elif state.domain == 'cover': # Only add covers that support set_cover_position features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 18d02a89e18d0..7136852c409eb 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -35,11 +35,18 @@ # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' +SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' +SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' +SERV_CONTACT_SENSOR = 'ContactSensor' SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity | StatusActive, StatusFault, StatusTampered, # StatusLowBattery, Name +SERV_LEAK_SENSOR = 'LeakSensor' SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name +SERV_MOTION_SENSOR = 'MotionSensor' +SERV_OCCUPANCY_SENSOR = 'OccupancySensor' SERV_SECURITY_SYSTEM = 'SecuritySystem' +SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' @@ -48,7 +55,10 @@ # #### Characteristics #### CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100] +CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' +CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' +CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' @@ -57,13 +67,17 @@ CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' CHAR_HUE = 'Hue' # arcdegress | [0, 360] +CHAR_LEAK_DETECTED = 'LeakDetected' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' +CHAR_MOTION_DETECTED = 'MotionDetected' CHAR_NAME = 'Name' +CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' CHAR_ON = 'On' # boolean CHAR_POSITION_STATE = 'PositionState' CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_SMOKE_DETECTED = 'SmokeDetected' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' CHAR_TARGET_POSITION = 'TargetPosition' CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' @@ -72,3 +86,12 @@ # #### Properties #### PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} + +# #### Device Class #### +DEVICE_CLASS_CO2 = 'co2' +DEVICE_CLASS_GAS = 'gas' +DEVICE_CLASS_MOISTURE = 'moisture' +DEVICE_CLASS_MOTION = 'motion' +DEVICE_CLASS_OCCUPANCY = 'occupancy' +DEVICE_CLASS_OPENING = 'opening' +DEVICE_CLASS_SMOKE = 'smoke' diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py old mode 100644 new mode 100755 index 393962eac21ba..b25eb784d6bde --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -2,19 +2,40 @@ import logging from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, + ATTR_DEVICE_CLASS, STATE_ON, STATE_HOME) from . import TYPES from .accessories import HomeAccessory, add_preload_service from .const import ( CATEGORY_SENSOR, SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR, - CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS) + CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS, + DEVICE_CLASS_CO2, SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, + DEVICE_CLASS_GAS, SERV_CARBON_MONOXIDE_SENSOR, + CHAR_CARBON_MONOXIDE_DETECTED, + DEVICE_CLASS_MOISTURE, SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED, + DEVICE_CLASS_MOTION, SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, + DEVICE_CLASS_OCCUPANCY, SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, + DEVICE_CLASS_OPENING, SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, + DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) from .util import convert_to_float, temperature_to_homekit _LOGGER = logging.getLogger(__name__) +BINARY_SENSOR_SERVICE_MAP = { + DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, + CHAR_CARBON_DIOXIDE_DETECTED), + DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, + CHAR_CARBON_MONOXIDE_DETECTED), + DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED), + DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED), + DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED), + DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED)} + + @TYPES.register('TemperatureSensor') class TemperatureSensor(HomeAccessory): """Generate a TemperatureSensor accessory for a temperature sensor. @@ -75,3 +96,35 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): self.char_humidity.set_value(humidity) _LOGGER.debug('%s: Percent set to %d%%', self.entity_id, humidity) + + +@TYPES.register('BinarySensor') +class BinarySensor(HomeAccessory): + """Generate a BinarySensor accessory as binary sensor.""" + + def __init__(self, hass, entity_id, name, **kwargs): + """Initialize a BinarySensor accessory object.""" + super().__init__(name, entity_id, CATEGORY_SENSOR, **kwargs) + + self.hass = hass + self.entity_id = entity_id + + device_class = hass.states.get(entity_id).attributes \ + .get(ATTR_DEVICE_CLASS) + service_char = BINARY_SENSOR_SERVICE_MAP[device_class] \ + if device_class in BINARY_SENSOR_SERVICE_MAP \ + else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY] + + service = add_preload_service(self, service_char[0]) + self.char_detected = service.get_characteristic(service_char[1]) + self.char_detected.value = 0 + + def update_state(self, entity_id=None, old_state=None, new_state=None): + """Update accessory after state change.""" + if new_state is None: + return + + state = new_state.state + detected = (state == STATE_ON) or (state == STATE_HOME) + self.char_detected.set_value(detected) + _LOGGER.debug('%s: Set to %d', self.entity_id, detected) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e29ed85b5fce0..e323431ac3f76 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -9,7 +9,7 @@ from homeassistant.components.homekit import get_accessory, TYPES from homeassistant.const import ( ATTR_CODE, ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_DEVICE_CLASS) _LOGGER = logging.getLogger(__name__) @@ -63,6 +63,19 @@ def test_sensor_humidity(self): {ATTR_UNIT_OF_MEASUREMENT: '%'}) get_accessory(None, state, 2, {}) + def test_binary_sensor(self): + """Test binary sensor with opening class.""" + with patch.dict(TYPES, {'BinarySensor': self.mock_type}): + state = State('binary_sensor.opening', 'on', + {ATTR_DEVICE_CLASS: 'opening'}) + get_accessory(None, state, 2, {}) + + def test_device_tracker(self): + """Test binary sensor with opening class.""" + with patch.dict(TYPES, {'BinarySensor': self.mock_type}): + state = State('device_tracker.someone', 'not_home', {}) + get_accessory(None, state, 2, {}) + def test_cover_set_position(self): """Test cover with support for set_cover_position.""" with patch.dict(TYPES, {'WindowCovering': self.mock_type}): diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index c04c250613dd6..a6e178bb226ce 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -3,9 +3,10 @@ from homeassistant.components.homekit.const import PROP_CELSIUS from homeassistant.components.homekit.type_sensors import ( - TemperatureSensor, HumiditySensor) + TemperatureSensor, HumiditySensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, STATE_UNKNOWN, STATE_ON, + STATE_OFF, STATE_HOME, STATE_NOT_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant @@ -68,3 +69,55 @@ def test_humidity(self): self.hass.states.set(entity_id, '20', {ATTR_UNIT_OF_MEASUREMENT: "%"}) self.hass.block_till_done() self.assertEqual(acc.char_humidity.value, 20) + + def test_binary(self): + """Test if accessory is updated after state change.""" + entity_id = 'binary_sensor.opening' + + self.hass.states.set(entity_id, STATE_UNKNOWN, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + + acc = BinarySensor(self.hass, entity_id, 'Window Opening', aid=2) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, STATE_ON, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 1) + + self.hass.states.set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, STATE_HOME, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 1) + + self.hass.states.set(entity_id, STATE_NOT_HOME, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.remove(entity_id) + self.hass.block_till_done() + + def test_binary_device_classes(self): + """Test if services and characteristics are assigned correctly.""" + entity_id = 'binary_sensor.demo' + + for device_class, (service, char) in BINARY_SENSOR_SERVICE_MAP.items(): + self.hass.states.set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: device_class}) + self.hass.block_till_done() + + acc = BinarySensor(self.hass, entity_id, 'Binary Sensor', aid=2) + self.assertEqual(acc.get_service(service).display_name, service) + self.assertEqual(acc.char_detected.display_name, char) From 73de74941127d4d9028fc2428bb9846cc167448b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Apr 2018 10:09:08 -0400 Subject: [PATCH 025/155] Use config entry to setup platforms (#13752) * Use config entry to setup platforms * Rename to async_forward_entry * Add tests * Catch if platform not exists for entry --- homeassistant/components/hue/bridge.py | 16 ++--- homeassistant/components/light/__init__.py | 7 +- homeassistant/components/light/hue.py | 14 ++-- homeassistant/config_entries.py | 29 ++++++++- homeassistant/helpers/entity_component.py | 20 ++++++ homeassistant/helpers/entity_platform.py | 75 ++++++++++++++++------ tests/common.py | 14 +++- tests/components/hue/test_bridge.py | 7 +- tests/components/light/test_hue.py | 7 +- tests/helpers/test_entity_component.py | 45 ++++++++++++- tests/helpers/test_entity_platform.py | 46 ++++++++++++- tests/test_config_entries.py | 36 +++++++++++ 12 files changed, 271 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 8093c84971ed5..4693a2f4dbe57 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -39,18 +39,17 @@ def host(self): async def async_setup(self, tries=0): """Set up a phue bridge based on host parameter.""" host = self.host + hass = self.hass try: self.api = await get_bridge( - self.hass, host, - self.config_entry.data['username'] - ) + hass, host, self.config_entry.data['username']) except AuthenticationRequired: # usernames can become invalid if hub is reset or user removed. # We are going to fail the config entry setup and initiate a new # linking procedure. When linking succeeds, it will remove the # old config entry. - self.hass.async_add_job(self.hass.config_entries.flow.async_init( + hass.async_add_job(hass.config_entries.flow.async_init( DOMAIN, source='import', data={ 'host': host, } @@ -69,7 +68,7 @@ async def retry_setup(_now): self.config_entry.state = config_entries.ENTRY_STATE_LOADED # Unhandled edge case: cancel this if we discover bridge on new IP - self.hass.helpers.event.async_call_later(retry_delay, retry_setup) + hass.helpers.event.async_call_later(retry_delay, retry_setup) return False @@ -78,11 +77,10 @@ async def retry_setup(_now): host) return False - self.hass.async_add_job( - self.hass.helpers.discovery.async_load_platform( - 'light', DOMAIN, {'host': host})) + hass.async_add_job(hass.config_entries.async_forward_entry( + self.config_entry, 'light')) - self.hass.services.async_register( + hass.services.async_register( DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, schema=SCENE_SCHEMA) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 39d3203795e2e..d497c8f9880d9 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -334,7 +334,7 @@ async def async_handle(self, intent_obj): async def async_setup(hass, config): """Expose light control via state machine and services.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) await component.async_setup(config) @@ -388,6 +388,11 @@ async def async_handle_light_service(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + class Profiles: """Representation of available color profiles.""" diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 1701b886b6873..6eb8de99c9947 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -49,11 +49,17 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the Hue lights.""" - if discovery_info is None: - return + """Old way of setting up Hue lights. + + Can only be called when a user accidentally mentions hue platform in their + config. But even in that case it would have been ignored. + """ + pass + - bridge = hass.data[hue.DOMAIN][discovery_info['host']] +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Hue lights from a config entry.""" + bridge = hass.data[hue.DOMAIN][config_entry.data['host']] cur_lights = {} cur_groups = {} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 69491af1aad09..fc781bd62c836 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -187,13 +187,17 @@ async def async_setup(self, hass, *, component=None): if not isinstance(result, bool): _LOGGER.error('%s.async_config_entry did not return boolean', - self.domain) + component.DOMAIN) result = False except Exception: # pylint: disable=broad-except _LOGGER.exception('Error setting up entry %s for %s', - self.title, self.domain) + self.title, component.DOMAIN) result = False + # Only store setup result as state if it was not forwarded. + if self.domain != component.DOMAIN: + return + if result: self.state = ENTRY_STATE_LOADED else: @@ -322,6 +326,27 @@ async def async_load(self): entries = await self.hass.async_add_job(load_json, path) self._entries = [ConfigEntry(**entry) for entry in entries] + async def async_forward_entry(self, entry, component): + """Forward the setup of an entry to a different component. + + By default an entry is setup with the component it belongs to. If that + component also has related platforms, the component will have to + forward the entry to be setup by that component. + + You don't want to await this coroutine if it is called as part of the + setup of a component, because it can cause a deadlock. + """ + # Setup Component if not set up yet + if component not in self.hass.config.components: + result = await async_setup_component( + self.hass, component, self._hass_config) + + if not result: + return False + + await entry.async_setup( + self.hass, component=getattr(self.hass.components, component)) + async def _async_add_entry(self, entry): """Add an entry.""" self._entries.append(entry) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 6ff9b6f657139..265464d548ddf 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -93,6 +93,26 @@ async def component_platform_discovered(platform, info): discovery.async_listen_platform( self.hass, self.domain, component_platform_discovered) + async def async_setup_entry(self, config_entry): + """Setup a config entry.""" + platform_type = config_entry.domain + platform = await async_prepare_setup_platform( + self.hass, self.config, self.domain, platform_type) + + if platform is None: + return False + + key = config_entry.entry_id + + if key in self._platforms: + raise ValueError('Config entry has already been setup!') + + self._platforms[key] = self._async_init_entity_platform( + platform_type, platform + ) + + return await self._platforms[key].async_setup_entry(config_entry) + @callback def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 3c6deaba94af9..ba8df7e01d812 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,15 +1,13 @@ """Class to manage the entities for a single platform.""" import asyncio -from datetime import timedelta from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import callback, valid_entity_id, split_entity_id from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) -import homeassistant.util.dt as dt_util -from .event import async_track_time_interval, async_track_point_in_time +from .event import async_track_time_interval, async_call_later from .entity_registry import async_get_registry SLOW_SETUP_WARNING = 10 @@ -42,6 +40,7 @@ def __init__(self, *, hass, logger, domain, platform_name, platform, self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.async_entities_added_callback = async_entities_added_callback + self.config_entry = None self.entities = {} self._tasks = [] self._async_unsub_polling = None @@ -68,9 +67,47 @@ def __init__(self, *, hass, logger, domain, platform_name, platform, else: self.parallel_updates = None - async def async_setup(self, platform_config, discovery_info=None, tries=0): - """Setup the platform.""" + async def async_setup(self, platform_config, discovery_info=None): + """Setup the platform from a config file.""" platform = self.platform + hass = self.hass + + @callback + def async_create_setup_task(): + """Get task to setup platform.""" + if getattr(platform, 'async_setup_platform', None): + return platform.async_setup_platform( + hass, platform_config, + self._async_schedule_add_entities, discovery_info + ) + + # This should not be replaced with hass.async_add_job because + # we don't want to track this task in case it blocks startup. + return hass.loop.run_in_executor( + None, platform.setup_platform, hass, platform_config, + self._schedule_add_entities, discovery_info + ) + await self._async_setup_platform(async_create_setup_task) + + async def async_setup_entry(self, config_entry): + """Setup the platform from a config entry.""" + # Store it so that we can save config entry ID in entity registry + self.config_entry = config_entry + platform = self.platform + + @callback + def async_create_setup_task(): + """Get task to setup platform.""" + return platform.async_setup_entry( + self.hass, config_entry, self._async_schedule_add_entities) + + return await self._async_setup_platform(async_create_setup_task) + + async def _async_setup_platform(self, async_create_setup_task, tries=0): + """Helper to setup a platform via config file or config entry. + + async_create_setup_task creates a coroutine that sets up platform. + """ logger = self.logger hass = self.hass full_name = '{}.{}'.format(self.domain, self.platform_name) @@ -82,18 +119,8 @@ async def async_setup(self, platform_config, discovery_info=None, tries=0): self.platform_name, SLOW_SETUP_WARNING) try: - if getattr(platform, 'async_setup_platform', None): - task = platform.async_setup_platform( - hass, platform_config, - self._async_schedule_add_entities, discovery_info - ) - else: - # This should not be replaced with hass.async_add_job because - # we don't want to track this task in case it blocks startup. - task = hass.loop.run_in_executor( - None, platform.setup_platform, hass, platform_config, - self._schedule_add_entities, discovery_info - ) + task = async_create_setup_task() + await asyncio.wait_for( asyncio.shield(task, loop=hass.loop), SLOW_SETUP_MAX_WAIT, loop=hass.loop) @@ -108,23 +135,31 @@ async def async_setup(self, platform_config, discovery_info=None, tries=0): pending, loop=self.hass.loop) hass.config.components.add(full_name) + return True except PlatformNotReady: tries += 1 wait_time = min(tries, 6) * 30 logger.warning( 'Platform %s not ready yet. Retrying in %d seconds.', self.platform_name, wait_time) - async_track_point_in_time( - hass, self.async_setup(platform_config, discovery_info, tries), - dt_util.utcnow() + timedelta(seconds=wait_time)) + + async def setup_again(now): + """Run setup again.""" + await self._async_setup_platform( + async_create_setup_task, tries) + + async_call_later(hass, wait_time, setup_again) + return False except asyncio.TimeoutError: logger.error( "Setup of platform %s is taking longer than %s seconds." " Startup will proceed without waiting any longer.", self.platform_name, SLOW_SETUP_MAX_WAIT) + return False except Exception: # pylint: disable=broad-except logger.exception( "Error while setting up platform %s", self.platform_name) + return False finally: warn_task.cancel() diff --git a/tests/common.py b/tests/common.py index 388898e702436..54c214da4e95f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -344,7 +344,8 @@ class MockPlatform(object): # pylint: disable=invalid-name def __init__(self, setup_platform=None, dependencies=None, - platform_schema=None, async_setup_platform=None): + platform_schema=None, async_setup_platform=None, + async_setup_entry=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] @@ -358,6 +359,9 @@ def __init__(self, setup_platform=None, dependencies=None, if async_setup_platform is not None: self.async_setup_platform = async_setup_platform + if async_setup_entry is not None: + self.async_setup_entry = async_setup_entry + if setup_platform is None and async_setup_platform is None: self.async_setup_platform = mock_coro_func() @@ -376,6 +380,14 @@ def __init__( async_entities_added_callback=lambda: None ): """Initialize a mock entity platform.""" + if logger is None: + logger = logging.getLogger('homeassistant.helpers.entity_platform') + + # Otherwise the constructor will blow up. + if (isinstance(platform, Mock) and + isinstance(platform.PARALLEL_UPDATES, Mock)): + platform.PARALLEL_UPDATES = 0 + super().__init__( hass=hass, logger=logger, diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 0845aa2f0773f..1f53d5aac1431 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -18,10 +18,9 @@ async def test_bridge_setup(): assert await hue_bridge.async_setup() is True assert hue_bridge.api is api - assert len(hass.helpers.discovery.async_load_platform.mock_calls) == 1 - assert hass.helpers.discovery.async_load_platform.mock_calls[0][1][2] == { - 'host': '1.2.3.4' - } + assert len(hass.config_entries.async_forward_entry.mock_calls) == 1 + assert hass.config_entries.async_forward_entry.mock_calls[0][1] == \ + (entry, 'light') async def test_bridge_setup_invalid_username(): diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 7b6c3a21a79ad..dee27adfe3473 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -9,6 +9,7 @@ from aiohue.groups import Groups import pytest +from homeassistant import config_entries from homeassistant.components import hue import homeassistant.components.light.hue as hue_light from homeassistant.util import color @@ -196,9 +197,11 @@ async def setup_bridge(hass, mock_bridge): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} - await hass.helpers.discovery.async_load_platform('light', 'hue', { + config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { 'host': 'mock-host' - }) + }, 'test') + await hass.config_entries.async_forward_entry(config_entry, 'light') + # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index d8dac11f6a041..f53b69274ef37 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -7,6 +7,8 @@ from unittest.mock import patch, Mock from datetime import timedelta +import pytest + import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.exceptions import PlatformNotReady @@ -19,7 +21,7 @@ from tests.common import ( get_test_home_assistant, MockPlatform, MockModule, mock_coro, - async_fire_time_changed, MockEntity) + async_fire_time_changed, MockEntity, MockConfigEntry) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -333,3 +335,44 @@ def test_setup_dependencies_platform(hass): assert 'test_component' in hass.config.components assert 'test_component2' in hass.config.components assert 'test_domain.test_component' in hass.config.components + + +async def test_setup_entry(hass): + """Test setup entry calls async_setup_entry on platform.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + assert len(mock_setup_entry.mock_calls) == 1 + p_hass, p_entry, p_add_entities = mock_setup_entry.mock_calls[0][1] + assert p_hass is hass + assert p_entry is entry + + +async def test_setup_entry_platform_not_exist(hass): + """Test setup entry fails if platform doesnt exist.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='non_existing') + + assert (await component.async_setup_entry(entry)) is False + + +async def test_setup_entry_fails_duplicate(hass): + """Test we don't allow setting up a config entry twice.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + + with pytest.raises(ValueError): + await component.async_setup_entry(entry) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 8c085e4abb13a..a8394ff6a4978 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -5,6 +5,7 @@ from unittest.mock import patch, Mock, MagicMock from datetime import timedelta +from homeassistant.exceptions import PlatformNotReady import homeassistant.loader as loader from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import ( @@ -15,7 +16,7 @@ from tests.common import ( get_test_home_assistant, MockPlatform, fire_time_changed, mock_registry, - MockEntity, MockEntityPlatform) + MockEntity, MockEntityPlatform, MockConfigEntry, mock_coro) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -511,3 +512,46 @@ async def test_entity_registry_updates(hass): state = hass.states.get('test_domain.world') assert state.name == 'after update' + + +async def test_setup_entry(hass): + """Test we can setup an entry.""" + async_setup_entry = Mock(return_value=mock_coro(True)) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + entity_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + + full_name = '{}.{}'.format(entity_platform.domain, config_entry.domain) + assert full_name in hass.config.components + assert len(async_setup_entry.mock_calls) == 1 + + +async def test_setup_entry_platform_not_ready(hass, caplog): + """Test when an entry is not ready yet.""" + async_setup_entry = Mock(side_effect=PlatformNotReady) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + ent_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + with patch.object(entity_platform, 'async_call_later') as mock_call_later: + assert not await ent_platform.async_setup_entry(config_entry) + + full_name = '{}.{}'.format(ent_platform.domain, config_entry.domain) + assert full_name not in hass.config.components + assert len(async_setup_entry.mock_calls) == 1 + assert 'Platform test not ready yet' in caplog.text + assert len(mock_call_later.mock_calls) == 1 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 5b1ec3b8ec0d5..8bbd79a7ac72f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -389,3 +389,39 @@ def async_step_discovery(self, info): assert entry.title == 'hello' assert entry.data == data assert entry.source == config_entries.SOURCE_DISCOVERY + + +async def test_forward_entry_sets_up_component(hass): + """Test we setup the component entry is forwarded to.""" + entry = MockConfigEntry(domain='original') + + mock_original_setup_entry = MagicMock(return_value=mock_coro(True)) + loader.set_component( + 'original', + MockModule('original', async_setup_entry=mock_original_setup_entry)) + + mock_forwarded_setup_entry = MagicMock(return_value=mock_coro(True)) + loader.set_component( + 'forwarded', + MockModule('forwarded', async_setup_entry=mock_forwarded_setup_entry)) + + await hass.config_entries.async_forward_entry(entry, 'forwarded') + assert len(mock_original_setup_entry.mock_calls) == 0 + assert len(mock_forwarded_setup_entry.mock_calls) == 1 + + +async def test_forward_entry_does_not_setup_entry_if_setup_fails(hass): + """Test we do not setup entry if component setup fails.""" + entry = MockConfigEntry(domain='original') + + mock_setup = MagicMock(return_value=mock_coro(False)) + mock_setup_entry = MagicMock() + loader.set_component('forwarded', MockModule( + 'forwarded', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry, + )) + + await hass.config_entries.async_forward_entry(entry, 'forwarded') + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 0 From c61611d2b4b0f0fd905cafd2ef629d27c4cb97f8 Mon Sep 17 00:00:00 2001 From: Phil Kates Date: Mon, 9 Apr 2018 07:23:49 -0700 Subject: [PATCH 026/155] Add Homekit locks support (#13625) * homekit: Add locks support * Improved upgradeability --- homeassistant/components/homekit/__init__.py | 7 +- homeassistant/components/homekit/const.py | 5 ++ .../components/homekit/type_locks.py | 77 +++++++++++++++++++ tests/components/homekit/test_type_locks.py | 77 +++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homekit/type_locks.py create mode 100644 tests/components/homekit/test_type_locks.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 06258bcc97a04..22c74faf5f0a7 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -127,6 +127,9 @@ def get_accessory(hass, state, aid, config): _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Light') return TYPES['Light'](hass, state.entity_id, state.name, aid=aid) + elif state.domain == 'lock': + return TYPES['Lock'](hass, state.entity_id, state.name, aid=aid) + elif state.domain == 'switch' or state.domain == 'remote' \ or state.domain == 'input_boolean' or state.domain == 'script': _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Switch') @@ -186,8 +189,8 @@ def start(self, *args): # pylint: disable=unused-variable from . import ( # noqa F401 - type_covers, type_lights, type_security_systems, type_sensors, - type_switches, type_thermostats) + type_covers, type_lights, type_locks, type_security_systems, + type_sensors, type_switches, type_thermostats) for state in self._hass.states.all(): self.add_bridge_accessory(state) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 7136852c409eb..e5a4c80a4301f 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -27,6 +27,7 @@ # #### Categories #### CATEGORY_ALARM_SYSTEM = 'ALARM_SYSTEM' CATEGORY_LIGHT = 'LIGHTBULB' +CATEGORY_LOCK = 'DOOR_LOCK' CATEGORY_SENSOR = 'SENSOR' CATEGORY_SWITCH = 'SWITCH' CATEGORY_THERMOSTAT = 'THERMOSTAT' @@ -43,6 +44,7 @@ # StatusLowBattery, Name SERV_LEAK_SENSOR = 'LeakSensor' SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name +SERV_LOCK = 'LockMechanism' SERV_MOTION_SENSOR = 'MotionSensor' SERV_OCCUPANCY_SENSOR = 'OccupancySensor' SERV_SECURITY_SYSTEM = 'SecuritySystem' @@ -68,6 +70,9 @@ CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' CHAR_HUE = 'Hue' # arcdegress | [0, 360] CHAR_LEAK_DETECTED = 'LeakDetected' +CHAR_LOCK_CURRENT_STATE = 'LockCurrentState' +CHAR_LOCK_TARGET_STATE = 'LockTargetState' +CHAR_LINK_QUALITY = 'LinkQuality' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' CHAR_MOTION_DETECTED = 'MotionDetected' diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py new file mode 100644 index 0000000000000..9df0c101eff49 --- /dev/null +++ b/homeassistant/components/homekit/type_locks.py @@ -0,0 +1,77 @@ +"""Class to hold all lock accessories.""" +import logging + +from homeassistant.components.lock import ( + ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import ( + CATEGORY_LOCK, SERV_LOCK, CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE) + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = {STATE_UNLOCKED: 0, + STATE_LOCKED: 1, + # value 2 is Jammed which hass doesn't have a state for + STATE_UNKNOWN: 3} +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +STATE_TO_SERVICE = {STATE_LOCKED: 'lock', + STATE_UNLOCKED: 'unlock'} + + +@TYPES.register('Lock') +class Lock(HomeAccessory): + """Generate a Lock accessory for a lock entity. + + The lock entity must support: unlock and lock. + """ + + def __init__(self, hass, entity_id, name, **kwargs): + """Initialize a Lock accessory object.""" + super().__init__(name, entity_id, CATEGORY_LOCK, **kwargs) + + self.hass = hass + self.entity_id = entity_id + + self.flag_target_state = False + + serv_lock_mechanism = add_preload_service(self, SERV_LOCK) + self.char_current_state = serv_lock_mechanism. \ + get_characteristic(CHAR_LOCK_CURRENT_STATE) + self.char_target_state = serv_lock_mechanism. \ + get_characteristic(CHAR_LOCK_TARGET_STATE) + + self.char_current_state.value = HASS_TO_HOMEKIT[STATE_UNKNOWN] + self.char_target_state.value = HASS_TO_HOMEKIT[STATE_LOCKED] + + self.char_target_state.setter_callback = self.set_state + + def set_state(self, value): + """Set lock state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self.flag_target_state = True + + hass_value = HOMEKIT_TO_HASS.get(value) + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call('lock', service, params) + + def update_state(self, entity_id=None, old_state=None, new_state=None): + """Update lock after state changed.""" + if new_state is None: + return + + hass_state = new_state.state + if hass_state in HASS_TO_HOMEKIT: + current_lock_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_lock_state) + _LOGGER.debug('%s: Updated current state to %s (%d)', + self.entity_id, hass_state, current_lock_state) + + # LockTargetState only supports locked and unlocked + if hass_state in (STATE_LOCKED, STATE_UNLOCKED): + if not self.flag_target_state: + self.char_target_state.set_value(current_lock_state) + self.flag_target_state = False diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py new file mode 100644 index 0000000000000..d19bcdf3ec561 --- /dev/null +++ b/tests/components/homekit/test_type_locks.py @@ -0,0 +1,77 @@ +"""Test different accessory types: Locks.""" +import unittest + +from homeassistant.core import callback +from homeassistant.components.homekit.type_locks import Lock +from homeassistant.const import ( + STATE_UNKNOWN, STATE_UNLOCKED, STATE_LOCKED, + ATTR_SERVICE, EVENT_CALL_SERVICE) + +from tests.common import get_test_home_assistant + + +class TestHomekitSensors(unittest.TestCase): + """Test class for all accessory types regarding covers.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_lock_unlock(self): + """Test if accessory and HA are updated accordingly.""" + kitchen_lock = 'lock.kitchen_door' + + acc = Lock(self.hass, kitchen_lock, 'Lock', aid=2) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 6) # DoorLock + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(kitchen_lock, STATE_LOCKED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 1) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(kitchen_lock, STATE_UNLOCKED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(kitchen_lock, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 0) + + # Set from HomeKit + acc.char_target_state.client_update_value(1) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'lock') + self.assertEqual(acc.char_target_state.value, 1) + + acc.char_target_state.client_update_value(0) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'unlock') + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.remove(kitchen_lock) + self.hass.block_till_done() From e593117ab6f224c8ecad9c1475610b80f1ecfc64 Mon Sep 17 00:00:00 2001 From: Tod Schmidt Date: Mon, 9 Apr 2018 11:46:27 -0400 Subject: [PATCH 027/155] Snips sounds (#13746) * Added feedback sound configuration * Added feedback sound configuration * Cleaned up feedback off * Cleaned up whitespace * Moved feedback pus to helper funx * Async * Used async_mock_service for tests * Lint --- homeassistant/components/snips.py | 74 +++++++-- tests/components/test_snips.py | 263 ++++++++++++++++++++++-------- 2 files changed, 256 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index d085b1279cb87..812906e7be9f5 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -4,13 +4,13 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/snips/ """ -import asyncio import json import logging from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback from homeassistant.helpers import intent, config_validation as cv import homeassistant.components.mqtt as mqtt @@ -19,11 +19,18 @@ CONF_INTENTS = 'intents' CONF_ACTION = 'action' +CONF_FEEDBACK = 'feedback_sounds' +CONF_PROBABILITY = 'probability_threshold' +CONF_SITE_IDS = 'site_ids' SERVICE_SAY = 'say' SERVICE_SAY_ACTION = 'say_action' +SERVICE_FEEDBACK_ON = 'feedback_on' +SERVICE_FEEDBACK_OFF = 'feedback_off' INTENT_TOPIC = 'hermes/intent/#' +FEEDBACK_ON_TOPIC = 'hermes/feedback/sound/toggleOn' +FEEDBACK_OFF_TOPIC = 'hermes/feedback/sound/toggleOff' ATTR_TEXT = 'text' ATTR_SITE_ID = 'site_id' @@ -34,7 +41,12 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {} + DOMAIN: vol.Schema({ + vol.Optional(CONF_FEEDBACK): cv.boolean, + vol.Optional(CONF_PROBABILITY, default=0): vol.Coerce(float), + vol.Optional(CONF_SITE_IDS, default=['default']): + vol.All(cv.ensure_list, [cv.string]), + }), }, extra=vol.ALLOW_EXTRA) INTENT_SCHEMA = vol.Schema({ @@ -57,7 +69,6 @@ vol.Optional(ATTR_SITE_ID, default='default'): str, vol.Optional(ATTR_CUSTOM_DATA, default=''): str }) - SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Required(ATTR_TEXT): str, vol.Optional(ATTR_SITE_ID, default='default'): str, @@ -65,13 +76,31 @@ vol.Optional(ATTR_CAN_BE_ENQUEUED, default=True): cv.boolean, vol.Optional(ATTR_INTENT_FILTER): vol.All(cv.ensure_list), }) +SERVICE_SCHEMA_FEEDBACK = vol.Schema({ + vol.Optional(ATTR_SITE_ID, default='default'): str +}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Activate Snips component.""" - @asyncio.coroutine - def message_received(topic, payload, qos): + @callback + def async_set_feedback(site_ids, state): + """Set Feedback sound state.""" + site_ids = (site_ids if site_ids + else config[DOMAIN].get(CONF_SITE_IDS)) + topic = (FEEDBACK_ON_TOPIC if state + else FEEDBACK_OFF_TOPIC) + for site_id in site_ids: + payload = json.dumps({'siteId': site_id}) + hass.components.mqtt.async_publish( + FEEDBACK_ON_TOPIC, None, qos=0, retain=False) + hass.components.mqtt.async_publish( + topic, payload, qos=int(state), retain=state) + + if CONF_FEEDBACK in config[DOMAIN]: + async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) + + async def message_received(topic, payload, qos): """Handle new messages on MQTT.""" _LOGGER.debug("New intent: %s", payload) @@ -81,6 +110,13 @@ def message_received(topic, payload, qos): _LOGGER.error('Received invalid JSON: %s', payload) return + if (request['intent']['probability'] + < config[DOMAIN].get(CONF_PROBABILITY)): + _LOGGER.warning("Intent below probaility threshold %s < %s", + request['intent']['probability'], + config[DOMAIN].get(CONF_PROBABILITY)) + return + try: request = INTENT_SCHEMA(request) except vol.Invalid as err: @@ -97,7 +133,7 @@ def message_received(topic, payload, qos): slots[slot['slotName']] = {'value': resolve_slot_values(slot)} try: - intent_response = yield from intent.async_handle( + intent_response = await intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) if 'plain' in intent_response.speech: snips_response = intent_response.speech['plain']['speech'] @@ -115,11 +151,10 @@ def message_received(topic, payload, qos): mqtt.async_publish(hass, 'hermes/dialogueManager/endSession', json.dumps(notification)) - yield from hass.components.mqtt.async_subscribe( + await hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) - @asyncio.coroutine - def snips_say(call): + async def snips_say(call): """Send a Snips notification message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -129,8 +164,7 @@ def snips_say(call): json.dumps(notification)) return - @asyncio.coroutine - def snips_say_action(call): + async def snips_say_action(call): """Send a Snips action message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -144,12 +178,26 @@ def snips_say_action(call): json.dumps(notification)) return + async def feedback_on(call): + """Turn feedback sounds on.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), True) + + async def feedback_off(call): + """Turn feedback sounds off.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), False) + hass.services.async_register( DOMAIN, SERVICE_SAY, snips_say, schema=SERVICE_SCHEMA_SAY) hass.services.async_register( DOMAIN, SERVICE_SAY_ACTION, snips_say_action, schema=SERVICE_SCHEMA_SAY_ACTION) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_ON, feedback_on, + schema=SERVICE_SCHEMA_FEEDBACK) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_OFF, feedback_off, + schema=SERVICE_SCHEMA_FEEDBACK) return True diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index f37beef79608d..2342e897708e6 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -1,20 +1,92 @@ """Test the Snips component.""" -import asyncio import json import logging -from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component +from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA +import homeassistant.components.snips as snips from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) -from homeassistant.components.snips import (SERVICE_SCHEMA_SAY, - SERVICE_SCHEMA_SAY_ACTION) -@asyncio.coroutine -def test_snips_intent(hass, mqtt_mock): +async def test_snips_config(hass, mqtt_mock): + """Test Snips Config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True, + "probability_threshold": .5, + "site_ids": ["default", "remote"] + }, + }) + assert result + + +async def test_snips_bad_config(hass, mqtt_mock): + """Test Snips bad config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": "on", + "probability": "none", + "site_ids": "default" + }, + }) + assert not result + + +async def test_snips_config_feedback_on(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + assert calls[1].data['qos'] == 1 + assert calls[1].data['retain'] + + +async def test_snips_config_feedback_off(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": False + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOff' + assert calls[1].data['qos'] == 0 + assert not calls[1].data['retain'] + + +async def test_snips_config_no_feedback(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'snips', 'say') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_snips_intent(hass, mqtt_mock): """Test intent via Snips.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -41,7 +113,7 @@ def test_snips_intent(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' @@ -50,10 +122,9 @@ def test_snips_intent(hass, mqtt_mock): assert intent.text_input == 'turn the lights green' -@asyncio.coroutine -def test_snips_intent_with_duration(hass, mqtt_mock): +async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -61,7 +132,8 @@ def test_snips_intent_with_duration(hass, mqtt_mock): { "input": "set a timer of five minutes", "intent": { - "intentName": "SetTimer" + "intentName": "SetTimer", + "probability": 1 }, "slots": [ { @@ -92,7 +164,7 @@ def test_snips_intent_with_duration(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/SetTimer', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' @@ -100,22 +172,14 @@ def test_snips_intent_with_duration(hass, mqtt_mock): assert intent.slots == {'timer_duration': {'value': 300}} -@asyncio.coroutine -def test_intent_speech_response(hass, mqtt_mock): +async def test_intent_speech_response(hass, mqtt_mock): """Test intent speech response via Snips.""" - event = 'call_service' - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - result = yield from async_setup_component(hass, "snips", { + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result - result = yield from async_setup_component(hass, "intent_script", { + result = await async_setup_component(hass, "intent_script", { "intent_script": { "spokenIntent": { "speech": { @@ -131,31 +195,28 @@ def record_event(event): "input": "speak to me", "sessionId": "abcdef0123456789", "intent": { - "intentName": "spokenIntent" + "intentName": "spokenIntent", + "probability": 1 }, "slots": [] } """ - hass.bus.async_listen(event, record_event) async_fire_mqtt_message(hass, 'hermes/intent/spokenIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() - assert len(events) == 1 - assert events[0].data['domain'] == 'mqtt' - assert events[0].data['service'] == 'publish' - payload = json.loads(events[0].data['service_data']['payload']) - topic = events[0].data['service_data']['topic'] + assert len(calls) == 1 + payload = json.loads(calls[0].data['payload']) + topic = calls[0].data['topic'] assert payload['sessionId'] == 'abcdef0123456789' assert payload['text'] == 'I am speaking to you' assert topic == 'hermes/dialogueManager/endSession' -@asyncio.coroutine -def test_unknown_intent(hass, mqtt_mock, caplog): +async def test_unknown_intent(hass, mqtt_mock, caplog): """Test unknown intent.""" caplog.set_level(logging.WARNING) - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -164,21 +225,21 @@ def test_unknown_intent(hass, mqtt_mock, caplog): "input": "I don't know what I am supposed to do", "sessionId": "abcdef1234567890", "intent": { - "intentName": "unknownIntent" + "intentName": "unknownIntent", + "probability": 1 }, "slots": [] } """ async_fire_mqtt_message(hass, 'hermes/intent/unknownIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert 'Received unknown intent unknownIntent' in caplog.text -@asyncio.coroutine -def test_snips_intent_user(hass, mqtt_mock): +async def test_snips_intent_user(hass, mqtt_mock): """Test intentName format user_XXX__intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -186,7 +247,8 @@ def test_snips_intent_user(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "user_ABCDEF123__Lights" + "intentName": "user_ABCDEF123__Lights", + "probability": 1 }, "slots": [] } @@ -194,7 +256,7 @@ def test_snips_intent_user(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/user_ABCDEF123__Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -202,10 +264,9 @@ def test_snips_intent_user(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_intent_username(hass, mqtt_mock): +async def test_snips_intent_username(hass, mqtt_mock): """Test intentName format username:intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -213,7 +274,8 @@ def test_snips_intent_username(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "username:Lights" + "intentName": "username:Lights", + "probability": 1 }, "slots": [] } @@ -221,7 +283,7 @@ def test_snips_intent_username(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/username:Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -229,15 +291,41 @@ def test_snips_intent_username(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_say(hass, caplog): +async def test_snips_low_probability(hass, mqtt_mock, caplog): + """Test intent via Snips.""" + caplog.set_level(logging.WARNING) + result = await async_setup_component(hass, "snips", { + "snips": { + "probability_threshold": 0.5 + }, + }) + assert result + payload = """ + { + "input": "I am not sure what to say", + "intent": { + "intentName": "LightsMaybe", + "probability": 0.49 + }, + "slots": [] + } + """ + + async_mock_intent(hass, 'LightsMaybe') + async_fire_mqtt_message(hass, 'hermes/intent/LightsMaybe', + payload) + await hass.async_block_till_done() + assert 'Intent below probaility threshold 0.49 < 0.5' in caplog.text + + +async def test_snips_say(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -245,15 +333,14 @@ def test_snips_say(hass, caplog): assert calls[0].data['text'] == 'Hello' -@asyncio.coroutine -def test_snips_say_action(hass, caplog): +async def test_snips_say_action(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'intent_filter': ['myIntent']} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -262,31 +349,71 @@ def test_snips_say_action(hass, caplog): assert calls[0].data['intent_filter'] == ['myIntent'] -@asyncio.coroutine -def test_snips_say_invalid_config(hass, caplog): +async def test_snips_say_invalid_config(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello', 'badKey': 'boo'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text -@asyncio.coroutine -def test_snips_say_action_invalid_config(hass, caplog): +async def test_snips_say_action_invalid(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text + + +async def test_snips_feedback_on(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_on' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_off(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_off', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_off', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_off' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_config(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote', 'test': 'test'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 0 From 2b86059fd0e8c6417d79f4e75faa92e3a5fb3da9 Mon Sep 17 00:00:00 2001 From: Sean Wilson Date: Mon, 9 Apr 2018 13:38:57 -0400 Subject: [PATCH 028/155] Add missing DISCHRG state (#13787) * Add missing ups.status states. * Add missing DISCHRG state. --- homeassistant/components/sensor/nut.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index e0d5b7250e9d4..b8917080efc5b 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -113,6 +113,7 @@ 'HB': 'High Battery', 'RB': 'Battery Needs Replaced', 'CHRG': 'Battery Charging', + 'DISCHRG': 'Battery Discharging', 'BYPASS': 'Bypass Active', 'CAL': 'Runtime Calibration', 'OFF': 'Offline', From ae4e792651d72ab6f629757e7b1c66219c4e3599 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Apr 2018 22:57:10 +0200 Subject: [PATCH 029/155] Improved upgradeability HomeKit security_systems (#13783) --- .../homekit/type_security_systems.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 235a8b22e7c3b..0c3c3e42d4b94 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -67,15 +67,13 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): return hass_state = new_state.state - if hass_state not in HASS_TO_HOMEKIT: - return - - current_security_state = HASS_TO_HOMEKIT[hass_state] - self.char_current_state.set_value(current_security_state) - _LOGGER.debug('%s: Updated current state to %s (%d)', - self.entity_id, hass_state, current_security_state) - - if not self.flag_target_state: - self.char_target_state.set_value(current_security_state) - if self.char_target_state.value == self.char_current_state.value: - self.flag_target_state = False + if hass_state in HASS_TO_HOMEKIT: + current_security_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_security_state) + _LOGGER.debug('%s: Updated current state to %s (%d)', + self.entity_id, hass_state, current_security_state) + + if not self.flag_target_state: + self.char_target_state.set_value(current_security_state) + if self.char_target_state.value == self.char_current_state.value: + self.flag_target_state = False From 7595401dcba96741832823d0a5d9b1855611f163 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 10 Apr 2018 01:24:06 +0200 Subject: [PATCH 030/155] Qwikswitch Entity Register (#13791) * Entity Register * feedback --- homeassistant/components/qwikswitch.py | 6 ++++++ homeassistant/components/sensor/qwikswitch.py | 7 +++++++ tests/components/sensor/test_qwikswitch.py | 3 --- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 36bd726fa2dc2..4d34ccca24a70 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -64,6 +64,12 @@ def poll(self): """QS sensors gets packets in update_packet.""" return False + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}".format(self.qsid) + + @callback def update_packet(self, packet): """Receive update packet from QSUSB. Match dispather_send signature.""" self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/qwikswitch.py b/homeassistant/components/sensor/qwikswitch.py index 98c67b7a21c98..ebd5f5254d446 100644 --- a/homeassistant/components/sensor/qwikswitch.py +++ b/homeassistant/components/sensor/qwikswitch.py @@ -7,6 +7,7 @@ import logging from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH, QSEntity +from homeassistant.core import callback DEPENDENCIES = [QWIKSWITCH] @@ -41,6 +42,7 @@ def __init__(self, sensor): if isinstance(self.unit, type): self.unit = "{}:{}".format(self.sensor_type, self.channel) + @callback def update_packet(self, packet): """Receive update packet from QSUSB.""" val = self._decode(packet.get('data'), channel=self.channel) @@ -55,6 +57,11 @@ def state(self): """Return the value of the sensor.""" return str(self._val) + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}:{}".format(self.qsid, self.channel) + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" diff --git a/tests/components/sensor/test_qwikswitch.py b/tests/components/sensor/test_qwikswitch.py index d9799b8530e72..d9dfe072fc0ab 100644 --- a/tests/components/sensor/test_qwikswitch.py +++ b/tests/components/sensor/test_qwikswitch.py @@ -1,5 +1,4 @@ """Test qwikswitch sensors.""" -import asyncio import logging import pytest @@ -29,7 +28,6 @@ def decode(self, _): async def wait_till_empty(self, hass): """Wait until empty.""" while self: - await asyncio.sleep(1) await hass.async_block_till_done() await hass.async_block_till_done() @@ -54,7 +52,6 @@ def aioclient_mock(): yield mock_session -# @asyncio.coroutine async def test_sensor_device(hass, aioclient_mock): """Test a sensor device.""" config = { From 5ac52b74e0951225905562bac68ba5eddb6c6e66 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Apr 2018 21:21:26 -0400 Subject: [PATCH 031/155] Remove vendor lookup for mac addresses (#13788) * Remove vendor lookup for mac addresses * Fix tests --- .../components/device_tracker/__init__.py | 61 +-------- tests/components/device_tracker/test_init.py | 128 +----------------- tests/conftest.py | 4 +- 3 files changed, 6 insertions(+), 187 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 682496335a024..45f0e51a2142f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -9,8 +9,6 @@ import logging from typing import Any, List, Sequence, Callable -import aiohttp -import async_timeout import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform @@ -19,7 +17,6 @@ from homeassistant.components import group, zone from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval @@ -76,7 +73,6 @@ ATTR_MAC = 'mac' ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' -ATTR_VENDOR = 'vendor' ATTR_CONSIDER_HOME = 'consider_home' SOURCE_TYPE_GPS = 'gps' @@ -328,14 +324,10 @@ def async_see( self.hass, util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, name=GROUP_NAME_ALL_DEVICES, add=[device.entity_id]) - # lookup mac vendor string to be stored in config - yield from device.set_vendor_for_mac() - self.hass.bus.async_fire(EVENT_NEW_DEVICE, { ATTR_ENTITY_ID: device.entity_id, ATTR_HOST_NAME: device.host_name, ATTR_MAC: device.mac, - ATTR_VENDOR: device.vendor, }) # update known_devices.yaml @@ -413,7 +405,6 @@ class Device(Entity): consider_home = None # type: dt_util.dt.timedelta battery = None # type: int attributes = None # type: dict - vendor = None # type: str icon = None # type: str # Track if the last update of this device was HOME. @@ -423,7 +414,7 @@ class Device(Entity): def __init__(self, hass: HomeAssistantType, consider_home: timedelta, track: bool, dev_id: str, mac: str, name: str = None, picture: str = None, gravatar: str = None, icon: str = None, - hide_if_away: bool = False, vendor: str = None) -> None: + hide_if_away: bool = False) -> None: """Initialize a device.""" self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -451,7 +442,6 @@ def __init__(self, hass: HomeAssistantType, consider_home: timedelta, self.icon = icon self.away_hide = hide_if_away - self.vendor = vendor self.source_type = None @@ -567,51 +557,6 @@ def async_update(self): self._state = STATE_HOME self.last_update_home = True - @asyncio.coroutine - def set_vendor_for_mac(self): - """Set vendor string using api.macvendors.com.""" - self.vendor = yield from self.get_vendor_for_mac() - - @asyncio.coroutine - def get_vendor_for_mac(self): - """Try to find the vendor string for a given MAC address.""" - if not self.mac: - return None - - if '_' in self.mac: - _, mac = self.mac.split('_', 1) - else: - mac = self.mac - - if not len(mac.split(':')) == 6: - return 'unknown' - - # We only need the first 3 bytes of the MAC for a lookup - # this improves somewhat on privacy - oui_bytes = mac.split(':')[0:3] - # bytes like 00 get truncates to 0, API needs full bytes - oui = '{:02x}:{:02x}:{:02x}'.format(*[int(b, 16) for b in oui_bytes]) - url = 'http://api.macvendors.com/' + oui - try: - websession = async_get_clientsession(self.hass) - - with async_timeout.timeout(5, loop=self.hass.loop): - resp = yield from websession.get(url) - # mac vendor found, response is the string - if resp.status == 200: - vendor_string = yield from resp.text() - return vendor_string - # If vendor is not known to the API (404) or there - # was a failure during the lookup (500); set vendor - # to something other then None to prevent retry - # as the value is only relevant when it is to be stored - # in the 'known_devices.yaml' file which only happens - # the first time the device is seen. - return 'unknown' - except (asyncio.TimeoutError, aiohttp.ClientError): - # Same as above - return 'unknown' - @asyncio.coroutine def async_added_to_hass(self): """Add an entity.""" @@ -685,7 +630,6 @@ def async_load_config(path: str, hass: HomeAssistantType, vol.Optional('picture', default=None): vol.Any(None, cv.string), vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( cv.time_period, cv.positive_timedelta), - vol.Optional('vendor', default=None): vol.Any(None, cv.string), }) try: result = [] @@ -697,6 +641,8 @@ def async_load_config(path: str, hass: HomeAssistantType, return [] for dev_id, device in devices.items(): + # Deprecated option. We just ignore it to avoid breaking change + device.pop('vendor', None) try: device = dev_schema(device) device['dev_id'] = cv.slugify(dev_id) @@ -772,7 +718,6 @@ def update_config(path: str, dev_id: str, device: Device): 'picture': device.config_picture, 'track': device.track, CONF_AWAY_HIDE: device.away_hide, - 'vendor': device.vendor, }} out.write('\n') out.write(dump(device)) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index c051983d8fa24..912bd315ecd8b 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -24,9 +24,7 @@ from tests.common import ( get_test_home_assistant, fire_time_changed, - patch_yaml_files, assert_setup_component, mock_restore_cache, mock_coro) - -from ...test_util.aiohttp import mock_aiohttp_client + patch_yaml_files, assert_setup_component, mock_restore_cache) TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}} @@ -111,7 +109,6 @@ def test_reading_yaml_config(self): self.assertEqual(device.config_picture, config.config_picture) self.assertEqual(device.away_hide, config.away_hide) self.assertEqual(device.consider_home, config.consider_home) - self.assertEqual(device.vendor, config.vendor) self.assertEqual(device.icon, config.icon) # pylint: disable=invalid-name @@ -173,124 +170,6 @@ def test_gravatar_and_picture(self): "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") self.assertEqual(device.config_picture, gravatar_url) - def test_mac_vendor_lookup(self): - """Test if vendor string is lookup on macvendors API.""" - mac = 'B8:27:EB:00:00:00' - vendor_string = 'Raspberry Pi Foundation' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - text=vendor_string) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - assert aioclient_mock.call_count == 1 - - self.assertEqual(device.vendor, vendor_string) - - def test_mac_vendor_mac_formats(self): - """Verify all variations of MAC addresses are handled correctly.""" - vendor_string = 'Raspberry Pi Foundation' - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - text=vendor_string) - aioclient_mock.get('http://api.macvendors.com/00:27:eb', - text=vendor_string) - - mac = 'B8:27:EB:00:00:00' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), - True, 'test', mac, 'Test name') - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - self.assertEqual(device.vendor, vendor_string) - - mac = '0:27:EB:00:00:00' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), - True, 'test', mac, 'Test name') - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - self.assertEqual(device.vendor, vendor_string) - - mac = 'PREFIXED_B8:27:EB:00:00:00' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), - True, 'test', mac, 'Test name') - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - self.assertEqual(device.vendor, vendor_string) - - def test_mac_vendor_lookup_unknown(self): - """Prevent another mac vendor lookup if was not found first time.""" - mac = 'B8:27:EB:00:00:00' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - status=404) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - - self.assertEqual(device.vendor, 'unknown') - - def test_mac_vendor_lookup_error(self): - """Prevent another lookup if failure during API call.""" - mac = 'B8:27:EB:00:00:00' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - status=500) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - - self.assertEqual(device.vendor, 'unknown') - - def test_mac_vendor_lookup_exception(self): - """Prevent another lookup if exception during API call.""" - mac = 'B8:27:EB:00:00:00' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - exc=asyncio.TimeoutError()) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - - self.assertEqual(device.vendor, 'unknown') - - def test_mac_vendor_lookup_on_see(self): - """Test if macvendor is looked up when device is seen.""" - mac = 'B8:27:EB:00:00:00' - vendor_string = 'Raspberry Pi Foundation' - - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, {}, []) - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - text=vendor_string) - - run_coroutine_threadsafe( - tracker.async_see(mac=mac), self.hass.loop).result() - assert aioclient_mock.call_count == 1, \ - 'No http request for macvendor made!' - self.assertEqual(tracker.devices['b827eb000000'].vendor, vendor_string) - @patch( 'homeassistant.components.device_tracker.DeviceTracker.see') @patch( @@ -463,7 +342,6 @@ def listener(event): 'entity_id': 'device_tracker.hello', 'host_name': 'hello', 'mac': 'MAC_1', - 'vendor': 'unknown', } # pylint: disable=invalid-name @@ -495,9 +373,7 @@ def test_not_allow_invalid_dev_id(self): timedelta(seconds=0)) assert len(config) == 0 - @patch('homeassistant.components.device_tracker.Device' - '.set_vendor_for_mac', return_value=mock_coro()) - def test_see_state(self, mock_set_vendor): + def test_see_state(self): """Test device tracker see records state correctly.""" self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, TEST_PLATFORM)) diff --git a/tests/conftest.py b/tests/conftest.py index 8f0ca787721cf..269d460ebb6e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -123,7 +123,5 @@ async def mock_update_config(path, id, entity): ), patch( 'homeassistant.components.device_tracker.async_load_config', side_effect=lambda *args: mock_coro(devices) - ), patch('homeassistant.components.device_tracker' - '.Device.set_vendor_for_mac'): - + ): yield devices From c8a464d8f9a5860446ed3c6b420ad3d9d76b2bc4 Mon Sep 17 00:00:00 2001 From: citruz Date: Tue, 10 Apr 2018 03:24:18 +0200 Subject: [PATCH 032/155] Updated beacontools to 1.2.3 (#13792) --- homeassistant/components/sensor/eddystone_temperature.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/eddystone_temperature.py b/homeassistant/components/sensor/eddystone_temperature.py index 06accb26eb6c5..2c8ad4781d003 100644 --- a/homeassistant/components/sensor/eddystone_temperature.py +++ b/homeassistant/components/sensor/eddystone_temperature.py @@ -18,7 +18,7 @@ CONF_NAME, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['beacontools[scan]==1.2.1', 'construct==2.9.41'] +REQUIREMENTS = ['beacontools[scan]==1.2.3', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a747b5c3090be..5471a1ce9e258 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ basicmodem==0.7 batinfo==0.4.2 # homeassistant.components.sensor.eddystone_temperature -# beacontools[scan]==1.2.1 +# beacontools[scan]==1.2.3 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.geizhals From bd93f10d3c5643a488d0fa74070af5f7ba82c4d1 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 10 Apr 2018 03:24:50 +0200 Subject: [PATCH 033/155] script/lazytox: Ensure Flake8 passes for tests/ (#13794) --- script/lazytox.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/script/lazytox.py b/script/lazytox.py index 2639d640753fe..19af5560dfb13 100755 --- a/script/lazytox.py +++ b/script/lazytox.py @@ -18,7 +18,7 @@ RE_ASCII = re.compile(r"\033\[[^m]*m") -Error = namedtuple('Error', ['file', 'line', 'col', 'msg']) +Error = namedtuple('Error', ['file', 'line', 'col', 'msg', 'skip']) PASS = 'green' FAIL = 'bold_red' @@ -109,8 +109,9 @@ async def pylint(files): line = line.split(':') if len(line) < 3: continue - res.append(Error(line[0].replace('\\', '/'), - line[1], "", line[2].strip())) + _fn = line[0].replace('\\', '/') + res.append(Error( + _fn, line[1], '', line[2].strip(), _fn.startswith('tests/'))) return res @@ -122,8 +123,8 @@ async def flake8(files): line = line.split(':') if len(line) < 4: continue - res.append(Error(line[0].replace('\\', '/'), - line[1], line[2], line[3].strip())) + _fn = line[0].replace('\\', '/') + res.append(Error(_fn, line[1], line[2], line[3].strip(), False)) return res @@ -144,7 +145,7 @@ async def lint(files): err_msg = "{} {}:{} {}".format(err.file, err.line, err.col, err.msg) # tests/* does not have to pass lint - if err.file.startswith('tests/'): + if err.skip: print(err_msg) else: printc(FAIL, err_msg) From 7ea776dff4cc88ff7de0752136a452dd44dca995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Kut=C3=BD?= <6du1ro.n@gmail.com> Date: Tue, 10 Apr 2018 08:20:47 +0200 Subject: [PATCH 034/155] Fix bad metrics format for short metrics. (#13778) --- homeassistant/components/prometheus.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index f9629ca726a54..dc1cbd945a7ef 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -185,6 +185,9 @@ def _handle_sensor(self, state): unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) metric = state.entity_id.split(".")[1] + if '_' not in str(metric): + metric = state.entity_id.replace('.', '_') + try: int(metric.split("_")[-1]) metric = "_".join(metric.split("_")[:-1]) From 2707d35a8638662decd04a3383d5f1927acf8bd0 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Tue, 10 Apr 2018 00:12:21 -0700 Subject: [PATCH 035/155] Update bellows to 0.5.2 (#13800) --- homeassistant/components/zha/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 39419034545b1..73c1fdf9075e0 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -16,7 +16,7 @@ from homeassistant.util import slugify REQUIREMENTS = [ - 'bellows==0.5.1', + 'bellows==0.5.2', 'zigpy==0.0.3', 'zigpy-xbee==0.0.2', ] diff --git a/requirements_all.txt b/requirements_all.txt index 5471a1ce9e258..cb45639a4d6b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,7 +140,7 @@ batinfo==0.4.2 beautifulsoup4==4.6.0 # homeassistant.components.zha -bellows==0.5.1 +bellows==0.5.2 # homeassistant.components.bmw_connected_drive bimmer_connected==0.5.0 From cf88d8a1b92b91f0843186a8d0f2b8eeadbf6885 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 10 Apr 2018 14:11:00 -0400 Subject: [PATCH 036/155] iglo hs color fix (#13808) --- homeassistant/components/light/iglo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index 77e3972968cdf..f40dc2ce84eea 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -79,7 +79,7 @@ def max_mireds(self): @property def hs_color(self): """Return the hs value.""" - return color_util.color_RGB_to_hsv(*self._lamp.state()['rgb']) + return color_util.color_RGB_to_hs(*self._lamp.state()['rgb']) @property def effect(self): From 978a79d369b2d0b1670e783c7e3856f7388dffcf Mon Sep 17 00:00:00 2001 From: Toby Gray Date: Tue, 10 Apr 2018 19:38:36 +0100 Subject: [PATCH 037/155] device_tracker.ubus: Handle devices not running DHCP (#13579) --- homeassistant/components/device_tracker/ubus.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index dd12df7b0707b..3d7ef5cef6ea4 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -103,6 +103,9 @@ def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: self._generate_mac2name() + if self.mac2name is None: + # Generation of mac2name dictionary failed + return None name = self.mac2name.get(device.upper(), None) return name From 191e32f6cf12bdda95c043b92c974c9aef90be19 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Tue, 10 Apr 2018 21:11:45 +0200 Subject: [PATCH 038/155] Update yweather.py (#13802) Map clear-night string to 31 value. --- homeassistant/components/weather/yweather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index f9610e469b222..5987cf7621f85 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -32,6 +32,7 @@ SCAN_INTERVAL = timedelta(minutes=10) CONDITION_CLASSES = { + 'clear-night': [31], 'cloudy': [26, 27, 28, 29, 30], 'fog': [19, 20, 21, 22, 23], 'hail': [17, 18, 35], From 16a1a4e0b187e99b9b113ad8f946bfac66e62ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 10 Apr 2018 22:12:55 +0200 Subject: [PATCH 039/155] Tibber lib update (#13811) --- homeassistant/components/sensor/tibber.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index aaaa8366909e9..ca1c1922ab565 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -REQUIREMENTS = ['pyTibber==0.4.0'] +REQUIREMENTS = ['pyTibber==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cb45639a4d6b4..a80a59c2509a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -670,7 +670,7 @@ pyHS100==0.3.0 pyRFXtrx==0.22.0 # homeassistant.components.sensor.tibber -pyTibber==0.4.0 +pyTibber==0.4.1 # homeassistant.components.switch.dlink pyW215==0.6.0 From b2695e498d00ae79f01035be885d34c9be69b5a7 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Tue, 10 Apr 2018 23:33:56 +0200 Subject: [PATCH 040/155] Update pyhomematic to 0.1.41 (#13814) * Update requirements_all.txt * Update __init__.py --- homeassistant/components/homematic/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index c542cd9e88e56..23fe9685418a7 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.40'] +REQUIREMENTS = ['pyhomematic==0.1.41'] DOMAIN = 'homematic' _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a80a59c2509a7..d5d6438152838 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ pyhik==0.1.8 pyhiveapi==0.2.11 # homeassistant.components.homematic -pyhomematic==0.1.40 +pyhomematic==0.1.41 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.1 From 8d48164f25f3b7f272ee486ecdeb6e1e8e4c6174 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Tue, 10 Apr 2018 18:38:23 -0700 Subject: [PATCH 041/155] Add support for Eufy bulbs and switches (#13773) * Add support for Eufy bulbs and switches Add support for driving bulbs and switches from the Eufy range. * Fix hound checks * Satisfy pylint * Handle review comments * Review updates and test fixes * PyLint is a bit too aggressive --- .coveragerc | 3 + homeassistant/components/eufy.py | 77 ++++++++++++ homeassistant/components/light/eufy.py | 158 ++++++++++++++++++++++++ homeassistant/components/switch/eufy.py | 73 +++++++++++ requirements_all.txt | 3 + 5 files changed, 314 insertions(+) create mode 100644 homeassistant/components/eufy.py create mode 100644 homeassistant/components/light/eufy.py create mode 100644 homeassistant/components/switch/eufy.py diff --git a/.coveragerc b/.coveragerc index 6b1ca91a574b3..666134488fe97 100644 --- a/.coveragerc +++ b/.coveragerc @@ -94,6 +94,9 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/eufy.py + homeassistant/components/*/eufy.py + homeassistant/components/gc100.py homeassistant/components/*/gc100.py diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py new file mode 100644 index 0000000000000..53584be9fdcdd --- /dev/null +++ b/homeassistant/components/eufy.py @@ -0,0 +1,77 @@ +""" +Support for Eufy devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/eufy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, \ + CONF_DEVICES, CONF_USERNAME, CONF_PASSWORD, CONF_TYPE, CONF_NAME +from homeassistant.helpers import discovery + +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['lakeside==0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'eufy' + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_TYPE): cv.string, + vol.Optional(CONF_NAME): cv.string +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, + [DEVICE_SCHEMA]), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +EUFY_DISPATCH = { + 'T1011': 'light', + 'T1012': 'light', + 'T1013': 'light', + 'T1201': 'switch', + 'T1202': 'switch', + 'T1211': 'switch' +} + + +def setup(hass, config): + """Set up Eufy devices.""" + # pylint: disable=import-error + import lakeside + + if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: + data = lakeside.get_devices(config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD]) + for device in data: + kind = device['type'] + if kind not in EUFY_DISPATCH: + continue + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, + config) + + for device_info in config[DOMAIN][CONF_DEVICES]: + kind = device_info['type'] + if kind not in EUFY_DISPATCH: + continue + device = {} + device['address'] = device_info['address'] + device['code'] = device_info['access_token'] + device['type'] = device_info['type'] + device['name'] = device_info['name'] + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, + config) + + return True diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py new file mode 100644 index 0000000000000..fa6550d2682d1 --- /dev/null +++ b/homeassistant/components/light/eufy.py @@ -0,0 +1,158 @@ +""" +Support for Eufy lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.eufy/ +""" +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) + +import homeassistant.util.color as color_util + +from homeassistant.util.color import ( + color_temperature_mired_to_kelvin as mired_to_kelvin, + color_temperature_kelvin_to_mired as kelvin_to_mired) + +DEPENDENCIES = ['eufy'] + +_LOGGER = logging.getLogger(__name__) + +EUFY_MAX_KELVIN = 6500 +EUFY_MIN_KELVIN = 2700 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Eufy bulbs.""" + if discovery_info is None: + return + add_devices([EufyLight(discovery_info)], True) + + +class EufyLight(Light): + """Representation of a Eufy light.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import lakeside + + self._temp = None + self._brightness = None + self._hs = None + self._state = None + self._name = device['name'] + self._address = device['address'] + self._code = device['code'] + self._type = device['type'] + self._bulb = lakeside.bulb(self._address, self._code, self._type) + if self._type == "T1011": + self._features = SUPPORT_BRIGHTNESS + elif self._type == "T1012": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + elif self._type == "T1013": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR + self._bulb.connect() + + def update(self): + """Synchronise state from the bulb.""" + self._bulb.update() + self._brightness = self._bulb.brightness + self._temp = self._bulb.temperature + if self._bulb.colors: + self._hs = color_util.color_RGB_to_hsv(*self._bulb.colors) + else: + self._hs = None + self._state = self._bulb.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._brightness * 255 / 100) + + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + return kelvin_to_mired(EUFY_MAX_KELVIN) + + @property + def max_mireds(self): + """Return maximu supported color temperature.""" + return kelvin_to_mired(EUFY_MIN_KELVIN) + + @property + def color_temp(self): + """Return the color temperature of this light.""" + temp_in_k = int(EUFY_MIN_KELVIN + (self._temp * + (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN) + / 100)) + return kelvin_to_mired(temp_in_k) + + @property + def hs_color(self): + """Return the color of this light.""" + return self._hs + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + colortemp = kwargs.get(ATTR_COLOR_TEMP) + # pylint: disable=invalid-name + hs = kwargs.get(ATTR_HS_COLOR) + + if brightness is not None: + brightness = int(brightness * 100 / 255) + else: + brightness = max(1, self._brightness) + + if colortemp is not None: + temp_in_k = mired_to_kelvin(colortemp) + relative_temp = temp_in_k - EUFY_MIN_KELVIN + temp = int(relative_temp * 100 / + (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN)) + else: + temp = None + + if hs is not None: + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], brightness / 255 * 100) + else: + rgb = None + + try: + self._bulb.set_state(power=True, brightness=brightness, + temperature=temp, colors=rgb) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=True, brightness=brightness, + temperature=temp, colors=rgb) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + try: + self._bulb.set_state(power=False) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=False) diff --git a/homeassistant/components/switch/eufy.py b/homeassistant/components/switch/eufy.py new file mode 100644 index 0000000000000..891525d397915 --- /dev/null +++ b/homeassistant/components/switch/eufy.py @@ -0,0 +1,73 @@ +""" +Support for Eufy switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.eufy/ +""" +import logging + +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['eufy'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Eufy switches.""" + if discovery_info is None: + return + add_devices([EufySwitch(discovery_info)], True) + + +class EufySwitch(SwitchDevice): + """Representation of a Eufy switch.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import lakeside + + self._state = None + self._name = device['name'] + self._address = device['address'] + self._code = device['code'] + self._type = device['type'] + self._switch = lakeside.switch(self._address, self._code, self._type) + self._switch.connect() + + def update(self): + """Synchronise state from the switch.""" + self._switch.update() + self._state = self._switch.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + try: + self._switch.set_state(True) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(power=True) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + try: + self._switch.set_state(False) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(False) diff --git a/requirements_all.txt b/requirements_all.txt index d5d6438152838..1b3d3206c6066 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,6 +454,9 @@ keyring==12.0.0 # homeassistant.scripts.keyring keyrings.alt==3.0 +# homeassistant.components.eufy +lakeside==0.4 + # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http libnacl==1.6.1 From 2a5751c09d62823371da14e9bdb1b19143851c85 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Wed, 11 Apr 2018 22:24:14 +0200 Subject: [PATCH 042/155] Homekit refactor (#13707) --- homeassistant/components/homekit/__init__.py | 85 +++++++---------- .../components/homekit/accessories.py | 59 ++++++++---- homeassistant/components/homekit/const.py | 2 - .../components/homekit/type_covers.py | 35 +++---- .../components/homekit/type_lights.py | 53 ++++------- .../components/homekit/type_locks.py | 30 ++---- .../homekit/type_security_systems.py | 31 ++---- .../components/homekit/type_sensors.py | 59 ++++-------- .../components/homekit/type_switches.py | 23 ++--- .../components/homekit/type_thermostats.py | 95 ++++++++----------- homeassistant/components/homekit/util.py | 2 +- tests/components/homekit/test_accessories.py | 40 ++++---- .../homekit/test_get_accessories.py | 22 ++--- tests/components/homekit/test_homekit.py | 2 +- tests/components/homekit/test_type_covers.py | 2 +- tests/components/homekit/test_type_lights.py | 16 +++- tests/components/homekit/test_type_locks.py | 2 +- .../homekit/test_type_security_systems.py | 8 +- tests/components/homekit/test_type_sensors.py | 11 ++- .../components/homekit/test_type_switches.py | 6 +- .../homekit/test_type_thermostats.py | 22 ++++- tests/components/homekit/test_util.py | 2 +- 22 files changed, 275 insertions(+), 332 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 22c74faf5f0a7..02d21889f6b79 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,11 +8,9 @@ import voluptuous as vol -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.components.cover import SUPPORT_SET_POSITION from homeassistant.const import ( - ATTR_CODE, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv @@ -79,63 +77,46 @@ def get_accessory(hass, state, aid, config): state.entity_id) return None - if state.domain == 'sensor': - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT: - _LOGGER.debug('Add "%s" as "%s"', - state.entity_id, 'TemperatureSensor') - return TYPES['TemperatureSensor'](hass, state.entity_id, - state.name, aid=aid) - elif unit == '%': - _LOGGER.debug('Add "%s" as %s"', - state.entity_id, 'HumiditySensor') - return TYPES['HumiditySensor'](hass, state.entity_id, state.name, - aid=aid) + a_type = None + config = config or {} + + if state.domain == 'alarm_control_panel': + a_type = 'SecuritySystem' elif state.domain == 'binary_sensor' or state.domain == 'device_tracker': - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'BinarySensor') - return TYPES['BinarySensor'](hass, state.entity_id, - state.name, aid=aid) + a_type = 'BinarySensor' + + elif state.domain == 'climate': + a_type = 'Thermostat' elif state.domain == 'cover': # Only add covers that support set_cover_position features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & SUPPORT_SET_POSITION: - _LOGGER.debug('Add "%s" as "%s"', - state.entity_id, 'WindowCovering') - return TYPES['WindowCovering'](hass, state.entity_id, state.name, - aid=aid) - - elif state.domain == 'alarm_control_panel': - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'SecuritySystem') - return TYPES['SecuritySystem'](hass, state.entity_id, state.name, - alarm_code=config.get(ATTR_CODE), - aid=aid) - - elif state.domain == 'climate': - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - support_temp_range = SUPPORT_TARGET_TEMPERATURE_LOW | \ - SUPPORT_TARGET_TEMPERATURE_HIGH - # Check if climate device supports auto mode - support_auto = bool(features & support_temp_range) - - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Thermostat') - return TYPES['Thermostat'](hass, state.entity_id, - state.name, support_auto, aid=aid) + a_type = 'WindowCovering' elif state.domain == 'light': - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Light') - return TYPES['Light'](hass, state.entity_id, state.name, aid=aid) + a_type = 'Light' elif state.domain == 'lock': - return TYPES['Lock'](hass, state.entity_id, state.name, aid=aid) + a_type = 'Lock' + + elif state.domain == 'sensor': + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT: + a_type = 'TemperatureSensor' + elif unit == '%': + a_type = 'HumiditySensor' elif state.domain == 'switch' or state.domain == 'remote' \ or state.domain == 'input_boolean' or state.domain == 'script': - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Switch') - return TYPES['Switch'](hass, state.entity_id, state.name, aid=aid) + a_type = 'Switch' + + if a_type is None: + return None - return None + _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) + return TYPES[a_type](hass, state.name, state.entity_id, aid, config=config) def generate_aid(entity_id): @@ -151,7 +132,7 @@ class HomeKit(): def __init__(self, hass, port, entity_filter, entity_config): """Initialize a HomeKit object.""" - self._hass = hass + self.hass = hass self._port = port self._filter = entity_filter self._config = entity_config @@ -164,11 +145,11 @@ def setup(self): """Setup bridge and accessory driver.""" from .accessories import HomeBridge, HomeDriver - self._hass.bus.async_listen_once( + self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self.stop) - path = self._hass.config.path(HOMEKIT_FILE) - self.bridge = HomeBridge(self._hass) + path = self.hass.config.path(HOMEKIT_FILE) + self.bridge = HomeBridge(self.hass) self.driver = HomeDriver(self.bridge, self._port, get_local_ip(), path) def add_bridge_accessory(self, state): @@ -177,7 +158,7 @@ def add_bridge_accessory(self, state): return aid = generate_aid(state.entity_id) conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self._hass, state, aid, conf) + acc = get_accessory(self.hass, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) @@ -192,12 +173,12 @@ def start(self, *args): type_covers, type_lights, type_locks, type_security_systems, type_sensors, type_switches, type_thermostats) - for state in self._hass.states.all(): + for state in self.hass.states.all(): self.add_bridge_accessory(state) self.bridge.set_broker(self.driver) if not self.bridge.paired: - show_setup_message(self.bridge, self._hass) + show_setup_message(self.hass, self.bridge) _LOGGER.debug('Driver start') self.driver.start() diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index ec2c49f5e4399..d9b90a77d68de 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -7,14 +7,14 @@ from pyhap.accessory import Accessory, Bridge, Category from pyhap.accessory_driver import AccessoryDriver -from homeassistant.core import callback +from homeassistant.core import callback as ha_callback from homeassistant.helpers.event import ( async_track_state_change, track_point_in_utc_time) from homeassistant.util import dt as dt_util from .const import ( - DEBOUNCE_TIMEOUT, ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, - BRIDGE_NAME, MANUFACTURER, SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, + DEBOUNCE_TIMEOUT, BRIDGE_MODEL, BRIDGE_NAME, MANUFACTURER, + SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER) from .util import ( show_setup_message, dismiss_setup_message) @@ -24,7 +24,7 @@ def debounce(func): """Decorator function. Debounce callbacks form HomeKit.""" - @callback + @ha_callback def call_later_listener(*args): """Callback listener called from call_later.""" # pylint: disable=unsubscriptable-object @@ -72,6 +72,18 @@ def add_preload_service(acc, service, chars=None): return service +def setup_char(char_name, service, value=None, properties=None, callback=None): + """Helper function to return fully configured characteristic.""" + char = service.get_characteristic(char_name) + if value: + char.value = value + if properties: + char.override_properties(properties) + if callback: + char.setter_callback = callback + return char + + def set_accessory_info(acc, name, model, manufacturer=MANUFACTURER, serial_number='0000'): """Set the default accessory information.""" @@ -85,14 +97,13 @@ def set_accessory_info(acc, name, model, manufacturer=MANUFACTURER, class HomeAccessory(Accessory): """Adapter class for Accessory.""" - # pylint: disable=no-member - - def __init__(self, name=ACCESSORY_NAME, model=ACCESSORY_MODEL, - category='OTHER', **kwargs): + def __init__(self, hass, name, entity_id, aid, category): """Initialize a Accessory object.""" - super().__init__(name, **kwargs) - set_accessory_info(self, name, model) + super().__init__(name, aid=aid) + set_accessory_info(self, name, model=entity_id) self.category = getattr(Category, category, Category.OTHER) + self.entity_id = entity_id + self.hass = hass def _set_services(self): add_preload_service(self, SERV_ACCESSORY_INFO) @@ -100,19 +111,33 @@ def _set_services(self): def run(self): """Method called by accessory after driver is started.""" state = self.hass.states.get(self.entity_id) - self.update_state(new_state=state) + self.update_state_callback(new_state=state) async_track_state_change( - self.hass, self.entity_id, self.update_state) + self.hass, self.entity_id, self.update_state_callback) + + def update_state_callback(self, entity_id=None, old_state=None, + new_state=None): + """Callback from state change listener.""" + _LOGGER.debug('New_state: %s', new_state) + if new_state is None: + return + self.update_state(new_state) + + def update_state(self, new_state): + """Method called on state change to update HomeKit value. + + Overridden by accessory types. + """ + pass class HomeBridge(Bridge): """Adapter class for Bridge.""" - def __init__(self, hass, name=BRIDGE_NAME, - model=BRIDGE_MODEL, **kwargs): + def __init__(self, hass, name=BRIDGE_NAME): """Initialize a Bridge object.""" - super().__init__(name, **kwargs) - set_accessory_info(self, name, model) + super().__init__(name) + set_accessory_info(self, name, model=BRIDGE_MODEL) self.hass = hass def _set_services(self): @@ -130,7 +155,7 @@ def add_paired_client(self, client_uuid, client_public): def remove_paired_client(self, client_uuid): """Override super function to show setup message if unpaired.""" super().remove_paired_client(client_uuid) - show_setup_message(self, self.hass) + show_setup_message(self.hass, self) class HomeDriver(AccessoryDriver): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index e5a4c80a4301f..80f2fd039e606 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -18,8 +18,6 @@ SERVICE_HOMEKIT_START = 'start' # #### STRING CONSTANTS #### -ACCESSORY_MODEL = 'homekit.accessory' -ACCESSORY_NAME = 'Home Accessory' BRIDGE_MODEL = 'homekit.bridge' BRIDGE_NAME = 'Home Assistant' MANUFACTURER = 'HomeAssistant' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 781f52941fcac..7c7ab3e3683b3 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -4,12 +4,11 @@ from homeassistant.components.cover import ATTR_CURRENT_POSITION from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_WINDOW_COVERING, SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE) - _LOGGER = logging.getLogger(__name__) @@ -20,29 +19,20 @@ class WindowCovering(HomeAccessory): The cover entity must support: set_cover_position. """ - def __init__(self, hass, entity_id, display_name, **kwargs): + def __init__(self, *args, config): """Initialize a WindowCovering accessory object.""" - super().__init__(display_name, entity_id, - CATEGORY_WINDOW_COVERING, **kwargs) - - self.hass = hass - self.entity_id = entity_id - + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) self.current_position = None self.homekit_target = None serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) - self.char_current_position = serv_cover. \ - get_characteristic(CHAR_CURRENT_POSITION) - self.char_target_position = serv_cover. \ - get_characteristic(CHAR_TARGET_POSITION) - self.char_position_state = serv_cover. \ - get_characteristic(CHAR_POSITION_STATE) - self.char_current_position.value = 0 - self.char_target_position.value = 0 - self.char_position_state.value = 0 - - self.char_target_position.setter_callback = self.move_cover + self.char_current_position = setup_char( + CHAR_CURRENT_POSITION, serv_cover, value=0) + self.char_target_position = setup_char( + CHAR_TARGET_POSITION, serv_cover, value=0, + callback=self.move_cover) + self.char_position_state = setup_char( + CHAR_POSITION_STATE, serv_cover, value=0) def move_cover(self, value): """Move cover to value if call came from HomeKit.""" @@ -56,11 +46,8 @@ def move_cover(self, value): self.hass.components.cover.set_cover_position( value, self.entity_id) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update cover position after state changed.""" - if new_state is None: - return - current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, int): self.current_position = current_position diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 4fbfb99585956..9a7bce76fbacd 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -7,7 +7,8 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF from . import TYPES -from .accessories import HomeAccessory, add_preload_service, debounce +from .accessories import ( + HomeAccessory, add_preload_service, debounce, setup_char) from .const import ( CATEGORY_LIGHT, SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE, CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION) @@ -24,12 +25,9 @@ class Light(HomeAccessory): Currently supports: state, brightness, color temperature, rgb_color. """ - def __init__(self, hass, entity_id, name, **kwargs): + def __init__(self, *args, config): """Initialize a new Light accessory object.""" - super().__init__(name, entity_id, CATEGORY_LIGHT, **kwargs) - - self.hass = hass - self.entity_id = entity_id + super().__init__(*args, category=CATEGORY_LIGHT) self._flag = {CHAR_ON: False, CHAR_BRIGHTNESS: False, CHAR_HUE: False, CHAR_SATURATION: False, CHAR_COLOR_TEMPERATURE: False, RGB_COLOR: False} @@ -49,36 +47,29 @@ def __init__(self, hass, entity_id, name, **kwargs): self._saturation = None serv_light = add_preload_service(self, SERV_LIGHTBULB, self.chars) - self.char_on = serv_light.get_characteristic(CHAR_ON) - self.char_on.setter_callback = self.set_state - self.char_on.value = self._state + self.char_on = setup_char( + CHAR_ON, serv_light, value=self._state, callback=self.set_state) if CHAR_BRIGHTNESS in self.chars: - self.char_brightness = serv_light \ - .get_characteristic(CHAR_BRIGHTNESS) - self.char_brightness.setter_callback = self.set_brightness - self.char_brightness.value = 0 + self.char_brightness = setup_char( + CHAR_BRIGHTNESS, serv_light, value=0, + callback=self.set_brightness) if CHAR_COLOR_TEMPERATURE in self.chars: - self.char_color_temperature = serv_light \ - .get_characteristic(CHAR_COLOR_TEMPERATURE) - self.char_color_temperature.setter_callback = \ - self.set_color_temperature min_mireds = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MIN_MIREDS, 153) max_mireds = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MAX_MIREDS, 500) - self.char_color_temperature.override_properties({ - 'minValue': min_mireds, 'maxValue': max_mireds}) - self.char_color_temperature.value = min_mireds + self.char_color_temperature = setup_char( + CHAR_COLOR_TEMPERATURE, serv_light, value=min_mireds, + properties={'minValue': min_mireds, 'maxValue': max_mireds}, + callback=self.set_color_temperature) if CHAR_HUE in self.chars: - self.char_hue = serv_light.get_characteristic(CHAR_HUE) - self.char_hue.setter_callback = self.set_hue - self.char_hue.value = 0 + self.char_hue = setup_char( + CHAR_HUE, serv_light, value=0, callback=self.set_hue) if CHAR_SATURATION in self.chars: - self.char_saturation = serv_light \ - .get_characteristic(CHAR_SATURATION) - self.char_saturation.setter_callback = self.set_saturation - self.char_saturation.value = 75 + self.char_saturation = setup_char( + CHAR_SATURATION, serv_light, value=75, + callback=self.set_saturation) def set_state(self, value): """Set state if call came from HomeKit.""" @@ -136,11 +127,8 @@ def set_color(self): self.hass.components.light.turn_on( self.entity_id, hs_color=color) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update light after state change.""" - if not new_state: - return - # Handle State state = new_state.state if state in (STATE_ON, STATE_OFF): @@ -162,7 +150,8 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): if CHAR_COLOR_TEMPERATURE in self.chars: color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) if not self._flag[CHAR_COLOR_TEMPERATURE] \ - and isinstance(color_temperature, int): + and isinstance(color_temperature, int) and \ + self.char_color_temperature.value != color_temperature: self.char_color_temperature.set_value(color_temperature) self._flag[CHAR_COLOR_TEMPERATURE] = False diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 9df0c101eff49..f34fc6c6a7ff0 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -5,7 +5,7 @@ ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_LOCK, SERV_LOCK, CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE) @@ -27,25 +27,18 @@ class Lock(HomeAccessory): The lock entity must support: unlock and lock. """ - def __init__(self, hass, entity_id, name, **kwargs): + def __init__(self, *args, config): """Initialize a Lock accessory object.""" - super().__init__(name, entity_id, CATEGORY_LOCK, **kwargs) - - self.hass = hass - self.entity_id = entity_id - + super().__init__(*args, category=CATEGORY_LOCK) self.flag_target_state = False serv_lock_mechanism = add_preload_service(self, SERV_LOCK) - self.char_current_state = serv_lock_mechanism. \ - get_characteristic(CHAR_LOCK_CURRENT_STATE) - self.char_target_state = serv_lock_mechanism. \ - get_characteristic(CHAR_LOCK_TARGET_STATE) - - self.char_current_state.value = HASS_TO_HOMEKIT[STATE_UNKNOWN] - self.char_target_state.value = HASS_TO_HOMEKIT[STATE_LOCKED] - - self.char_target_state.setter_callback = self.set_state + self.char_current_state = setup_char( + CHAR_LOCK_CURRENT_STATE, serv_lock_mechanism, + value=HASS_TO_HOMEKIT[STATE_UNKNOWN]) + self.char_target_state = setup_char( + CHAR_LOCK_TARGET_STATE, serv_lock_mechanism, + value=HASS_TO_HOMEKIT[STATE_LOCKED], callback=self.set_state) def set_state(self, value): """Set lock state to value if call came from HomeKit.""" @@ -58,11 +51,8 @@ def set_state(self, value): params = {ATTR_ENTITY_ID: self.entity_id} self.hass.services.call('lock', service, params) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update lock after state changed.""" - if new_state is None: - return - hass_state = new_state.state if hass_state in HASS_TO_HOMEKIT: current_lock_state = HASS_TO_HOMEKIT[hass_state] diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 0c3c3e42d4b94..6b8457a3aa54b 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -7,7 +7,7 @@ ATTR_ENTITY_ID, ATTR_CODE) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_ALARM_SYSTEM, SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE) @@ -27,26 +27,18 @@ class SecuritySystem(HomeAccessory): """Generate an SecuritySystem accessory for an alarm control panel.""" - def __init__(self, hass, entity_id, display_name, alarm_code, **kwargs): + def __init__(self, *args, config): """Initialize a SecuritySystem accessory object.""" - super().__init__(display_name, entity_id, - CATEGORY_ALARM_SYSTEM, **kwargs) - - self.hass = hass - self.entity_id = entity_id - self._alarm_code = alarm_code - + super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) + self._alarm_code = config[ATTR_CODE] self.flag_target_state = False serv_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM) - self.char_current_state = serv_alarm. \ - get_characteristic(CHAR_CURRENT_SECURITY_STATE) - self.char_current_state.value = 3 - self.char_target_state = serv_alarm. \ - get_characteristic(CHAR_TARGET_SECURITY_STATE) - self.char_target_state.value = 3 - - self.char_target_state.setter_callback = self.set_security_state + self.char_current_state = setup_char( + CHAR_CURRENT_SECURITY_STATE, serv_alarm, value=3) + self.char_target_state = setup_char( + CHAR_TARGET_SECURITY_STATE, serv_alarm, value=3, + callback=self.set_security_state) def set_security_state(self, value): """Move security state to value if call came from HomeKit.""" @@ -61,11 +53,8 @@ def set_security_state(self, value): params[ATTR_CODE] = self._alarm_code self.hass.services.call('alarm_control_panel', service, params) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update security state after state changed.""" - if new_state is None: - return - hass_state = new_state.state if hass_state in HASS_TO_HOMEKIT: current_security_state = HASS_TO_HOMEKIT[hass_state] diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index b25eb784d6bde..790f0de61033e 100755 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -6,7 +6,7 @@ ATTR_DEVICE_CLASS, STATE_ON, STATE_HOME) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_SENSOR, SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR, CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS, @@ -20,10 +20,8 @@ DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) from .util import convert_to_float, temperature_to_homekit - _LOGGER = logging.getLogger(__name__) - BINARY_SENSOR_SERVICE_MAP = { DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED), @@ -43,24 +41,17 @@ class TemperatureSensor(HomeAccessory): Sensor entity must return temperature in °C, °F. """ - def __init__(self, hass, entity_id, name, **kwargs): + def __init__(self, *args, config): """Initialize a TemperatureSensor accessory object.""" - super().__init__(name, entity_id, CATEGORY_SENSOR, **kwargs) - - self.hass = hass - self.entity_id = entity_id - + super().__init__(*args, category=CATEGORY_SENSOR) serv_temp = add_preload_service(self, SERV_TEMPERATURE_SENSOR) - self.char_temp = serv_temp.get_characteristic(CHAR_CURRENT_TEMPERATURE) - self.char_temp.override_properties(properties=PROP_CELSIUS) - self.char_temp.value = 0 + self.char_temp = setup_char( + CHAR_CURRENT_TEMPERATURE, serv_temp, value=0, + properties=PROP_CELSIUS) self.unit = None - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update temperature after state changed.""" - if new_state is None: - return - unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) temperature = convert_to_float(new_state.state) if temperature: @@ -74,23 +65,15 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): class HumiditySensor(HomeAccessory): """Generate a HumiditySensor accessory as humidity sensor.""" - def __init__(self, hass, entity_id, name, *args, **kwargs): + def __init__(self, *args, config): """Initialize a HumiditySensor accessory object.""" - super().__init__(name, entity_id, CATEGORY_SENSOR, *args, **kwargs) - - self.hass = hass - self.entity_id = entity_id - + super().__init__(*args, category=CATEGORY_SENSOR) serv_humidity = add_preload_service(self, SERV_HUMIDITY_SENSOR) - self.char_humidity = serv_humidity \ - .get_characteristic(CHAR_CURRENT_HUMIDITY) - self.char_humidity.value = 0 + self.char_humidity = setup_char( + CHAR_CURRENT_HUMIDITY, serv_humidity, value=0) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update accessory after state change.""" - if new_state is None: - return - humidity = convert_to_float(new_state.state) if humidity: self.char_humidity.set_value(humidity) @@ -102,28 +85,20 @@ def update_state(self, entity_id=None, old_state=None, new_state=None): class BinarySensor(HomeAccessory): """Generate a BinarySensor accessory as binary sensor.""" - def __init__(self, hass, entity_id, name, **kwargs): + def __init__(self, *args, config): """Initialize a BinarySensor accessory object.""" - super().__init__(name, entity_id, CATEGORY_SENSOR, **kwargs) - - self.hass = hass - self.entity_id = entity_id - - device_class = hass.states.get(entity_id).attributes \ + super().__init__(*args, category=CATEGORY_SENSOR) + device_class = self.hass.states.get(self.entity_id).attributes \ .get(ATTR_DEVICE_CLASS) service_char = BINARY_SENSOR_SERVICE_MAP[device_class] \ if device_class in BINARY_SENSOR_SERVICE_MAP \ else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY] service = add_preload_service(self, service_char[0]) - self.char_detected = service.get_characteristic(service_char[1]) - self.char_detected.value = 0 + self.char_detected = setup_char(service_char[1], service, value=0) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update accessory after state change.""" - if new_state is None: - return - state = new_state.state detected = (state == STATE_ON) or (state == STATE_HOME) self.char_detected.set_value(detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 854cb49d1819c..aaf13e4ea7e96 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -6,7 +6,7 @@ from homeassistant.core import split_entity_id from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import CATEGORY_SWITCH, SERV_SWITCH, CHAR_ON _LOGGER = logging.getLogger(__name__) @@ -16,20 +16,15 @@ class Switch(HomeAccessory): """Generate a Switch accessory.""" - def __init__(self, hass, entity_id, display_name, **kwargs): + def __init__(self, *args, config): """Initialize a Switch accessory object to represent a remote.""" - super().__init__(display_name, entity_id, CATEGORY_SWITCH, **kwargs) - - self.hass = hass - self.entity_id = entity_id - self._domain = split_entity_id(entity_id)[0] - + super().__init__(*args, category=CATEGORY_SWITCH) + self._domain = split_entity_id(self.entity_id)[0] self.flag_target_state = False serv_switch = add_preload_service(self, SERV_SWITCH) - self.char_on = serv_switch.get_characteristic(CHAR_ON) - self.char_on.value = False - self.char_on.setter_callback = self.set_state + self.char_on = setup_char( + CHAR_ON, serv_switch, value=False, callback=self.set_state) def set_state(self, value): """Move switch state to value if call came from HomeKit.""" @@ -40,15 +35,11 @@ def set_state(self, value): self.hass.services.call(self._domain, service, {ATTR_ENTITY_ID: self.entity_id}) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update switch state after state changed.""" - if new_state is None: - return - current_state = (new_state.state == STATE_ON) if not self.flag_target_state: _LOGGER.debug('%s: Set current state to %s', self.entity_id, current_state) self.char_on.set_value(current_state) - self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index daf81c51c4d93..ce10b96c51c9e 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -5,12 +5,15 @@ ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, - STATE_HEAT, STATE_COOL, STATE_AUTO) + STATE_HEAT, STATE_COOL, STATE_AUTO, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import TYPES -from .accessories import HomeAccessory, add_preload_service, debounce +from .accessories import ( + HomeAccessory, add_preload_service, debounce, setup_char) from .const import ( CATEGORY_THERMOSTAT, SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, @@ -26,74 +29,63 @@ STATE_COOL: 2, STATE_AUTO: 3} HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()} +SUPPORT_TEMP_RANGE = SUPPORT_TARGET_TEMPERATURE_LOW | \ + SUPPORT_TARGET_TEMPERATURE_HIGH + @TYPES.register('Thermostat') class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" - def __init__(self, hass, entity_id, display_name, support_auto, **kwargs): + def __init__(self, *args, config): """Initialize a Thermostat accessory object.""" - super().__init__(display_name, entity_id, - CATEGORY_THERMOSTAT, **kwargs) - - self.hass = hass - self.entity_id = entity_id - self._call_timer = None + super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = TEMP_CELSIUS - self.heat_cool_flag_target_state = False self.temperature_flag_target_state = False self.coolingthresh_flag_target_state = False self.heatingthresh_flag_target_state = False # Add additional characteristics if auto mode is supported - extra_chars = [ - CHAR_COOLING_THRESHOLD_TEMPERATURE, - CHAR_HEATING_THRESHOLD_TEMPERATURE] if support_auto else None + self.chars = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + if features & SUPPORT_TEMP_RANGE: + self.chars.extend((CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE)) - # Preload the thermostat service - serv_thermostat = add_preload_service(self, SERV_THERMOSTAT, - extra_chars) + serv_thermostat = add_preload_service( + self, SERV_THERMOSTAT, self.chars) # Current and target mode characteristics - self.char_current_heat_cool = serv_thermostat. \ - get_characteristic(CHAR_CURRENT_HEATING_COOLING) - self.char_current_heat_cool.value = 0 - self.char_target_heat_cool = serv_thermostat. \ - get_characteristic(CHAR_TARGET_HEATING_COOLING) - self.char_target_heat_cool.value = 0 - self.char_target_heat_cool.setter_callback = self.set_heat_cool + self.char_current_heat_cool = setup_char( + CHAR_CURRENT_HEATING_COOLING, serv_thermostat, value=0) + self.char_target_heat_cool = setup_char( + CHAR_TARGET_HEATING_COOLING, serv_thermostat, value=0, + callback=self.set_heat_cool) # Current and target temperature characteristics - self.char_current_temp = serv_thermostat. \ - get_characteristic(CHAR_CURRENT_TEMPERATURE) - self.char_current_temp.value = 21.0 - self.char_target_temp = serv_thermostat. \ - get_characteristic(CHAR_TARGET_TEMPERATURE) - self.char_target_temp.value = 21.0 - self.char_target_temp.setter_callback = self.set_target_temperature + self.char_current_temp = setup_char( + CHAR_CURRENT_TEMPERATURE, serv_thermostat, value=21.0) + self.char_target_temp = setup_char( + CHAR_TARGET_TEMPERATURE, serv_thermostat, value=21.0, + callback=self.set_target_temperature) # Display units characteristic - self.char_display_units = serv_thermostat. \ - get_characteristic(CHAR_TEMP_DISPLAY_UNITS) - self.char_display_units.value = 0 + self.char_display_units = setup_char( + CHAR_TEMP_DISPLAY_UNITS, serv_thermostat, value=0) # If the device supports it: high and low temperature characteristics - if support_auto: - self.char_cooling_thresh_temp = serv_thermostat. \ - get_characteristic(CHAR_COOLING_THRESHOLD_TEMPERATURE) - self.char_cooling_thresh_temp.value = 23.0 - self.char_cooling_thresh_temp.setter_callback = \ - self.set_cooling_threshold - - self.char_heating_thresh_temp = serv_thermostat. \ - get_characteristic(CHAR_HEATING_THRESHOLD_TEMPERATURE) - self.char_heating_thresh_temp.value = 19.0 - self.char_heating_thresh_temp.setter_callback = \ - self.set_heating_threshold - else: - self.char_cooling_thresh_temp = None - self.char_heating_thresh_temp = None + self.char_cooling_thresh_temp = None + self.char_heating_thresh_temp = None + if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars: + self.char_cooling_thresh_temp = setup_char( + CHAR_COOLING_THRESHOLD_TEMPERATURE, serv_thermostat, + value=23.0, callback=self.set_cooling_threshold) + if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: + self.char_heating_thresh_temp = setup_char( + CHAR_HEATING_THRESHOLD_TEMPERATURE, serv_thermostat, + value=19.0, callback=self.set_heating_threshold) def set_heat_cool(self, value): """Move operation mode to value if call came from HomeKit.""" @@ -141,11 +133,8 @@ def set_target_temperature(self, value): self.hass.components.climate.set_temperature( temperature=value, entity_id=self.entity_id) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update security state after state changed.""" - if new_state is None: - return - self._unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index af2c74d9c3c66..e14b6c47bc884 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -33,7 +33,7 @@ def validate_entity_config(values): return entities -def show_setup_message(bridge, hass): +def show_setup_message(hass, bridge): """Display persistent notification with setup information.""" pin = bridge.pincode.decode() _LOGGER.info('Pincode: %s', pin) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index b7bf625a2d645..f8e026483aaed 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -10,9 +10,8 @@ add_preload_service, set_accessory_info, debounce, HomeAccessory, HomeBridge, HomeDriver) from homeassistant.components.homekit.const import ( - ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, BRIDGE_NAME, - SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, CHAR_MODEL, - CHAR_NAME, CHAR_SERIAL_NUMBER) + BRIDGE_MODEL, BRIDGE_NAME, SERV_ACCESSORY_INFO, + CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER) from homeassistant.const import ATTR_NOW, EVENT_TIME_CHANGED import homeassistant.util.dt as dt_util @@ -92,7 +91,7 @@ def test_add_preload_service(self): def test_set_accessory_info(self): """Test setting the basic accessory information.""" # Test HomeAccessory - acc = HomeAccessory() + acc = HomeAccessory('HA', 'Home Accessory', 'homekit.accessory', 2, '') set_accessory_info(acc, 'name', 'model', 'manufacturer', '0000') serv = acc.get_service(SERV_ACCESSORY_INFO) @@ -104,7 +103,7 @@ def test_set_accessory_info(self): serv.get_characteristic(CHAR_SERIAL_NUMBER).value, '0000') # Test HomeBridge - acc = HomeBridge(None) + acc = HomeBridge('hass') set_accessory_info(acc, 'name', 'model', 'manufacturer', '0000') serv = acc.get_service(SERV_ACCESSORY_INFO) @@ -116,26 +115,37 @@ def test_set_accessory_info(self): def test_home_accessory(self): """Test HomeAccessory class.""" - acc = HomeAccessory() - self.assertEqual(acc.display_name, ACCESSORY_NAME) + hass = get_test_home_assistant() + + acc = HomeAccessory(hass, 'Home Accessory', 'homekit.accessory', 2, '') + self.assertEqual(acc.hass, hass) + self.assertEqual(acc.display_name, 'Home Accessory') self.assertEqual(acc.category, 1) # Category.OTHER self.assertEqual(len(acc.services), 1) serv = acc.services[0] # SERV_ACCESSORY_INFO self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, ACCESSORY_MODEL) + serv.get_characteristic(CHAR_MODEL).value, 'homekit.accessory') + + hass.states.set('homekit.accessory', 'on') + hass.block_till_done() + acc.run() + hass.states.set('homekit.accessory', 'off') + hass.block_till_done() - acc = HomeAccessory('test_name', 'test_model', 'FAN', aid=2) + acc = HomeAccessory('hass', 'test_name', 'test_model', 2, '') self.assertEqual(acc.display_name, 'test_name') - self.assertEqual(acc.category, 3) # Category.FAN self.assertEqual(acc.aid, 2) self.assertEqual(len(acc.services), 1) serv = acc.services[0] # SERV_ACCESSORY_INFO self.assertEqual( serv.get_characteristic(CHAR_MODEL).value, 'test_model') + hass.stop() + def test_home_bridge(self): """Test HomeBridge class.""" - bridge = HomeBridge(None) + bridge = HomeBridge('hass') + self.assertEqual(bridge.hass, 'hass') self.assertEqual(bridge.display_name, BRIDGE_NAME) self.assertEqual(bridge.category, 2) # Category.BRIDGE self.assertEqual(len(bridge.services), 1) @@ -144,12 +154,10 @@ def test_home_bridge(self): self.assertEqual( serv.get_characteristic(CHAR_MODEL).value, BRIDGE_MODEL) - bridge = HomeBridge('hass', 'test_name', 'test_model') + bridge = HomeBridge('hass', 'test_name') self.assertEqual(bridge.display_name, 'test_name') self.assertEqual(len(bridge.services), 1) serv = bridge.services[0] # SERV_ACCESSORY_INFO - self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, 'test_model') # setup_message bridge.setup_message() @@ -174,11 +182,11 @@ def test_home_bridge(self): self.assertEqual( mock_remove_paired_client.call_args, call('client_uuid')) - self.assertEqual(mock_show_msg.call_args, call(bridge, 'hass')) + self.assertEqual(mock_show_msg.call_args, call('hass', bridge)) def test_home_driver(self): """Test HomeDriver class.""" - bridge = HomeBridge(None) + bridge = HomeBridge('hass') ip_address = '127.0.0.1' port = 51826 path = '.homekit.state' diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e323431ac3f76..6f2521fc4e5fe 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -19,14 +19,14 @@ def test_get_accessory_invalid_aid(caplog): """Test with unsupported component.""" assert get_accessory(None, State('light.demo', 'on'), - aid=None, config=None) is None + None, config=None) is None assert caplog.records[0].levelname == 'WARNING' assert 'invalid aid' in caplog.records[0].msg def test_not_supported(): """Test if none is returned if entity isn't supported.""" - assert get_accessory(None, State('demo.demo', 'on'), aid=2, config=None) \ + assert get_accessory(None, State('demo.demo', 'on'), 2, config=None) \ is None @@ -48,7 +48,6 @@ def test_sensor_temperature_celsius(self): {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) get_accessory(None, state, 2, {}) - # pylint: disable=invalid-name def test_sensor_temperature_fahrenheit(self): """Test temperature sensor with Fahrenheit as unit.""" with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): @@ -91,8 +90,9 @@ def test_alarm_control_panel(self): get_accessory(None, state, 2, config) # pylint: disable=unsubscriptable-object + print(self.mock_type.call_args[1]) self.assertEqual( - self.mock_type.call_args[1].get('alarm_code'), '1234') + self.mock_type.call_args[1]['config'][ATTR_CODE], '1234') def test_climate(self): """Test climate devices.""" @@ -100,10 +100,6 @@ def test_climate(self): state = State('climate.test', 'auto') get_accessory(None, state, 2, {}) - # pylint: disable=unsubscriptable-object - self.assertEqual( - self.mock_type.call_args[0][-1], False) # support_auto - def test_light(self): """Test light devices.""" with patch.dict(TYPES, {'Light': self.mock_type}): @@ -119,10 +115,6 @@ def test_climate_support_auto(self): SUPPORT_TARGET_TEMPERATURE_HIGH}) get_accessory(None, state, 2, {}) - # pylint: disable=unsubscriptable-object - self.assertEqual( - self.mock_type.call_args[0][-1], True) # support_auto - def test_switch(self): """Test switch.""" with patch.dict(TYPES, {'Switch': self.mock_type}): @@ -140,3 +132,9 @@ def test_input_boolean(self): with patch.dict(TYPES, {'Switch': self.mock_type}): state = State('input_boolean.test', 'on') get_accessory(None, state, 2, {}) + + def test_lock(self): + """Test lock.""" + with patch.dict(TYPES, {'Lock': self.mock_type}): + state = State('lock.test', 'locked') + get_accessory(None, state, 2, {}) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 51a965b581714..d1ad232d27935 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -173,7 +173,7 @@ def test_homekit_start(self, mock_add_bridge_acc, mock_show_setup_msg): self.assertEqual(mock_add_bridge_acc.mock_calls, [call(state)]) self.assertEqual(mock_show_setup_msg.mock_calls, [ - call(homekit.bridge, self.hass)]) + call(self.hass, homekit.bridge)]) self.assertEqual(homekit.driver.mock_calls, [call.start()]) self.assertTrue(homekit.started) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 1fa1ef1728e04..8e26ab519d12f 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -35,7 +35,7 @@ def test_window_set_cover_position(self): """Test if accessory and HA are updated accordingly.""" window_cover = 'cover.window' - acc = WindowCovering(self.hass, window_cover, 'Cover', aid=2) + acc = WindowCovering(self.hass, 'Cover', window_cover, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index af8676dfd742b..10bf469c08df8 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -50,9 +50,11 @@ def tearDown(self): def test_light_basic(self): """Test light with char state.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) - acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.aid, 2) self.assertEqual(acc.category, 5) # Lightbulb self.assertEqual(acc.char_on.value, 0) @@ -94,9 +96,11 @@ def test_light_basic(self): def test_light_brightness(self): """Test light with brightness.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}) - acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.char_brightness.value, 0) acc.run() @@ -135,10 +139,12 @@ def test_light_brightness(self): def test_light_color_temperature(self): """Test light with color temperature.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP: 190}) - acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.char_color_temperature.value, 153) acc.run() @@ -157,10 +163,12 @@ def test_light_color_temperature(self): def test_light_rgb_color(self): """Test light with rgb_color.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, ATTR_HS_COLOR: (260, 90)}) - acc = self.light_cls(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.char_hue.value, 0) self.assertEqual(acc.char_saturation.value, 75) diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index d19bcdf3ec561..b205311606011 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -33,7 +33,7 @@ def test_lock_unlock(self): """Test if accessory and HA are updated accordingly.""" kitchen_lock = 'lock.kitchen_door' - acc = Lock(self.hass, kitchen_lock, 'Lock', aid=2) + acc = Lock(self.hass, 'Lock', kitchen_lock, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 46f886c4d35b9..ec538ce4b503a 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -35,8 +35,8 @@ def test_switch_set_state(self): """Test if accessory and HA are updated accordingly.""" acp = 'alarm_control_panel.test' - acc = SecuritySystem(self.hass, acp, 'SecuritySystem', - alarm_code='1234', aid=2) + acc = SecuritySystem(self.hass, 'SecuritySystem', acp, + 2, config={ATTR_CODE: '1234'}) acc.run() self.assertEqual(acc.aid, 2) @@ -107,8 +107,8 @@ def test_no_alarm_code(self): """Test accessory if security_system doesn't require a alarm_code.""" acp = 'alarm_control_panel.test' - acc = SecuritySystem(self.hass, acp, 'SecuritySystem', - alarm_code=None, aid=2) + acc = SecuritySystem(self.hass, 'SecuritySystem', acp, + 2, config={ATTR_CODE: None}) acc.run() # Set from HomeKit diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index a6e178bb226ce..f9dfb04b37c83 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -26,7 +26,8 @@ def test_temperature(self): """Test if accessory is updated after state change.""" entity_id = 'sensor.temperature' - acc = TemperatureSensor(self.hass, entity_id, 'Temperature', aid=2) + acc = TemperatureSensor(self.hass, 'Temperature', entity_id, + 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -54,7 +55,7 @@ def test_humidity(self): """Test if accessory is updated after state change.""" entity_id = 'sensor.humidity' - acc = HumiditySensor(self.hass, entity_id, 'Humidity', aid=2) + acc = HumiditySensor(self.hass, 'Humidity', entity_id, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -78,7 +79,8 @@ def test_binary(self): {ATTR_DEVICE_CLASS: "opening"}) self.hass.block_till_done() - acc = BinarySensor(self.hass, entity_id, 'Window Opening', aid=2) + acc = BinarySensor(self.hass, 'Window Opening', entity_id, + 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -118,6 +120,7 @@ def test_binary_device_classes(self): {ATTR_DEVICE_CLASS: device_class}) self.hass.block_till_done() - acc = BinarySensor(self.hass, entity_id, 'Binary Sensor', aid=2) + acc = BinarySensor(self.hass, 'Binary Sensor', entity_id, + 2, config=None) self.assertEqual(acc.get_service(service).display_name, service) self.assertEqual(acc.char_detected.display_name, char) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 7f30e45730869..65b107e24cd8b 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -34,7 +34,7 @@ def test_switch_set_state(self): entity_id = 'switch.test' domain = split_entity_id(entity_id)[0] - acc = Switch(self.hass, entity_id, 'Switch', aid=2) + acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -70,7 +70,7 @@ def test_remote_set_state(self): entity_id = 'remote.test' domain = split_entity_id(entity_id)[0] - acc = Switch(self.hass, entity_id, 'Switch', aid=2) + acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) acc.run() self.assertEqual(acc.char_on.value, False) @@ -89,7 +89,7 @@ def test_input_boolean_set_state(self): entity_id = 'input_boolean.test' domain = split_entity_id(entity_id)[0] - acc = Switch(self.hass, entity_id, 'Switch', aid=2) + acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) acc.run() self.assertEqual(acc.char_on.value, False) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index feea5c0d01a88..adc3fb018f8c8 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -7,8 +7,9 @@ ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, STATE_COOL, STATE_HEAT, STATE_AUTO) from homeassistant.const import ( - ATTR_SERVICE, EVENT_CALL_SERVICE, ATTR_SERVICE_DATA, - ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, EVENT_CALL_SERVICE, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant from tests.components.homekit.test_accessories import patch_debounce @@ -52,7 +53,10 @@ def test_default_thermostat(self): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = self.thermostat_cls(self.hass, climate, 'Climate', False, aid=2) + self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) + self.hass.block_till_done() + acc = self.thermostat_cls(self.hass, 'Climate', climate, + 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -187,7 +191,11 @@ def test_auto_thermostat(self): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = self.thermostat_cls(self.hass, climate, 'Climate', True) + # support_auto = True + self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + self.hass.block_till_done() + acc = self.thermostat_cls(self.hass, 'Climate', climate, + 2, config=None) acc.run() self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) @@ -257,7 +265,11 @@ def test_thermostat_fahrenheit(self): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = self.thermostat_cls(self.hass, climate, 'Climate', True) + # support_auto = True + self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + self.hass.block_till_done() + acc = self.thermostat_cls(self.hass, 'Climate', climate, + 2, config=None) acc.run() self.hass.states.set(climate, STATE_AUTO, diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index d6ef5856f8592..7465e9affab9d 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -58,7 +58,7 @@ def test_show_setup_msg(self): """Test show setup message as persistence notification.""" bridge = HomeBridge(self.hass) - show_setup_message(bridge, self.hass) + show_setup_message(self.hass, bridge) self.hass.block_till_done() data = self.events[0].data From 9c1bc18defbc92a587d3508bb568ef1e17cddbce Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 12 Apr 2018 02:58:57 +0200 Subject: [PATCH 043/155] Fix too green color conversion (#13828) * Prepare test * Fix too green color conversion * Fix remaining tests --- homeassistant/util/color.py | 2 +- tests/components/light/test_demo.py | 8 ++-- tests/components/light/test_mqtt.py | 10 ++-- tests/components/light/test_mqtt_json.py | 2 +- tests/components/switch/test_flux.py | 58 ++++++++++++------------ tests/util/test_color.py | 20 ++++---- 6 files changed, 50 insertions(+), 50 deletions(-) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index c2e4ac737e836..32e9df70a03e1 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -203,7 +203,7 @@ def color_RGB_to_xy_brightness( # Wide RGB D65 conversion formula X = R * 0.664511 + G * 0.154324 + B * 0.162028 - Y = R * 0.313881 + G * 0.668433 + B * 0.047685 + Y = R * 0.283881 + G * 0.668433 + B * 0.047685 Z = R * 0.000088 + G * 0.072310 + B * 0.986039 # Convert XYZ to xy diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index 963cda6abc4ed..8ba6385166b46 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -29,15 +29,15 @@ def tearDown(self): def test_state_attributes(self): """Test light state attributes.""" light.turn_on( - self.hass, ENTITY_LIGHT, xy_color=(.4, .6), brightness=25) + self.hass, ENTITY_LIGHT, xy_color=(.4, .4), brightness=25) self.hass.block_till_done() state = self.hass.states.get(ENTITY_LIGHT) self.assertTrue(light.is_on(self.hass, ENTITY_LIGHT)) - self.assertEqual((0.378, 0.574), state.attributes.get( + self.assertEqual((0.4, 0.4), state.attributes.get( light.ATTR_XY_COLOR)) self.assertEqual(25, state.attributes.get(light.ATTR_BRIGHTNESS)) self.assertEqual( - (207, 255, 0), state.attributes.get(light.ATTR_RGB_COLOR)) + (255, 234, 164), state.attributes.get(light.ATTR_RGB_COLOR)) self.assertEqual('rainbow', state.attributes.get(light.ATTR_EFFECT)) light.turn_on( self.hass, ENTITY_LIGHT, rgb_color=(251, 253, 255), @@ -48,7 +48,7 @@ def test_state_attributes(self): self.assertEqual( (250, 252, 255), state.attributes.get(light.ATTR_RGB_COLOR)) self.assertEqual( - (0.316, 0.333), state.attributes.get(light.ATTR_XY_COLOR)) + (0.319, 0.326), state.attributes.get(light.ATTR_XY_COLOR)) light.turn_on(self.hass, ENTITY_LIGHT, color_temp=400, effect='none') self.hass.block_till_done() state = self.hass.states.get(ENTITY_LIGHT) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 71fe77ef6bea5..7f7841b1a69b0 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -255,7 +255,7 @@ def test_controlling_state_via_topic(self): \ self.assertEqual(150, state.attributes.get('color_temp')) self.assertEqual('none', state.attributes.get('effect')) self.assertEqual(255, state.attributes.get('white_value')) - self.assertEqual((0.32, 0.336), state.attributes.get('xy_color')) + self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) fire_mqtt_message(self.hass, 'test_light_rgb/status', '0') self.hass.block_till_done() @@ -311,7 +311,7 @@ def test_controlling_state_via_topic(self): \ self.hass.block_till_done() light_state = self.hass.states.get('light.test') - self.assertEqual((0.652, 0.343), + self.assertEqual((0.672, 0.324), light_state.attributes.get('xy_color')) def test_brightness_controlling_scale(self): @@ -519,7 +519,7 @@ def test_sending_mqtt_commands_and_optimistic(self): \ mock.call('test_light_rgb/rgb/set', '50,50,50', 2, False), mock.call('test_light_rgb/brightness/set', 50, 2, False), mock.call('test_light_rgb/white_value/set', 80, 2, False), - mock.call('test_light_rgb/xy/set', '0.32,0.336', 2, False), + mock.call('test_light_rgb/xy/set', '0.323,0.329', 2, False), ], any_order=True) state = self.hass.states.get('light.test') @@ -527,7 +527,7 @@ def test_sending_mqtt_commands_and_optimistic(self): \ self.assertEqual((255, 255, 255), state.attributes['rgb_color']) self.assertEqual(50, state.attributes['brightness']) self.assertEqual(80, state.attributes['white_value']) - self.assertEqual((0.32, 0.336), state.attributes['xy_color']) + self.assertEqual((0.323, 0.329), state.attributes['xy_color']) def test_sending_mqtt_rgb_command_with_template(self): """Test the sending of RGB command with template.""" @@ -679,7 +679,7 @@ def test_show_xy_if_only_command_topic(self): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((0.32, 0.336), state.attributes.get('xy_color')) + self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) def test_on_command_first(self): """Test on command being sent before brightness.""" diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index a183355fbb379..d6835b00be05e 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -206,7 +206,7 @@ def test_controlling_state_via_topic(self): \ self.assertEqual(155, state.attributes.get('color_temp')) self.assertEqual('colorloop', state.attributes.get('effect')) self.assertEqual(150, state.attributes.get('white_value')) - self.assertEqual((0.32, 0.336), state.attributes.get('xy_color')) + self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) # Turn the light off fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index a1e600860f904..c42061db958b1 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -154,8 +154,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_after_sunrise_before_sunset(self): @@ -201,8 +201,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 173) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.439, 0.37]) # pylint: disable=invalid-name def test_flux_after_sunset_before_stop(self): @@ -249,8 +249,8 @@ def event_date(hass, event, now=None): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 153) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 146) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.506, 0.385]) # pylint: disable=invalid-name def test_flux_after_stop_before_sunrise(self): @@ -296,8 +296,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_with_custom_start_stop_times(self): @@ -345,8 +345,8 @@ def event_date(hass, event, now=None): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 147) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.504, 0.385]) def test_flux_before_sunrise_stop_next_day(self): """Test the flux switch before sunrise. @@ -395,8 +395,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_after_sunrise_before_sunset_stop_next_day(self): @@ -447,8 +447,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 173) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.439, 0.37]) # pylint: disable=invalid-name def test_flux_after_sunset_before_midnight_stop_next_day(self): @@ -498,8 +498,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.588, 0.386]) # pylint: disable=invalid-name def test_flux_after_sunset_after_midnight_stop_next_day(self): @@ -549,8 +549,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 114) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.601, 0.382]) # pylint: disable=invalid-name def test_flux_after_stop_before_sunrise_stop_next_day(self): @@ -600,8 +600,8 @@ def event_date(hass, event, now=None): 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]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_with_custom_colortemps(self): @@ -650,8 +650,8 @@ def event_date(hass, event, now=None): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 167) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.461, 0.389]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 159) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.469, 0.378]) # pylint: disable=invalid-name def test_flux_with_custom_brightness(self): @@ -700,7 +700,7 @@ def event_date(hass, event, now=None): self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 255) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397]) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.506, 0.385]) def test_flux_with_multiple_lights(self): """Test the flux switch with multiple light entities.""" @@ -762,14 +762,14 @@ def event_date(hass, event, now=None): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) call = turn_on_calls[-2] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) call = turn_on_calls[-3] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) def test_flux_with_mired(self): """Test the flux switch´s mode mired.""" diff --git a/tests/util/test_color.py b/tests/util/test_color.py index b64cf0acf80d1..74ba72cd3d1fb 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -14,7 +14,7 @@ def test_color_RGB_to_xy_brightness(self): """Test color_RGB_to_xy_brightness.""" self.assertEqual((0, 0, 0), color_util.color_RGB_to_xy_brightness(0, 0, 0)) - self.assertEqual((0.32, 0.336, 255), + self.assertEqual((0.323, 0.329, 255), color_util.color_RGB_to_xy_brightness(255, 255, 255)) self.assertEqual((0.136, 0.04, 12), @@ -23,17 +23,17 @@ def test_color_RGB_to_xy_brightness(self): self.assertEqual((0.172, 0.747, 170), color_util.color_RGB_to_xy_brightness(0, 255, 0)) - self.assertEqual((0.679, 0.321, 80), + self.assertEqual((0.701, 0.299, 72), color_util.color_RGB_to_xy_brightness(255, 0, 0)) - self.assertEqual((0.679, 0.321, 17), + self.assertEqual((0.701, 0.299, 16), color_util.color_RGB_to_xy_brightness(128, 0, 0)) def test_color_RGB_to_xy(self): """Test color_RGB_to_xy.""" self.assertEqual((0, 0), color_util.color_RGB_to_xy(0, 0, 0)) - self.assertEqual((0.32, 0.336), + self.assertEqual((0.323, 0.329), color_util.color_RGB_to_xy(255, 255, 255)) self.assertEqual((0.136, 0.04), @@ -42,10 +42,10 @@ def test_color_RGB_to_xy(self): self.assertEqual((0.172, 0.747), color_util.color_RGB_to_xy(0, 255, 0)) - self.assertEqual((0.679, 0.321), + self.assertEqual((0.701, 0.299), color_util.color_RGB_to_xy(255, 0, 0)) - self.assertEqual((0.679, 0.321), + self.assertEqual((0.701, 0.299), color_util.color_RGB_to_xy(128, 0, 0)) def test_color_xy_brightness_to_RGB(self): @@ -155,16 +155,16 @@ def test_color_hs_to_xy(self): self.assertEqual((0.151, 0.343), color_util.color_hs_to_xy(180, 100)) - self.assertEqual((0.352, 0.329), + self.assertEqual((0.356, 0.321), color_util.color_hs_to_xy(350, 12.5)) - self.assertEqual((0.228, 0.476), + self.assertEqual((0.229, 0.474), color_util.color_hs_to_xy(140, 50)) - self.assertEqual((0.465, 0.33), + self.assertEqual((0.474, 0.317), color_util.color_hs_to_xy(0, 40)) - self.assertEqual((0.32, 0.336), + self.assertEqual((0.323, 0.329), color_util.color_hs_to_xy(360, 0)) def test_rgb_hex_to_rgb_list(self): From b752ca3bef01acbb433d5f9e571e5a0601ac9f1b Mon Sep 17 00:00:00 2001 From: Marco Orovecchia Date: Thu, 12 Apr 2018 09:24:07 +0200 Subject: [PATCH 044/155] Rename from aurora light to nanoleaf_aurora (#13831) --- .coveragerc | 2 +- .../components/light/{aurora.py => nanoleaf_aurora.py} | 0 requirements_all.txt | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename homeassistant/components/light/{aurora.py => nanoleaf_aurora.py} (100%) diff --git a/.coveragerc b/.coveragerc index 666134488fe97..2b733dd699fa8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -410,7 +410,6 @@ omit = homeassistant/components/image_processing/seven_segments.py homeassistant/components/keyboard_remote.py homeassistant/components/keyboard.py - homeassistant/components/light/aurora.py homeassistant/components/light/avion.py homeassistant/components/light/blinksticklight.py homeassistant/components/light/blinkt.py @@ -425,6 +424,7 @@ omit = homeassistant/components/light/lifx.py homeassistant/components/light/limitlessled.py homeassistant/components/light/mystrom.py + homeassistant/components/light/nanoleaf_aurora.py homeassistant/components/light/osramlightify.py homeassistant/components/light/piglow.py homeassistant/components/light/rpi_gpio_pwm.py diff --git a/homeassistant/components/light/aurora.py b/homeassistant/components/light/nanoleaf_aurora.py similarity index 100% rename from homeassistant/components/light/aurora.py rename to homeassistant/components/light/nanoleaf_aurora.py diff --git a/requirements_all.txt b/requirements_all.txt index 1b3d3206c6066..86cff3f942044 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ myusps==1.3.2 # homeassistant.components.media_player.nadtcp nad_receiver==0.0.9 -# homeassistant.components.light.aurora +# homeassistant.components.light.nanoleaf_aurora nanoleaf==0.4.1 # homeassistant.components.discovery From dd7e6edf6132560f6085a6a8c7f4bada0f618fc8 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Thu, 12 Apr 2018 13:19:21 +0200 Subject: [PATCH 045/155] HomeKit type_cover fix (#13832) * Removed char_position_state * Changed service call --- homeassistant/components/homekit/const.py | 6 ++-- .../components/homekit/type_covers.py | 31 +++++++------------ tests/components/homekit/test_type_covers.py | 5 --- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 80f2fd039e606..37ee9722bc4f9 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -51,6 +51,7 @@ SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' SERV_WINDOW_COVERING = 'WindowCovering' +# CurrentPosition, TargetPosition, PositionState # #### Characteristics #### @@ -61,7 +62,7 @@ CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' -CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100] CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' @@ -77,12 +78,11 @@ CHAR_NAME = 'Name' CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' CHAR_ON = 'On' # boolean -CHAR_POSITION_STATE = 'PositionState' CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' -CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100] CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' CHAR_TARGET_TEMPERATURE = 'TargetTemperature' CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 7c7ab3e3683b3..d8a6a8c2fdc03 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -1,13 +1,15 @@ """Class to hold all cover accessories.""" import logging -from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION from . import TYPES from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_WINDOW_COVERING, SERV_WINDOW_COVERING, - CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE) + CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION) _LOGGER = logging.getLogger(__name__) @@ -22,7 +24,6 @@ class WindowCovering(HomeAccessory): def __init__(self, *args, config): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) - self.current_position = None self.homekit_target = None serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) @@ -31,29 +32,21 @@ def __init__(self, *args, config): self.char_target_position = setup_char( CHAR_TARGET_POSITION, serv_cover, value=0, callback=self.move_cover) - self.char_position_state = setup_char( - CHAR_POSITION_STATE, serv_cover, value=0) def move_cover(self, value): """Move cover to value if call came from HomeKit.""" - if value != self.current_position: - _LOGGER.debug('%s: Set position to %d', self.entity_id, value) - self.homekit_target = value - if value > self.current_position: - self.char_position_state.set_value(1) - elif value < self.current_position: - self.char_position_state.set_value(0) - self.hass.components.cover.set_cover_position( - value, self.entity_id) + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + self.homekit_target = value + + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} + self.hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, params) def update_state(self, new_state): """Update cover position after state changed.""" current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, int): - self.current_position = current_position - self.char_current_position.set_value(self.current_position) + self.char_current_position.set_value(current_position) if self.homekit_target is None or \ - abs(self.current_position - self.homekit_target) < 6: - self.char_target_position.set_value(self.current_position) - self.char_position_state.set_value(2) + abs(current_position - self.homekit_target) < 6: + self.char_target_position.set_value(current_position) self.homekit_target = None diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 8e26ab519d12f..43e82e74b1abe 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -43,7 +43,6 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_UNKNOWN, {ATTR_CURRENT_POSITION: None}) @@ -51,7 +50,6 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_OPEN, {ATTR_CURRENT_POSITION: 50}) @@ -59,7 +57,6 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 50) - self.assertEqual(acc.char_position_state.value, 2) # Set from HomeKit acc.char_target_position.client_update_value(25) @@ -71,7 +68,6 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 25) - self.assertEqual(acc.char_position_state.value, 0) # Set from HomeKit acc.char_target_position.client_update_value(75) @@ -83,4 +79,3 @@ def test_window_set_cover_position(self): self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 75) - self.assertEqual(acc.char_position_state.value, 1) From f47572d3c0b81f73be41e8161e32e42be14c164f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 12 Apr 2018 08:28:54 -0400 Subject: [PATCH 046/155] Allow platform unloading (#13784) * Allow platform unloading * Add tests * Add last test --- homeassistant/components/hue/__init__.py | 6 +++ homeassistant/components/hue/bridge.py | 35 +++++++++++-- homeassistant/components/light/__init__.py | 5 ++ homeassistant/config_entries.py | 20 +++++-- homeassistant/helpers/entity_component.py | 12 +++++ homeassistant/helpers/entity_platform.py | 11 +++- tests/components/hue/test_bridge.py | 61 +++++++++++++++++++++- tests/components/hue/test_init.py | 19 +++++++ tests/components/light/test_hue.py | 2 +- tests/helpers/test_entity_component.py | 31 +++++++++++ tests/helpers/test_entity_platform.py | 26 +++++++++ tests/test_config_entries.py | 4 +- 12 files changed, 218 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 557a47f3e0578..0aed854d4e499 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -131,3 +131,9 @@ async def async_setup_entry(hass, entry): bridge = HueBridge(hass, entry, allow_unreachable, allow_groups) hass.data[DOMAIN][host] = bridge return await bridge.async_setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + bridge = hass.data[DOMAIN].pop(entry.data['host']) + return await bridge.async_reset() diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 4693a2f4dbe57..5ff5e2dbf6f86 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -30,6 +30,7 @@ def __init__(self, hass, config_entry, allow_unreachable, allow_groups): self.allow_groups = allow_groups self.available = True self.api = None + self._cancel_retry_setup = None @property def host(self): @@ -67,8 +68,8 @@ async def retry_setup(_now): # This feels hacky, we should find a better way to do this self.config_entry.state = config_entries.ENTRY_STATE_LOADED - # Unhandled edge case: cancel this if we discover bridge on new IP - hass.helpers.event.async_call_later(retry_delay, retry_setup) + self._cancel_retry_setup = hass.helpers.event.async_call_later( + retry_delay, retry_setup) return False @@ -77,7 +78,7 @@ async def retry_setup(_now): host) return False - hass.async_add_job(hass.config_entries.async_forward_entry( + hass.async_add_job(hass.config_entries.async_forward_entry_setup( self.config_entry, 'light')) hass.services.async_register( @@ -86,6 +87,34 @@ async def retry_setup(_now): return True + async def async_reset(self): + """Reset this bridge to default state. + + Will cancel any scheduled setup retry and will unload + the config entry. + """ + # The bridge can be in 3 states: + # - Setup was successful, self.api is not None + # - Authentication was wrong, self.api is None, not retrying setup. + # - Host was down. self.api is None, we're retrying setup + + # If we have a retry scheduled, we were never setup. + if self._cancel_retry_setup is not None: + self._cancel_retry_setup() + self._cancel_retry_setup = None + return True + + # If the authentication was wrong. + if self.api is None: + return True + + self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + + # If setup was successful, we set api variable, forwarded entry and + # register service + return await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'light') + async def hue_activate_scene(self, call, updated=False): """Service to call directly into bridge to set scenes.""" group_name = call.data[ATTR_GROUP_NAME] diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d497c8f9880d9..30a1a800a4498 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -393,6 +393,11 @@ async def async_setup_entry(hass, entry): return await hass.data[DOMAIN].async_setup_entry(entry) +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class Profiles: """Representation of available color profiles.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index fc781bd62c836..e2e45cb5819e5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -203,12 +203,13 @@ async def async_setup(self, hass, *, component=None): else: self.state = ENTRY_STATE_SETUP_ERROR - async def async_unload(self, hass): + async def async_unload(self, hass, *, component=None): """Unload an entry. Returns if unload is possible and was successful. """ - component = getattr(hass.components, self.domain) + if component is None: + component = getattr(hass.components, self.domain) supports_unload = hasattr(component, 'async_unload_entry') @@ -220,13 +221,13 @@ async def async_unload(self, hass): if not isinstance(result, bool): _LOGGER.error('%s.async_unload_entry did not return boolean', - self.domain) + component.DOMAIN) result = False return result except Exception: # pylint: disable=broad-except _LOGGER.exception('Error unloading entry %s for %s', - self.title, self.domain) + self.title, component.DOMAIN) self.state = ENTRY_STATE_FAILED_UNLOAD return False @@ -326,7 +327,7 @@ async def async_load(self): entries = await self.hass.async_add_job(load_json, path) self._entries = [ConfigEntry(**entry) for entry in entries] - async def async_forward_entry(self, entry, component): + async def async_forward_entry_setup(self, entry, component): """Forward the setup of an entry to a different component. By default an entry is setup with the component it belongs to. If that @@ -347,6 +348,15 @@ async def async_forward_entry(self, entry, component): await entry.async_setup( self.hass, component=getattr(self.hass.components, component)) + async def async_forward_entry_unload(self, entry, component): + """Forward the unloading of an entry to a different component.""" + # It was never loaded. + if component not in self.hass.config.components: + return True + + await entry.async_unload( + self.hass, component=getattr(self.hass.components, component)) + async def _async_add_entry(self, entry): """Add an entry.""" self._entries.append(entry) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 265464d548ddf..c82ae2a46f083 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -113,6 +113,18 @@ async def async_setup_entry(self, config_entry): return await self._platforms[key].async_setup_entry(config_entry) + async def async_unload_entry(self, config_entry): + """Unload a config entry.""" + key = config_entry.entry_id + + platform = self._platforms.pop(key, None) + + if platform is None: + raise ValueError('Config entry was never loaded!') + + await platform.async_reset() + return True + @callback def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ba8df7e01d812..00a7e49840e15 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -43,7 +43,10 @@ def __init__(self, *, hass, logger, domain, platform_name, platform, self.config_entry = None self.entities = {} self._tasks = [] + # Method to cancel the state change listener self._async_unsub_polling = None + # Method to cancel the retry of setup + self._async_cancel_retry_setup = None self._process_updates = asyncio.Lock(loop=hass.loop) # Platform is None for the EntityComponent "catch-all" EntityPlatform @@ -145,10 +148,12 @@ async def _async_setup_platform(self, async_create_setup_task, tries=0): async def setup_again(now): """Run setup again.""" + self._async_cancel_retry_setup = None await self._async_setup_platform( async_create_setup_task, tries) - async_call_later(hass, wait_time, setup_again) + self._async_cancel_retry_setup = \ + async_call_later(hass, wait_time, setup_again) return False except asyncio.TimeoutError: logger.error( @@ -315,6 +320,10 @@ async def async_reset(self): This method must be run in the event loop. """ + if self._async_cancel_retry_setup is not None: + self._async_cancel_retry_setup() + self._async_cancel_retry_setup = None + if not self.entities: return diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 1f53d5aac1431..c20cee0d0e8c8 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -18,8 +18,8 @@ async def test_bridge_setup(): assert await hue_bridge.async_setup() is True assert hue_bridge.api is api - assert len(hass.config_entries.async_forward_entry.mock_calls) == 1 - assert hass.config_entries.async_forward_entry.mock_calls[0][1] == \ + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'light') @@ -54,3 +54,60 @@ async def test_bridge_setup_timeout(hass): assert len(hass.helpers.event.async_call_later.mock_calls) == 1 # Assert we are going to wait 2 seconds assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2 + + +async def test_reset_cancels_retry_setup(): + """Test resetting a bridge while we're waiting to retry setup.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', side_effect=errors.CannotConnect): + assert await hue_bridge.async_setup() is False + + mock_call_later = hass.helpers.event.async_call_later + + assert len(mock_call_later.mock_calls) == 1 + + assert await hue_bridge.async_reset() + + assert len(mock_call_later.mock_calls) == 2 + assert len(mock_call_later.return_value.mock_calls) == 1 + + +async def test_reset_if_entry_had_wrong_auth(): + """Test calling reset when the entry contained wrong auth.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', + side_effect=errors.AuthenticationRequired): + assert await hue_bridge.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 1 + + assert await hue_bridge.async_reset() + + +async def test_reset_unloads_entry_if_setup(): + """Test calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', return_value=mock_coro(Mock())): + assert await hue_bridge.async_setup() is True + + assert len(hass.services.async_register.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await hue_bridge.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 + assert len(hass.services.async_remove.mock_calls) == 1 diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 47e74b70e8382..ea656ba8fc672 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -167,3 +167,22 @@ async def test_config_passed_to_config_entry(hass): assert p_entry is entry assert p_allow_unreachable is True assert p_allow_groups is False + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=hue.DOMAIN, data={ + 'host': '0.0.0.0', + }) + entry.add_to_hass(hass) + + with patch.object(hue, 'HueBridge') as mock_bridge: + mock_bridge.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + + assert len(mock_bridge.return_value.mock_calls) == 1 + + mock_bridge.return_value.async_reset.return_value = mock_coro(True) + assert await hue.async_unload_entry(hass, entry) + assert len(mock_bridge.return_value.async_reset.mock_calls) == 1 + assert hass.data[hue.DOMAIN] == {} diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index dee27adfe3473..712cd17a7c733 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -200,7 +200,7 @@ async def setup_bridge(hass, mock_bridge): config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { 'host': 'mock-host' }, 'test') - await hass.config_entries.async_forward_entry(config_entry, 'light') + await hass.config_entries.async_forward_entry_setup(config_entry, 'light') # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index f53b69274ef37..0bc6a7601dcb5 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -376,3 +376,34 @@ async def test_setup_entry_fails_duplicate(hass): with pytest.raises(ValueError): await component.async_setup_entry(entry) + + +async def test_unload_entry_resets_platform(hass): + """Test unloading an entry removes all entities.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + assert len(mock_setup_entry.mock_calls) == 1 + add_entities = mock_setup_entry.mock_calls[0][1][2] + add_entities([MockEntity()]) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + assert await component.async_unload_entry(entry) + assert len(hass.states.async_entity_ids()) == 0 + + +async def test_unload_entry_fails_if_never_loaded(hass): + """.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + with pytest.raises(ValueError): + await component.async_unload_entry(entry) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index a8394ff6a4978..2018cb2754104 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -555,3 +555,29 @@ async def test_setup_entry_platform_not_ready(hass, caplog): assert len(async_setup_entry.mock_calls) == 1 assert 'Platform test not ready yet' in caplog.text assert len(mock_call_later.mock_calls) == 1 + + +async def test_reset_cancels_retry_setup(hass): + """Test that resetting a platform will cancel scheduled a setup retry.""" + async_setup_entry = Mock(side_effect=PlatformNotReady) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + ent_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + with patch.object(entity_platform, 'async_call_later') as mock_call_later: + assert not await ent_platform.async_setup_entry(config_entry) + + assert len(mock_call_later.mock_calls) == 1 + assert len(mock_call_later.return_value.mock_calls) == 0 + assert ent_platform._async_cancel_retry_setup is not None + + await ent_platform.async_reset() + + assert len(mock_call_later.return_value.mock_calls) == 1 + assert ent_platform._async_cancel_retry_setup is None diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8bbd79a7ac72f..b9b39b11c135b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -405,7 +405,7 @@ async def test_forward_entry_sets_up_component(hass): 'forwarded', MockModule('forwarded', async_setup_entry=mock_forwarded_setup_entry)) - await hass.config_entries.async_forward_entry(entry, 'forwarded') + await hass.config_entries.async_forward_entry_setup(entry, 'forwarded') assert len(mock_original_setup_entry.mock_calls) == 0 assert len(mock_forwarded_setup_entry.mock_calls) == 1 @@ -422,6 +422,6 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails(hass): async_setup_entry=mock_setup_entry, )) - await hass.config_entries.async_forward_entry(entry, 'forwarded') + await hass.config_entries.async_forward_entry_setup(entry, 'forwarded') assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 0 From c863b9614cf5d1cc132aff664530d673ac6afb3d Mon Sep 17 00:00:00 2001 From: Yonsm Date: Thu, 12 Apr 2018 21:01:41 +0800 Subject: [PATCH 047/155] Support CO2/PM2.5/Light sensors in HomeKit (#13804) * Support co2/light/air sensor in HomeKit * Add tests * Added tests * changed device_class lux to light --- homeassistant/components/homekit/__init__.py | 22 ++++- homeassistant/components/homekit/const.py | 18 +++- .../components/homekit/type_sensors.py | 78 +++++++++++++++- homeassistant/components/homekit/util.py | 13 +++ .../homekit/test_get_accessories.py | 61 +++++++++++++ tests/components/homekit/test_type_sensors.py | 91 ++++++++++++++++++- tests/components/homekit/test_util.py | 82 ++++++++++------- 7 files changed, 318 insertions(+), 47 deletions(-) mode change 100755 => 100644 homeassistant/components/homekit/type_sensors.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 02d21889f6b79..1092cea0c6e74 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import SUPPORT_SET_POSITION from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + ATTR_DEVICE_CLASS, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA @@ -19,7 +19,9 @@ from homeassistant.util.decorator import Registry from .const import ( DOMAIN, HOMEKIT_FILE, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FILTER, - DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START) + DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START, + DEVICE_CLASS_CO2, DEVICE_CLASS_LIGHT, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE) from .util import ( validate_entity_config, show_setup_message) @@ -103,10 +105,22 @@ def get_accessory(hass, state, aid, config): elif state.domain == 'sensor': unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT: + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == DEVICE_CLASS_TEMPERATURE or unit == TEMP_CELSIUS \ + or unit == TEMP_FAHRENHEIT: a_type = 'TemperatureSensor' - elif unit == '%': + elif device_class == DEVICE_CLASS_HUMIDITY or unit == '%': a_type = 'HumiditySensor' + elif device_class == DEVICE_CLASS_PM25 \ + or DEVICE_CLASS_PM25 in state.entity_id: + a_type = 'AirQualitySensor' + elif device_class == DEVICE_CLASS_CO2 \ + or DEVICE_CLASS_CO2 in state.entity_id: + a_type = 'CarbonDioxideSensor' + elif device_class == DEVICE_CLASS_LIGHT or unit == 'lm' or \ + unit == 'lux': + a_type = 'LightSensor' elif state.domain == 'switch' or state.domain == 'remote' \ or state.domain == 'input_boolean' or state.domain == 'script': diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 37ee9722bc4f9..7cde51b541685 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -34,13 +34,13 @@ # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' +SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' SERV_CONTACT_SENSOR = 'ContactSensor' -SERV_HUMIDITY_SENSOR = 'HumiditySensor' -# CurrentRelativeHumidity | StatusActive, StatusFault, StatusTampered, -# StatusLowBattery, Name +SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity SERV_LEAK_SENSOR = 'LeakSensor' +SERV_LIGHT_SENSOR = 'LightSensor' SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name SERV_LOCK = 'LockMechanism' SERV_MOTION_SENSOR = 'MotionSensor' @@ -50,17 +50,21 @@ SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' -SERV_WINDOW_COVERING = 'WindowCovering' -# CurrentPosition, TargetPosition, PositionState +SERV_WINDOW_COVERING = 'WindowCovering' # CurrentPosition, TargetPosition # #### Characteristics #### +CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' +CHAR_AIR_QUALITY = 'AirQuality' CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100] CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' +CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' +CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' +CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100] CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent @@ -93,8 +97,12 @@ # #### Device Class #### DEVICE_CLASS_CO2 = 'co2' DEVICE_CLASS_GAS = 'gas' +DEVICE_CLASS_HUMIDITY = 'humidity' +DEVICE_CLASS_LIGHT = 'light' DEVICE_CLASS_MOISTURE = 'moisture' DEVICE_CLASS_MOTION = 'motion' DEVICE_CLASS_OCCUPANCY = 'occupancy' DEVICE_CLASS_OPENING = 'opening' +DEVICE_CLASS_PM25 = 'pm25' DEVICE_CLASS_SMOKE = 'smoke' +DEVICE_CLASS_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py old mode 100755 new mode 100644 index 790f0de61033e..6aa8d92c0afc2 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -10,6 +10,9 @@ from .const import ( CATEGORY_SENSOR, SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR, CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS, + SERV_AIR_QUALITY_SENSOR, CHAR_AIR_QUALITY, CHAR_AIR_PARTICULATE_DENSITY, + CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL, + SERV_LIGHT_SENSOR, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, DEVICE_CLASS_CO2, SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, DEVICE_CLASS_GAS, SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED, @@ -18,7 +21,8 @@ DEVICE_CLASS_OCCUPANCY, SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, DEVICE_CLASS_OPENING, SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) -from .util import convert_to_float, temperature_to_homekit +from .util import ( + convert_to_float, temperature_to_homekit, density_to_air_quality) _LOGGER = logging.getLogger(__name__) @@ -81,6 +85,78 @@ def update_state(self, new_state): self.entity_id, humidity) +@TYPES.register('AirQualitySensor') +class AirQualitySensor(HomeAccessory): + """Generate a AirQualitySensor accessory as air quality sensor.""" + + def __init__(self, *args, config): + """Initialize a AirQualitySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_air_quality = add_preload_service(self, SERV_AIR_QUALITY_SENSOR, + [CHAR_AIR_PARTICULATE_DENSITY]) + self.char_quality = setup_char( + CHAR_AIR_QUALITY, serv_air_quality, value=0) + self.char_density = setup_char( + CHAR_AIR_PARTICULATE_DENSITY, serv_air_quality, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + density = convert_to_float(new_state.state) + if density is not None: + self.char_density.set_value(density) + self.char_quality.set_value(density_to_air_quality(density)) + _LOGGER.debug('%s: Set to %d', self.entity_id, density) + + +@TYPES.register('CarbonDioxideSensor') +class CarbonDioxideSensor(HomeAccessory): + """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" + + def __init__(self, *args, config): + """Initialize a CarbonDioxideSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_co2 = add_preload_service(self, SERV_CARBON_DIOXIDE_SENSOR, [ + CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL]) + self.char_co2 = setup_char( + CHAR_CARBON_DIOXIDE_LEVEL, serv_co2, value=0) + self.char_peak = setup_char( + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, serv_co2, value=0) + self.char_detected = setup_char( + CHAR_CARBON_DIOXIDE_DETECTED, serv_co2, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + co2 = convert_to_float(new_state.state) + if co2 is not None: + self.char_co2.set_value(co2) + if co2 > self.char_peak.value: + self.char_peak.set_value(co2) + self.char_detected.set_value(co2 > 1000) + _LOGGER.debug('%s: Set to %d', self.entity_id, co2) + + +@TYPES.register('LightSensor') +class LightSensor(HomeAccessory): + """Generate a LightSensor accessory as light sensor.""" + + def __init__(self, *args, config): + """Initialize a LightSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_light = add_preload_service(self, SERV_LIGHT_SENSOR) + self.char_light = setup_char( + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, serv_light, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + luminance = convert_to_float(new_state.state) + if luminance is not None: + self.char_light.set_value(luminance) + _LOGGER.debug('%s: Set to %d', self.entity_id, luminance) + + @TYPES.register('BinarySensor') class BinarySensor(HomeAccessory): """Generate a BinarySensor accessory as binary sensor.""" diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index e14b6c47bc884..29fe3c8f26567 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -64,3 +64,16 @@ def temperature_to_homekit(temperature, unit): def temperature_to_states(temperature, unit): """Convert temperature back from Celsius to Home Assistant unit.""" return round(temp_util.convert(temperature, TEMP_CELSIUS, unit), 1) + + +def density_to_air_quality(density): + """Map PM2.5 density to HomeKit AirQuality level.""" + if density <= 35: + return 1 + elif density <= 75: + return 2 + elif density <= 115: + return 3 + elif density <= 150: + return 4 + return 5 diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 6f2521fc4e5fe..052b7557c1178 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -41,6 +41,13 @@ def tearDown(self): """Test if mock type was called.""" self.assertTrue(self.mock_type.called) + def test_sensor_temperature(self): + """Test temperature sensor with device class temperature.""" + with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): + state = State('sensor.temperature', '23', + {ATTR_DEVICE_CLASS: 'temperature'}) + get_accessory(None, state, 2, {}) + def test_sensor_temperature_celsius(self): """Test temperature sensor with Celsius as unit.""" with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): @@ -56,12 +63,66 @@ def test_sensor_temperature_fahrenheit(self): get_accessory(None, state, 2, {}) def test_sensor_humidity(self): + """Test humidity sensor with device class humidity.""" + with patch.dict(TYPES, {'HumiditySensor': self.mock_type}): + state = State('sensor.humidity', '20', + {ATTR_DEVICE_CLASS: 'humidity'}) + get_accessory(None, state, 2, {}) + + def test_sensor_humidity_unit(self): """Test humidity sensor with % as unit.""" with patch.dict(TYPES, {'HumiditySensor': self.mock_type}): state = State('sensor.humidity', '20', {ATTR_UNIT_OF_MEASUREMENT: '%'}) get_accessory(None, state, 2, {}) + def test_air_quality_sensor(self): + """Test air quality sensor with pm25 class.""" + with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}): + state = State('sensor.air_quality', '40', + {ATTR_DEVICE_CLASS: 'pm25'}) + get_accessory(None, state, 2, {}) + + def test_air_quality_sensor_entity_id(self): + """Test air quality sensor with entity_id contains pm25.""" + with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}): + state = State('sensor.air_quality_pm25', '40', {}) + get_accessory(None, state, 2, {}) + + def test_co2_sensor(self): + """Test co2 sensor with device class co2.""" + with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}): + state = State('sensor.airmeter', '500', + {ATTR_DEVICE_CLASS: 'co2'}) + get_accessory(None, state, 2, {}) + + def test_co2_sensor_entity_id(self): + """Test co2 sensor with entity_id contains co2.""" + with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}): + state = State('sensor.airmeter_co2', '500', {}) + get_accessory(None, state, 2, {}) + + def test_light_sensor(self): + """Test light sensor with device class lux.""" + with patch.dict(TYPES, {'LightSensor': self.mock_type}): + state = State('sensor.light', '900', + {ATTR_DEVICE_CLASS: 'light'}) + get_accessory(None, state, 2, {}) + + def test_light_sensor_unit_lm(self): + """Test light sensor with lm as unit.""" + with patch.dict(TYPES, {'LightSensor': self.mock_type}): + state = State('sensor.light', '900', + {ATTR_UNIT_OF_MEASUREMENT: 'lm'}) + get_accessory(None, state, 2, {}) + + def test_light_sensor_unit_lux(self): + """Test light sensor with lux as unit.""" + with patch.dict(TYPES, {'LightSensor': self.mock_type}): + state = State('sensor.light', '900', + {ATTR_UNIT_OF_MEASUREMENT: 'lux'}) + get_accessory(None, state, 2, {}) + def test_binary_sensor(self): """Test binary sensor with opening class.""" with patch.dict(TYPES, {'BinarySensor': self.mock_type}): diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index f9dfb04b37c83..77bfc0c890129 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -3,7 +3,8 @@ from homeassistant.components.homekit.const import PROP_CELSIUS from homeassistant.components.homekit.type_sensors import ( - TemperatureSensor, HumiditySensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP) + TemperatureSensor, HumiditySensor, AirQualitySensor, CarbonDioxideSensor, + LightSensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, STATE_UNKNOWN, STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -40,6 +41,7 @@ def test_temperature(self): self.hass.states.set(entity_id, STATE_UNKNOWN, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.block_till_done() + self.assertEqual(acc.char_temp.value, 0.0) self.hass.states.set(entity_id, '20', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -63,14 +65,95 @@ def test_humidity(self): self.assertEqual(acc.char_humidity.value, 0) - self.hass.states.set(entity_id, STATE_UNKNOWN, - {ATTR_UNIT_OF_MEASUREMENT: "%"}) + self.hass.states.set(entity_id, STATE_UNKNOWN) self.hass.block_till_done() + self.assertEqual(acc.char_humidity.value, 0) - self.hass.states.set(entity_id, '20', {ATTR_UNIT_OF_MEASUREMENT: "%"}) + self.hass.states.set(entity_id, '20') self.hass.block_till_done() self.assertEqual(acc.char_humidity.value, 20) + def test_air_quality(self): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.air_quality' + + acc = AirQualitySensor(self.hass, 'Air Quality', entity_id, + 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_density.value, 0) + self.assertEqual(acc.char_quality.value, 0) + + self.hass.states.set(entity_id, STATE_UNKNOWN) + self.hass.block_till_done() + self.assertEqual(acc.char_density.value, 0) + self.assertEqual(acc.char_quality.value, 0) + + self.hass.states.set(entity_id, '34') + self.hass.block_till_done() + self.assertEqual(acc.char_density.value, 34) + self.assertEqual(acc.char_quality.value, 1) + + self.hass.states.set(entity_id, '200') + self.hass.block_till_done() + self.assertEqual(acc.char_density.value, 200) + self.assertEqual(acc.char_quality.value, 5) + + def test_co2(self): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.co2' + + acc = CarbonDioxideSensor(self.hass, 'CO2', entity_id, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_co2.value, 0) + self.assertEqual(acc.char_peak.value, 0) + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, STATE_UNKNOWN) + self.hass.block_till_done() + self.assertEqual(acc.char_co2.value, 0) + self.assertEqual(acc.char_peak.value, 0) + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, '1100') + self.hass.block_till_done() + self.assertEqual(acc.char_co2.value, 1100) + self.assertEqual(acc.char_peak.value, 1100) + self.assertEqual(acc.char_detected.value, 1) + + self.hass.states.set(entity_id, '800') + self.hass.block_till_done() + self.assertEqual(acc.char_co2.value, 800) + self.assertEqual(acc.char_peak.value, 1100) + self.assertEqual(acc.char_detected.value, 0) + + def test_light(self): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.light' + + acc = LightSensor(self.hass, 'Light', entity_id, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_light.value, 0.0001) + + self.hass.states.set(entity_id, STATE_UNKNOWN) + self.hass.block_till_done() + self.assertEqual(acc.char_light.value, 0.0001) + + self.hass.states.set(entity_id, '300') + self.hass.block_till_done() + self.assertEqual(acc.char_light.value, 300) + def test_binary(self): """Test if accessory is updated after state change.""" entity_id = 'binary_sensor.opening' diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 7465e9affab9d..4a9521384bd55 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -2,13 +2,15 @@ import unittest import voluptuous as vol +import pytest from homeassistant.core import callback from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import HOMEKIT_NOTIFY_ID from homeassistant.components.homekit.util import ( show_setup_message, dismiss_setup_message, convert_to_float, - temperature_to_homekit, temperature_to_states, ATTR_CODE) + temperature_to_homekit, temperature_to_states, ATTR_CODE, + density_to_air_quality) from homeassistant.components.homekit.util import validate_entity_config \ as vec from homeassistant.components.persistent_notification import ( @@ -20,6 +22,52 @@ from tests.common import get_test_home_assistant +def test_validate_entity_config(): + """Test validate entities.""" + configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, + {'demo.test': 'test'}, {'demo.test': [1, 2]}, + {'demo.test': None}] + + for conf in configs: + with pytest.raises(vol.Invalid): + vec(conf) + + assert vec({}) == {} + assert vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) == \ + {'alarm_control_panel.demo': {ATTR_CODE: '1234'}} + + +def test_convert_to_float(): + """Test convert_to_float method.""" + assert convert_to_float(12) == 12 + assert convert_to_float(12.4) == 12.4 + assert convert_to_float(STATE_UNKNOWN) is None + assert convert_to_float(None) is None + + +def test_temperature_to_homekit(): + """Test temperature conversion from HA to HomeKit.""" + assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5 + assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.4 + + +def test_temperature_to_states(): + """Test temperature conversion from HomeKit to HA.""" + assert temperature_to_states(20, TEMP_CELSIUS) == 20.0 + assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.4 + + +def test_density_to_air_quality(): + """Test map PM2.5 density to HomeKit AirQuality level.""" + assert density_to_air_quality(0) == 1 + assert density_to_air_quality(35) == 1 + assert density_to_air_quality(35.1) == 2 + assert density_to_air_quality(75) == 2 + assert density_to_air_quality(115) == 3 + assert density_to_air_quality(150) == 4 + assert density_to_air_quality(300) == 5 + + class TestUtil(unittest.TestCase): """Test all HomeKit util methods.""" @@ -39,21 +87,6 @@ def tearDown(self): """Stop down everything that was started.""" self.hass.stop() - def test_validate_entity_config(self): - """Test validate entities.""" - configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, - {'demo.test': 'test'}, {'demo.test': [1, 2]}, - {'demo.test': None}] - - for conf in configs: - with self.assertRaises(vol.Invalid): - vec(conf) - - self.assertEqual(vec({}), {}) - self.assertEqual( - vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}), - {'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) - def test_show_setup_msg(self): """Test show setup message as persistence notification.""" bridge = HomeBridge(self.hass) @@ -83,20 +116,3 @@ def test_dismiss_setup_msg(self): self.assertEqual( data[ATTR_SERVICE_DATA].get(ATTR_NOTIFICATION_ID, None), HOMEKIT_NOTIFY_ID) - - def test_convert_to_float(self): - """Test convert_to_float method.""" - self.assertEqual(convert_to_float(12), 12) - self.assertEqual(convert_to_float(12.4), 12.4) - self.assertIsNone(convert_to_float(STATE_UNKNOWN)) - self.assertIsNone(convert_to_float(None)) - - def test_temperature_to_homekit(self): - """Test temperature conversion from HA to HomeKit.""" - self.assertEqual(temperature_to_homekit(20.46, TEMP_CELSIUS), 20.5) - self.assertEqual(temperature_to_homekit(92.1, TEMP_FAHRENHEIT), 33.4) - - def test_temperature_to_states(self): - """Test temperature conversion from HomeKit to HA.""" - self.assertEqual(temperature_to_states(20, TEMP_CELSIUS), 20.0) - self.assertEqual(temperature_to_states(20.2, TEMP_FAHRENHEIT), 68.4) From d2804b0a27a0f73ab912217e4e2be4412c7ad68a Mon Sep 17 00:00:00 2001 From: stephanerosi Date: Thu, 12 Apr 2018 15:44:56 +0200 Subject: [PATCH 048/155] Channel up/down for LiveTV and next/previous for other apps (#13829) --- homeassistant/components/media_player/webostv.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 860d69e22c32b..ae9d259a47c92 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -35,6 +35,7 @@ CONF_ON_ACTION = 'turn_on_action' DEFAULT_NAME = 'LG webOS Smart TV' +LIVETV_APP_ID = 'com.webos.app.livetv' WEBOSTV_CONFIG_FILE = 'webostv.conf' @@ -357,8 +358,16 @@ def media_pause(self): def media_next_track(self): """Send next track command.""" - self._client.channel_up() + current_input = self._client.get_input() + if current_input == LIVETV_APP_ID: + self._client.channel_up() + else: + self._client.fast_forward() def media_previous_track(self): """Send the previous track command.""" - self._client.channel_down() + current_input = self._client.get_input() + if current_input == LIVETV_APP_ID: + self._client.channel_down() + else: + self._client.rewind() From 51bdd06d1f075169282dc44eef7689a16ee52d31 Mon Sep 17 00:00:00 2001 From: xTCx Date: Thu, 12 Apr 2018 17:13:31 +0300 Subject: [PATCH 049/155] Clicksend: Added support for multiple recipients (#13812) * Clicksend: Added support for multiple recipients * Removed whitespace --- homeassistant/components/notify/clicksend.py | 23 ++++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 2b2cb4e7f22c5..c028da2c57942 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -37,7 +37,8 @@ def validate_sender(config): vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_RECIPIENT, default=[]): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SENDER): cv.string, }), validate_sender)) @@ -59,21 +60,19 @@ def __init__(self, config): """Initialize the service.""" self.username = config.get(CONF_USERNAME) self.api_key = config.get(CONF_API_KEY) - self.recipient = config.get(CONF_RECIPIENT) + self.recipients = config.get(CONF_RECIPIENT) self.sender = config.get(CONF_SENDER, CONF_RECIPIENT) def send_message(self, message="", **kwargs): """Send a message to a user.""" - data = ({ - 'messages': [ - { - 'source': 'hass.notify', - 'from': self.sender, - 'to': self.recipient, - 'body': message, - } - ] - }) + data = {"messages": []} + for recipient in self.recipients: + data["messages"].append({ + 'source': 'hass.notify', + 'from': self.sender, + 'to': recipient, + 'body': message, + }) api_url = "{}/sms/send".format(BASE_API_URL) From 993866a31435d286d56aad8d4a5316d4d3007de1 Mon Sep 17 00:00:00 2001 From: Mark Coombes Date: Thu, 12 Apr 2018 12:08:48 -0400 Subject: [PATCH 050/155] Support Garage Doors in HomeKit (#13796) --- homeassistant/components/homekit/__init__.py | 11 +++- homeassistant/components/homekit/const.py | 4 ++ .../components/homekit/type_covers.py | 50 ++++++++++++++- .../homekit/test_get_accessories.py | 11 ++++ tests/components/homekit/test_type_covers.py | 63 ++++++++++++++++++- 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 1092cea0c6e74..306f399092a32 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,7 +8,8 @@ import voluptuous as vol -from homeassistant.components.cover import SUPPORT_SET_POSITION +from homeassistant.components.cover import ( + SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -92,9 +93,13 @@ def get_accessory(hass, state, aid, config): a_type = 'Thermostat' elif state.domain == 'cover': - # Only add covers that support set_cover_position features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & SUPPORT_SET_POSITION: + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == 'garage' and \ + features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'GarageDoorOpener' + elif features & SUPPORT_SET_POSITION: a_type = 'WindowCovering' elif state.domain == 'light': diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 7cde51b541685..79466cd9ff0a8 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -24,6 +24,7 @@ # #### Categories #### CATEGORY_ALARM_SYSTEM = 'ALARM_SYSTEM' +CATEGORY_GARAGE_DOOR_OPENER = 'GARAGE_DOOR_OPENER' CATEGORY_LIGHT = 'LIGHTBULB' CATEGORY_LOCK = 'DOOR_LOCK' CATEGORY_SENSOR = 'SENSOR' @@ -38,6 +39,7 @@ SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' SERV_CONTACT_SENSOR = 'ContactSensor' +SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity SERV_LEAK_SENSOR = 'LeakSensor' SERV_LIGHT_SENSOR = 'LightSensor' @@ -65,6 +67,7 @@ CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' +CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100] CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent @@ -85,6 +88,7 @@ CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' +CHAR_TARGET_DOOR_STATE = 'TargetDoorState' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100] CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index d8a6a8c2fdc03..9c852bb4d8635 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -3,17 +3,63 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED) from . import TYPES from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_WINDOW_COVERING, SERV_WINDOW_COVERING, - CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION) + CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, + CATEGORY_GARAGE_DOOR_OPENER, SERV_GARAGE_DOOR_OPENER, + CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) _LOGGER = logging.getLogger(__name__) +@TYPES.register('GarageDoorOpener') +class GarageDoorOpener(HomeAccessory): + """Generate a Garage Door Opener accessory for a cover entity. + + The cover entity must be in the 'garage' device class + and support no more than open, close, and stop. + """ + + def __init__(self, *args, config): + """Initialize a GarageDoorOpener accessory object.""" + super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) + self.flag_target_state = False + + serv_garage_door = add_preload_service(self, SERV_GARAGE_DOOR_OPENER) + self.char_current_state = setup_char( + CHAR_CURRENT_DOOR_STATE, serv_garage_door, value=0) + self.char_target_state = setup_char( + CHAR_TARGET_DOOR_STATE, serv_garage_door, value=0, + callback=self.set_state) + + def set_state(self, value): + """Change garage state if call came from HomeKit.""" + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self.flag_target_state = True + + if value == 0: + self.char_current_state.set_value(3) + self.hass.components.cover.open_cover(self.entity_id) + elif value == 1: + self.char_current_state.set_value(2) + self.hass.components.cover.close_cover(self.entity_id) + + def update_state(self, new_state): + """Update cover state after state changed.""" + hass_state = new_state.state + if hass_state in (STATE_OPEN, STATE_CLOSED): + current_state = 0 if hass_state == STATE_OPEN else 1 + self.char_current_state.set_value(current_state) + if not self.flag_target_state: + self.char_target_state.set_value(current_state) + self.flag_target_state = False + + @TYPES.register('WindowCovering') class WindowCovering(HomeAccessory): """Generate a Window accessory for a cover entity. diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 052b7557c1178..8333f1fb893a8 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -4,6 +4,8 @@ from unittest.mock import patch, Mock from homeassistant.core import State +from homeassistant.components.cover import ( + SUPPORT_OPEN, SUPPORT_CLOSE) from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.components.homekit import get_accessory, TYPES @@ -136,6 +138,15 @@ def test_device_tracker(self): state = State('device_tracker.someone', 'not_home', {}) get_accessory(None, state, 2, {}) + def test_garage_door(self): + """Test cover with device_class: 'garage' and required features.""" + with patch.dict(TYPES, {'GarageDoorOpener': self.mock_type}): + state = State('cover.garage_door', 'open', { + ATTR_DEVICE_CLASS: 'garage', + ATTR_SUPPORTED_FEATURES: + SUPPORT_OPEN | SUPPORT_CLOSE}) + get_accessory(None, state, 2, {}) + def test_cover_set_position(self): """Test cover with support for set_cover_position.""" with patch.dict(TYPES, {'WindowCovering': self.mock_type}): diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 43e82e74b1abe..f9889b1bdd831 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -4,9 +4,10 @@ from homeassistant.core import callback from homeassistant.components.cover import ( ATTR_POSITION, ATTR_CURRENT_POSITION) -from homeassistant.components.homekit.type_covers import WindowCovering +from homeassistant.components.homekit.type_covers import ( + GarageDoorOpener, WindowCovering) from homeassistant.const import ( - STATE_UNKNOWN, STATE_OPEN, + STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OPEN, ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) from tests.common import get_test_home_assistant @@ -31,6 +32,64 @@ def tearDown(self): """Stop down everything that was started.""" self.hass.stop() + def test_garage_door_open_close(self): + """Test if accessory and HA are updated accordingly.""" + garage_door = 'cover.garage_door' + + acc = GarageDoorOpener(self.hass, 'Cover', garage_door, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 4) # GarageDoorOpener + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_CLOSED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 1) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(garage_door, STATE_OPEN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_UNAVAILABLE) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + # Set closed from HomeKit + acc.char_target_state.client_update_value(1) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 2) + self.assertEqual(acc.char_target_state.value, 1) + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.hass.states.set(garage_door, STATE_CLOSED) + self.hass.block_till_done() + + # Set open from HomeKit + acc.char_target_state.client_update_value(0) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 0) + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + def test_window_set_cover_position(self): """Test if accessory and HA are updated accordingly.""" window_cover = 'cover.window' From 62dc737ea313f5298e032d81f89dd6f2434726e0 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Thu, 12 Apr 2018 13:27:23 -0700 Subject: [PATCH 051/155] Abode better events (#13809) * Push abodepy version to 0.13.0 * Bump to 0.13.1. Now uses a cache to store the generated UUID. * Reorganize to not be a dumb dumb. --- homeassistant/components/abode.py | 11 +++++++---- requirements_all.txt | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 08918c77f015e..2f56bb7c2b50d 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -19,7 +19,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['abodepy==0.12.3'] +REQUIREMENTS = ['abodepy==0.13.1'] _LOGGER = logging.getLogger(__name__) @@ -27,6 +27,7 @@ CONF_POLLING = 'polling' DOMAIN = 'abode' +DEFAULT_CACHEDB = './abodepy_cache.pickle' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' @@ -87,12 +88,13 @@ class AbodeSystem(object): """Abode System class.""" - def __init__(self, username, password, name, polling, exclude, lights): + def __init__(self, username, password, cache, + name, polling, exclude, lights): """Initialize the system.""" import abodepy self.abode = abodepy.Abode( username, password, auto_login=True, get_devices=True, - get_automations=True) + get_automations=True, cache_path=cache) self.name = name self.polling = polling self.exclude = exclude @@ -129,8 +131,9 @@ def setup(hass, config): lights = conf.get(CONF_LIGHTS) try: + cache = hass.config.path(DEFAULT_CACHEDB) hass.data[DOMAIN] = AbodeSystem( - username, password, name, polling, exclude, lights) + username, password, cache, name, polling, exclude, lights) except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) diff --git a/requirements_all.txt b/requirements_all.txt index 86cff3f942044..8fe360df8e8a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -64,7 +64,7 @@ WazeRouteCalculator==0.5 YesssSMS==0.1.1b3 # homeassistant.components.abode -abodepy==0.12.3 +abodepy==0.13.1 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.3 From 22a1b99e57ba31197fcc4a9233803ba65a66ed49 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 12 Apr 2018 23:22:52 +0100 Subject: [PATCH 052/155] UPnP async (#13666) * moved from miniupnpc to pyupnp-async * update requirements * Tests added * hound * update requirements_test_all.txt * update gen_requirements_all.py * addresses @pvizeli requested changes * address review comments --- homeassistant/components/sensor/upnp.py | 52 ++++---- homeassistant/components/upnp.py | 67 ++++++---- requirements_all.txt | 6 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/test_upnp.py | 160 +++++++++++++++--------- 6 files changed, 178 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/sensor/upnp.py b/homeassistant/components/sensor/upnp.py index e5acae6791655..e0c57ca9ac686 100644 --- a/homeassistant/components/sensor/upnp.py +++ b/homeassistant/components/sensor/upnp.py @@ -6,38 +6,44 @@ """ import logging -from homeassistant.components.upnp import DATA_UPNP, UNITS +from homeassistant.components.upnp import DATA_UPNP, UNITS, CIC_SERVICE from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) +BYTES_RECEIVED = 1 +BYTES_SENT = 2 +PACKETS_RECEIVED = 3 +PACKETS_SENT = 4 + # sensor_type: [friendly_name, convert_unit, icon] SENSOR_TYPES = { - 'byte_received': ['received bytes', True, 'mdi:server-network'], - 'byte_sent': ['sent bytes', True, 'mdi:server-network'], - 'packets_in': ['packets received', False, 'mdi:server-network'], - 'packets_out': ['packets sent', False, 'mdi:server-network'], + BYTES_RECEIVED: ['received bytes', True, 'mdi:server-network'], + BYTES_SENT: ['sent bytes', True, 'mdi:server-network'], + PACKETS_RECEIVED: ['packets received', False, 'mdi:server-network'], + PACKETS_SENT: ['packets sent', False, 'mdi:server-network'], } -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up the IGD sensors.""" - upnp = hass.data[DATA_UPNP] + device = hass.data[DATA_UPNP] + service = device.find_first_service(CIC_SERVICE) unit = discovery_info['unit'] add_devices([ - IGDSensor(upnp, t, unit if SENSOR_TYPES[t][1] else None) + IGDSensor(service, t, unit if SENSOR_TYPES[t][1] else '#') for t in SENSOR_TYPES], True) class IGDSensor(Entity): """Representation of a UPnP IGD sensor.""" - def __init__(self, upnp, sensor_type, unit=""): + def __init__(self, service, sensor_type, unit=None): """Initialize the IGD sensor.""" - self._upnp = upnp + self._service = service self.type = sensor_type self.unit = unit - self.unit_factor = UNITS[unit] if unit is not None else 1 + self.unit_factor = UNITS[unit] if unit in UNITS else 1 self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0]) self._state = None @@ -49,9 +55,9 @@ def name(self): @property def state(self): """Return the state of the device.""" - if self._state is None: - return None - return format(self._state / self.unit_factor, '.1f') + if self._state: + return format(float(self._state) / self.unit_factor, '.1f') + return self._state @property def icon(self): @@ -63,13 +69,13 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self.unit - def update(self): + async def async_update(self): """Get the latest information from the IGD.""" - if self.type == "byte_received": - self._state = self._upnp.totalbytereceived() - elif self.type == "byte_sent": - self._state = self._upnp.totalbytesent() - elif self.type == "packets_in": - self._state = self._upnp.totalpacketreceived() - elif self.type == "packets_out": - self._state = self._upnp.totalpacketsent() + if self.type == BYTES_RECEIVED: + self._state = await self._service.get_total_bytes_received() + elif self.type == BYTES_SENT: + self._state = await self._service.get_total_bytes_sent() + elif self.type == PACKETS_RECEIVED: + self._state = await self._service.get_total_packets_received() + elif self.type == PACKETS_SENT: + self._state = await self._service.get_total_packets_sent() diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 960d8f3780e5f..dd611090c222a 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -6,6 +6,7 @@ """ from ipaddress import ip_address import logging +import asyncio import voluptuous as vol @@ -14,7 +15,7 @@ from homeassistant.helpers import discovery from homeassistant.util import get_local_ip -REQUIREMENTS = ['miniupnpc==2.0.2'] +REQUIREMENTS = ['pyupnp-async==0.1.0.1'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -22,7 +23,7 @@ DEPENDENCIES = ['api'] DOMAIN = 'upnp' -DATA_UPNP = 'UPNP' +DATA_UPNP = 'upnp_device' CONF_LOCAL_IP = 'local_ip' CONF_ENABLE_PORT_MAPPING = 'port_mapping' @@ -33,6 +34,11 @@ NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP Setup' +IGD_DEVICE = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1' +PPP_SERVICE = 'urn:schemas-upnp-org:service:WANPPPConnection:1' +IP_SERVICE = 'urn:schemas-upnp-org:service:WANIPConnection:1' +CIC_SERVICE = 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1' + UNITS = { "Bytes": 1, "KBytes": 1024, @@ -51,8 +57,7 @@ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=import-error, no-member, broad-except, c-extension-no-member -def setup(hass, config): +async def async_setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" config = config[DOMAIN] host = config.get(CONF_LOCAL_IP) @@ -67,21 +72,35 @@ def setup(hass, config): 'Unable to determine local IP. Add it to your configuration.') return False - import miniupnpc + import pyupnp_async + from pyupnp_async.error import UpnpSoapError - upnp = miniupnpc.UPnP() - hass.data[DATA_UPNP] = upnp + service = None + resp = await pyupnp_async.msearch_first(search_target=IGD_DEVICE) + if not resp: + return False - upnp.discoverdelay = 200 - upnp.discover() try: - upnp.selectigd() - except Exception: - _LOGGER.exception("Error when attempting to discover an UPnP IGD") + device = await resp.get_device() + hass.data[DATA_UPNP] = device + for _service in device.services: + if _service['serviceType'] == PPP_SERVICE: + service = device.find_first_service(PPP_SERVICE) + if _service['serviceType'] == IP_SERVICE: + service = device.find_first_service(IP_SERVICE) + if _service['serviceType'] == CIC_SERVICE: + unit = config.get(CONF_UNITS) + discovery.load_platform(hass, 'sensor', + DOMAIN, + {'unit': unit}, + config) + except UpnpSoapError as error: + _LOGGER.error(error) return False - unit = config.get(CONF_UNITS) - discovery.load_platform(hass, 'sensor', DOMAIN, {'unit': unit}, config) + if not service: + _LOGGER.warning("Could not find any UPnP IGD") + return False port_mapping = config.get(CONF_ENABLE_PORT_MAPPING) if not port_mapping: @@ -98,12 +117,12 @@ def setup(hass, config): if internal == CONF_HASS: internal = internal_port try: - upnp.addportmapping( - external, 'TCP', host, internal, 'Home Assistant', '') + await service.add_port_mapping(internal, external, host, 'TCP', + desc='Home Assistant') registered.append(external) - except Exception: - _LOGGER.exception("UPnP failed to configure port mapping for %s", - external) + _LOGGER.debug("external %s -> %s @ %s", external, internal, host) + except UpnpSoapError as error: + _LOGGER.error(error) hass.components.persistent_notification.create( 'ERROR: tcp port {} is already mapped in your router.' '
Please disable port_mapping in the upnp ' @@ -113,11 +132,13 @@ def setup(hass, config): title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) - def deregister_port(event): + async def deregister_port(event): """De-register the UPnP port mapping.""" - for external in registered: - upnp.deleteportmapping(external, 'TCP') + tasks = [service.delete_port_mapping(external, 'TCP') + for external in registered] + if tasks: + await asyncio.wait(tasks) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) return True diff --git a/requirements_all.txt b/requirements_all.txt index 8fe360df8e8a9..d26f8717384cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -517,9 +517,6 @@ mficlient==0.3.0 # homeassistant.components.sensor.miflora miflora==0.3.0 -# homeassistant.components.upnp -miniupnpc==2.0.2 - # homeassistant.components.sensor.mopar motorparts==1.0.2 @@ -1055,6 +1052,9 @@ pytradfri[async]==5.4.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 +# homeassistant.components.upnp +pyupnp-async==0.1.0.1 + # homeassistant.components.keyboard # pyuserinput==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 484fd1c39f52d..e17cbffe8d653 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,6 +158,9 @@ pythonwhois==2.4.3 # homeassistant.components.device_tracker.unifi pyunifi==2.13 +# homeassistant.components.upnp +pyupnp-async==0.1.0.1 + # homeassistant.components.notify.html5 pywebpush==1.6.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 708d9dbd30b77..27b972dcefac9 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -76,6 +76,7 @@ 'pyqwikswitch', 'python-forecastio', 'pyunifi', + 'pyupnp-async', 'pywebpush', 'restrictedpython', 'rflink', diff --git a/tests/components/test_upnp.py b/tests/components/test_upnp.py index e2096d28e58b9..4956b8a62783f 100644 --- a/tests/components/test_upnp.py +++ b/tests/components/test_upnp.py @@ -1,5 +1,4 @@ """Test the UPNP component.""" -import asyncio from collections import OrderedDict from unittest.mock import patch, MagicMock @@ -7,15 +6,64 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.setup import async_setup_component +from homeassistant.components.upnp import IP_SERVICE, DATA_UPNP + + +class MockService(MagicMock): + """Mock upnp IP service.""" + + async def add_port_mapping(self, *args, **kwargs): + """Original function.""" + self.mock_add_port_mapping(*args, **kwargs) + + async def delete_port_mapping(self, *args, **kwargs): + """Original function.""" + self.mock_delete_port_mapping(*args, **kwargs) + + +class MockDevice(MagicMock): + """Mock upnp device.""" + + def find_first_service(self, *args, **kwargs): + """Original function.""" + self._service = MockService() + return self._service + + def peep_first_service(self): + """Access Mock first service.""" + return self._service + + +class MockResp(MagicMock): + """Mock upnp msearch response.""" + + async def get_device(self, *args, **kwargs): + """Original function.""" + device = MockDevice() + service = {'serviceType': IP_SERVICE} + device.services = [service] + return device @pytest.fixture -def mock_miniupnpc(): - """Mock miniupnpc.""" - mock = MagicMock() +def mock_msearch_first(*args, **kwargs): + """Wrapper to async mock function.""" + async def async_mock_msearch_first(*args, **kwargs): + """Mock msearch_first.""" + return MockResp(*args, **kwargs) - with patch.dict('sys.modules', {'miniupnpc': mock}): - yield mock.UPnP() + with patch('pyupnp_async.msearch_first', new=async_mock_msearch_first): + yield + + +@pytest.fixture +def mock_async_exception(*args, **kwargs): + """Wrapper to async mock function with exception.""" + async def async_mock_exception(*args, **kwargs): + return Exception + + with patch('pyupnp_async.msearch_first', new=async_mock_exception): + yield @pytest.fixture @@ -26,75 +74,66 @@ def mock_local_ip(): yield -@pytest.fixture(autouse=True) -def mock_discovery(): - """Mock discovery of upnp sensor.""" - with patch('homeassistant.components.upnp.discovery'): - yield - - -@asyncio.coroutine -def test_setup_fail_if_no_ip(hass): +async def test_setup_fail_if_no_ip(hass): """Test setup fails if we can't find a local IP.""" with patch('homeassistant.components.upnp.get_local_ip', return_value='127.0.0.1'): - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': {} }) assert not result -@asyncio.coroutine -def test_setup_fail_if_cannot_select_igd(hass, mock_local_ip, mock_miniupnpc): +async def test_setup_fail_if_cannot_select_igd(hass, + mock_local_ip, + mock_async_exception): """Test setup fails if we can't find an UPnP IGD.""" - mock_miniupnpc.selectigd.side_effect = Exception - - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': {} }) assert not result -@asyncio.coroutine -def test_setup_succeeds_if_specify_ip(hass, mock_miniupnpc): +async def test_setup_succeeds_if_specify_ip(hass, mock_msearch_first): """Test setup succeeds if we specify IP and can't find a local IP.""" with patch('homeassistant.components.upnp.get_local_ip', return_value='127.0.0.1'): - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { 'local_ip': '192.168.0.10' } }) assert result + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_no_config_maps_hass_local_to_remote_port(hass, mock_miniupnpc): +async def test_no_config_maps_hass_local_to_remote_port(hass, + mock_local_ip, + mock_msearch_first): """Test by default we map local to remote port.""" - result = yield from async_setup_component(hass, 'upnp', { - 'upnp': { - 'local_ip': '192.168.0.10' - } + result = await async_setup_component(hass, 'upnp', { + 'upnp': {} }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert host == '192.168.0.10' - assert external == 8123 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_map_hass_to_remote_port(hass, mock_miniupnpc): +async def test_map_hass_to_remote_port(hass, + mock_local_ip, + mock_msearch_first): """Test mapping hass to remote port.""" - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { - 'local_ip': '192.168.0.10', 'ports': { 'hass': 1000 } @@ -102,41 +141,38 @@ def test_map_hass_to_remote_port(hass, mock_miniupnpc): }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert external == 1000 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_map_internal_to_remote_ports(hass, mock_miniupnpc): +async def test_map_internal_to_remote_ports(hass, + mock_local_ip, + mock_msearch_first): """Test mapping local to remote ports.""" ports = OrderedDict() ports['hass'] = 1000 ports[1883] = 3883 - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { - 'local_ip': '192.168.0.10', 'ports': ports } }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 2 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert external == 1000 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 2 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[1][1] - assert external == 3883 - assert internal == 1883 + mock_service.mock_add_port_mapping.assert_any_call( + 8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant') + mock_service.mock_add_port_mapping.assert_any_call( + 1883, 3883, '192.168.0.10', 'TCP', desc='Home Assistant') hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - yield from hass.async_block_till_done() - assert len(mock_miniupnpc.deleteportmapping.mock_calls) == 2 - assert mock_miniupnpc.deleteportmapping.mock_calls[0][1][0] == 1000 - assert mock_miniupnpc.deleteportmapping.mock_calls[1][1][0] == 3883 + await hass.async_block_till_done() + assert len(mock_service.mock_delete_port_mapping.mock_calls) == 2 + + mock_service.mock_delete_port_mapping.assert_any_call(1000, 'TCP') + mock_service.mock_delete_port_mapping.assert_any_call(3883, 'TCP') From 3906250c9e1e7a314f0bf62cce0bf5155cddb300 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 13 Apr 2018 08:50:58 +0200 Subject: [PATCH 053/155] Update example (fixes #13834) (#13839) --- homeassistant/components/vacuum/services.yaml | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index fea365ac7c712..863157074bce1 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -4,93 +4,93 @@ turn_on: description: Start a new cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' turn_off: description: Stop the current cleaning task and return to home. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' stop: description: Stop the current cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' locate: description: Locate the vacuum cleaner robot. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' start_pause: description: Start, pause, or resume the cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' return_to_base: description: Tell the vacuum cleaner to return to its dock. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' clean_spot: description: Tell the vacuum cleaner to do a spot clean-up. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' send_command: description: Send a raw command to the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' command: description: Command to execute. example: 'set_dnd_timer' params: description: Parameters for the command. - example: '[22,0,6,0]' + example: '{ "key": "value" }' set_fan_speed: description: Set the fan speed of the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' fan_speed: - description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium', or by percentage, between 0 and 100. + description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium' or by percentage, between 0 and 100. example: 'low' xiaomi_remote_control_start: description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' xiaomi_remote_control_stop: description: Stop remote control mode of the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' xiaomi_remote_control_move: description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' velocity: description: Speed, between -0.29 and 0.29. @@ -106,7 +106,7 @@ xiaomi_remote_control_move_step: description: Remote control the vacuum cleaner, only makes one move and then stops. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' velocity: description: Speed, between -0.29 and 0.29. From d3b261a25d6b8b67b1c7de9028c31b660799bb8b Mon Sep 17 00:00:00 2001 From: Mark Coombes Date: Fri, 13 Apr 2018 02:58:57 -0400 Subject: [PATCH 054/155] Add support for deCONZ daylight sensor (#13479) * Add support for deCONZ daylight sensor Bump pydeconz to 34 * Remove 'daylight' reason from async u --- homeassistant/components/sensor/deconz.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 081b304dc55ff..e569c5578aca1 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -16,6 +16,7 @@ DEPENDENCIES = ['deconz'] ATTR_CURRENT = 'current' +ATTR_DAYLIGHT = 'daylight' ATTR_EVENT_ID = 'event_id' @@ -113,6 +114,8 @@ def device_state_attributes(self): if self.unit_of_measurement == 'Watts': attr[ATTR_CURRENT] = self._sensor.current attr[ATTR_VOLTAGE] = self._sensor.voltage + if self._sensor.sensor_class == 'daylight': + attr[ATTR_DAYLIGHT] = self._sensor.daylight return attr From 20ababec3e28ced91fc3d3e79b229612a3d9c99d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Apr 2018 07:32:05 -0400 Subject: [PATCH 055/155] Add authentication to error log endpoint (#13836) --- homeassistant/components/api.py | 16 +++++++++++++--- tests/components/test_api.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index d272ebcb1c0cc..6fdf0c027a4d9 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -52,9 +52,8 @@ def setup(hass, config): hass.http.register_view(APIComponentsView) hass.http.register_view(APITemplateView) - log_path = hass.data.get(DATA_LOGGING, None) - if log_path: - hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False) + if DATA_LOGGING in hass.data: + hass.http.register_view(APIErrorLog) return True @@ -356,6 +355,17 @@ def post(self, request): HTTP_BAD_REQUEST) +class APIErrorLog(HomeAssistantView): + """View to fetch the error log.""" + + url = URL_API_ERROR_LOG + name = "api:error_log" + + async def get(self, request): + """Retrieve API error log.""" + return await self.file(request, request.app['hass'].data[DATA_LOGGING]) + + @asyncio.coroutine def async_services_json(hass): """Generate services data to JSONify.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 6d5bec046f1ae..c9dae27d14c82 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -2,13 +2,18 @@ # pylint: disable=protected-access import asyncio import json +from unittest.mock import patch +from aiohttp import web import pytest from homeassistant import const +from homeassistant.bootstrap import DATA_LOGGING import homeassistant.core as ha from homeassistant.setup import async_setup_component +from tests.common import mock_coro + @pytest.fixture def mock_api_client(hass, aiohttp_client): @@ -398,3 +403,31 @@ def _stream_next_event(stream): def _listen_count(hass): """Return number of event listeners.""" return sum(hass.bus.async_listeners().values()) + + +async def test_api_error_log(hass, aiohttp_client): + """Test if we can fetch the error log.""" + hass.data[DATA_LOGGING] = '/some/path' + await async_setup_component(hass, 'api', { + 'http': { + 'api_password': 'yolo' + } + }) + client = await aiohttp_client(hass.http.app) + + resp = await client.get(const.URL_API_ERROR_LOG) + # Verufy auth required + assert resp.status == 401 + + with patch( + 'homeassistant.components.http.view.HomeAssistantView.file', + return_value=mock_coro(web.Response(status=200, text='Hello')) + ) as mock_file: + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'x-ha-access': 'yolo' + }) + + assert len(mock_file.mock_calls) == 1 + assert mock_file.mock_calls[0][1][1] == hass.data[DATA_LOGGING] + assert resp.status == 200 + assert await resp.text() == 'Hello' From ddd20036292853c1436f60a3d3ec7726a5db6714 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 13 Apr 2018 13:25:03 +0100 Subject: [PATCH 056/155] initialize queue before filtering (#13842) --- homeassistant/components/sensor/filter.py | 2 +- tests/components/sensor/test_filter.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 27730a8f63e4e..5b28faf78ca09 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -341,7 +341,7 @@ def __init__(self, window_size, precision, entity, radius): def _filter_state(self, new_state): """Implement the outlier filter.""" - if (self.states and + if (len(self.states) == self.states.maxlen and abs(new_state.state - statistics.median([s.state for s in self.states])) > self._radius): diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 8b8e7607b0776..43432f3304c21 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -95,11 +95,11 @@ def test_chain(self): self.hass.block_till_done() state = self.hass.states.get('sensor.test') - self.assertEqual('19.25', state.state) + self.assertEqual('17.05', state.state) def test_outlier(self): """Test if outlier filter works.""" - filt = OutlierFilter(window_size=10, + filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0) @@ -107,6 +107,17 @@ def test_outlier(self): filtered = filt.filter_state(state) self.assertEqual(22, filtered.state) + def test_initial_outlier(self): + """Test issue #13363.""" + filt = OutlierFilter(window_size=3, + precision=2, + entity=None, + radius=4.0) + out = ha.State('sensor.test_monitored', 4000) + for state in [out]+self.values: + filtered = filt.filter_state(state) + self.assertEqual(22, filtered.state) + def test_lowpass(self): """Test if lowpass filter works.""" filt = LowPassFilter(window_size=10, From 60508f72158bcf5fd1b553f03a2d8a15917bc085 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Apr 2018 10:14:53 -0400 Subject: [PATCH 057/155] Extract config flow to own module (#13840) * Extract config flow to own module * Lint * fix lint * fix typo * ConfigFlowHandler -> FlowHandler * Rename to data_entry_flow --- .../components/config/config_entries.py | 20 +- homeassistant/components/deconz/__init__.py | 4 +- homeassistant/components/discovery.py | 4 +- homeassistant/components/hue/config_flow.py | 4 +- homeassistant/config_entries.py | 199 +++--------------- homeassistant/data_entry_flow.py | 180 ++++++++++++++++ tests/common.py | 4 +- .../components/config/test_config_entries.py | 21 +- tests/components/test_discovery.py | 4 +- tests/test_config_entries.py | 190 +---------------- tests/test_data_entry_flow.py | 186 ++++++++++++++++ 11 files changed, 428 insertions(+), 388 deletions(-) create mode 100644 homeassistant/data_entry_flow.py create mode 100644 tests/test_data_entry_flow.py diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index aa42325b75bdb..967317134c2db 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -3,7 +3,7 @@ import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator @@ -24,7 +24,7 @@ def async_setup(hass): def _prepare_json(result): """Convert result for JSON.""" - if result['type'] != config_entries.RESULT_TYPE_FORM: + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: return result import voluptuous_serialize @@ -94,8 +94,8 @@ def get(self, request): hass = request.app['hass'] return self.json([ - flow for flow in hass.config_entries.flow.async_progress() - if flow['source'] != config_entries.SOURCE_USER]) + flw for flw in hass.config_entries.flow.async_progress() + if flw['source'] != data_entry_flow.SOURCE_USER]) @RequestDataValidator(vol.Schema({ vol.Required('domain'): str, @@ -108,9 +108,9 @@ def post(self, request, data): try: result = yield from hass.config_entries.flow.async_init( data['domain']) - except config_entries.UnknownHandler: + except data_entry_flow.UnknownHandler: return self.json_message('Invalid handler specified', 404) - except config_entries.UnknownStep: + except data_entry_flow.UnknownStep: return self.json_message('Handler does not support init', 400) result = _prepare_json(result) @@ -126,13 +126,13 @@ class ConfigManagerFlowResourceView(HomeAssistantView): @asyncio.coroutine def get(self, request, flow_id): - """Get the current state of a flow.""" + """Get the current state of a data_entry_flow.""" hass = request.app['hass'] try: result = yield from hass.config_entries.flow.async_configure( flow_id) - except config_entries.UnknownFlow: + except data_entry_flow.UnknownFlow: return self.json_message('Invalid flow specified', 404) result = _prepare_json(result) @@ -148,7 +148,7 @@ def post(self, request, flow_id, data): try: result = yield from hass.config_entries.flow.async_configure( flow_id, data) - except config_entries.UnknownFlow: + except data_entry_flow.UnknownFlow: return self.json_message('Invalid flow specified', 404) except vol.Invalid: return self.json_message('User input malformed', 400) @@ -164,7 +164,7 @@ def delete(self, request, flow_id): try: hass.config_entries.flow.async_abort(flow_id) - except config_entries.UnknownFlow: + except data_entry_flow.UnknownFlow: return self.json_message('Invalid flow specified', 404) return self.json_message('Flow aborted') diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 85ba271ec3a30..04cd42ca620e8 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.discovery import SERVICE_DECONZ from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) @@ -191,7 +191,7 @@ async def async_configuration_callback(data): @config_entries.HANDLERS.register(DOMAIN) -class DeconzFlowHandler(config_entries.ConfigFlowHandler): +class DeconzFlowHandler(data_entry_flow.FlowHandler): """Handle a deCONZ config flow.""" VERSION = 1 diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 677a13d6a9d09..693cd3d90f1c7 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -13,7 +13,7 @@ import voluptuous as vol -from homeassistant import config_entries +from homeassistant import data_entry_flow from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv @@ -119,7 +119,7 @@ async def new_service_found(service, info): if service in CONFIG_ENTRY_HANDLERS: await hass.config_entries.flow.async_init( CONFIG_ENTRY_HANDLERS[service], - source=config_entries.SOURCE_DISCOVERY, + source=data_entry_flow.SOURCE_DISCOVERY, data=info ) return diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 11e399c984dfc..af67a594495e5 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -6,7 +6,7 @@ import async_timeout import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -41,7 +41,7 @@ def _find_username_from_config(hass, filename): @config_entries.HANDLERS.register(DOMAIN) -class HueFlowHandler(config_entries.ConfigFlowHandler): +class HueFlowHandler(data_entry_flow.FlowHandler): """Handle a Hue config flow.""" VERSION = 1 diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e2e45cb5819e5..d06bf8f1f8fd1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -27,7 +27,7 @@ 'init' step. @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.ConfigFlowHandler): + class ExampleConfigFlow(config_entries.FlowHandler): VERSION = 1 @@ -117,6 +117,7 @@ async def async_step_discovery(info): from .core import callback from .exceptions import HomeAssistantError +from .data_entry_flow import FlowManager from .setup import async_setup_component, async_process_deps_reqs from .util.json import load_json, save_json from .util.decorator import Registry @@ -130,17 +131,11 @@ async def async_step_discovery(info): 'hue', ] -SOURCE_USER = 'user' -SOURCE_DISCOVERY = 'discovery' PATH_CONFIG = '.config_entries.json' SAVE_DELAY = 1 -RESULT_TYPE_FORM = 'form' -RESULT_TYPE_CREATE_ENTRY = 'create_entry' -RESULT_TYPE_ABORT = 'abort' - ENTRY_STATE_LOADED = 'loaded' ENTRY_STATE_SETUP_ERROR = 'setup_error' ENTRY_STATE_NOT_LOADED = 'not_loaded' @@ -251,18 +246,6 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" -class UnknownHandler(ConfigError): - """Unknown handler specified.""" - - -class UnknownFlow(ConfigError): - """Uknown flow specified.""" - - -class UnknownStep(ConfigError): - """Unknown step specified.""" - - class ConfigEntries: """Manage the configuration entries. @@ -272,7 +255,8 @@ class ConfigEntries: def __init__(self, hass, hass_config): """Initialize the entry manager.""" self.hass = hass - self.flow = FlowManager(hass, hass_config, self._async_add_entry) + self.flow = FlowManager(hass, HANDLERS, self._async_missing_handler, + self._async_save_entry) self._hass_config = hass_config self._entries = None self._sched_save = None @@ -357,8 +341,15 @@ async def async_forward_entry_unload(self, entry, component): await entry.async_unload( self.hass, component=getattr(self.hass.components, component)) - async def _async_add_entry(self, entry): + async def _async_save_entry(self, result): """Add an entry.""" + entry = ConfigEntry( + version=result['version'], + domain=result['domain'], + title=result['title'], + data=result['data'], + source=result['source'], + ) self._entries.append(entry) self._async_schedule_save() @@ -371,6 +362,18 @@ async def _async_add_entry(self, entry): await async_setup_component( self.hass, entry.domain, self._hass_config) + async def _async_missing_handler(self, domain): + """Called when a flow handler is not loaded.""" + # This will load the component and thus register the handler + component = getattr(self.hass.components, domain) + + if domain not in HANDLERS: + return + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs( + self.hass, self._hass_config, domain, component) + @callback def _async_schedule_save(self): """Schedule saving the entity registry.""" @@ -388,157 +391,3 @@ async def _async_save(self): await self.hass.async_add_job( save_json, self.hass.config.path(PATH_CONFIG), data) - - -class FlowManager: - """Manage all the config flows that are in progress.""" - - def __init__(self, hass, hass_config, async_add_entry): - """Initialize the flow manager.""" - self.hass = hass - self._hass_config = hass_config - self._progress = {} - self._async_add_entry = async_add_entry - - @callback - def async_progress(self): - """Return the flows in progress.""" - return [{ - 'flow_id': flow.flow_id, - 'domain': flow.domain, - 'source': flow.source, - } for flow in self._progress.values()] - - async def async_init(self, domain, *, source=SOURCE_USER, data=None): - """Start a configuration flow.""" - handler = HANDLERS.get(domain) - - if handler is None: - # This will load the component and thus register the handler - component = getattr(self.hass.components, domain) - handler = HANDLERS.get(domain) - - if handler is None: - raise UnknownHandler - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs( - self.hass, self._hass_config, domain, component) - - flow_id = uuid.uuid4().hex - flow = self._progress[flow_id] = handler() - flow.hass = self.hass - flow.domain = domain - flow.flow_id = flow_id - flow.source = source - - if source == SOURCE_USER: - step = 'init' - else: - step = source - - return await self._async_handle_step(flow, step, data) - - async def async_configure(self, flow_id, user_input=None): - """Start or continue a configuration flow.""" - flow = self._progress.get(flow_id) - - if flow is None: - raise UnknownFlow - - step_id, data_schema = flow.cur_step - - if data_schema is not None and user_input is not None: - user_input = data_schema(user_input) - - return await self._async_handle_step( - flow, step_id, user_input) - - @callback - def async_abort(self, flow_id): - """Abort a flow.""" - if self._progress.pop(flow_id, None) is None: - raise UnknownFlow - - async def _async_handle_step(self, flow, step_id, user_input): - """Handle a step of a flow.""" - method = "async_step_{}".format(step_id) - - if not hasattr(flow, method): - self._progress.pop(flow.flow_id) - raise UnknownStep("Handler {} doesn't support step {}".format( - flow.__class__.__name__, step_id)) - - result = await getattr(flow, method)(user_input) - - if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT): - raise ValueError( - 'Handler returned incorrect type: {}'.format(result['type'])) - - if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) - return result - - # Abort and Success results both finish the flow - self._progress.pop(flow.flow_id) - - if result['type'] == RESULT_TYPE_ABORT: - return result - - entry = ConfigEntry( - version=flow.VERSION, - domain=flow.domain, - title=result['title'], - data=result.pop('data'), - source=flow.source - ) - await self._async_add_entry(entry) - return result - - -class ConfigFlowHandler: - """Handle the configuration flow of a component.""" - - # Set by flow manager - flow_id = None - hass = None - domain = None - source = SOURCE_USER - cur_step = None - - # Set by dev - # VERSION - - @callback - def async_show_form(self, *, step_id, data_schema=None, errors=None): - """Return the definition of a form to gather user input.""" - return { - 'type': RESULT_TYPE_FORM, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'step_id': step_id, - 'data_schema': data_schema, - 'errors': errors, - } - - @callback - def async_create_entry(self, *, title, data): - """Finish config flow and create a config entry.""" - return { - 'type': RESULT_TYPE_CREATE_ENTRY, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'title': title, - 'data': data, - } - - @callback - def async_abort(self, *, reason): - """Abort the config flow.""" - return { - 'type': RESULT_TYPE_ABORT, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'reason': reason - } diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py new file mode 100644 index 0000000000000..5644481210c4d --- /dev/null +++ b/homeassistant/data_entry_flow.py @@ -0,0 +1,180 @@ +"""Classes to help gather user submissions.""" +import logging +import uuid + +from .core import callback +from .exceptions import HomeAssistantError + +_LOGGER = logging.getLogger(__name__) + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' + +RESULT_TYPE_FORM = 'form' +RESULT_TYPE_CREATE_ENTRY = 'create_entry' +RESULT_TYPE_ABORT = 'abort' + + +class FlowError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownHandler(FlowError): + """Unknown handler specified.""" + + +class UnknownFlow(FlowError): + """Uknown flow specified.""" + + +class UnknownStep(FlowError): + """Unknown step specified.""" + + +class FlowManager: + """Manage all the flows that are in progress.""" + + def __init__(self, hass, handlers, async_missing_handler, + async_save_entry): + """Initialize the flow manager.""" + self.hass = hass + self._handlers = handlers + self._progress = {} + self._async_missing_handler = async_missing_handler + self._async_save_entry = async_save_entry + + @callback + def async_progress(self): + """Return the flows in progress.""" + return [{ + 'flow_id': flow.flow_id, + 'domain': flow.domain, + 'source': flow.source, + } for flow in self._progress.values()] + + async def async_init(self, domain, *, source=SOURCE_USER, data=None): + """Start a configuration flow.""" + handler = self._handlers.get(domain) + + if handler is None: + await self._async_missing_handler(domain) + handler = self._handlers.get(domain) + + if handler is None: + raise UnknownHandler + + flow_id = uuid.uuid4().hex + flow = self._progress[flow_id] = handler() + flow.hass = self.hass + flow.domain = domain + flow.flow_id = flow_id + flow.source = source + + if source == SOURCE_USER: + step = 'init' + else: + step = source + + return await self._async_handle_step(flow, step, data) + + async def async_configure(self, flow_id, user_input=None): + """Start or continue a configuration flow.""" + flow = self._progress.get(flow_id) + + if flow is None: + raise UnknownFlow + + step_id, data_schema = flow.cur_step + + if data_schema is not None and user_input is not None: + user_input = data_schema(user_input) + + return await self._async_handle_step( + flow, step_id, user_input) + + @callback + def async_abort(self, flow_id): + """Abort a flow.""" + if self._progress.pop(flow_id, None) is None: + raise UnknownFlow + + async def _async_handle_step(self, flow, step_id, user_input): + """Handle a step of a flow.""" + method = "async_step_{}".format(step_id) + + if not hasattr(flow, method): + self._progress.pop(flow.flow_id) + raise UnknownStep("Handler {} doesn't support step {}".format( + flow.__class__.__name__, step_id)) + + result = await getattr(flow, method)(user_input) + + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_ABORT): + raise ValueError( + 'Handler returned incorrect type: {}'.format(result['type'])) + + if result['type'] == RESULT_TYPE_FORM: + flow.cur_step = (result['step_id'], result['data_schema']) + return result + + # Abort and Success results both finish the flow + self._progress.pop(flow.flow_id) + + if result['type'] == RESULT_TYPE_ABORT: + return result + + # We pass a copy of the result because we're going to mutate our + # version afterwards and don't want to cause unexpected bugs. + await self._async_save_entry(dict(result)) + result.pop('data') + return result + + +class FlowHandler: + """Handle the configuration flow of a component.""" + + # Set by flow manager + flow_id = None + hass = None + domain = None + source = SOURCE_USER + cur_step = None + + # Set by developer + VERSION = 1 + + @callback + def async_show_form(self, *, step_id, data_schema=None, errors=None): + """Return the definition of a form to gather user input.""" + return { + 'type': RESULT_TYPE_FORM, + 'flow_id': self.flow_id, + 'domain': self.domain, + 'step_id': step_id, + 'data_schema': data_schema, + 'errors': errors, + } + + @callback + def async_create_entry(self, *, title, data): + """Finish config flow and create a config entry.""" + return { + 'version': self.VERSION, + 'type': RESULT_TYPE_CREATE_ENTRY, + 'flow_id': self.flow_id, + 'domain': self.domain, + 'title': title, + 'data': data, + 'source': self.source, + } + + @callback + def async_abort(self, *, reason): + """Abort the config flow.""" + return { + 'type': RESULT_TYPE_ABORT, + 'flow_id': self.flow_id, + 'domain': self.domain, + 'reason': reason + } diff --git a/tests/common.py b/tests/common.py index 54c214da4e95f..67fd8bab23fe6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ import threading from contextlib import contextmanager -from homeassistant import core as ha, loader, config_entries +from homeassistant import core as ha, loader, data_entry_flow, config_entries from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -455,7 +455,7 @@ class MockConfigEntry(config_entries.ConfigEntry): """Helper for creating config entries that adds some defaults.""" def __init__(self, *, domain='test', data=None, version=0, entry_id=None, - source=config_entries.SOURCE_USER, title='Mock Title', + source=data_entry_flow.SOURCE_USER, title='Mock Title', state=None): """Initialize a mock config entry.""" kwargs = { diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index cfe6b12baac5e..d649076395119 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -8,7 +8,8 @@ import voluptuous as vol from homeassistant import config_entries as core_ce -from homeassistant.config_entries import ConfigFlowHandler, HANDLERS +from homeassistant.config_entries import HANDLERS +from homeassistant.data_entry_flow import FlowHandler from homeassistant.setup import async_setup_component from homeassistant.components.config import config_entries from homeassistant.loader import set_component @@ -93,7 +94,7 @@ def test_available_flows(hass, client): @asyncio.coroutine def test_initialize_flow(hass, client): """Test we can initialize a flow.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): schema = OrderedDict() @@ -142,7 +143,7 @@ def async_step_init(self, user_input=None): @asyncio.coroutine def test_abort(hass, client): """Test a flow that aborts.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): return self.async_abort(reason='bla') @@ -167,7 +168,7 @@ def test_create_account(hass, client): set_component( 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine @@ -187,7 +188,9 @@ def async_step_init(self, user_input=None): assert data == { 'domain': 'test', 'title': 'Test Entry', - 'type': 'create_entry' + 'type': 'create_entry', + 'source': 'user', + 'version': 1, } @@ -197,7 +200,7 @@ def test_two_step_flow(hass, client): set_component( 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine @@ -245,13 +248,15 @@ def async_step_account(self, user_input=None): 'domain': 'test', 'type': 'create_entry', 'title': 'user-title', + 'version': 1, + 'source': 'user', } @asyncio.coroutine def test_get_progress_index(hass, client): """Test querying for the flows that are in progress.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 5 @asyncio.coroutine @@ -283,7 +288,7 @@ def async_step_account(self, user_input=None): @asyncio.coroutine def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): schema = OrderedDict() diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index b4c80bf321088..f3f63654e8b7f 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -5,7 +5,7 @@ import pytest -from homeassistant import config_entries +from homeassistant import data_entry_flow from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.util.dt import utcnow @@ -174,5 +174,5 @@ def discover(netdisco): assert len(m_init.mock_calls) == 1 args, kwargs = m_init.mock_calls[0][1:] assert args == ('mock-component',) - assert kwargs['source'] == config_entries.SOURCE_DISCOVERY + assert kwargs['source'] == data_entry_flow.SOURCE_DISCOVERY assert kwargs['data'] == discovery_info diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b9b39b11c135b..94b1dcb47da93 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3,9 +3,8 @@ from unittest.mock import MagicMock, patch, mock_open import pytest -import voluptuous as vol -from homeassistant import config_entries, loader +from homeassistant import config_entries, loader, data_entry_flow from homeassistant.setup import async_setup_component from tests.common import MockModule, mock_coro, MockConfigEntry @@ -100,7 +99,7 @@ def test_add_entry_calls_setup_entry(hass, manager): 'comp', MockModule('comp', async_setup_entry=mock_setup_entry)) - class TestFlow(config_entries.ConfigFlowHandler): + class TestFlow(data_entry_flow.FlowHandler): VERSION = 1 @@ -112,7 +111,7 @@ def async_step_init(self, user_input=None): 'token': 'supersecret' }) - with patch.dict(config_entries.HANDLERS, {'comp': TestFlow}): + with patch.dict(config_entries.HANDLERS, {'comp': TestFlow, 'beer': 5}): yield from manager.flow.async_init('comp') yield from hass.async_block_till_done() @@ -152,7 +151,7 @@ def test_domains_gets_uniques(manager): @asyncio.coroutine def test_saving_and_loading(hass): """Test that we're saving and loading correctly.""" - class TestFlow(config_entries.ConfigFlowHandler): + class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 @asyncio.coroutine @@ -167,7 +166,7 @@ def async_step_init(self, user_input=None): with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): yield from hass.config_entries.flow.async_init('test') - class Test2Flow(config_entries.ConfigFlowHandler): + class Test2Flow(data_entry_flow.FlowHandler): VERSION = 3 @asyncio.coroutine @@ -212,185 +211,6 @@ def async_step_init(self, user_input=None): assert orig.source == loaded.source -####################### -# FLOW MANAGER TESTS # -####################### - -@asyncio.coroutine -def test_configure_reuses_handler_instance(manager): - """Test that we reuse instances.""" - class TestFlow(config_entries.ConfigFlowHandler): - handle_count = 0 - - @asyncio.coroutine - def async_step_init(self, user_input=None): - self.handle_count += 1 - return self.async_show_form( - errors={'base': str(self.handle_count)}, - step_id='init') - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['errors']['base'] == '1' - form = yield from manager.flow.async_configure(form['flow_id']) - assert form['errors']['base'] == '2' - assert len(manager.flow.async_progress()) == 1 - assert len(manager.async_entries()) == 0 - - -@asyncio.coroutine -def test_configure_two_steps(manager): - """Test that we reuse instances.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 1 - - @asyncio.coroutine - def async_step_init(self, user_input=None): - if user_input is not None: - self.init_data = user_input - return self.async_step_second() - return self.async_show_form( - step_id='init', - data_schema=vol.Schema([str]) - ) - - @asyncio.coroutine - def async_step_second(self, user_input=None): - if user_input is not None: - return self.async_create_entry( - title='Test Entry', - data=self.init_data + user_input - ) - return self.async_show_form( - step_id='second', - data_schema=vol.Schema([str]) - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - - with pytest.raises(vol.Invalid): - form = yield from manager.flow.async_configure( - form['flow_id'], 'INCORRECT-DATA') - - form = yield from manager.flow.async_configure( - form['flow_id'], ['INIT-DATA']) - form = yield from manager.flow.async_configure( - form['flow_id'], ['SECOND-DATA']) - assert form['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - entry = manager.async_entries()[0] - assert entry.domain == 'test' - assert entry.data == ['INIT-DATA', 'SECOND-DATA'] - - -@asyncio.coroutine -def test_show_form(manager): - """Test that abort removes the flow from progress.""" - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str - }) - - class TestFlow(config_entries.ConfigFlowHandler): - @asyncio.coroutine - def async_step_init(self, user_input=None): - return self.async_show_form( - step_id='init', - data_schema=schema, - errors={ - 'username': 'Should be unique.' - } - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['type'] == 'form' - assert form['data_schema'] is schema - assert form['errors'] == { - 'username': 'Should be unique.' - } - - -@asyncio.coroutine -def test_abort_removes_instance(manager): - """Test that abort removes the flow from progress.""" - class TestFlow(config_entries.ConfigFlowHandler): - is_new = True - - @asyncio.coroutine - def async_step_init(self, user_input=None): - old = self.is_new - self.is_new = False - return self.async_abort(reason=str(old)) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['reason'] == 'True' - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 0 - form = yield from manager.flow.async_init('test') - assert form['reason'] == 'True' - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 0 - - -@asyncio.coroutine -def test_create_saves_data(manager): - """Test creating a config entry.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 5 - - @asyncio.coroutine - def async_step_init(self, user_input=None): - return self.async_create_entry( - title='Test Title', - data='Test Data' - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from manager.flow.async_init('test') - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - - entry = manager.async_entries()[0] - assert entry.version == 5 - assert entry.domain == 'test' - assert entry.title == 'Test Title' - assert entry.data == 'Test Data' - assert entry.source == config_entries.SOURCE_USER - - -@asyncio.coroutine -def test_discovery_init_flow(manager): - """Test a flow initialized by discovery.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 5 - - @asyncio.coroutine - def async_step_discovery(self, info): - return self.async_create_entry(title=info['id'], data=info) - - data = { - 'id': 'hello', - 'token': 'secret' - } - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from manager.flow.async_init( - 'test', source=config_entries.SOURCE_DISCOVERY, data=data) - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - - entry = manager.async_entries()[0] - assert entry.version == 5 - assert entry.domain == 'test' - assert entry.title == 'hello' - assert entry.data == data - assert entry.source == config_entries.SOURCE_DISCOVERY - - async def test_forward_entry_sets_up_component(hass): """Test we setup the component entry is forwarded to.""" entry = MockConfigEntry(domain='original') diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py new file mode 100644 index 0000000000000..f70678711741d --- /dev/null +++ b/tests/test_data_entry_flow.py @@ -0,0 +1,186 @@ +"""Test the flow classes.""" +import pytest +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.util.decorator import Registry + +from tests.common import mock_coro + + +@pytest.fixture +def manager(): + """Return a flow manager.""" + handlers = Registry() + entries = [] + + async def async_add_entry(result): + entries.append(result) + + manager = data_entry_flow.FlowManager( + None, handlers, mock_coro, async_add_entry) + manager.mock_created_entries = entries + manager.mock_reg_handler = handlers.register + return manager + + +async def test_configure_reuses_handler_instance(manager): + """Test that we reuse instances.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + handle_count = 0 + + async def async_step_init(self, user_input=None): + self.handle_count += 1 + return self.async_show_form( + errors={'base': str(self.handle_count)}, + step_id='init') + + form = await manager.async_init('test') + assert form['errors']['base'] == '1' + form = await manager.async_configure(form['flow_id']) + assert form['errors']['base'] == '2' + assert len(manager.async_progress()) == 1 + assert len(manager.mock_created_entries) == 0 + + +async def test_configure_two_steps(manager): + """Test that we reuse instances.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, user_input=None): + if user_input is not None: + self.init_data = user_input + return await self.async_step_second() + return self.async_show_form( + step_id='init', + data_schema=vol.Schema([str]) + ) + + async def async_step_second(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title='Test Entry', + data=self.init_data + user_input + ) + return self.async_show_form( + step_id='second', + data_schema=vol.Schema([str]) + ) + + form = await manager.async_init('test') + + with pytest.raises(vol.Invalid): + form = await manager.async_configure( + form['flow_id'], 'INCORRECT-DATA') + + form = await manager.async_configure( + form['flow_id'], ['INIT-DATA']) + form = await manager.async_configure( + form['flow_id'], ['SECOND-DATA']) + assert form['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + result = manager.mock_created_entries[0] + assert result['domain'] == 'test' + assert result['data'] == ['INIT-DATA', 'SECOND-DATA'] + + +async def test_show_form(manager): + """Test that abort removes the flow from progress.""" + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str + }) + + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id='init', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + form = await manager.async_init('test') + assert form['type'] == 'form' + assert form['data_schema'] is schema + assert form['errors'] == { + 'username': 'Should be unique.' + } + + +async def test_abort_removes_instance(manager): + """Test that abort removes the flow from progress.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + is_new = True + + async def async_step_init(self, user_input=None): + old = self.is_new + self.is_new = False + return self.async_abort(reason=str(old)) + + form = await manager.async_init('test') + assert form['reason'] == 'True' + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + form = await manager.async_init('test') + assert form['reason'] == 'True' + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + +async def test_create_saves_data(manager): + """Test creating a config entry.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data='Test Data' + ) + + await manager.async_init('test') + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry['version'] == 5 + assert entry['domain'] == 'test' + assert entry['title'] == 'Test Title' + assert entry['data'] == 'Test Data' + assert entry['source'] == data_entry_flow.SOURCE_USER + + +async def test_discovery_init_flow(manager): + """Test a flow initialized by discovery.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_discovery(self, info): + return self.async_create_entry(title=info['id'], data=info) + + data = { + 'id': 'hello', + 'token': 'secret' + } + + await manager.async_init( + 'test', source=data_entry_flow.SOURCE_DISCOVERY, data=data) + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry['version'] == 5 + assert entry['domain'] == 'test' + assert entry['title'] == 'hello' + assert entry['data'] == data + assert entry['source'] == data_entry_flow.SOURCE_DISCOVERY From ac2298189e9ba4b65fe310cb8ce61f289cd678de Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Fri, 13 Apr 2018 10:25:35 -0700 Subject: [PATCH 058/155] Add support for controlling homekit lights and switches (#13346) * Add support for controlling homekit lights and switches This adds support for controlling lights and switches that expose a HomeKit control interface, avoiding the requirement to implement protocol-specific components. * Comment out the homekit requirement This needs to build native code, so leave it commented for now * Review updates * Make HomeKit auto-discovery optional Add an "enable" argument to the discovery component and add a list of optional devices types (currently just HomeKit) to discover * Further review comments * Update requirements_all.txt * Fix houndci complaints * Further review updates * Final review fixup * Lint fixups * Fix discovery tests * Further review updates --- .coveragerc | 3 + homeassistant/components/discovery.py | 16 +- .../components/homekit_controller/__init__.py | 228 ++++++++++++++++++ .../components/light/homekit_controller.py | 134 ++++++++++ .../components/switch/homekit_controller.py | 68 ++++++ requirements_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/test_discovery.py | 3 +- 8 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homekit_controller/__init__.py create mode 100644 homeassistant/components/light/homekit_controller.py create mode 100644 homeassistant/components/switch/homekit_controller.py diff --git a/.coveragerc b/.coveragerc index 2b733dd699fa8..3009eed24f001 100644 --- a/.coveragerc +++ b/.coveragerc @@ -109,6 +109,9 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py + homeassistant/components/homekit_controller/__init__.py + homeassistant/components/*/homekit_controller.py + homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 693cd3d90f1c7..7a343018db55d 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -40,6 +40,7 @@ SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' +SERVICE_HOMEKIT = 'homekit' CONFIG_ENTRY_HANDLERS = { SERVICE_HUE: 'hue', @@ -79,13 +80,20 @@ 'songpal': ('media_player', 'songpal'), } +OPTIONAL_SERVICE_HANDLERS = { + SERVICE_HOMEKIT: ('homekit_controller', None), +} + CONF_IGNORE = 'ignore' +CONF_ENABLE = 'enable' CONFIG_SCHEMA = vol.Schema({ vol.Required(DOMAIN): vol.Schema({ vol.Optional(CONF_IGNORE, default=[]): vol.All(cv.ensure_list, [ - vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]) + vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]), + vol.Optional(CONF_ENABLE, default=[]): + vol.All(cv.ensure_list, [vol.In(OPTIONAL_SERVICE_HANDLERS)]) }), }, extra=vol.ALLOW_EXTRA) @@ -104,6 +112,9 @@ async def async_setup(hass, config): # Platforms ignore by config ignored_platforms = config[DOMAIN][CONF_IGNORE] + # Optional platforms enabled by config + enabled_platforms = config[DOMAIN][CONF_ENABLE] + async def new_service_found(service, info): """Handle a new service if one is found.""" if service in ignored_platforms: @@ -126,6 +137,9 @@ async def new_service_found(service, info): comp_plat = SERVICE_HANDLERS.get(service) + if not comp_plat and service in enabled_platforms: + comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + # We do not know how to handle this service. if not comp_plat: logger.info("Unknown service discovered: %s %s", service, info) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py new file mode 100644 index 0000000000000..c33edd079188e --- /dev/null +++ b/homeassistant/components/homekit_controller/__init__.py @@ -0,0 +1,228 @@ +""" +Support for Homekit device discovery. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homekit_controller/ +""" +import http +import json +import logging +import os +import uuid + +from homeassistant.components.discovery import SERVICE_HOMEKIT +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['homekit==0.5'] + +DOMAIN = 'homekit_controller' +HOMEKIT_DIR = '.homekit' + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + 'lightbulb': 'light', + 'outlet': 'switch', +} + +KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) +KNOWN_DEVICES = "{}-devices".format(DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +def homekit_http_send(self, message_body=None): + r"""Send the currently buffered request and clear the buffer. + + Appends an extra \r\n to the buffer. + A message_body may be specified, to be appended to the request. + """ + self._buffer.extend((b"", b"")) + msg = b"\r\n".join(self._buffer) + del self._buffer[:] + + if message_body is not None: + msg = msg + message_body + + self.send(msg) + + +def get_serial(accessory): + """Obtain the serial number of a HomeKit device.""" + # pylint: disable=import-error + import homekit + for service in accessory['services']: + if homekit.ServicesTypes.get_short(service['type']) != \ + 'accessory-information': + continue + for characteristic in service['characteristics']: + ctype = homekit.CharacteristicsTypes.get_short( + characteristic['type']) + if ctype != 'serial-number': + continue + return characteristic['value'] + return None + + +class HKDevice(): + """HomeKit device.""" + + def __init__(self, hass, host, port, model, hkid, config_num, config): + """Initialise a generic HomeKit device.""" + # pylint: disable=import-error + import homekit + + _LOGGER.info("Setting up Homekit device %s", model) + self.hass = hass + self.host = host + self.port = port + self.model = model + self.hkid = hkid + self.config_num = config_num + self.config = config + self.configurator = hass.components.configurator + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + if not os.path.isdir(data_dir): + os.mkdir(data_dir) + + self.pairing_file = os.path.join(data_dir, 'hk-{}'.format(hkid)) + self.pairing_data = homekit.load_pairing(self.pairing_file) + + # Monkey patch httpclient for increased compatibility + # pylint: disable=protected-access + http.client.HTTPConnection._send_output = homekit_http_send + + self.conn = http.client.HTTPConnection(self.host, port=self.port) + if self.pairing_data is not None: + self.accessory_setup() + else: + self.configure() + + def accessory_setup(self): + """Handle setup of a HomeKit accessory.""" + # pylint: disable=import-error + import homekit + self.controllerkey, self.accessorykey = \ + homekit.get_session_keys(self.conn, self.pairing_data) + self.securecon = homekit.SecureHttp(self.conn.sock, + self.accessorykey, + self.controllerkey) + response = self.securecon.get('/accessories') + data = json.loads(response.read().decode()) + for accessory in data['accessories']: + serial = get_serial(accessory) + if serial in self.hass.data[KNOWN_ACCESSORIES]: + continue + self.hass.data[KNOWN_ACCESSORIES][serial] = self + aid = accessory['aid'] + for service in accessory['services']: + service_info = {'serial': serial, + 'aid': aid, + 'iid': service['iid']} + devtype = homekit.ServicesTypes.get_short(service['type']) + _LOGGER.debug("Found %s", devtype) + component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) + if component is not None: + discovery.load_platform(self.hass, component, DOMAIN, + service_info, self.config) + + def device_config_callback(self, callback_data): + """Handle initial pairing.""" + # pylint: disable=import-error + import homekit + pairing_id = str(uuid.uuid4()) + code = callback_data.get('code').strip() + self.pairing_data = homekit.perform_pair_setup( + self.conn, code, pairing_id) + if self.pairing_data is not None: + homekit.save_pairing(self.pairing_file, self.pairing_data) + self.accessory_setup() + else: + error_msg = "Unable to pair, please try again" + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + + def configure(self): + """Obtain the pairing code for a HomeKit device.""" + description = "Please enter the HomeKit code for your {}".format( + self.model) + self.hass.data[DOMAIN+self.hkid] = \ + self.configurator.request_config(self.model, + self.device_config_callback, + description=description, + submit_caption="submit", + fields=[{'id': 'code', + 'name': 'HomeKit code', + 'type': 'string'}]) + + +class HomeKitEntity(Entity): + """Representation of a Home Assistant HomeKit device.""" + + def __init__(self, accessory, devinfo): + """Initialise a generic HomeKit device.""" + self._name = accessory.model + self._securecon = accessory.securecon + self._aid = devinfo['aid'] + self._iid = devinfo['iid'] + self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid) + self._features = 0 + self._chars = {} + + def update(self): + """Obtain a HomeKit device's state.""" + response = self._securecon.get('/accessories') + data = json.loads(response.read().decode()) + for accessory in data['accessories']: + if accessory['aid'] != self._aid: + continue + for service in accessory['services']: + if service['iid'] != self._iid: + continue + self.update_characteristics(service['characteristics']) + break + + @property + def unique_id(self): + """Return the ID of this device.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + def update_characteristics(self, characteristics): + """Synchronise a HomeKit device state with Home Assistant.""" + raise NotImplementedError + + +# pylint: too-many-function-args +def setup(hass, config): + """Set up for Homekit devices.""" + def discovery_dispatch(service, discovery_info): + """Dispatcher for Homekit discovery events.""" + # model, id + host = discovery_info['host'] + port = discovery_info['port'] + model = discovery_info['properties']['md'] + hkid = discovery_info['properties']['id'] + config_num = int(discovery_info['properties']['c#']) + + # Only register a device once, but rescan if the config has changed + if hkid in hass.data[KNOWN_DEVICES]: + device = hass.data[KNOWN_DEVICES][hkid] + if config_num > device.config_num and \ + device.pairing_info is not None: + device.accessory_setup() + return + + _LOGGER.debug('Discovered unique device %s', hkid) + device = HKDevice(hass, host, port, model, hkid, config_num, config) + hass.data[KNOWN_DEVICES][hkid] = device + + hass.data[KNOWN_ACCESSORIES] = {} + hass.data[KNOWN_DEVICES] = {} + discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch) + return True diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py new file mode 100644 index 0000000000000..e6dc09e455cb2 --- /dev/null +++ b/homeassistant/components/light/homekit_controller.py @@ -0,0 +1,134 @@ +""" +Support for Homekit lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.homekit_controller/ +""" +import json +import logging + +from homeassistant.components.homekit_controller import ( + HomeKitEntity, KNOWN_ACCESSORIES) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit lighting.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitLight(accessory, discovery_info)], True) + + +class HomeKitLight(HomeKitEntity, Light): + """Representation of a Homekit light.""" + + def __init__(self, *args): + """Initialise the light.""" + super().__init__(*args) + self._on = None + self._brightness = None + self._color_temperature = None + self._hue = None + self._saturation = None + + def update_characteristics(self, characteristics): + """Synchronise light state with Home Assistant.""" + # pylint: disable=import-error + import homekit + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = homekit.CharacteristicsTypes.get_short(ctype) + if ctype == "on": + self._chars['on'] = characteristic['iid'] + self._on = characteristic['value'] + elif ctype == 'brightness': + self._chars['brightness'] = characteristic['iid'] + self._features |= SUPPORT_BRIGHTNESS + self._brightness = characteristic['value'] + elif ctype == 'color-temperature': + self._chars['color_temperature'] = characteristic['iid'] + self._features |= SUPPORT_COLOR_TEMP + self._color_temperature = characteristic['value'] + elif ctype == "hue": + self._chars['hue'] = characteristic['iid'] + self._features |= SUPPORT_COLOR + self._hue = characteristic['value'] + elif ctype == "saturation": + self._chars['saturation'] = characteristic['iid'] + self._features |= SUPPORT_COLOR + self._saturation = characteristic['value'] + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._features & SUPPORT_BRIGHTNESS: + return self._brightness * 255 / 100 + return None + + @property + def hs_color(self): + """Return the color property.""" + if self._features & SUPPORT_COLOR: + return (self._hue, self._saturation) + return None + + @property + def color_temp(self): + """Return the color temperature.""" + if self._features & SUPPORT_COLOR_TEMP: + return self._color_temperature + return None + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + hs_color = kwargs.get(ATTR_HS_COLOR) + temperature = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + + characteristics = [] + if hs_color is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['hue'], + 'value': hs_color[0]}) + characteristics.append({'aid': self._aid, + 'iid': self._chars['saturation'], + 'value': hs_color[1]}) + if brightness is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['brightness'], + 'value': int(brightness * 100 / 255)}) + + if temperature is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['color-temperature'], + 'value': int(temperature)}) + characteristics.append({'aid': self._aid, + 'iid': self._chars['on'], + 'value': True}) + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': False}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py new file mode 100644 index 0000000000000..6b97200ba499a --- /dev/null +++ b/homeassistant/components/switch/homekit_controller.py @@ -0,0 +1,68 @@ +""" +Support for Homekit switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.homekit_controller/ +""" +import json +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit switch support.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitSwitch(accessory, discovery_info)], True) + + +class HomeKitSwitch(HomeKitEntity, SwitchDevice): + """Representation of a Homekit switch.""" + + def __init__(self, *args): + """Initialise the switch.""" + super().__init__(*args) + self._on = None + + def update_characteristics(self, characteristics): + """Synchronise the switch state with Home Assistant.""" + # pylint: disable=import-error + import homekit + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = homekit.CharacteristicsTypes.get_short(ctype) + if ctype == "on": + self._chars['on'] = characteristic['iid'] + self._on = characteristic['value'] + elif ctype == "outlet-in-use": + self._chars['outlet-in-use'] = characteristic['iid'] + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + self._on = True + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': True}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': False}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) diff --git a/requirements_all.txt b/requirements_all.txt index d26f8717384cd..290f538f4a117 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,6 +381,9 @@ holidays==0.9.4 # homeassistant.components.frontend home-assistant-frontend==20180404.0 +# homeassistant.components.homekit_controller +# homekit==0.5 + # homeassistant.components.homematicip_cloud homematicip==0.8 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 27b972dcefac9..f15425063b47f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,6 +33,7 @@ 'i2csense', 'credstash', 'bme680', + 'homekit', ) TEST_REQUIREMENTS = ( diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index f3f63654e8b7f..a956b672ec532 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -25,7 +25,8 @@ BASE_CONFIG = { discovery.DOMAIN: { - 'ignore': [] + 'ignore': [], + 'enable': [] } } From 0daf38d18c2171455865ca4d3fc7e14fb3449d62 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Apr 2018 18:02:51 -0400 Subject: [PATCH 059/155] Version bump to 0.68.0.dev0 --- homeassistant/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5364fe6951e54..43380d00a2d88 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 67 -PATCH_VERSION = '0' +MINOR_VERSION = 68 +PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From c6c166645d56d7d94380ddf0f51b3bf858b69c22 Mon Sep 17 00:00:00 2001 From: geekofweek Date: Fri, 13 Apr 2018 20:36:46 -0500 Subject: [PATCH 060/155] bump python-ecobee-api version to 0.0.18 (#13854) * bump python-ecobee-api version to 0.0.18 * Update requirements_all.txt --- homeassistant/components/ecobee.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index d1503dc74dc0f..9c29cea704c32 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -16,7 +16,7 @@ from homeassistant.util import Throttle from homeassistant.util.json import save_json -REQUIREMENTS = ['python-ecobee-api==0.0.17'] +REQUIREMENTS = ['python-ecobee-api==0.0.18'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 290f538f4a117..0729b0e39d91d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -942,7 +942,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.0.17 +python-ecobee-api==0.0.18 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.9 From 99ded8a0a662513ab0c50f9a3f000369c6a190da Mon Sep 17 00:00:00 2001 From: Mohamad Tarbin Date: Fri, 13 Apr 2018 21:54:23 -0400 Subject: [PATCH 061/155] Adding USCIS component (#13764) * Adding USCIS component * Adding Line after the class DOC * Update : Extract USCIS logic code to Component * Update : Extract USCIS logic code to Component * Adding CURRENT_STATUS * Change Error handling, remove date from attributes * Update the Version for USCIS * Update uscis.py --- .coveragerc | 1 + homeassistant/components/sensor/uscis.py | 87 ++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 91 insertions(+) create mode 100644 homeassistant/components/sensor/uscis.py diff --git a/.coveragerc b/.coveragerc index 3009eed24f001..70cfbded98fd0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -675,6 +675,7 @@ omit = homeassistant/components/sensor/uber.py homeassistant/components/sensor/upnp.py homeassistant/components/sensor/ups.py + homeassistant/components/sensor/uscis.py homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/viaggiatreno.py homeassistant/components/sensor/waqi.py diff --git a/homeassistant/components/sensor/uscis.py b/homeassistant/components/sensor/uscis.py new file mode 100644 index 0000000000000..ed3c9ca858755 --- /dev/null +++ b/homeassistant/components/sensor/uscis.py @@ -0,0 +1,87 @@ +""" +Support for USCIS Case Status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.uscis/ +""" + +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_FRIENDLY_NAME + + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['uscisstatus==0.1.1'] + +DEFAULT_NAME = "USCIS" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, + vol.Required('case_id'): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setting the platform in HASS and Case Information.""" + uscis = UscisSensor(config['case_id'], config[CONF_FRIENDLY_NAME]) + uscis.update() + if uscis.valid_case_id: + add_devices([uscis]) + else: + _LOGGER.error("Setup USCIS Sensor Fail" + " check if your Case ID is Valid") + + +class UscisSensor(Entity): + """USCIS Sensor will check case status on daily basis.""" + + MIN_TIME_BETWEEN_UPDATES = timedelta(hours=24) + + CURRENT_STATUS = "current_status" + LAST_CASE_UPDATE = "last_update_date" + + def __init__(self, case, name): + """Initialize the sensor.""" + self._state = None + self._case_id = case + self._attributes = None + self.valid_case_id = None + self._name = name + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Using Request to access USCIS website and fetch data.""" + import uscisstatus + try: + status = uscisstatus.get_case_status(self._case_id) + self._attributes = { + self.CURRENT_STATUS: status['status'] + } + self._state = status['date'] + self.valid_case_id = True + + except ValueError: + _LOGGER("Please Check that you have valid USCIS case id") + self.valid_case_id = False diff --git a/requirements_all.txt b/requirements_all.txt index 0729b0e39d91d..90420444feb2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1268,6 +1268,9 @@ upcloud-api==0.4.2 # homeassistant.components.sensor.ups upsmychoice==1.0.6 +# homeassistant.components.sensor.uscis +uscisstatus==0.1.1 + # homeassistant.components.camera.uvc uvcclient==0.10.1 From 80a3220b88f0febc0a060b656c44c212dd973b19 Mon Sep 17 00:00:00 2001 From: dersger Date: Sat, 14 Apr 2018 04:22:02 +0200 Subject: [PATCH 062/155] Avoid unnecessary cast state updates (#13770) * Avoid unnecessary cast state updates * Add test * Fixed bad syntax * Fixed imports * Fixed test --- homeassistant/components/media_player/cast.py | 46 +++++++--- tests/components/media_player/test_cast.py | 85 ++++++++++++++++++- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 2edda0645b023..30d4bd166d0ca 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -288,7 +288,8 @@ def __init__(self, cast_info): self._chromecast = None # type: Optional[pychromecast.Chromecast] self.cast_status = None self.media_status = None - self.media_status_received = None + self.media_status_position = None + self.media_status_position_received = None self._available = False # type: bool self._status_listener = None # type: Optional[CastStatusListener] @@ -361,7 +362,8 @@ def _async_disconnect(self): self._chromecast = None self.cast_status = None self.media_status = None - self.media_status_received = None + self.media_status_position = None + self.media_status_position_received = None self._status_listener.invalidate() self._status_listener = None @@ -388,8 +390,36 @@ def new_cast_status(self, cast_status): def new_media_status(self, media_status): """Handle updates of the media status.""" + # Only use media position for playing/paused, + # and for normal playback rate + if (media_status is None or + abs(media_status.playback_rate - 1) > 0.01 or + not (media_status.player_is_playing or + media_status.player_is_paused)): + self.media_status_position = None + self.media_status_position_received = None + else: + # Avoid unnecessary state attribute updates if player_state and + # calculated position stay the same + now = dt_util.utcnow() + do_update = \ + (self.media_status is None or + self.media_status_position is None or + self.media_status.player_state != media_status.player_state) + if not do_update: + if media_status.player_is_playing: + elapsed = now - self.media_status_position_received + do_update = abs(media_status.current_time - + (self.media_status_position + + elapsed.total_seconds())) > 1 + else: + do_update = \ + self.media_status_position != media_status.current_time + if do_update: + self.media_status_position = media_status.current_time + self.media_status_position_received = now + self.media_status = media_status - self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() def new_connection_status(self, connection_status): @@ -595,13 +625,7 @@ def supported_features(self): @property def media_position(self): """Position of current playing media in seconds.""" - if self.media_status is None or \ - not (self.media_status.player_is_playing or - self.media_status.player_is_paused or - self.media_status.player_is_idle): - return None - - return self.media_status.current_time + return self.media_status_position @property def media_position_updated_at(self): @@ -609,7 +633,7 @@ def media_position_updated_at(self): Returns value from homeassistant.util.dt.utcnow(). """ - return self.media_status_received + return self.media_status_position_received @property def unique_id(self) -> Optional[str]: diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index ee69ec1c85d37..0c0f3906dc2ba 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,6 +1,7 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access import asyncio +import datetime as dt from typing import Optional from unittest.mock import patch, MagicMock, Mock from uuid import UUID @@ -14,7 +15,8 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ async_dispatcher_send -from homeassistant.components.media_player import cast +from homeassistant.components.media_player import cast, \ + ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT from homeassistant.setup import async_setup_component @@ -286,6 +288,8 @@ async def test_entity_media_states(hass: HomeAssistantType): assert entity.unique_id == full_info.uuid media_status = MagicMock(images=None) + media_status.current_time = 0 + media_status.playback_rate = 1 media_status.player_is_playing = True entity.new_media_status(media_status) await hass.async_block_till_done() @@ -320,6 +324,85 @@ async def test_entity_media_states(hass: HomeAssistantType): assert state.state == 'unknown' +async def test_entity_media_position(hass: HomeAssistantType): + """Test various entity media states.""" + info = get_fake_chromecast_info() + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) + + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + chromecast, entity = await async_setup_media_player_cast(hass, info) + + media_status = MagicMock(images=None) + media_status.current_time = 10 + media_status.playback_rate = 1 + media_status.player_is_playing = True + media_status.player_is_paused = False + media_status.player_is_idle = False + now = dt.datetime.now(dt.timezone.utc) + with patch('homeassistant.util.dt.utcnow', return_value=now): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 10 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now + + media_status.current_time = 15 + now_plus_5 = now + dt.timedelta(seconds=5) + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_5): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 10 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now + + media_status.current_time = 20 + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_5): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 20 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_5 + + media_status.current_time = 25 + now_plus_10 = now + dt.timedelta(seconds=10) + media_status.player_is_playing = False + media_status.player_is_paused = True + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_10): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 25 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_10 + + now_plus_15 = now + dt.timedelta(seconds=15) + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_15): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 25 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_10 + + media_status.current_time = 30 + now_plus_20 = now + dt.timedelta(seconds=20) + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_20): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 30 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_20 + + media_status.player_is_paused = False + media_status.player_is_idle = True + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_20): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert ATTR_MEDIA_POSITION not in state.attributes + assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes + + async def test_switched_host(hass: HomeAssistantType): """Test cast device listens for changed hosts and disconnects old cast.""" info = get_fake_chromecast_info() From ee6acadae20507afa6ed13523ea674610b43e79d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 14 Apr 2018 04:31:03 -0400 Subject: [PATCH 063/155] Prevent vesync doing I/O in event loop (#13862) --- homeassistant/components/switch/vesync.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/vesync.py b/homeassistant/components/switch/vesync.py index fbc73545e19f1..d8579a508e20f 100644 --- a/homeassistant/components/switch/vesync.py +++ b/homeassistant/components/switch/vesync.py @@ -60,6 +60,8 @@ class VeSyncSwitchHA(SwitchDevice): def __init__(self, plug): """Initialize the VeSync switch device.""" self.smartplug = plug + self._current_power_w = None + self._today_energy_kwh = None @property def unique_id(self): @@ -74,12 +76,12 @@ def name(self): @property def current_power_w(self): """Return the current power usage in W.""" - return self.smartplug.get_power() + return self._current_power_w @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" - return self.smartplug.get_kwh_today() + return self._today_energy_kwh @property def available(self) -> bool: @@ -102,3 +104,5 @@ def turn_off(self, **kwargs): def update(self): """Handle data changes for node values.""" self.smartplug.update() + self._current_power_w = self.smartplug.get_power() + self._today_energy_kwh = self.smartplug.get_kwh_today() From c3388d63a1929a6c197644d1ae1f622c4d58c793 Mon Sep 17 00:00:00 2001 From: TheCellMC Date: Sat, 14 Apr 2018 08:32:44 +0000 Subject: [PATCH 064/155] Update yweather.py (#13851) * Update yweather.py * Update yweather.py * Update yweather.py * Update yweather.py --- homeassistant/components/weather/yweather.py | 31 ++++++++++++-------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 5987cf7621f85..8e638895660dc 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -32,20 +32,27 @@ SCAN_INTERVAL = timedelta(minutes=10) CONDITION_CLASSES = { - 'clear-night': [31], - 'cloudy': [26, 27, 28, 29, 30], + 'clear-night': [31, 33], + 'sunny': [32, 34, 25, 36], + 'windy': [24], + 'fair': [34], + 'fair-night': [33], + 'cloudy': [26], + 'mostly-cloudy': [28], + 'mostly-cloudy-night': [27], + 'partly-cloudy': [30, 44], + 'partly-cloudy-night': [29], 'fog': [19, 20, 21, 22, 23], 'hail': [17, 18, 35], - 'lightning': [37], - 'lightning-rainy': [3, 4, 38, 39, 47], - 'partlycloudy': [44], - 'pouring': [40, 45], - 'rainy': [9, 11, 12], - 'snowy': [8, 13, 14, 15, 16, 41, 42, 43], - 'snowy-rainy': [5, 6, 7, 10, 46], - 'sunny': [32, 33, 34, 25, 36], - 'windy': [24], - 'windy-variant': [], + 'light-rain': [8, 9], + 'light-snow': [14], + 'heavy-rain': [11, 12, 45, 40], + 'heavy-snow': [41, 42, 43, 46], + 'snowy': [13, 15, 16], + 'rainy': [10], + 'snowy-rainy': [5, 6, 7, 10], + 'lightning': [3, 4, 37, 38, 39], + 'lightning-rainy': [45, 47], 'exceptional': [0, 1, 2], } From 5a5dad689b3421522b8cfa473c111d155eb2ecb6 Mon Sep 17 00:00:00 2001 From: escoand Date: Sat, 14 Apr 2018 14:31:12 +0200 Subject: [PATCH 065/155] add support for Kodi discovery (#13790) * add support for Kodi discovery * remove "too many blank lines" * register service only once * optimize "workflow" --- homeassistant/components/discovery.py | 1 + homeassistant/components/media_player/kodi.py | 38 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 7a343018db55d..31ec3f2f60a8f 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -78,6 +78,7 @@ 'bose_soundtouch': ('media_player', 'soundtouch'), 'bluesound': ('media_player', 'bluesound'), 'songpal': ('media_player', 'songpal'), + 'kodi': ('media_player', 'kodi'), } OPTIONAL_SERVICE_HANDLERS = { diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 9f2a653b8eece..770d57b5b8e49 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -8,6 +8,7 @@ from collections import OrderedDict from functools import wraps import logging +import socket import urllib import re @@ -157,13 +158,29 @@ def _check_deprecated_turn_off(hass, turn_off_action): def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Kodi platform.""" if DATA_KODI not in hass.data: - hass.data[DATA_KODI] = [] - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - tcp_port = config.get(CONF_TCP_PORT) - encryption = config.get(CONF_PROXY_SSL) - websocket = config.get(CONF_ENABLE_WEBSOCKET) + hass.data[DATA_KODI] = dict() + + # Is this a manual configuration? + if discovery_info is None: + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + tcp_port = config.get(CONF_TCP_PORT) + encryption = config.get(CONF_PROXY_SSL) + websocket = config.get(CONF_ENABLE_WEBSOCKET) + else: + name = "{} ({})".format(DEFAULT_NAME, discovery_info.get('hostname')) + host = discovery_info.get('host') + port = discovery_info.get('port') + tcp_port = DEFAULT_TCP_PORT + encryption = DEFAULT_PROXY_SSL + websocket = DEFAULT_ENABLE_WEBSOCKET + + # Only add a device once, so discovered devices do not override manual + # config. + ip_addr = socket.gethostbyname(host) + if ip_addr in hass.data[DATA_KODI]: + return entity = KodiDevice( hass, @@ -175,7 +192,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): turn_off_action=config.get(CONF_TURN_OFF_ACTION), timeout=config.get(CONF_TIMEOUT), websocket=websocket) - hass.data[DATA_KODI].append(entity) + hass.data[DATA_KODI][ip_addr] = entity async_add_devices([entity], update_before_add=True) @asyncio.coroutine @@ -189,10 +206,11 @@ def async_service_handler(service): if key != 'entity_id'} entity_ids = service.data.get('entity_id') if entity_ids: - target_players = [player for player in hass.data[DATA_KODI] + target_players = [player + for player in hass.data[DATA_KODI].values() if player.entity_id in entity_ids] else: - target_players = hass.data[DATA_KODI] + target_players = hass.data[DATA_KODI].values() update_tasks = [] for player in target_players: From 4d44c0feff03ddf1c0c57bd130cd1bdfa8c6621a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 14 Apr 2018 14:38:24 -0400 Subject: [PATCH 066/155] Further untangle data entry flow (#13855) * Further untangle data entry flow * Fix test * Remove helper class --- homeassistant/config_entries.py | 27 ++++++++------ homeassistant/data_entry_flow.py | 35 +++++++------------ .../components/config/test_config_entries.py | 12 +++---- tests/components/test_discovery.py | 2 +- tests/test_data_entry_flow.py | 18 ++++++---- 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d06bf8f1f8fd1..e143c94197e5b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -115,9 +115,9 @@ async def async_step_discovery(info): import os import uuid +from . import data_entry_flow from .core import callback from .exceptions import HomeAssistantError -from .data_entry_flow import FlowManager from .setup import async_setup_component, async_process_deps_reqs from .util.json import load_json, save_json from .util.decorator import Registry @@ -255,8 +255,8 @@ class ConfigEntries: def __init__(self, hass, hass_config): """Initialize the entry manager.""" self.hass = hass - self.flow = FlowManager(hass, HANDLERS, self._async_missing_handler, - self._async_save_entry) + self.flow = data_entry_flow.FlowManager( + hass, self._async_create_flow, self._async_save_entry) self._hass_config = hass_config self._entries = None self._sched_save = None @@ -345,7 +345,7 @@ async def _async_save_entry(self, result): """Add an entry.""" entry = ConfigEntry( version=result['version'], - domain=result['domain'], + domain=result['handler'], title=result['title'], data=result['data'], source=result['source'], @@ -362,17 +362,22 @@ async def _async_save_entry(self, result): await async_setup_component( self.hass, entry.domain, self._hass_config) - async def _async_missing_handler(self, domain): - """Called when a flow handler is not loaded.""" - # This will load the component and thus register the handler - component = getattr(self.hass.components, domain) + async def _async_create_flow(self, handler): + """Create a flow for specified handler. - if domain not in HANDLERS: - return + Handler key is the domain of the component that we want to setup. + """ + component = getattr(self.hass.components, handler) + handler = HANDLERS.get(handler) + + if handler is None: + raise data_entry_flow.UnknownHandler # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs( - self.hass, self._hass_config, domain, component) + self.hass, self._hass_config, handler, component) + + return handler() @callback def _async_schedule_save(self): diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 5644481210c4d..361b6653cfded 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -34,13 +34,11 @@ class UnknownStep(FlowError): class FlowManager: """Manage all the flows that are in progress.""" - def __init__(self, hass, handlers, async_missing_handler, - async_save_entry): + def __init__(self, hass, async_create_flow, async_save_entry): """Initialize the flow manager.""" self.hass = hass - self._handlers = handlers self._progress = {} - self._async_missing_handler = async_missing_handler + self._async_create_flow = async_create_flow self._async_save_entry = async_save_entry @callback @@ -48,27 +46,18 @@ def async_progress(self): """Return the flows in progress.""" return [{ 'flow_id': flow.flow_id, - 'domain': flow.domain, + 'handler': flow.handler, 'source': flow.source, } for flow in self._progress.values()] - async def async_init(self, domain, *, source=SOURCE_USER, data=None): + async def async_init(self, handler, *, source=SOURCE_USER, data=None): """Start a configuration flow.""" - handler = self._handlers.get(domain) - - if handler is None: - await self._async_missing_handler(domain) - handler = self._handlers.get(domain) - - if handler is None: - raise UnknownHandler - - flow_id = uuid.uuid4().hex - flow = self._progress[flow_id] = handler() + flow = await self._async_create_flow(handler) flow.hass = self.hass - flow.domain = domain - flow.flow_id = flow_id + flow.handler = handler + flow.flow_id = uuid.uuid4().hex flow.source = source + self._progress[flow.flow_id] = flow if source == SOURCE_USER: step = 'init' @@ -137,7 +126,7 @@ class FlowHandler: # Set by flow manager flow_id = None hass = None - domain = None + handler = None source = SOURCE_USER cur_step = None @@ -150,7 +139,7 @@ def async_show_form(self, *, step_id, data_schema=None, errors=None): return { 'type': RESULT_TYPE_FORM, 'flow_id': self.flow_id, - 'domain': self.domain, + 'handler': self.handler, 'step_id': step_id, 'data_schema': data_schema, 'errors': errors, @@ -163,7 +152,7 @@ def async_create_entry(self, *, title, data): 'version': self.VERSION, 'type': RESULT_TYPE_CREATE_ENTRY, 'flow_id': self.flow_id, - 'domain': self.domain, + 'handler': self.handler, 'title': title, 'data': data, 'source': self.source, @@ -175,6 +164,6 @@ def async_abort(self, *, reason): return { 'type': RESULT_TYPE_ABORT, 'flow_id': self.flow_id, - 'domain': self.domain, + 'handler': self.handler, 'reason': reason } diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index d649076395119..70cb6c3fbaa25 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -120,7 +120,7 @@ def async_step_init(self, user_input=None): assert data == { 'type': 'form', - 'domain': 'test', + 'handler': 'test', 'step_id': 'init', 'data_schema': [ { @@ -156,7 +156,7 @@ def async_step_init(self, user_input=None): data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'reason': 'bla', 'type': 'abort' } @@ -186,7 +186,7 @@ def async_step_init(self, user_input=None): data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'title': 'Test Entry', 'type': 'create_entry', 'source': 'user', @@ -226,7 +226,7 @@ def async_step_account(self, user_input=None): flow_id = data.pop('flow_id') assert data == { 'type': 'form', - 'domain': 'test', + 'handler': 'test', 'step_id': 'account', 'data_schema': [ { @@ -245,7 +245,7 @@ def async_step_account(self, user_input=None): data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'type': 'create_entry', 'title': 'user-title', 'version': 1, @@ -279,7 +279,7 @@ def async_step_account(self, user_input=None): assert data == [ { 'flow_id': form['flow_id'], - 'domain': 'test', + 'handler': 'test', 'source': 'hassio' } ] diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index a956b672ec532..dd22c87cb1805 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -169,7 +169,7 @@ def discover(netdisco): with patch.dict(discovery.CONFIG_ENTRY_HANDLERS, { 'mock-service': 'mock-component'}), patch( - 'homeassistant.config_entries.FlowManager.async_init') as m_init: + 'homeassistant.data_entry_flow.FlowManager.async_init') as m_init: await mock_discovery(hass, discover) assert len(m_init.mock_calls) == 1 diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index f70678711741d..2767e206c3066 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -5,8 +5,6 @@ from homeassistant import data_entry_flow from homeassistant.util.decorator import Registry -from tests.common import mock_coro - @pytest.fixture def manager(): @@ -14,11 +12,19 @@ def manager(): handlers = Registry() entries = [] + async def async_create_flow(handler_name): + handler = handlers.get(handler_name) + + if handler is None: + raise data_entry_flow.UnknownHandler + + return handler() + async def async_add_entry(result): entries.append(result) manager = data_entry_flow.FlowManager( - None, handlers, mock_coro, async_add_entry) + None, async_create_flow, async_add_entry) manager.mock_created_entries = entries manager.mock_reg_handler = handlers.register return manager @@ -84,7 +90,7 @@ async def async_step_second(self, user_input=None): assert len(manager.async_progress()) == 0 assert len(manager.mock_created_entries) == 1 result = manager.mock_created_entries[0] - assert result['domain'] == 'test' + assert result['handler'] == 'test' assert result['data'] == ['INIT-DATA', 'SECOND-DATA'] @@ -153,7 +159,7 @@ async def async_step_init(self, user_input=None): entry = manager.mock_created_entries[0] assert entry['version'] == 5 - assert entry['domain'] == 'test' + assert entry['handler'] == 'test' assert entry['title'] == 'Test Title' assert entry['data'] == 'Test Data' assert entry['source'] == data_entry_flow.SOURCE_USER @@ -180,7 +186,7 @@ async def async_step_discovery(self, info): entry = manager.mock_created_entries[0] assert entry['version'] == 5 - assert entry['domain'] == 'test' + assert entry['handler'] == 'test' assert entry['title'] == 'hello' assert entry['data'] == data assert entry['source'] == data_entry_flow.SOURCE_DISCOVERY From 1617fbea4c4570bea02d7d94bbe314248a5a5a17 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 14 Apr 2018 14:41:21 -0400 Subject: [PATCH 067/155] Update frontend to 20180414.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3fc3eff0a1485..80b7cdff5a8b4 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180404.0'] +REQUIREMENTS = ['home-assistant-frontend==20180414.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 90420444feb2f..d5036a795fe04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -379,7 +379,7 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180404.0 +home-assistant-frontend==20180414.0 # homeassistant.components.homekit_controller # homekit==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e17cbffe8d653..901074bcad39e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180404.0 +home-assistant-frontend==20180414.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From bf98b793c5cfb163f587047b24cfc55e041adbe9 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 14 Apr 2018 23:53:35 +0200 Subject: [PATCH 068/155] Missing property decorator added (#13889) --- homeassistant/components/fan/xiaomi_miio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 8dc6bb54bd1db..16affc08467bb 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -748,6 +748,7 @@ async def async_update(self): self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + @property def speed_list(self) -> list: """Get the list of available speeds.""" return self._speed_list From bba997e484baaf9a415352a971f8ae91e99a6a27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 14 Apr 2018 17:58:45 -0400 Subject: [PATCH 069/155] Fix race condition for component loaded before listening (#13887) * Fix race condition for component loaded before listening * async/await syntax --- homeassistant/components/config/__init__.py | 49 +++++++++------------ 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 4d0295c382a72..5a8800d9583f2 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -18,37 +18,26 @@ ON_DEMAND = ('zwave',) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the config component.""" - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'mdi:settings') - @asyncio.coroutine - def setup_panel(panel_name): + async def setup_panel(panel_name): """Set up a panel.""" - panel = yield from async_prepare_setup_platform( + panel = await async_prepare_setup_platform( hass, config, DOMAIN, panel_name) if not panel: return - success = yield from panel.async_setup(hass) + success = await panel.async_setup(hass) if success: key = '{}.{}'.format(DOMAIN, panel_name) hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) hass.config.components.add(key) - tasks = [setup_panel(panel_name) for panel_name in SECTIONS] - - for panel_name in ON_DEMAND: - if panel_name in hass.config.components: - tasks.append(setup_panel(panel_name)) - - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - @callback def component_loaded(event): """Respond to components being loaded.""" @@ -58,6 +47,15 @@ def component_loaded(event): hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + + for panel_name in ON_DEMAND: + if panel_name in hass.config.components: + tasks.append(setup_panel(panel_name)) + + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + return True @@ -86,11 +84,10 @@ def _write_value(self, hass, data, config_key, new_value): """Set value.""" raise NotImplementedError - @asyncio.coroutine - def get(self, request, config_key): + async def get(self, request, config_key): """Fetch device specific config.""" hass = request.app['hass'] - current = yield from self.read_config(hass) + current = await self.read_config(hass) value = self._get_value(hass, current, config_key) if value is None: @@ -98,11 +95,10 @@ def get(self, request, config_key): return self.json(value) - @asyncio.coroutine - def post(self, request, config_key): + async def post(self, request, config_key): """Validate config and return results.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON specified', 400) @@ -121,10 +117,10 @@ def post(self, request, config_key): hass = request.app['hass'] path = hass.config.path(self.path) - current = yield from self.read_config(hass) + current = await self.read_config(hass) self._write_value(hass, current, config_key, data) - yield from hass.async_add_job(_write, path, current) + await hass.async_add_job(_write, path, current) if self.post_write_hook is not None: hass.async_add_job(self.post_write_hook(hass)) @@ -133,10 +129,9 @@ def post(self, request, config_key): 'result': 'ok', }) - @asyncio.coroutine - def read_config(self, hass): + async def read_config(self, hass): """Read the config.""" - current = yield from hass.async_add_job( + current = await hass.async_add_job( _read, hass.config.path(self.path)) if not current: current = self._empty_config() From 1c4da0c4a6c3146d4783e09614e151f4eb90cbe8 Mon Sep 17 00:00:00 2001 From: Tod Schmidt Date: Sat, 14 Apr 2018 18:07:55 -0400 Subject: [PATCH 070/155] Added snips service descriptions (#13883) * Added snips service descriptions. * Added snips service descriptions. --- homeassistant/components/services.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 519d3b98704ac..746c3c7f4838f 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -395,6 +395,18 @@ snips: intent_filter: description: Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query. example: turnOnLights, turnOffLights + feedback_on: + description: Turns feedback sounds on. + fields: + site_id: + description: Site to turn sounds on, defaults to all sites (optional) + example: bedroom + feedback_off: + description: Turns feedback sounds off. + fields: + site_id: + description: Site to turn sounds on, defaults to all sites (optional) + example: bedroom input_boolean: toggle: From 9014e2684565066afadd25ea8dc980f63d01a8c4 Mon Sep 17 00:00:00 2001 From: Gerard Date: Sun, 15 Apr 2018 05:15:52 +0200 Subject: [PATCH 071/155] Add unique_id for BMW ConnectedDrive (#13888) * Add unique_id for BMW ConnectedDrive * Changed some comments --- .../components/binary_sensor/bmw_connected_drive.py | 6 ++++++ homeassistant/components/lock/bmw_connected_drive.py | 6 ++++++ homeassistant/components/sensor/bmw_connected_drive.py | 8 +++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py index e7af5af988b99..0abf6eb106434 100644 --- a/homeassistant/components/binary_sensor/bmw_connected_drive.py +++ b/homeassistant/components/binary_sensor/bmw_connected_drive.py @@ -46,6 +46,7 @@ def __init__(self, account, vehicle, attribute: str, sensor_name, self._vehicle = vehicle self._attribute = attribute self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._device_class = device_class self._state = None @@ -55,6 +56,11 @@ def should_poll(self) -> bool: """Data update is triggered from BMWConnectedDriveEntity.""" return False + @property + def unique_id(self): + """Return the unique ID of the binary sensor.""" + return self._unique_id + @property def name(self): """Return the name of the binary sensor.""" diff --git a/homeassistant/components/lock/bmw_connected_drive.py b/homeassistant/components/lock/bmw_connected_drive.py index c992bf1225aa3..52734b1259ca4 100644 --- a/homeassistant/components/lock/bmw_connected_drive.py +++ b/homeassistant/components/lock/bmw_connected_drive.py @@ -38,6 +38,7 @@ def __init__(self, account, vehicle, attribute: str, sensor_name): self._vehicle = vehicle self._attribute = attribute self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._state = None @@ -49,6 +50,11 @@ def should_poll(self): """ return False + @property + def unique_id(self): + """Return the unique ID of the lock.""" + return self._unique_id + @property def name(self): """Return the name of the lock.""" diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py index bd582da1ef474..ed75520c1798f 100644 --- a/homeassistant/components/sensor/bmw_connected_drive.py +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -52,6 +52,7 @@ def __init__(self, account, vehicle, attribute: str, sensor_name, icon): self._state = None self._unit_of_measurement = None self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._icon = icon @@ -60,6 +61,11 @@ def should_poll(self) -> bool: """Data update is triggered from BMWConnectedDriveEntity.""" return False + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._unique_id + @property def name(self) -> str: """Return the name of the sensor.""" @@ -86,7 +92,7 @@ def unit_of_measurement(self) -> str: @property def device_state_attributes(self): - """Return the state attributes of the binary sensor.""" + """Return the state attributes of the sensor.""" return { 'car': self._vehicle.name } From c0180712188322142fdabc7d62c1e98065845237 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 15 Apr 2018 09:50:44 +0200 Subject: [PATCH 072/155] Revert "Update yweather.py" (#13900) * Revert "Add unique_id for BMW ConnectedDrive (#13888)" This reverts commit 9014e2684565066afadd25ea8dc980f63d01a8c4. * Revert "Added snips service descriptions (#13883)" This reverts commit 1c4da0c4a6c3146d4783e09614e151f4eb90cbe8. * Revert "Fix race condition for component loaded before listening (#13887)" This reverts commit bba997e484baaf9a415352a971f8ae91e99a6a27. * Revert "Missing property decorator added (#13889)" This reverts commit bf98b793c5cfb163f587047b24cfc55e041adbe9. * Revert "Update frontend to 20180414.0" This reverts commit 1617fbea4c4570bea02d7d94bbe314248a5a5a17. * Revert "Further untangle data entry flow (#13855)" This reverts commit 4d44c0feff03ddf1c0c57bd130cd1bdfa8c6621a. * Revert "add support for Kodi discovery (#13790)" This reverts commit 5a5dad689b3421522b8cfa473c111d155eb2ecb6. * Revert "Update yweather.py (#13851)" This reverts commit c3388d63a1929a6c197644d1ae1f622c4d58c793. --- homeassistant/components/weather/yweather.py | 31 ++++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 8e638895660dc..5987cf7621f85 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -32,27 +32,20 @@ SCAN_INTERVAL = timedelta(minutes=10) CONDITION_CLASSES = { - 'clear-night': [31, 33], - 'sunny': [32, 34, 25, 36], - 'windy': [24], - 'fair': [34], - 'fair-night': [33], - 'cloudy': [26], - 'mostly-cloudy': [28], - 'mostly-cloudy-night': [27], - 'partly-cloudy': [30, 44], - 'partly-cloudy-night': [29], + 'clear-night': [31], + 'cloudy': [26, 27, 28, 29, 30], 'fog': [19, 20, 21, 22, 23], 'hail': [17, 18, 35], - 'light-rain': [8, 9], - 'light-snow': [14], - 'heavy-rain': [11, 12, 45, 40], - 'heavy-snow': [41, 42, 43, 46], - 'snowy': [13, 15, 16], - 'rainy': [10], - 'snowy-rainy': [5, 6, 7, 10], - 'lightning': [3, 4, 37, 38, 39], - 'lightning-rainy': [45, 47], + 'lightning': [37], + 'lightning-rainy': [3, 4, 38, 39, 47], + 'partlycloudy': [44], + 'pouring': [40, 45], + 'rainy': [9, 11, 12], + 'snowy': [8, 13, 14, 15, 16, 41, 42, 43], + 'snowy-rainy': [5, 6, 7, 10, 46], + 'sunny': [32, 33, 34, 25, 36], + 'windy': [24], + 'windy-variant': [], 'exceptional': [0, 1, 2], } From 390086bb7ef4988b9f67ef2c518ba022758d923b Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Sun, 15 Apr 2018 00:54:02 -0700 Subject: [PATCH 073/155] Eufy colour bulb updates (#13895) * Fix up Eufy handling of colour lights The Eufy colour lights have separate colour and temperature modes, and give much less light output when in colour mode. Brightness is also handled in a slightly confusing way, which means that state must be maintained in order to avoid switching the light between modes by accident. Add some additional handling for that. * Bump the lakeside version This version has important bugfixes for colour bulbs. * Hound fixes --- homeassistant/components/eufy.py | 2 +- homeassistant/components/light/eufy.py | 16 +++++++++++++--- requirements_all.txt | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py index 53584be9fdcdd..733aa0adbfe31 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['lakeside==0.4'] +REQUIREMENTS = ['lakeside==0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py index fa6550d2682d1..a66e219c1a826 100644 --- a/homeassistant/components/light/eufy.py +++ b/homeassistant/components/light/eufy.py @@ -48,12 +48,14 @@ def __init__(self, device): self._code = device['code'] self._type = device['type'] self._bulb = lakeside.bulb(self._address, self._code, self._type) + self._colormode = False if self._type == "T1011": self._features = SUPPORT_BRIGHTNESS elif self._type == "T1012": self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP elif self._type == "T1013": - self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | \ + SUPPORT_COLOR self._bulb.connect() def update(self): @@ -62,9 +64,10 @@ def update(self): self._brightness = self._bulb.brightness self._temp = self._bulb.temperature if self._bulb.colors: - self._hs = color_util.color_RGB_to_hsv(*self._bulb.colors) + self._colormode = True + self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) else: - self._hs = None + self._colormode = False self._state = self._bulb.power @property @@ -108,6 +111,8 @@ def color_temp(self): @property def hs_color(self): """Return the color of this light.""" + if not self._colormode: + return None return self._hs @property @@ -128,6 +133,7 @@ def turn_on(self, **kwargs): brightness = max(1, self._brightness) if colortemp is not None: + self._colormode = False temp_in_k = mired_to_kelvin(colortemp) relative_temp = temp_in_k - EUFY_MIN_KELVIN temp = int(relative_temp * 100 / @@ -138,6 +144,10 @@ def turn_on(self, **kwargs): if hs is not None: rgb = color_util.color_hsv_to_RGB( hs[0], hs[1], brightness / 255 * 100) + self._colormode = True + elif self._colormode: + rgb = color_util.color_hsv_to_RGB( + self._hs[0], self._hs[1], brightness / 255 * 100) else: rgb = None diff --git a/requirements_all.txt b/requirements_all.txt index d5036a795fe04..b4056f61cd04a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -458,7 +458,7 @@ keyring==12.0.0 keyrings.alt==3.0 # homeassistant.components.eufy -lakeside==0.4 +lakeside==0.5 # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http From 2bff03836bb32ee5edd781c7e9391b64a7d2a158 Mon Sep 17 00:00:00 2001 From: Kyle Niewiada Date: Sun, 15 Apr 2018 07:59:10 -0400 Subject: [PATCH 074/155] Fix #13846 Double underscore in bluetooth address (#13884) --- homeassistant/components/device_tracker/bluetooth_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 807f6c0d0a4a7..2ca519d225c4a 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -40,7 +40,7 @@ def see_device(mac, name, rssi=None): attributes = {} if rssi is not None: attributes['rssi'] = rssi - see(mac="{}_{}".format(BT_PREFIX, mac), host_name=name, + see(mac="{}{}".format(BT_PREFIX, mac), host_name=name, attributes=attributes, source_type=SOURCE_TYPE_BLUETOOTH) def discover_devices(): From 2f26b0084f392aebda44c4ebf599fe7a906d1fc7 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sun, 15 Apr 2018 15:19:28 +0200 Subject: [PATCH 075/155] Import operation modes from air humidifier (#13908) --- homeassistant/components/fan/xiaomi_miio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 16affc08467bb..2acc3895f3e5a 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -708,7 +708,7 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): def __init__(self, name, device, model, unique_id): """Initialize the plug switch.""" - from miio.airpurifier import OperationMode + from miio.airhumidifier import OperationMode super().__init__(name, device, model, unique_id) From cd8935cbd245f505dbbb73f4b7ca578ae57c346b Mon Sep 17 00:00:00 2001 From: escoand Date: Sun, 15 Apr 2018 15:20:37 +0200 Subject: [PATCH 076/155] Fritzbox netmonitor name (#13903) * Addd name to netmonitor * import conf_name --- .../components/sensor/fritzbox_netmonitor.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index f4f774cad1e89..857e6cc4a074e 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_HOST, STATE_UNAVAILABLE) +from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_UNAVAILABLE) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__) +CONF_DEFAULT_NAME = 'fritz_netmonitor' CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. ATTR_BYTES_RECEIVED = 'bytes_received' @@ -42,6 +43,7 @@ ICON = 'mdi:web' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=CONF_DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, }) @@ -52,6 +54,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import fritzconnection as fc from fritzconnection.fritzconnection import FritzConnectionException + name = config.get(CONF_NAME) host = config.get(CONF_HOST) try: @@ -65,15 +68,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): else: _LOGGER.info("Successfully connected to FRITZ!Box") - add_devices([FritzboxMonitorSensor(fstatus)], True) + add_devices([FritzboxMonitorSensor(name, fstatus)], True) class FritzboxMonitorSensor(Entity): """Implementation of a fritzbox monitor sensor.""" - def __init__(self, fstatus): + def __init__(self, name, fstatus): """Initialize the sensor.""" - self._name = 'fritz_netmonitor' + self._name = name self._fstatus = fstatus self._state = STATE_UNAVAILABLE self._is_linked = self._is_connected = self._wan_access_type = None From c69f37500a9d14bf2e16f275cdc3d8d8e3a52ee7 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Sun, 15 Apr 2018 15:25:30 +0200 Subject: [PATCH 077/155] Restore typeerror check for units sans energy tracking (#13824) --- homeassistant/components/switch/edimax.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index 49eb5d32110ad..40ebb54b60307 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -84,12 +84,12 @@ def update(self): """Update edimax switch.""" try: self._now_power = float(self.smartplug.now_power) - except ValueError: + except (TypeError, ValueError): self._now_power = None try: self._now_energy_day = float(self.smartplug.now_energy_day) - except ValueError: + except (TypeError, ValueError): self._now_energy_day = None self._state = self.smartplug.state == 'ON' From 9677bc081e46d631f5e6383da29711b46aaef261 Mon Sep 17 00:00:00 2001 From: Benedict Aas Date: Sun, 15 Apr 2018 17:51:45 +0100 Subject: [PATCH 078/155] Add more math functions to templates (#13915) We make `sin`, `cos`, `tan`, and `sqrt` functions, and the `pi`, `tau`, and `e` constants available in templates. --- homeassistant/helpers/template.py | 43 +++++++++++++++++++ tests/helpers/test_template.py | 68 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 353fda28875ec..3a24de6b39c1e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -452,6 +452,38 @@ def logarithm(value, base=math.e): return value +def sine(value): + """Filter to get sine of the value.""" + try: + return math.sin(float(value)) + except (ValueError, TypeError): + return value + + +def cosine(value): + """Filter to get cosine of the value.""" + try: + return math.cos(float(value)) + except (ValueError, TypeError): + return value + + +def tangent(value): + """Filter to get tangent of the value.""" + try: + return math.tan(float(value)) + except (ValueError, TypeError): + return value + + +def square_root(value): + """Filter to get square root of the value.""" + try: + return math.sqrt(float(value)) + except (ValueError, TypeError): + return value + + def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): """Filter to convert given timestamp to format.""" try: @@ -571,6 +603,10 @@ def is_safe_callable(self, obj): ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply ENV.filters['log'] = logarithm +ENV.filters['sin'] = sine +ENV.filters['cos'] = cosine +ENV.filters['tan'] = tangent +ENV.filters['sqrt'] = square_root ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc @@ -583,6 +619,13 @@ def is_safe_callable(self, obj): ENV.filters['regex_search'] = regex_search ENV.filters['regex_findall_index'] = regex_findall_index ENV.globals['log'] = logarithm +ENV.globals['sin'] = sine +ENV.globals['cos'] = cosine +ENV.globals['tan'] = tangent +ENV.globals['sqrt'] = square_root +ENV.globals['pi'] = math.pi +ENV.globals['tau'] = math.pi * 2 +ENV.globals['e'] = math.e ENV.globals['float'] = forgiving_float ENV.globals['now'] = dt_util.now ENV.globals['utcnow'] = dt_util.utcnow diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 650b98509d03e..2dfcb2a58e508 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -149,6 +149,74 @@ def test_logarithm(self): '{{ log(%s, %s) | round(1) }}' % (value, base), self.hass).render()) + def test_sine(self): + """Test sine.""" + tests = [ + (0, '0.0'), + (math.pi / 2, '1.0'), + (math.pi, '0.0'), + (math.pi * 1.5, '-1.0'), + (math.pi / 10, '0.309') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | sin | round(3) }}' % value, + self.hass).render()) + + def test_cos(self): + """Test cosine.""" + tests = [ + (0, '1.0'), + (math.pi / 2, '0.0'), + (math.pi, '-1.0'), + (math.pi * 1.5, '-0.0'), + (math.pi / 10, '0.951') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | cos | round(3) }}' % value, + self.hass).render()) + + def test_tan(self): + """Test tangent.""" + tests = [ + (0, '0.0'), + (math.pi, '-0.0'), + (math.pi / 180 * 45, '1.0'), + (math.pi / 180 * 90, '1.633123935319537e+16'), + (math.pi / 180 * 135, '-1.0') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | tan | round(3) }}' % value, + self.hass).render()) + + def test_sqrt(self): + """Test square root.""" + tests = [ + (0, '0.0'), + (1, '1.0'), + (2, '1.414'), + (10, '3.162'), + (100, '10.0'), + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | sqrt | round(3) }}' % value, + self.hass).render()) + def test_strptime(self): """Test the parse timestamp method.""" tests = [ From 517fb2e983d2e9060712989c9a10356801ee02ca Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 15 Apr 2018 22:19:15 +0200 Subject: [PATCH 079/155] Upgrade pyqwikswitch to 0.71 (#13920) --- homeassistant/components/qwikswitch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 4d34ccca24a70..3dc16f513dc06 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyqwikswitch==0.7'] +REQUIREMENTS = ['pyqwikswitch==0.71'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b4056f61cd04a..9772cf87a4440 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -888,7 +888,7 @@ pyowm==2.8.0 pypollencom==1.1.1 # homeassistant.components.qwikswitch -pyqwikswitch==0.7 +pyqwikswitch==0.71 # homeassistant.components.rainbird pyrainbird==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 901074bcad39e..050038b034ac5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.qwikswitch -pyqwikswitch==0.7 +pyqwikswitch==0.71 # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky From 36a663adeb88664800ccffb3d676688861547af3 Mon Sep 17 00:00:00 2001 From: stephanerosi Date: Mon, 16 Apr 2018 08:20:58 +0200 Subject: [PATCH 080/155] Add extra attributes for device scanner, Nmap and Unifi (IP, SSID, etc.) (#13673) * Start of development * Add extra attributes from unifi scanner * Store IP of the device in the state attributes with nmap * Allow not defining get_extra_attributes method in derived classes --- .../components/device_tracker/__init__.py | 23 ++++++++++++++++++- .../components/device_tracker/nmap_tracker.py | 11 +++++++++ .../components/device_tracker/unifi.py | 6 +++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 45f0e51a2142f..b24f7784faf2a 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -605,6 +605,17 @@ def async_get_device_name(self, device: str) -> Any: """ return self.hass.async_add_job(self.get_device_name, device) + def get_extra_attributes(self, device: str) -> dict: + """Get the extra attributes of a device.""" + raise NotImplementedError() + + def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_extra_attributes, device) + def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): """Load devices from YAML configuration file.""" @@ -690,10 +701,20 @@ def async_device_tracker_scan(now: dt_util.dt.datetime): host_name = yield from scanner.async_get_device_name(mac) seen.add(mac) + try: + extra_attributes = (yield from + scanner.async_get_extra_attributes(mac)) + except NotImplementedError: + extra_attributes = dict() + kwargs = { 'mac': mac, 'host_name': host_name, - 'source_type': SOURCE_TYPE_ROUTER + 'source_type': SOURCE_TYPE_ROUTER, + 'attributes': { + 'scanner': scanner.__class__.__name__, + **extra_attributes + } } zone_home = hass.states.get(zone.ENTITY_ID_HOME) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 23cb7ea8f9d35..f62f53fe5fc68 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -80,6 +80,8 @@ def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() + _LOGGER.debug("Nmap last results %s", self.last_results) + return [device.mac for device in self.last_results] def get_device_name(self, device): @@ -91,6 +93,15 @@ def get_device_name(self, device): return filter_named[0] return None + def get_extra_attributes(self, device): + """Return the IP pf the given device.""" + filter_ip = [result.ip for result in self.last_results + if result.mac == device] + + if filter_ip: + return {'ip': filter_ip[0]} + return None + def _update_info(self): """Scan the network for devices. diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index d8a52aaaeb4c8..b7efe65dd0166 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -122,3 +122,9 @@ def get_device_name(self, device): name = client.get('name') or client.get('hostname') _LOGGER.debug("Device mac %s name %s", device, name) return name + + def get_extra_attributes(self, device): + """Return the extra attributes of the device.""" + client = self._clients.get(device, {}) + _LOGGER.debug("Device mac %s attributes %s", device, client) + return client From 86709427b6025d4e5fc3165de0b068c15f666891 Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 16 Apr 2018 09:54:57 +0200 Subject: [PATCH 081/155] Fixed Capsman data not being used (#13917) --- homeassistant/components/device_tracker/mikrotik.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 154fc3d2a6368..a6a67749f764e 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -176,7 +176,7 @@ def _update_info(self): for device in device_names if device.get('mac-address')} - if self.wireless_exist: + if self.wireless_exist or self.capsman_exist: self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) From ad212d8dd4153e02d26778cdfc36cebe97e0b9c3 Mon Sep 17 00:00:00 2001 From: Paxy Date: Mon, 16 Apr 2018 12:06:41 +0200 Subject: [PATCH 082/155] Broadlink Sensor - switch to connection-less mode (#13761) * Broadlink Sensor - switch to connection-less mode Solved the issue with broadlink sensor that occurs when short connection loss with RM2/3 is present on poor WiFi networks. * Update broadlink.py * Update broadlink.py * Update broadlink.py * Update broadlink.py * Update broadlink.py * Update broadlink.py * Update broadlink.py --- homeassistant/components/sensor/broadlink.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 044b77ebfe8ac..5182ba4530e88 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -56,9 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) timeout = config.get(CONF_TIMEOUT) update_interval = config.get(CONF_UPDATE_INTERVAL) - broadlink_data = BroadlinkData(update_interval, host, mac_addr, timeout) - dev = [] for variable in config[CONF_MONITORED_CONDITIONS]: dev.append(BroadlinkSensor(name, broadlink_data, variable)) @@ -104,10 +102,11 @@ class BroadlinkData(object): def __init__(self, interval, ip_addr, mac_addr, timeout): """Initialize the data object.""" - import broadlink self.data = None - self._device = broadlink.a1((ip_addr, 80), mac_addr, None) - self._device.timeout = timeout + self.ip_addr = ip_addr + self.mac_addr = mac_addr + self.timeout = timeout + self._connect() self._schema = vol.Schema({ vol.Optional('temperature'): vol.Range(min=-50, max=150), vol.Optional('humidity'): vol.Range(min=0, max=100), @@ -119,6 +118,11 @@ def __init__(self, interval, ip_addr, mac_addr, timeout): if not self._auth(): _LOGGER.warning("Failed to connect to device") + def _connect(self): + import broadlink + self._device = broadlink.a1((self.ip_addr, 80), self.mac_addr, None) + self._device.timeout = self.timeout + def _update(self, retry=3): try: data = self._device.check_sensors_raw() @@ -140,5 +144,6 @@ def _auth(self, retry=3): except socket.timeout: auth = False if not auth and retry > 0: + self._connect() return self._auth(retry-1) return auth From 595600dea5df4375e0bac5e25de8f683d3028c08 Mon Sep 17 00:00:00 2001 From: Lincoln Kirchoff Date: Mon, 16 Apr 2018 13:31:25 -0500 Subject: [PATCH 083/155] Add support for new platform: climate.modbus (#12224) * Added support for a new platform: climate.modbus * Made changes based on code review. * Made changes based on code review * Made changes that were recommended in the pull request review. * Fixed spacing line 144 * Added docstrings for the added helper functions. * Fixed set_temperature() function to use a variable local to the function for the target temp. * Fixed lint formatting error * Modified logic when checking the target temperature, as well as fixing the setup_platform function --- homeassistant/components/climate/modbus.py | 148 +++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 homeassistant/components/climate/modbus.py diff --git a/homeassistant/components/climate/modbus.py b/homeassistant/components/climate/modbus.py new file mode 100644 index 0000000000000..7d392e5a40f6a --- /dev/null +++ b/homeassistant/components/climate/modbus.py @@ -0,0 +1,148 @@ +""" +Platform for a Generic Modbus Thermostat. + +This uses a setpoint and process +value within the controller, so both the current temperature register and the +target temperature register need to be configured. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.modbus/ +""" +import logging +import struct + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_SLAVE, ATTR_TEMPERATURE) +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) + +import homeassistant.components.modbus as modbus +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['modbus'] + +# Parameters not defined by homeassistant.const +CONF_TARGET_TEMP = 'target_temp_register' +CONF_CURRENT_TEMP = 'current_temp_register' +CONF_DATA_TYPE = 'data_type' +CONF_COUNT = 'data_count' +CONF_PRECISION = 'precision' + +DATA_TYPE_INT = 'int' +DATA_TYPE_UINT = 'uint' +DATA_TYPE_FLOAT = 'float' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SLAVE): cv.positive_int, + vol.Required(CONF_TARGET_TEMP): cv.positive_int, + vol.Required(CONF_CURRENT_TEMP): cv.positive_int, + vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): + vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]), + vol.Optional(CONF_COUNT, default=2): cv.positive_int, + vol.Optional(CONF_PRECISION, default=1): cv.positive_int +}) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Modbus Thermostat Platform.""" + name = config.get(CONF_NAME) + modbus_slave = config.get(CONF_SLAVE) + target_temp_register = config.get(CONF_TARGET_TEMP) + current_temp_register = config.get(CONF_CURRENT_TEMP) + data_type = config.get(CONF_DATA_TYPE) + count = config.get(CONF_COUNT) + precision = config.get(CONF_PRECISION) + + add_devices([ModbusThermostat(name, modbus_slave, + target_temp_register, current_temp_register, + data_type, count, precision)], True) + + +class ModbusThermostat(ClimateDevice): + """Representation of a Modbus Thermostat.""" + + def __init__(self, name, modbus_slave, target_temp_register, + current_temp_register, data_type, count, precision): + """Initialize the unit.""" + self._name = name + self._slave = modbus_slave + self._target_temperature_register = target_temp_register + self._current_temperature_register = current_temp_register + self._target_temperature = None + self._current_temperature = None + self._data_type = data_type + self._count = int(count) + self._precision = precision + self._structure = '>f' + + data_types = {DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'}, + DATA_TYPE_UINT: {1: 'H', 2: 'I', 4: 'Q'}, + DATA_TYPE_FLOAT: {1: 'e', 2: 'f', 4: 'd'}} + + self._structure = '>{}'.format(data_types[self._data_type] + [self._count]) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def update(self): + """Update Target & Current Temperature.""" + self._target_temperature = self.read_register( + self._target_temperature_register) + self._current_temperature = self.read_register( + self._current_temperature_register) + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_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 temperature.""" + target_temperature = kwargs.get(ATTR_TEMPERATURE) + if target_temperature is None: + return + byte_string = struct.pack(self._structure, target_temperature) + register_value = struct.unpack('>h', byte_string[0:2])[0] + + try: + self.write_register(self._target_temperature_register, + register_value) + except AttributeError as ex: + _LOGGER.error(ex) + + def read_register(self, register): + """Read holding register using the modbus hub slave.""" + try: + result = modbus.HUB.read_holding_registers(self._slave, register, + self._count) + except AttributeError as ex: + _LOGGER.error(ex) + byte_string = b''.join( + [x.to_bytes(2, byteorder='big') for x in result.registers]) + val = struct.unpack(self._structure, byte_string)[0] + register_value = format(val, '.{}f'.format(self._precision)) + return register_value + + def write_register(self, register, value): + """Write register using the modbus hub slave.""" + modbus.HUB.write_registers(self._slave, register, [value, 0]) From e0c5b44994772f82cd334dbcdd039269ae65fde7 Mon Sep 17 00:00:00 2001 From: Khole Date: Mon, 16 Apr 2018 20:00:13 +0100 Subject: [PATCH 084/155] Hive R3 update (#13357) * Rebase * Update version number to 0.2.14 * Remove Blank Line * Added period to docstring * Update Tox Fix * Removed Lines --- .../components/binary_sensor/hive.py | 8 +++++ homeassistant/components/climate/hive.py | 13 +++++++ homeassistant/components/hive.py | 6 +++- homeassistant/components/light/hive.py | 8 +++++ homeassistant/components/sensor/hive.py | 34 ++++++++++++++++--- homeassistant/components/switch/hive.py | 8 +++++ requirements_all.txt | 2 +- 7 files changed, 72 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/binary_sensor/hive.py b/homeassistant/components/binary_sensor/hive.py index 2d4cbd8d070ef..46dd1b193e86f 100644 --- a/homeassistant/components/binary_sensor/hive.py +++ b/homeassistant/components/binary_sensor/hive.py @@ -32,6 +32,7 @@ def __init__(self, hivesession, hivedevice): self.device_type = hivedevice["HA_DeviceType"] self.node_device_type = hivedevice["Hive_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -52,6 +53,11 @@ def name(self): """Return the name of the binary sensor.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def is_on(self): """Return true if the binary sensor is on.""" @@ -61,3 +67,5 @@ def is_on(self): def update(self): """Update all Node data from Hive.""" self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes( + self.node_id) diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py index 760ef1310496d..eb3aecae3a10b 100644 --- a/homeassistant/components/climate/hive.py +++ b/homeassistant/components/climate/hive.py @@ -38,7 +38,10 @@ def __init__(self, hivesession, hivedevice): self.node_id = hivedevice["Hive_NodeID"] self.node_name = hivedevice["Hive_NodeName"] self.device_type = hivedevice["HA_DeviceType"] + if self.device_type == "Heating": + self.thermostat_node_id = hivedevice["Thermostat_NodeID"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -71,6 +74,11 @@ def name(self): friendly_name = "Hot Water" return friendly_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def temperature_unit(self): """Return the unit of measurement.""" @@ -175,4 +183,9 @@ def turn_aux_heat_off(self): def update(self): """Update all Node data from Hive.""" + node = self.node_id + if self.device_type == "Heating": + node = self.thermostat_node_id + self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes(node) diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py index abe52ebe98a40..aa662fc2fb6d9 100644 --- a/homeassistant/components/hive.py +++ b/homeassistant/components/hive.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['pyhiveapi==0.2.11'] +REQUIREMENTS = ['pyhiveapi==0.2.14'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'hive' @@ -44,6 +44,8 @@ class HiveSession: light = None sensor = None switch = None + weather = None + attributes = None def setup(hass, config): @@ -70,6 +72,8 @@ def setup(hass, config): session.hotwater = Pyhiveapi.Hotwater() session.light = Pyhiveapi.Light() session.switch = Pyhiveapi.Switch() + session.weather = Pyhiveapi.Weather() + session.attributes = Pyhiveapi.Attributes() hass.data[DATA_HIVE] = session for ha_type, hive_type in DEVICETYPES.items(): diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index c4ecc5a9d2c6b..1fd9e8aaacae7 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -34,6 +34,7 @@ def __init__(self, hivesession, hivedevice): self.device_type = hivedevice["HA_DeviceType"] self.light_device_type = hivedevice["Hive_Light_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) self.session.entities.append(self) @@ -48,6 +49,11 @@ def name(self): """Return the display name of this light.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def brightness(self): """Brightness of the light (an integer in the range 1-255).""" @@ -136,3 +142,5 @@ def supported_features(self): def update(self): """Update all Node data from Hive.""" self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes( + self.node_id) diff --git a/homeassistant/components/sensor/hive.py b/homeassistant/components/sensor/hive.py index cae2eaf7437e7..8f8ce2d16815c 100644 --- a/homeassistant/components/sensor/hive.py +++ b/homeassistant/components/sensor/hive.py @@ -4,11 +4,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.hive/ """ +from homeassistant.const import TEMP_CELSIUS from homeassistant.components.hive import DATA_HIVE from homeassistant.helpers.entity import Entity DEPENDENCIES = ['hive'] +FRIENDLY_NAMES = {'Hub_OnlineStatus': 'Hub Status', + 'Hive_OutsideTemperature': 'Outside Temperature'} +DEVICETYPE_ICONS = {'Hub_OnlineStatus': 'mdi:switch', + 'Hive_OutsideTemperature': 'mdi:thermometer'} + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Hive sensor devices.""" @@ -16,7 +22,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return session = hass.data.get(DATA_HIVE) - if discovery_info["HA_DeviceType"] == "Hub_OnlineStatus": + if (discovery_info["HA_DeviceType"] == "Hub_OnlineStatus" or + discovery_info["HA_DeviceType"] == "Hive_OutsideTemperature"): add_devices([HiveSensorEntity(session, discovery_info)]) @@ -27,6 +34,7 @@ def __init__(self, hivesession, hivedevice): """Initialize the sensor.""" self.node_id = hivedevice["Hive_NodeID"] self.device_type = hivedevice["HA_DeviceType"] + self.node_device_type = hivedevice["Hive_DeviceType"] self.session = hivesession self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -40,13 +48,29 @@ def handle_update(self, updatesource): @property def name(self): """Return the name of the sensor.""" - return "Hive hub status" + return FRIENDLY_NAMES.get(self.device_type) @property def state(self): """Return the state of the sensor.""" - return self.session.sensor.hub_online_status(self.node_id) + if self.device_type == "Hub_OnlineStatus": + return self.session.sensor.hub_online_status(self.node_id) + elif self.device_type == "Hive_OutsideTemperature": + return self.session.weather.temperature() + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.device_type == "Hive_OutsideTemperature": + return TEMP_CELSIUS + + @property + def icon(self): + """Return the icon to use.""" + return DEVICETYPE_ICONS.get(self.device_type) def update(self): - """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) + """Update all Node data frome Hive.""" + if self.session.core.update_data(self.node_id): + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) diff --git a/homeassistant/components/switch/hive.py b/homeassistant/components/switch/hive.py index 67ebe95ba8e8a..49fc9696b5ef5 100644 --- a/homeassistant/components/switch/hive.py +++ b/homeassistant/components/switch/hive.py @@ -28,6 +28,7 @@ def __init__(self, hivesession, hivedevice): self.node_name = hivedevice["Hive_NodeName"] self.device_type = hivedevice["HA_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) self.session.entities.append(self) @@ -42,6 +43,11 @@ def name(self): """Return the name of this Switch device if any.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def current_power_w(self): """Return the current power usage in W.""" @@ -67,3 +73,5 @@ def turn_off(self, **kwargs): def update(self): """Update all Node data from Hive.""" self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes( + self.node_id) diff --git a/requirements_all.txt b/requirements_all.txt index 9772cf87a4440..fe82942cab896 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ pyharmony==1.0.20 pyhik==0.1.8 # homeassistant.components.hive -pyhiveapi==0.2.11 +pyhiveapi==0.2.14 # homeassistant.components.homematic pyhomematic==0.1.41 From acdba7a27c0a00297a1fa56535ce6f165d98832a Mon Sep 17 00:00:00 2001 From: Fabien Piuzzi Date: Mon, 16 Apr 2018 21:35:24 +0200 Subject: [PATCH 085/155] Updated foobot_async package version (#13942) Fix #13886 --- homeassistant/components/sensor/foobot.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/foobot.py b/homeassistant/components/sensor/foobot.py index 8f65a33587267..d247a90e93afc 100644 --- a/homeassistant/components/sensor/foobot.py +++ b/homeassistant/components/sensor/foobot.py @@ -21,7 +21,7 @@ from homeassistant.util import Throttle -REQUIREMENTS = ['foobot_async==0.3.0'] +REQUIREMENTS = ['foobot_async==0.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index fe82942cab896..2820d3036a4d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -311,7 +311,7 @@ fixerio==0.1.1 flux_led==0.21 # homeassistant.components.sensor.foobot -foobot_async==0.3.0 +foobot_async==0.3.1 # homeassistant.components.notify.free_mobile freesms==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 050038b034ac5..4b9470a9b2938 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -63,7 +63,7 @@ evohomeclient==0.2.5 feedparser==5.2.1 # homeassistant.components.sensor.foobot -foobot_async==0.3.0 +foobot_async==0.3.1 # homeassistant.components.tts.google gTTS-token==1.1.1 From 9da239178c0343f78153321e7f6b5bef571ad983 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Mon, 16 Apr 2018 20:52:56 -0400 Subject: [PATCH 086/155] Update pyhydroquebec to 2.2.2 (#13946) --- homeassistant/components/sensor/hydroquebec.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index 9129ee17d80cf..2195153ab1edf 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,7 +21,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==2.2.1'] +REQUIREMENTS = ['pyhydroquebec==2.2.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2820d3036a4d8..bb174ae74ca2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -783,7 +783,7 @@ pyhiveapi==0.2.14 pyhomematic==0.1.41 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==2.2.1 +pyhydroquebec==2.2.2 # homeassistant.components.alarm_control_panel.ialarm pyialarm==0.2 From e8ad36feb66b3f1e8f4f3d67246d884b22899686 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 17 Apr 2018 04:16:12 +0200 Subject: [PATCH 087/155] Upgrade alpha_vantage to 2.0.0 (#13943) --- homeassistant/components/sensor/alpha_vantage.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 896497a93d50b..77d8ba9322f82 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.9.0'] +REQUIREMENTS = ['alpha_vantage==2.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bb174ae74ca2c..288ccc50c5b78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -98,7 +98,7 @@ aiopvapi==1.5.4 alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.9.0 +alpha_vantage==2.0.0 # homeassistant.components.amcrest amcrest==1.2.2 From d0d61d1b5f42b3e4808f3ef90572e38d0142aeeb Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Mon, 16 Apr 2018 22:16:28 -0400 Subject: [PATCH 088/155] Update pyfido to 2.1.1 (#13947) --- homeassistant/components/sensor/fido.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fido.py b/homeassistant/components/sensor/fido.py index 25a104bf25961..a2ee18b3659b4 100644 --- a/homeassistant/components/sensor/fido.py +++ b/homeassistant/components/sensor/fido.py @@ -21,7 +21,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyfido==2.1.0'] +REQUIREMENTS = ['pyfido==2.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 288ccc50c5b78..576c94135eef4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -759,7 +759,7 @@ pyenvisalink==2.2 pyephember==0.1.1 # homeassistant.components.sensor.fido -pyfido==2.1.0 +pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 From 8fdeebc50d225227ab61a38f562324a4ee91ad0e Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 17 Apr 2018 03:21:39 +0100 Subject: [PATCH 089/155] Cleanup on exit (#13918) * Cleanup on exit * lint * version bump * pymediaroom version bump * address @kellerza comment * avoid None in the _name --- .../components/media_player/mediaroom.py | 22 ++++++++++++++----- requirements_all.txt | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/mediaroom.py b/homeassistant/components/media_player/mediaroom.py index a6d5841bb0f4e..f5b7567aa348c 100644 --- a/homeassistant/components/media_player/mediaroom.py +++ b/homeassistant/components/media_player/mediaroom.py @@ -8,6 +8,7 @@ import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.media_player import ( MEDIA_TYPE_CHANNEL, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, @@ -20,11 +21,11 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, STATE_OFF, CONF_TIMEOUT, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNAVAILABLE + STATE_UNAVAILABLE, EVENT_HOMEASSISTANT_STOP ) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pymediaroom==0.6'] +REQUIREMENTS = ['pymediaroom==0.6.3'] _LOGGER = logging.getLogger(__name__) @@ -81,12 +82,21 @@ def callback_notify(notify): if not config[CONF_OPTIMISTIC]: from pymediaroom import install_mediaroom_protocol - already_installed = hass.data.get(DISCOVERY_MEDIAROOM, False) + already_installed = hass.data.get(DISCOVERY_MEDIAROOM, None) if not already_installed: - await install_mediaroom_protocol( + hass.data[DISCOVERY_MEDIAROOM] = await install_mediaroom_protocol( responses_callback=callback_notify) + + @callback + def stop_discovery(event): + """Stop discovery of new mediaroom STB's.""" + _LOGGER.debug("Stopping internal pymediaroom discovery.") + hass.data[DISCOVERY_MEDIAROOM].close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + stop_discovery) + _LOGGER.debug("Auto discovery installed") - hass.data[DISCOVERY_MEDIAROOM] = True class MediaroomDevice(MediaPlayerDevice): @@ -120,7 +130,7 @@ def __init__(self, host, device_id, optimistic=False, self._channel = None self._optimistic = optimistic self._state = STATE_PLAYING if optimistic else STATE_STANDBY - self._name = 'Mediaroom {}'.format(device_id) + self._name = 'Mediaroom {}'.format(device_id if device_id else host) self._available = True if device_id: self._unique_id = device_id diff --git a/requirements_all.txt b/requirements_all.txt index 576c94135eef4..f97f625e3f610 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -832,7 +832,7 @@ pylutron==0.1.0 pymailgunner==1.4 # homeassistant.components.media_player.mediaroom -pymediaroom==0.6 +pymediaroom==0.6.3 # homeassistant.components.media_player.xiaomi_tv pymitv==1.0.0 From 6e9669c18d1235c4e70ae481d057c73f5e0b1f46 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Apr 2018 23:24:20 -0400 Subject: [PATCH 090/155] Upgrade somecomfort to 0.5.2 (#13940) --- homeassistant/components/climate/honeywell.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 20d93e3116a92..11a507aded2d0 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -20,7 +20,7 @@ CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, CONF_REGION) -REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.0'] +REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f97f625e3f610..b665f8e9b0018 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1191,7 +1191,7 @@ smappy==0.2.15 snapcast==2.0.8 # homeassistant.components.climate.honeywell -somecomfort==0.5.0 +somecomfort==0.5.2 # homeassistant.components.sensor.speedtest speedtest-cli==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b9470a9b2938..b7ce54c64c030 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -180,7 +180,7 @@ rxv==0.5.1 sleepyq==0.6 # homeassistant.components.climate.honeywell -somecomfort==0.5.0 +somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator From 534aa0e4b54b992ac55de0ae576276e94089ce49 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Apr 2018 05:44:32 -0400 Subject: [PATCH 091/155] Add data entry flow helper (#13935) * Extract data entry flows HTTP views into helper * Remove use of domain * Lint * Fix tests * Update doc --- .../components/config/config_entries.py | 80 ++----------- homeassistant/config_entries.py | 4 +- homeassistant/data_entry_flow.py | 10 +- homeassistant/helpers/data_entry_flow.py | 106 ++++++++++++++++++ .../components/config/test_config_entries.py | 16 ++- 5 files changed, 132 insertions(+), 84 deletions(-) create mode 100644 homeassistant/helpers/data_entry_flow.py diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 967317134c2db..d2aa918eda269 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,11 +1,10 @@ """Http views to control the config manager.""" import asyncio -import voluptuous as vol - from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers.data_entry_flow import ( + FlowManagerIndexView, FlowManagerResourceView) REQUIREMENTS = ['voluptuous-serialize==1'] @@ -16,8 +15,10 @@ def async_setup(hass): """Enable the Home Assistant views.""" hass.http.register_view(ConfigManagerEntryIndexView) hass.http.register_view(ConfigManagerEntryResourceView) - hass.http.register_view(ConfigManagerFlowIndexView) - hass.http.register_view(ConfigManagerFlowResourceView) + hass.http.register_view( + ConfigManagerFlowIndexView(hass.config_entries.flow)) + hass.http.register_view( + ConfigManagerFlowResourceView(hass.config_entries.flow)) hass.http.register_view(ConfigManagerAvailableFlowView) return True @@ -78,7 +79,7 @@ def delete(self, request, entry_id): return self.json(result) -class ConfigManagerFlowIndexView(HomeAssistantView): +class ConfigManagerFlowIndexView(FlowManagerIndexView): """View to create config flows.""" url = '/api/config/config_entries/flow' @@ -97,78 +98,13 @@ def get(self, request): flw for flw in hass.config_entries.flow.async_progress() if flw['source'] != data_entry_flow.SOURCE_USER]) - @RequestDataValidator(vol.Schema({ - vol.Required('domain'): str, - })) - @asyncio.coroutine - def post(self, request, data): - """Handle a POST request.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_init( - data['domain']) - except data_entry_flow.UnknownHandler: - return self.json_message('Invalid handler specified', 404) - except data_entry_flow.UnknownStep: - return self.json_message('Handler does not support init', 400) - - result = _prepare_json(result) - - return self.json(result) - -class ConfigManagerFlowResourceView(HomeAssistantView): +class ConfigManagerFlowResourceView(FlowManagerResourceView): """View to interact with the flow manager.""" url = '/api/config/config_entries/flow/{flow_id}' name = 'api:config:config_entries:flow:resource' - @asyncio.coroutine - def get(self, request, flow_id): - """Get the current state of a data_entry_flow.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_configure( - flow_id) - except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - - result = _prepare_json(result) - - return self.json(result) - - @RequestDataValidator(vol.Schema(dict), allow_empty=True) - @asyncio.coroutine - def post(self, request, flow_id, data): - """Handle a POST request.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_configure( - flow_id, data) - except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - except vol.Invalid: - return self.json_message('User input malformed', 400) - - result = _prepare_json(result) - - return self.json(result) - - @asyncio.coroutine - def delete(self, request, flow_id): - """Cancel a flow in progress.""" - hass = request.app['hass'] - - try: - hass.config_entries.flow.async_abort(flow_id) - except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - - return self.json_message('Flow aborted') - class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e143c94197e5b..46bb2f7bfe2b1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -338,7 +338,7 @@ async def async_forward_entry_unload(self, entry, component): if component not in self.hass.config.components: return True - await entry.async_unload( + return await entry.async_unload( self.hass, component=getattr(self.hass.components, component)) async def _async_save_entry(self, result): @@ -362,6 +362,8 @@ async def _async_save_entry(self, result): await async_setup_component( self.hass, entry.domain, self._hass_config) + return entry + async def _async_create_flow(self, handler): """Create a flow for specified handler. diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 361b6653cfded..cadec3f3d6961 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -34,12 +34,12 @@ class UnknownStep(FlowError): class FlowManager: """Manage all the flows that are in progress.""" - def __init__(self, hass, async_create_flow, async_save_entry): + def __init__(self, hass, async_create_flow, async_finish_flow): """Initialize the flow manager.""" self.hass = hass self._progress = {} self._async_create_flow = async_create_flow - self._async_save_entry = async_save_entry + self._async_finish_flow = async_finish_flow @callback def async_progress(self): @@ -113,10 +113,8 @@ async def _async_handle_step(self, flow, step_id, user_input): if result['type'] == RESULT_TYPE_ABORT: return result - # We pass a copy of the result because we're going to mutate our - # version afterwards and don't want to cause unexpected bugs. - await self._async_save_entry(dict(result)) - result.pop('data') + # We pass a copy of the result because we're mutating our version + result['result'] = await self._async_finish_flow(dict(result)) return result diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py new file mode 100644 index 0000000000000..a8aca2fd2e91c --- /dev/null +++ b/homeassistant/helpers/data_entry_flow.py @@ -0,0 +1,106 @@ +"""Helpers for the data entry flow.""" + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +def _prepare_json(result): + """Convert result for JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + data.pop('result') + data.pop('data') + return data + + elif result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class FlowManagerIndexView(HomeAssistantView): + """View to create config flows.""" + + def __init__(self, flow_mgr): + """Initialize the flow manager index view.""" + self._flow_mgr = flow_mgr + + async def get(self, request): + """List flows that are in progress.""" + return self.json(self._flow_mgr.async_progress()) + + @RequestDataValidator(vol.Schema({ + vol.Required('handler'): vol.Any(str, list), + })) + async def post(self, request, data): + """Handle a POST request.""" + if isinstance(data['handler'], list): + handler = tuple(data['handler']) + else: + handler = data['handler'] + + try: + result = await self._flow_mgr.async_init(handler) + except data_entry_flow.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except data_entry_flow.UnknownStep: + return self.json_message('Handler does not support init', 400) + + result = _prepare_json(result) + + return self.json(result) + + +class FlowManagerResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + def __init__(self, flow_mgr): + """Initialize the flow manager resource view.""" + self._flow_mgr = flow_mgr + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + try: + result = await self._flow_mgr.async_configure(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + result = _prepare_json(result) + + return self.json(result) + + @RequestDataValidator(vol.Schema(dict), allow_empty=True) + async def post(self, request, flow_id, data): + """Handle a POST request.""" + try: + result = await self._flow_mgr.async_configure(flow_id, data) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + except vol.Invalid: + return self.json_message('User input malformed', 400) + + result = _prepare_json(result) + + return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 70cb6c3fbaa25..f53be8818a35e 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -17,6 +17,12 @@ from tests.common import MockConfigEntry, MockModule, mock_coro_func +@pytest.fixture(scope='session', autouse=True) +def mock_test_component(): + """Ensure a component called 'test' exists.""" + set_component('test', MockModule('test')) + + @pytest.fixture def client(hass, aiohttp_client): """Fixture that can interact with the config manager API.""" @@ -111,7 +117,7 @@ def async_step_init(self, user_input=None): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() @@ -150,7 +156,7 @@ def async_step_init(self, user_input=None): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() @@ -180,7 +186,7 @@ def async_step_init(self, user_input=None): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() @@ -220,7 +226,7 @@ def async_step_account(self, user_input=None): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() flow_id = data.pop('flow_id') @@ -305,7 +311,7 @@ def async_step_init(self, user_input=None): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() From add0afe31a3dce9663999e575119c84ab3611c28 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Tue, 17 Apr 2018 11:45:19 +0200 Subject: [PATCH 092/155] Xiaomi MiIO Device Tracker: Unused variable removed (#13948) * Unused variable removed and pinning added to be in sync with all xiaomi_miio components * requirements_all.txt updated --- homeassistant/components/device_tracker/xiaomi_miio.py | 6 +++--- requirements_all.txt | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 615688923888e..c5769253657c6 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -20,7 +20,7 @@ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), }) -REQUIREMENTS = ['python-miio==0.3.9'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] def get_scanner(hass, config): @@ -41,7 +41,7 @@ def get_scanner(hass, config): device_info.model, device_info.firmware_version, device_info.hardware_version) - scanner = XiaomiMiioDeviceScanner(hass, device) + scanner = XiaomiMiioDeviceScanner(device) except DeviceException as ex: _LOGGER.error("Device unavailable or token incorrect: %s", ex) @@ -51,7 +51,7 @@ def get_scanner(hass, config): class XiaomiMiioDeviceScanner(DeviceScanner): """This class queries a Xiaomi Mi WiFi Repeater.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize the scanner.""" self.device = device diff --git a/requirements_all.txt b/requirements_all.txt index b665f8e9b0018..12d3a8a0e86fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,6 +203,7 @@ colorlog==3.1.2 concord232==0.15 # homeassistant.components.climate.eq3btsmart +# homeassistant.components.device_tracker.xiaomi_miio # homeassistant.components.fan.xiaomi_miio # homeassistant.components.light.xiaomi_miio # homeassistant.components.remote.xiaomi_miio From 998d8c177110ae8dc33338f51a269f0e6daa41f4 Mon Sep 17 00:00:00 2001 From: stephanerosi Date: Tue, 17 Apr 2018 11:50:26 +0200 Subject: [PATCH 093/155] Implement play media to set a channel based on (by priority): (#13934) - exact channel number - exact channel name - similar channel name temp --- .../components/media_player/webostv.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index ae9d259a47c92..d7682a611b936 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -344,6 +344,42 @@ def select_source(self, source): self._current_source = source_dict['label'] self._client.set_input(source_dict['id']) + def play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + _LOGGER.debug( + "Call play media type <%s>, Id <%s>", media_type, media_id) + + if media_type == MEDIA_TYPE_CHANNEL: + _LOGGER.debug("Searching channel...") + partial_match_channel_id = None + + for channel in self._client.get_channels(): + _LOGGER.debug( + "Checking channel number <%s>, name <%s>, id <%s>...", + channel['channelNumber'], + channel['channelName'], + channel['channelId']) + if media_id == channel['channelNumber']: + _LOGGER.debug( + "Perfect match on channel number: switching!") + self._client.set_channel(channel['channelId']) + return + elif media_id.lower() == channel['channelName'].lower(): + _LOGGER.debug( + "Perfect match on channel name: switching!") + self._client.set_channel(channel['channelId']) + return + elif media_id.lower() in channel['channelName'].lower(): + _LOGGER.debug( + "Partial match on channel name: saving it...") + partial_match_channel_id = channel['channelId'] + + if partial_match_channel_id is not None: + _LOGGER.debug( + "Using partial match on channel name: switching!") + self._client.set_channel(partial_match_channel_id) + return + def media_play(self): """Send play command.""" self._playing = True From f2d4dd25f04ed7dce632bd35a55fcefd4246082a Mon Sep 17 00:00:00 2001 From: karlkar Date: Tue, 17 Apr 2018 11:55:35 +0200 Subject: [PATCH 094/155] Update of python-mpd2 (#13921) --- homeassistant/components/media_player/mpd.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 81a18ab93c538..04dd1ac5f2e1e 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['python-mpd2==0.5.5'] +REQUIREMENTS = ['python-mpd2==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 12d3a8a0e86fa..c2ee5814d7bf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -981,7 +981,7 @@ python-juicenet==0.0.5 python-miio==0.3.9 # homeassistant.components.media_player.mpd -python-mpd2==0.5.5 +python-mpd2==1.0.0 # homeassistant.components.light.mystrom # homeassistant.components.switch.mystrom From 9487bd455a3aa3bc4a4d72e5486e1360e7f5a651 Mon Sep 17 00:00:00 2001 From: Heiko Thiery Date: Tue, 17 Apr 2018 12:40:36 +0200 Subject: [PATCH 095/155] Add AVM fritzbox smarthome component (#10688) * initial commit Signed-off-by: Heiko Thiery * fix failed flake8 tests Signed-off-by: Heiko Thiery * add fritzhome files to .coveragerc Signed-off-by: Heiko Thiery * fix wrong module import Signed-off-by: Heiko Thiery * remove too general exception Signed-off-by: Heiko Thiery * incorporate review comments Signed-off-by: Heiko Thiery * remove blank line Signed-off-by: Heiko Thiery * fix wrong import Signed-off-by: Heiko Thiery * fix issue with operations Signed-off-by: Heiko Thiery * incorporate review comments Signed-off-by: Heiko Thiery * remove unused attributes Signed-off-by: Heiko Thiery * adapt to supported_features Signed-off-by: Heiko Thiery * change checking of kwargs to canonical way Signed-off-by: Heiko Thiery * remove unused self._state Signed-off-by: Heiko Thiery * Don't overwrite the platform domain Signed-off-by: Heiko Thiery * Remove parenthesis from import without line break Signed-off-by: Heiko Thiery * Do not pass hass to the components on init Signed-off-by: Heiko Thiery * Remove check for available in current_operation Signed-off-by: Heiko Thiery * Remove redundant logging message Signed-off-by: Heiko Thiery * Add blank line between standard and hass imports Signed-off-by: Heiko Thiery * Use states from base climate component Also add the new state STATE_MANUAL to the base. Signed-off-by: Heiko Thiery * add reconnect when access failed Signed-off-by: Heiko Thiery * add device specific attributes Signed-off-by: Heiko Thiery * group the imports from the same module Signed-off-by: Heiko Thiery * change domain data to fritz instance This let us use the fritz instance to reconnect from platform without accessing protected attributes. Signed-off-by: Heiko Thiery * fix typo Signed-off-by: Heiko Thiery * rename platform from fritzhome to fritzbox Signed-off-by: Heiko Thiery * Add device_state_attributes Add attributes to have compatiblity to fritzdect. Signed-off-by: Heiko Thiery * add support for multiple fritzboxes Signed-off-by: Heiko Thiery * fix pylint issues Signed-off-by: Heiko Thiery * fixed pyfritzhome version Signed-off-by: Heiko Thiery * fix import Signed-off-by: Heiko Thiery * fix component name in requirements_all.txt Signed-off-by: Heiko Thiery * upgrade pyfritzhome to 0.3.7 Signed-off-by: Heiko Thiery * rename platform/component also in .coveragerc Signed-off-by: Heiko Thiery * use DEFAULT_HOST when no host is in dict Signed-off-by: Heiko Thiery * add config schema for dict Signed-off-by: Heiko Thiery * remove check The check since since the config scheme takes case. Signed-off-by: Heiko Thiery * add check for empty devices Signed-off-by: Heiko Thiery * use standard attribute from base class Signed-off-by: Heiko Thiery * remove STATE_MANUAL from operation list Signed-off-by: Heiko Thiery * remove set DEFAULT_HOST Signed-off-by: Heiko Thiery * don't pass hass to the SwitchDevice Signed-off-by: Heiko Thiery * remove unsed DEFAULT_HOST Signed-off-by: Heiko Thiery * refactored device attributes Signed-off-by: Heiko Thiery * add info output if no fritzbox is configured Signed-off-by: Heiko Thiery * small fixes according review comment Signed-off-by: Heiko Thiery * remove unneeded default value Signed-off-by: Heiko Thiery * remove non required code from try..except block Signed-off-by: Heiko Thiery * line break for line that is too long Signed-off-by: Heiko Thiery * remove too many empty lines Signed-off-by: Heiko Thiery --- .coveragerc | 3 + homeassistant/components/climate/__init__.py | 1 + homeassistant/components/climate/fritzbox.py | 153 +++++++++++++++++++ homeassistant/components/fritzbox.py | 83 ++++++++++ homeassistant/components/switch/fritzbox.py | 104 +++++++++++++ requirements_all.txt | 3 + 6 files changed, 347 insertions(+) create mode 100755 homeassistant/components/climate/fritzbox.py create mode 100755 homeassistant/components/fritzbox.py create mode 100755 homeassistant/components/switch/fritzbox.py diff --git a/.coveragerc b/.coveragerc index 70cfbded98fd0..3da28762df0c6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -94,6 +94,9 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/fritzbox.py + homeassistant/components/*/fritzbox.py + homeassistant/components/eufy.py homeassistant/components/*/eufy.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 7ea23f4fd6512..550d4035ddd14 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -40,6 +40,7 @@ STATE_COOL = 'cool' STATE_IDLE = 'idle' STATE_AUTO = 'auto' +STATE_MANUAL = 'manual' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' STATE_ECO = 'eco' diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py new file mode 100755 index 0000000000000..839da8c9d5333 --- /dev/null +++ b/homeassistant/components/climate/fritzbox.py @@ -0,0 +1,153 @@ +""" +Support for AVM Fritz!Box smarthome thermostate devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/climate.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_BATTERY_LOW, ATTR_STATE_LOCKED) +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, ClimateDevice, STATE_ECO, STATE_HEAT, STATE_MANUAL, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS) + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) + +OPERATION_LIST = [STATE_HEAT, STATE_ECO] + +MIN_TEMPERATURE = 8 +MAX_TEMPERATURE = 28 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome thermostat platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_thermostat: + devices.append(FritzboxThermostat(device, fritz)) + + add_devices(devices) + + +class FritzboxThermostat(ClimateDevice): + """The thermostat class for Fritzbox smarthome thermostates.""" + + def __init__(self, device, fritz): + """Initialize the thermostat.""" + self._device = device + self._fritz = fritz + self._current_temperature = self._device.actual_temperature + self._target_temperature = self._device.target_temperature + self._comfort_temperature = self._device.comfort_temperature + self._eco_temperature = self._device.eco_temperature + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def available(self): + """Return if thermostat is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return precision 0.5.""" + return PRECISION_HALVES + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_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 temperature.""" + if ATTR_OPERATION_MODE in kwargs: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + self.set_operation_mode(operation_mode) + elif ATTR_TEMPERATURE in kwargs: + temperature = kwargs.get(ATTR_TEMPERATURE) + self._device.set_target_temperature(temperature) + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._target_temperature == self._comfort_temperature: + return STATE_HEAT + elif self._target_temperature == self._eco_temperature: + return STATE_ECO + return STATE_MANUAL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + if operation_mode == STATE_HEAT: + self.set_temperature(temperature=self._comfort_temperature) + elif operation_mode == STATE_ECO: + self.set_temperature(temperature=self._eco_temperature) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMPERATURE + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMPERATURE + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attrs = { + ATTR_STATE_DEVICE_LOCKED: self._device.device_lock, + ATTR_STATE_LOCKED: self._device.lock, + ATTR_STATE_BATTERY_LOW: self._device.battery_low, + } + return attrs + + def update(self): + """Update the data from the thermostat.""" + try: + self._device.update() + self._current_temperature = self._device.actual_temperature + self._target_temperature = self._device.target_temperature + self._comfort_temperature = self._device.comfort_temperature + self._eco_temperature = self._device.eco_temperature + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzbox connection error: %s", ex) + self._fritz.login() diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py new file mode 100755 index 0000000000000..a3c35aaa59719 --- /dev/null +++ b/homeassistant/components/fritzbox.py @@ -0,0 +1,83 @@ +""" +Support for AVM Fritz!Box smarthome devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/fritzbox/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyfritzhome==0.3.7'] + +SUPPORTED_DOMAINS = ['climate', 'switch'] + +DOMAIN = 'fritzbox' + +ATTR_STATE_DEVICE_LOCKED = 'device_locked' +ATTR_STATE_LOCKED = 'locked' +ATTR_STATE_BATTERY_LOW = 'battery_low' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICES): + vol.All(cv.ensure_list, [ + vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + }), + ]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the fritzbox component.""" + from pyfritzhome import Fritzhome, LoginError + + fritz_list = [] + + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + host = device.get(CONF_HOST) + username = device.get(CONF_USERNAME) + password = device.get(CONF_PASSWORD) + fritzbox = Fritzhome(host=host, user=username, + password=password) + try: + fritzbox.login() + _LOGGER.info("Connected to device %s", device) + except LoginError: + _LOGGER.warning("Login to Fritz!Box %s as %s failed", + host, username) + continue + + fritz_list.append(fritzbox) + + if not fritz_list: + _LOGGER.info("No fritzboxes configured") + return False + + hass.data[DOMAIN] = fritz_list + + def logout_fritzboxes(event): + """Close all connections to the fritzboxes.""" + for fritz in fritz_list: + fritz.logout() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzboxes) + + for domain in SUPPORTED_DOMAINS: + discovery.load_platform(hass, domain, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py new file mode 100755 index 0000000000000..c8313b0dfef5b --- /dev/null +++ b/homeassistant/components/switch/fritzbox.py @@ -0,0 +1,104 @@ +""" +Support for AVM Fritz!Box smarthome switch devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/switch.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED) +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' + +ATTR_TEMPERATURE_UNIT = 'temperature_unit' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome switch platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_switch: + devices.append(FritzboxSwitch(device, fritz)) + + add_devices(devices) + + +class FritzboxSwitch(SwitchDevice): + """The switch class for Fritzbox switches.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def available(self): + """Return if switch is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._device.switch_state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._device.set_switch_state_on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._device.set_switch_state_off() + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = {} + attrs[ATTR_STATE_DEVICE_LOCKED] = self._device.device_lock + attrs[ATTR_STATE_LOCKED] = self._device.lock + + if self._device.has_powermeter: + attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( + (self._device.energy or 0.0) / 100000) + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = \ + ATTR_TOTAL_CONSUMPTION_UNIT_VALUE + if self._device.has_temperature_sensor: + attrs[ATTR_TEMPERATURE] = \ + str(self.hass.config.units.temperature( + self._device.temperature, TEMP_CELSIUS)) + attrs[ATTR_TEMPERATURE_UNIT] = \ + self.hass.config.units.temperature_unit + return attrs + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.power / 1000 diff --git a/requirements_all.txt b/requirements_all.txt index c2ee5814d7bf3..b3cf4dbeec1cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,6 +765,9 @@ pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 +# homeassistant.components.fritzbox +pyfritzhome==0.3.7 + # homeassistant.components.ifttt pyfttt==0.3 From 569f5c111fc1e1064df680b8f22a8584435dc495 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 17 Apr 2018 12:08:32 +0100 Subject: [PATCH 096/155] Adds SigFox sensor (#13731) * Create sigfox.py * Create test_sigfox.py * Update .coveragerc * Fix lints * Fix logger message string * More lints * Address reviewer comments * edit exception handling * Update sigfox.py * Update sigfox.py * Update sigfox.py * Update sigfox.py --- .coveragerc | 1 + homeassistant/components/sensor/sigfox.py | 161 ++++++++++++++++++++++ tests/components/sensor/test_sigfox.py | 68 +++++++++ 3 files changed, 230 insertions(+) create mode 100644 homeassistant/components/sensor/sigfox.py create mode 100644 tests/components/sensor/test_sigfox.py diff --git a/.coveragerc b/.coveragerc index 3da28762df0c6..1f86a13f6ae13 100644 --- a/.coveragerc +++ b/.coveragerc @@ -649,6 +649,7 @@ omit = homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/serial.py homeassistant/components/sensor/shodan.py + homeassistant/components/sensor/sigfox.py homeassistant/components/sensor/simulated.py homeassistant/components/sensor/skybeacon.py homeassistant/components/sensor/sma.py diff --git a/homeassistant/components/sensor/sigfox.py b/homeassistant/components/sensor/sigfox.py new file mode 100644 index 0000000000000..ef47132eefc5a --- /dev/null +++ b/homeassistant/components/sensor/sigfox.py @@ -0,0 +1,161 @@ +""" +Sensor for SigFox devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sigfox/ +""" +import logging +import datetime +import json +from urllib.parse import urljoin + +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_NAME +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=30) +API_URL = 'https://backend.sigfox.com/api/' +CONF_API_LOGIN = 'api_login' +CONF_API_PASSWORD = 'api_password' +DEFAULT_NAME = 'sigfox' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_LOGIN): cv.string, + vol.Required(CONF_API_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the sigfox sensor.""" + api_login = config[CONF_API_LOGIN] + api_password = config[CONF_API_PASSWORD] + name = config[CONF_NAME] + try: + sigfox = SigfoxAPI(api_login, api_password) + except ValueError: + return False + auth = sigfox.auth + devices = sigfox.devices + + sensors = [] + for device in devices: + sensors.append(SigfoxDevice(device, auth, name)) + add_devices(sensors, True) + + +def epoch_to_datetime(epoch_time): + """Take an ms since epoch and return datetime string.""" + return datetime.datetime.fromtimestamp(epoch_time).isoformat() + + +class SigfoxAPI(object): + """Class for interacting with the SigFox API.""" + + def __init__(self, api_login, api_password): + """Initialise the API object.""" + self._auth = requests.auth.HTTPBasicAuth(api_login, api_password) + if self.check_credentials(): + device_types = self.get_device_types() + self._devices = self.get_devices(device_types) + + def check_credentials(self): + """"Check API credentials are valid.""" + url = urljoin(API_URL, 'devicetypes') + response = requests.get(url, auth=self._auth, timeout=10) + if response.status_code != 200: + if response.status_code == 401: + _LOGGER.error( + "Invalid credentials for Sigfox API") + else: + _LOGGER.error( + "Unable to login to Sigfox API, error code %s", str( + response.status_code)) + raise ValueError('Sigfox component not setup') + return True + + def get_device_types(self): + """Get a list of device types.""" + url = urljoin(API_URL, 'devicetypes') + response = requests.get(url, auth=self._auth, timeout=10) + device_types = [] + for device in json.loads(response.text)['data']: + device_types.append(device['id']) + return device_types + + def get_devices(self, device_types): + """Get the device_id of each device registered.""" + devices = [] + for unique_type in device_types: + location_url = 'devicetypes/{}/devices'.format(unique_type) + url = urljoin(API_URL, location_url) + response = requests.get(url, auth=self._auth, timeout=10) + devices_data = json.loads(response.text)['data'] + for device in devices_data: + devices.append(device['id']) + return devices + + @property + def auth(self): + """Return the API authentification.""" + return self._auth + + @property + def devices(self): + """Return the list of device_id.""" + return self._devices + + +class SigfoxDevice(Entity): + """Class for single sigfox device.""" + + def __init__(self, device_id, auth, name): + """Initialise the device object.""" + self._device_id = device_id + self._auth = auth + self._message_data = {} + self._name = '{}_{}'.format(name, device_id) + self._state = None + + def get_last_message(self): + """Return the last message from a device.""" + device_url = 'devices/{}/messages?limit=1'.format(self._device_id) + url = urljoin(API_URL, device_url) + response = requests.get(url, auth=self._auth, timeout=10) + data = json.loads(response.text)['data'][0] + payload = bytes.fromhex(data['data']).decode('utf-8') + lat = data['rinfos'][0]['lat'] + lng = data['rinfos'][0]['lng'] + snr = data['snr'] + epoch_time = data['time'] + return {'lat': lat, + 'lng': lng, + 'payload': payload, + 'snr': snr, + 'time': epoch_to_datetime(epoch_time)} + + def update(self): + """Fetch the latest device message.""" + self._message_data = self.get_last_message() + self._state = self._message_data['payload'] + + @property + def name(self): + """Return the HA name of the sensor.""" + return self._name + + @property + def state(self): + """Return the payload of the last message.""" + return self._state + + @property + def device_state_attributes(self): + """Return other details about the last message.""" + return self._message_data diff --git a/tests/components/sensor/test_sigfox.py b/tests/components/sensor/test_sigfox.py new file mode 100644 index 0000000000000..dcdeef56b9879 --- /dev/null +++ b/tests/components/sensor/test_sigfox.py @@ -0,0 +1,68 @@ +"""Tests for the sigfox sensor.""" +import re +import requests_mock +import unittest + +from homeassistant.components.sensor.sigfox import ( + API_URL, CONF_API_LOGIN, CONF_API_PASSWORD) +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + +TEST_API_LOGIN = 'foo' +TEST_API_PASSWORD = 'ebcd1234' + +VALID_CONFIG = { + 'sensor': { + 'platform': 'sigfox', + CONF_API_LOGIN: TEST_API_LOGIN, + CONF_API_PASSWORD: TEST_API_PASSWORD}} + +VALID_MESSAGE = """ +{"data":[{ +"time":1521879720, +"data":"7061796c6f6164", +"rinfos":[{"lat":"0.0","lng":"0.0"}], +"snr":"50.0"}]} +""" + + +class TestSigfoxSensor(unittest.TestCase): + """Test the sigfox platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_invalid_credentials(self): + """Test for a invalid credentials.""" + with requests_mock.Mocker() as mock_req: + url = re.compile(API_URL + 'devicetypes') + mock_req.get(url, text='{}', status_code=401) + self.assertTrue( + setup_component(self.hass, 'sensor', VALID_CONFIG)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_credentials(self): + """Test for a valid credentials.""" + with requests_mock.Mocker() as mock_req: + url1 = re.compile(API_URL + 'devicetypes') + mock_req.get(url1, text='{"data":[{"id":"fake_type"}]}', + status_code=200) + + url2 = re.compile(API_URL + 'devicetypes/fake_type/devices') + mock_req.get(url2, text='{"data":[{"id":"fake_id"}]}') + + url3 = re.compile(API_URL + 'devices/fake_id/messages*') + mock_req.get(url3, text=VALID_MESSAGE) + + self.assertTrue( + setup_component(self.hass, 'sensor', VALID_CONFIG)) + + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.sigfox_fake_id') + assert state.state == 'payload' + assert state.attributes.get('snr') == '50.0' From 9fe43714c61441ed36042b996c2e178da979fa2b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 17 Apr 2018 13:32:16 +0200 Subject: [PATCH 097/155] Upgrade aiohttp to 3.1.3 (#13938) --- 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 9e21055f0c105..6de885942fb54 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==3.1.2 +aiohttp==3.1.3 async_timeout==2.0.1 astral==1.6 certifi>=2017.4.17 diff --git a/requirements_all.txt b/requirements_all.txt index b3cf4dbeec1cb..83c3503385821 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==3.1.2 +aiohttp==3.1.3 async_timeout==2.0.1 astral==1.6 certifi>=2017.4.17 diff --git a/setup.py b/setup.py index 602c1d19cbd3c..8815b0227ad70 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ 'jinja2>=2.10', 'voluptuous==0.11.1', 'typing>=3,<4', - 'aiohttp==3.1.2', + 'aiohttp==3.1.3', 'async_timeout==2.0.1', 'astral==1.6', 'certifi>=2017.4.17', From cff3bed1f034bbe929ef1ff3fa418dc9c19a1892 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 17 Apr 2018 13:32:44 +0200 Subject: [PATCH 098/155] Upgrade youtube_dl to 2018.04.16 (#13937) --- 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 85c569789a2c2..b5fd26b0bcb4f 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.04.03'] +REQUIREMENTS = ['youtube_dl==2018.04.16'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 83c3503385821..eae9e132a00e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1352,7 +1352,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.04.03 +youtube_dl==2018.04.16 # homeassistant.components.light.zengge zengge==0.2 From 3b44f91395ba67ebc1363c4e8f2e02189f218cac Mon Sep 17 00:00:00 2001 From: Dmitry Avramenko Date: Tue, 17 Apr 2018 20:23:41 +0800 Subject: [PATCH 099/155] Added FB messenger broadcast api to notify.facebook component (#12459) * Added ability to use FB messenger broadcast api. use 'BROADCAST' keyword for first target in the facebook notifiy component to enable. * Added ability to use FB messenger broadcast api. use 'BROADCAST' keyword for first target in the facebook notifiy component to enable. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Added ability for broadcast messaging for facebook messenger notify platform. * Update facebook.py * Update facebook.py * Update facebook.py * Update facebook.py --- homeassistant/components/notify/facebook.py | 74 +++++++++++++++------ 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index 791440fdb5b1a..b73f845ea175c 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.facebook/ """ +import json import logging from aiohttp.hdrs import CONTENT_TYPE @@ -19,6 +20,8 @@ CONF_PAGE_ACCESS_TOKEN = 'page_access_token' BASE_URL = 'https://graph.facebook.com/v2.6/me/messages' +CREATE_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/message_creatives' +SEND_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/broadcast_messages' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string, @@ -55,27 +58,60 @@ def send_message(self, message="", **kwargs): _LOGGER.error("At least 1 target is required") return - for target in targets: - # If the target starts with a "+", we suppose it's a phone number, - # otherwise it's a user id. - if target.startswith('+'): - recipient = {"phone_number": target} - else: - recipient = {"id": target} - - body = { - "recipient": recipient, - "message": body_message + # broadcast message + if targets[0].lower() == 'broadcast': + broadcast_create_body = {"messages": [body_message]} + _LOGGER.debug("Broadcast body %s : ", broadcast_create_body) + + resp = requests.post(CREATE_BROADCAST_URL, + data=json.dumps(broadcast_create_body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10) + _LOGGER.debug("FB Messager broadcast id %s : ", resp.json()) + + # at this point we get broadcast id + broadcast_body = { + "message_creative_id": resp.json().get('message_creative_id'), + "notification_type": "REGULAR", } - import json - resp = requests.post(BASE_URL, data=json.dumps(body), + + resp = requests.post(SEND_BROADCAST_URL, + data=json.dumps(broadcast_body), params=payload, headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10) if resp.status_code != 200: - obj = resp.json() - error_message = obj['error']['message'] - error_code = obj['error']['code'] - _LOGGER.error( - "Error %s : %s (Code %s)", resp.status_code, error_message, - error_code) + log_error(resp) + + # non-broadcast message + else: + for target in targets: + # If the target starts with a "+", it's a phone number, + # otherwise it's a user id. + if target.startswith('+'): + recipient = {"phone_number": target} + else: + recipient = {"id": target} + + body = { + "recipient": recipient, + "message": body_message + } + resp = requests.post(BASE_URL, data=json.dumps(body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10) + if resp.status_code != 200: + log_error(resp) + + +def log_error(response): + """Log error message.""" + obj = response.json() + error_message = obj['error']['message'] + error_code = obj['error']['code'] + + _LOGGER.error( + "Error %s : %s (Code %s)", response.status_code, error_message, + error_code) From f4b1a8e42d4f142151b252cd1ac214793fe01ba0 Mon Sep 17 00:00:00 2001 From: Tod Schmidt Date: Tue, 17 Apr 2018 09:24:54 -0400 Subject: [PATCH 100/155] Added web view for TTS to get url (#13882) * Added web view for to get url * Added web view for TTS to get url * Added web view for TTS to get url * Added web view for TTS to get url * Fixed test * added auth * Update __init__.py --- homeassistant/components/tts/__init__.py | 115 ++++++++++++++--------- tests/components/tts/test_init.py | 46 ++++++++- 2 files changed, 117 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 17aa66ea825a1..999b584360cd7 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -37,6 +37,7 @@ ATTR_LANGUAGE = 'language' ATTR_MESSAGE = 'message' ATTR_OPTIONS = 'options' +ATTR_PLATFORM = 'platform' CONF_CACHE = 'cache' CONF_CACHE_DIR = 'cache_dir' @@ -77,8 +78,7 @@ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up TTS.""" tts = SpeechManager(hass) @@ -88,27 +88,27 @@ def async_setup(hass, config): cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - yield from tts.async_init_cache(use_cache, cache_dir, time_memory) + await tts.async_init_cache(use_cache, cache_dir, time_memory) except (HomeAssistantError, KeyError) as err: _LOGGER.error("Error on cache init %s", err) return False hass.http.register_view(TextToSpeechView(tts)) + hass.http.register_view(TextToSpeechUrlView(tts)) - @asyncio.coroutine - def async_setup_platform(p_type, p_config, disc_info=None): + async def async_setup_platform(p_type, p_config, disc_info=None): """Set up a TTS platform.""" - platform = yield from async_prepare_setup_platform( + platform = await async_prepare_setup_platform( hass, config, DOMAIN, p_type) if platform is None: return try: if hasattr(platform, 'async_get_engine'): - provider = yield from platform.async_get_engine( + provider = await platform.async_get_engine( hass, p_config) else: - provider = yield from hass.async_add_job( + provider = await hass.async_add_job( platform.get_engine, hass, p_config) if provider is None: @@ -120,8 +120,7 @@ def async_setup_platform(p_type, p_config, disc_info=None): _LOGGER.exception("Error setting up platform %s", p_type) return - @asyncio.coroutine - def async_say_handle(service): + async def async_say_handle(service): """Service handle for say.""" entity_ids = service.data.get(ATTR_ENTITY_ID) message = service.data.get(ATTR_MESSAGE) @@ -130,7 +129,7 @@ def async_say_handle(service): options = service.data.get(ATTR_OPTIONS) try: - url = yield from tts.async_get_url( + url = await tts.async_get_url( p_type, message, cache=cache, language=language, options=options ) @@ -146,7 +145,7 @@ def async_say_handle(service): if entity_ids: data[ATTR_ENTITY_ID] = entity_ids - yield from hass.services.async_call( + await hass.services.async_call( DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True) hass.services.async_register( @@ -157,12 +156,11 @@ def async_say_handle(service): in config_per_platform(config, DOMAIN)] if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks, loop=hass.loop) - @asyncio.coroutine - def async_clear_cache_handle(service): + async def async_clear_cache_handle(service): """Handle clear cache service call.""" - yield from tts.async_clear_cache() + await tts.async_clear_cache() hass.services.async_register( DOMAIN, SERVICE_CLEAR_CACHE, async_clear_cache_handle, @@ -185,8 +183,7 @@ def __init__(self, hass): self.file_cache = {} self.mem_cache = {} - @asyncio.coroutine - def async_init_cache(self, use_cache, cache_dir, time_memory): + async def async_init_cache(self, use_cache, cache_dir, time_memory): """Init config folder and load file cache.""" self.use_cache = use_cache self.time_memory = time_memory @@ -201,7 +198,7 @@ def init_tts_cache_dir(cache_dir): return cache_dir try: - self.cache_dir = yield from self.hass.async_add_job( + self.cache_dir = await self.hass.async_add_job( init_tts_cache_dir, cache_dir) except OSError as err: raise HomeAssistantError("Can't init cache dir {}".format(err)) @@ -222,15 +219,14 @@ def get_cache_files(): return cache try: - cache_files = yield from self.hass.async_add_job(get_cache_files) + cache_files = await self.hass.async_add_job(get_cache_files) except OSError as err: raise HomeAssistantError("Can't read cache dir {}".format(err)) if cache_files: self.file_cache.update(cache_files) - @asyncio.coroutine - def async_clear_cache(self): + async def async_clear_cache(self): """Read file cache and delete files.""" self.mem_cache = {} @@ -243,7 +239,7 @@ def remove_files(): _LOGGER.warning( "Can't remove cache file '%s': %s", filename, err) - yield from self.hass.async_add_job(remove_files) + await self.hass.async_add_job(remove_files) self.file_cache = {} @callback @@ -254,9 +250,8 @@ def async_register_engine(self, engine, provider, config): provider.name = engine self.providers[engine] = provider - @asyncio.coroutine - def async_get_url(self, engine, message, cache=None, language=None, - options=None): + async def async_get_url(self, engine, message, cache=None, language=None, + options=None): """Get URL for play message. This method is a coroutine. @@ -301,21 +296,20 @@ def async_get_url(self, engine, message, cache=None, language=None, self.hass.async_add_job(self.async_file_to_mem(key)) # Load speech from provider into memory else: - filename = yield from self.async_get_tts_audio( + filename = await self.async_get_tts_audio( engine, key, message, use_cache, language, options) return "{}/api/tts_proxy/{}".format( self.hass.config.api.base_url, filename) - @asyncio.coroutine - def async_get_tts_audio(self, engine, key, message, cache, language, - options): + async def async_get_tts_audio(self, engine, key, message, cache, language, + options): """Receive TTS and store for view in cache. This method is a coroutine. """ provider = self.providers[engine] - extension, data = yield from provider.async_get_tts_audio( + extension, data = await provider.async_get_tts_audio( message, language, options) if data is None or extension is None: @@ -337,8 +331,7 @@ def async_get_tts_audio(self, engine, key, message, cache, language, return filename - @asyncio.coroutine - def async_save_tts_audio(self, key, filename, data): + async def async_save_tts_audio(self, key, filename, data): """Store voice data to file and file_cache. This method is a coroutine. @@ -351,13 +344,12 @@ def save_speech(): speech.write(data) try: - yield from self.hass.async_add_job(save_speech) + await self.hass.async_add_job(save_speech) self.file_cache[key] = filename except OSError: _LOGGER.error("Can't write %s", filename) - @asyncio.coroutine - def async_file_to_mem(self, key): + async def async_file_to_mem(self, key): """Load voice from file cache into memory. This method is a coroutine. @@ -374,7 +366,7 @@ def load_speech(): return speech.read() try: - data = yield from self.hass.async_add_job(load_speech) + data = await self.hass.async_add_job(load_speech) except OSError: del self.file_cache[key] raise HomeAssistantError("Can't read {}".format(voice_file)) @@ -396,8 +388,7 @@ def async_remove_from_mem(): self.hass.loop.call_later(self.time_memory, async_remove_from_mem) - @asyncio.coroutine - def async_read_tts(self, filename): + async def async_read_tts(self, filename): """Read a voice file and return binary. This method is a coroutine. @@ -412,7 +403,7 @@ def async_read_tts(self, filename): if key not in self.mem_cache: if key not in self.file_cache: raise HomeAssistantError("{} not in cache!".format(key)) - yield from self.async_file_to_mem(key) + await self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) return (content, self.mem_cache[key][MEM_CACHE_VOICE]) @@ -490,6 +481,45 @@ def async_get_tts_audio(self, message, language, options=None): ft.partial(self.get_tts_audio, message, language, options=options)) +class TextToSpeechUrlView(HomeAssistantView): + """TTS view to get a url to a generated speech file.""" + + requires_auth = True + url = '/api/tts_get_url' + name = 'api:tts:geturl' + + def __init__(self, tts): + """Initialize a tts view.""" + self.tts = tts + + async def post(self, request): + """Generate speech and provide url.""" + try: + data = await request.json() + except ValueError: + return self.json_message('Invalid JSON specified', 400) + if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): + return self.json_message('Must specify platform and message', 400) + + p_type = data[ATTR_PLATFORM] + message = data[ATTR_MESSAGE] + cache = data.get(ATTR_CACHE) + language = data.get(ATTR_LANGUAGE) + options = data.get(ATTR_OPTIONS) + + try: + url = await self.tts.async_get_url( + p_type, message, cache=cache, language=language, + options=options + ) + resp = self.json({'url': url}, 200) + except HomeAssistantError as err: + _LOGGER.error("Error on init tts: %s", err) + resp = self.json({'error': err}, 400) + + return resp + + class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" @@ -501,11 +531,10 @@ def __init__(self, tts): """Initialize a tts view.""" self.tts = tts - @asyncio.coroutine - def get(self, request, filename): + async def get(self, request, filename): """Start a get request.""" try: - content, data = yield from self.tts.async_read_tts(filename) + content, data = await self.tts.async_read_tts(filename) except HomeAssistantError as err: _LOGGER.error("Error on load tts: %s", err) return web.Response(status=404) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7a15ed28f975a..b6bfa430fd24f 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,6 +2,7 @@ import ctypes import os import shutil +import json from unittest.mock import patch, PropertyMock import pytest @@ -353,7 +354,7 @@ def test_setup_component_and_test_service_with_receive_voice(self): demo_data = tts.SpeechManager.write_tags( "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3", demo_data, self.demo_provider, - "I person is on front of your door.", 'en', None) + "AI person is in front of your door.", 'en', None) assert req.status_code == 200 assert req.content == demo_data @@ -562,3 +563,46 @@ def test_setup_component_load_cache_retrieve_without_mem_cache(self): req = requests.get(url) assert req.status_code == 200 assert req.content == demo_data + + def test_setup_component_and_web_get_url(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) + data = {'platform': 'demo', + 'message': "I person is on front of your door."} + + req = requests.post(url, data=json.dumps(data)) + assert req.status_code == 200 + response = json.loads(req.text) + assert response.get('url') == (("{}/api/tts_proxy/265944c108cbb00b2a62" + "1be5930513e03a0bb2cd_en_-_demo.mp3") + .format(self.hass.config.api.base_url)) + + def test_setup_component_and_web_get_url_bad_config(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) + data = {'message': "I person is on front of your door."} + + req = requests.post(url, data=data) + assert req.status_code == 400 From 783e9a5f8c538493f956a01673693d3f4617134e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Apr 2018 10:17:54 -0400 Subject: [PATCH 101/155] Update frontend to 20180417 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 80b7cdff5a8b4..45d16ae5fb6b9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180414.0'] +REQUIREMENTS = ['home-assistant-frontend==20180417.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index eae9e132a00e8..c496793c11f4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -380,7 +380,7 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180414.0 +home-assistant-frontend==20180417.0 # homeassistant.components.homekit_controller # homekit==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7ce54c64c030..4c06b6ad959af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180414.0 +home-assistant-frontend==20180417.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From e472436b8467f5bfee9659badced49ac58fb4a06 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Tue, 17 Apr 2018 17:37:00 +0200 Subject: [PATCH 102/155] Add services for bmw_connected_drive (#13497) * implemented services for bmw remote services * added vin to attributes of tracker * moved component to new package * added service description * fixed static analysis warnings * implemented first set of code reviews * removed locking related services * fixed static analysis warnings * removed excess blank lines * refactoring of setup() to resolve warning "Cell variable bimmer defined in loop (cell-var-from-loop)" * added missing docstring * added service to update all vehicles from the server * implemented changes requested in code review * added check if invalid vin is entered --- .../__init__.py} | 85 ++++++++++++++----- .../bmw_connected_drive/services.yaml | 42 +++++++++ .../device_tracker/bmw_connected_drive.py | 7 +- 3 files changed, 113 insertions(+), 21 deletions(-) rename homeassistant/components/{bmw_connected_drive.py => bmw_connected_drive/__init__.py} (55%) create mode 100644 homeassistant/components/bmw_connected_drive/services.yaml diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive/__init__.py similarity index 55% rename from homeassistant/components/bmw_connected_drive.py rename to homeassistant/components/bmw_connected_drive/__init__.py index 48452b6d79b78..347bab6f52940 100644 --- a/homeassistant/components/bmw_connected_drive.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -20,7 +20,7 @@ DOMAIN = 'bmw_connected_drive' CONF_REGION = 'region' - +ATTR_VIN = 'vin' ACCOUNT_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, @@ -35,35 +35,40 @@ }, }, extra=vol.ALLOW_EXTRA) +SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_VIN): cv.string, +}) + BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor'] UPDATE_INTERVAL = 5 # in minutes +SERVICE_UPDATE_STATE = 'update_state' -def setup(hass, config): +_SERVICE_MAP = { + 'light_flash': 'trigger_remote_light_flash', + 'sound_horn': 'trigger_remote_horn', + 'activate_air_conditioning': 'trigger_remote_air_conditioning', +} + + +def setup(hass, config: dict): """Set up the BMW connected drive components.""" accounts = [] for name, account_config in config[DOMAIN].items(): - username = account_config[CONF_USERNAME] - password = account_config[CONF_PASSWORD] - region = account_config[CONF_REGION] - _LOGGER.debug('Adding new account %s', name) - bimmer = BMWConnectedDriveAccount(username, password, region, name) - accounts.append(bimmer) - - # update every UPDATE_INTERVAL minutes, starting now - # this should even out the load on the servers - - now = datetime.datetime.now() - track_utc_time_change( - hass, bimmer.update, - minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), - second=now.second) + accounts.append(setup_account(account_config, hass, name)) hass.data[DOMAIN] = accounts - for account in accounts: - account.update() + def _update_all(call) -> None: + """Update all BMW accounts.""" + for cd_account in hass.data[DOMAIN]: + cd_account.update() + + # Service to manually trigger updates for all accounts. + hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all) + + _update_all(None) for component in BMW_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -71,6 +76,48 @@ def setup(hass, config): return True +def setup_account(account_config: dict, hass, name: str) \ + -> 'BMWConnectedDriveAccount': + """Set up a new BMWConnectedDriveAccount based on the config.""" + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + region = account_config[CONF_REGION] + _LOGGER.debug('Adding new account %s', name) + cd_account = BMWConnectedDriveAccount(username, password, region, name) + + def execute_service(call): + """Execute a service for a vehicle. + + This must be a member function as we need access to the cd_account + object here. + """ + vin = call.data[ATTR_VIN] + vehicle = cd_account.account.get_vehicle(vin) + if not vehicle: + _LOGGER.error('Could not find a vehicle for VIN "%s"!', vin) + return + function_name = _SERVICE_MAP[call.service] + function_call = getattr(vehicle.remote_services, function_name) + function_call() + + # register the remote services + for service in _SERVICE_MAP: + hass.services.register( + DOMAIN, service, + execute_service, + schema=SERVICE_SCHEMA) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + now = datetime.datetime.now() + track_utc_time_change( + hass, cd_account.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second) + + return cd_account + + class BMWConnectedDriveAccount(object): """Representation of a BMW vehicle.""" diff --git a/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml new file mode 100644 index 0000000000000..3c18027191925 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/services.yaml @@ -0,0 +1,42 @@ +# Describes the format for available services for bmw_connected_drive +# +# The services related to locking/unlocking are implemented in the lock +# component to avoid redundancy. + +light_flash: + description: > + Flash the lights of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +sound_horn: + description: > + Sound the horn of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +activate_air_conditioning: + description: > + Start the air conditioning of the vehicle. What exactly is started here + depends on the type of vehicle. It might range from just ventilation over + auxilary heating to real air conditioning. The vehicle is identified via + the vin (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +update_state: + description: > + Fetch the last state of the vehicles of all your accounts from the BMW + server. This does *not* trigger an update from the vehicle, it just gets + the data from the BMW servers. This service does not require any attributes. \ No newline at end of file diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py index 2267bb5194431..f36afc622ee1b 100644 --- a/homeassistant/components/device_tracker/bmw_connected_drive.py +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -48,8 +48,11 @@ def update(self) -> None: return _LOGGER.debug('Updating %s', dev_id) - + attrs = { + 'vin': self.vehicle.vin, + } self._see( dev_id=dev_id, host_name=self.vehicle.name, - gps=self.vehicle.state.gps_position, icon='mdi:car' + gps=self.vehicle.state.gps_position, attributes=attrs, + icon='mdi:car' ) From 08f545d67b2b8add12bc6ac9961f8f510164668b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 17 Apr 2018 17:40:52 +0200 Subject: [PATCH 103/155] Fix call to parent broadlink switch (#13906) * Broadlink switch, fixes issue #13799 * slugify --- homeassistant/components/switch/broadlink.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 3e620a6a25b01..50c334b1f09ad 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -19,7 +19,7 @@ CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_FRIENDLY_NAME, CONF_HOST, CONF_MAC, CONF_SWITCHES, CONF_TIMEOUT, CONF_TYPE) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.util import Throttle, slugify from homeassistant.util.dt import utcnow REQUIREMENTS = ['broadlink==0.8.0'] @@ -187,7 +187,7 @@ class BroadlinkRMSwitch(SwitchDevice): def __init__(self, name, friendly_name, device, command_on, command_off): """Initialize the switch.""" - self.entity_id = ENTITY_ID_FORMAT.format(name) + self.entity_id = ENTITY_ID_FORMAT.format(slugify(name)) self._name = friendly_name self._state = False self._command_on = b64decode(command_on) if command_on else None @@ -257,7 +257,7 @@ class BroadlinkSP1Switch(BroadlinkRMSwitch): def __init__(self, friendly_name, device): """Initialize the switch.""" - super().__init__(friendly_name, device, None, None) + super().__init__(friendly_name, friendly_name, device, None, None) self._command_on = 1 self._command_off = 0 @@ -313,7 +313,7 @@ class BroadlinkMP1Slot(BroadlinkRMSwitch): def __init__(self, friendly_name, device, slot, parent_device): """Initialize the slot of switch.""" - super().__init__(friendly_name, device, None, None) + super().__init__(friendly_name, friendly_name, device, None, None) self._command_on = 1 self._command_off = 0 self._slot = slot From 1a9ea11665bbf15f37b83dd9a226825c5a1fe3e6 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Tue, 17 Apr 2018 20:00:53 +0200 Subject: [PATCH 104/155] Bump deCONZ requirement to v36 (#13960) --- homeassistant/components/deconz/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 04cd42ca620e8..0cf96576223df 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pydeconz==35'] +REQUIREMENTS = ['pydeconz==36'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c496793c11f4a..e40e513bbc9f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -730,7 +730,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==35 +pydeconz==36 # homeassistant.components.zwave pydispatcher==2.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c06b6ad959af..56a645d4fd941 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -130,7 +130,7 @@ pushbullet.py==0.11.0 py-canary==0.5.0 # homeassistant.components.deconz -pydeconz==35 +pydeconz==36 # homeassistant.components.zwave pydispatcher==2.0.5 From 65b8f9764abb24982a0bf4e488bb4e8e9460d573 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 17 Apr 2018 12:03:22 -0600 Subject: [PATCH 105/155] Bumped pypollencom to 1.1.2 (#13959) * Bumped pypollencom to 1.1.2 * Updated requirements_all.txt --- homeassistant/components/sensor/pollen.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 640e13e437ab6..b55c60f6e7cf5 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['pypollencom==1.1.1'] +REQUIREMENTS = ['pypollencom==1.1.2'] _LOGGER = logging.getLogger(__name__) ATTR_ALLERGEN_GENUS = 'primary_allergen_genus' diff --git a/requirements_all.txt b/requirements_all.txt index e40e513bbc9f5..38e9ee01ab116 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -889,7 +889,7 @@ pyotp==2.2.6 pyowm==2.8.0 # homeassistant.components.sensor.pollen -pypollencom==1.1.1 +pypollencom==1.1.2 # homeassistant.components.qwikswitch pyqwikswitch==0.71 From 4ba58d0760943b7a7b2c028636647e3e68962802 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Wed, 18 Apr 2018 01:10:32 -0700 Subject: [PATCH 106/155] Bump skybellpy version to 0.1.2 (#13974) --- homeassistant/components/skybell.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/skybell.py b/homeassistant/components/skybell.py index 854abdda7bc25..3f27c91e7c5b0 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['skybellpy==0.1.1'] +REQUIREMENTS = ['skybellpy==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 38e9ee01ab116..8839fb841ae22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1169,7 +1169,7 @@ simplepush==1.1.4 simplisafe-python==1.0.5 # homeassistant.components.skybell -skybellpy==0.1.1 +skybellpy==0.1.2 # homeassistant.components.notify.slack slacker==0.9.65 From f11d4319d2b9b07384380c8ff4a2d132d4bfbbb2 Mon Sep 17 00:00:00 2001 From: stephanerosi Date: Wed, 18 Apr 2018 12:43:55 +0200 Subject: [PATCH 107/155] Fix typo an coding style (#13970) --- .../components/device_tracker/nmap_tracker.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index f62f53fe5fc68..3c090e8cd3b9f 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -94,13 +94,11 @@ def get_device_name(self, device): return None def get_extra_attributes(self, device): - """Return the IP pf the given device.""" - filter_ip = [result.ip for result in self.last_results - if result.mac == device] - - if filter_ip: - return {'ip': filter_ip[0]} - return None + """Return the IP of the given device.""" + filter_ip = next(( + result.ip for result in self.last_results + if result.mac == device), None) + return {'ip': filter_ip} def _update_info(self): """Scan the network for devices. From 23b97b9105fbb20396488acd6a4e6bd447195e99 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 18 Apr 2018 14:38:44 +0200 Subject: [PATCH 108/155] Params of the send command can be a list now (#13905) --- homeassistant/components/vacuum/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 095e8bfb124ff..d403a776ddfe6 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ vol.Required(ATTR_COMMAND): cv.string, - vol.Optional(ATTR_PARAMS): cv.Dict, + vol.Optional(ATTR_PARAMS): vol.Any(cv.Dict, cv.ensure_list), }) SERVICE_TO_METHOD = { From b589dbf26c1d387105935b45f8d5bde1c3d52be1 Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Wed, 18 Apr 2018 22:39:58 +1000 Subject: [PATCH 109/155] Support basic covers with open/close/stop services HomeKit (#13819) * Support basic covers with open/close/stop services * Support optional stop * Tests --- homeassistant/components/homekit/__init__.py | 2 + homeassistant/components/homekit/const.py | 4 +- .../components/homekit/type_covers.py | 67 +++++++++- .../homekit/test_get_accessories.py | 7 ++ tests/components/homekit/test_type_covers.py | 117 +++++++++++++++++- 5 files changed, 189 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 306f399092a32..24c6dfa8a7686 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -101,6 +101,8 @@ def get_accessory(hass, state, aid, config): a_type = 'GarageDoorOpener' elif features & SUPPORT_SET_POSITION: a_type = 'WindowCovering' + elif features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'WindowCoveringBasic' elif state.domain == 'light': a_type = 'Light' diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 79466cd9ff0a8..1c498b4b3b9e5 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -52,7 +52,8 @@ SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' -SERV_WINDOW_COVERING = 'WindowCovering' # CurrentPosition, TargetPosition +SERV_WINDOW_COVERING = 'WindowCovering' +# CurrentPosition, TargetPosition, PositionState # #### Characteristics #### @@ -85,6 +86,7 @@ CHAR_NAME = 'Name' CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' CHAR_ON = 'On' # boolean +CHAR_POSITION_STATE = 'PositionState' CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 9c852bb4d8635..8ec715e0e01bc 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -2,15 +2,17 @@ import logging from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN) + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED) + ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED, + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_STOP_COVER, + ATTR_SUPPORTED_FEATURES) from . import TYPES from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_WINDOW_COVERING, SERV_WINDOW_COVERING, - CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, + CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE, CATEGORY_GARAGE_DOOR_OPENER, SERV_GARAGE_DOOR_OPENER, CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) @@ -96,3 +98,62 @@ def update_state(self, new_state): abs(current_position - self.homekit_target) < 6: self.char_target_position.set_value(current_position) self.homekit_target = None + + +@TYPES.register('WindowCoveringBasic') +class WindowCoveringBasic(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: open_cover, close_cover, + stop_cover (optional). + """ + + def __init__(self, *args, config): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + self.supports_stop = features & SUPPORT_STOP + + serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) + self.char_current_position = setup_char( + CHAR_CURRENT_POSITION, serv_cover, value=0) + self.char_target_position = setup_char( + CHAR_TARGET_POSITION, serv_cover, value=0, + callback=self.move_cover) + self.char_position_state = setup_char( + CHAR_POSITION_STATE, serv_cover, value=2) + + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + + if self.supports_stop: + if value > 70: + service, position = (SERVICE_OPEN_COVER, 100) + elif value < 30: + service, position = (SERVICE_CLOSE_COVER, 0) + else: + service, position = (SERVICE_STOP_COVER, 50) + else: + if value >= 50: + service, position = (SERVICE_OPEN_COVER, 100) + else: + service, position = (SERVICE_CLOSE_COVER, 0) + + self.hass.services.call(DOMAIN, service, + {ATTR_ENTITY_ID: self.entity_id}) + + # Snap the current/target position to the expected final position. + self.char_current_position.set_value(position) + self.char_target_position.set_value(position) + self.char_position_state.set_value(2) + + def update_state(self, new_state): + """Update cover position after state changed.""" + position_mapping = {STATE_OPEN: 100, STATE_CLOSED: 0} + hk_position = position_mapping.get(new_state.state) + if hk_position is not None: + self.char_current_position.set_value(hk_position) + self.char_target_position.set_value(hk_position) + self.char_position_state.set_value(2) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 8333f1fb893a8..c26982e170be9 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -154,6 +154,13 @@ def test_cover_set_position(self): {ATTR_SUPPORTED_FEATURES: 4}) get_accessory(None, state, 2, {}) + def test_cover_open_close(self): + """Test cover with support for open and close.""" + with patch.dict(TYPES, {'WindowCoveringBasic': self.mock_type}): + state = State('cover.open_window', 'open', + {ATTR_SUPPORTED_FEATURES: 3}) + get_accessory(None, state, 2, {}) + def test_alarm_control_panel(self): """Test alarm control panel.""" config = {ATTR_CODE: '1234'} diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index f9889b1bdd831..2dcb48a4d4c45 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -3,12 +3,13 @@ from homeassistant.core import callback from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_CURRENT_POSITION) + ATTR_POSITION, ATTR_CURRENT_POSITION, SUPPORT_STOP) from homeassistant.components.homekit.type_covers import ( - GarageDoorOpener, WindowCovering) + GarageDoorOpener, WindowCovering, WindowCoveringBasic) from homeassistant.const import ( STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OPEN, - ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) + ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + ATTR_SUPPORTED_FEATURES) from tests.common import get_test_home_assistant @@ -132,9 +133,117 @@ def test_window_set_cover_position(self): acc.char_target_position.client_update_value(75) self.hass.block_till_done() self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.events[1].data[ATTR_SERVICE], 'set_cover_position') self.assertEqual( self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75) self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 75) + + def test_window_open_close(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: 0}) + acc = WindowCoveringBasic(self.hass, 'Cover', window_cover, 2, + config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 14) # WindowCovering + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_OPEN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_CLOSED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(90) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(55) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + def test_window_open_close_stop(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP}) + acc = WindowCoveringBasic(self.hass, 'Cover', window_cover, 2, + config=None) + acc.run() + + # Set from HomeKit + acc.char_target_position.client_update_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(90) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(55) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'stop_cover') + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 50) + self.assertEqual(acc.char_position_state.value, 2) From 7d43ad6a37ef80c06890933113f5a9eace46a938 Mon Sep 17 00:00:00 2001 From: Ben Randall Date: Wed, 18 Apr 2018 07:18:44 -0700 Subject: [PATCH 110/155] Colorlog windows fix (#13929) * Fix colorlog on windows Modified the way logging is initialized to fix two things. 1. If the import of `colorlog` fails the logs will still be formatted using the expected HASS log format. 2. Ensure that `logging.basicConfig` is called AFTER `colorlog` is imported so that the default handler generated will be writing to the wrapped stream generated when `colorama` is initialized. This allows colored logging to work on Windows. Added support for a `--log-no-color` command line switch in the event that someone just wants to disable colored log output entirely. * Fix line lengths * Switch default value --- homeassistant/__main__.py | 9 ++- homeassistant/bootstrap.py | 76 +++++++++++++++---------- homeassistant/components/notify/xmpp.py | 2 - 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index aa96602792212..deb1746c1670d 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -126,6 +126,10 @@ def get_arguments() -> argparse.Namespace: default=None, help='Log file to write to. If not set, CONFIG/home-assistant.log ' 'is used') + parser.add_argument( + '--log-no-color', + action='store_true', + help="Disable color logs") parser.add_argument( '--runner', action='store_true', @@ -259,13 +263,14 @@ def setup_and_run_hass(config_dir: str, hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, - log_file=args.log_file) + log_file=args.log_file, log_no_color=args.log_no_color) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days, log_file=args.log_file) + log_rotate_days=args.log_rotate_days, log_file=args.log_file, + log_no_color=args.log_no_color) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 00822d9329907..e0962568a6691 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -42,7 +42,8 @@ def from_config_dict(config: Dict[str, Any], verbose: bool = False, skip_pip: bool = False, log_rotate_days: Any = None, - log_file: Any = None) \ + log_file: Any = None, + log_no_color: bool = False) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -60,7 +61,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days, log_file) + log_rotate_days, log_file, log_no_color) ) return hass @@ -74,7 +75,8 @@ def async_from_config_dict(config: Dict[str, Any], verbose: bool = False, skip_pip: bool = False, log_rotate_days: Any = None, - log_file: Any = None) \ + log_file: Any = None, + log_no_color: bool = False) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -84,7 +86,8 @@ def async_from_config_dict(config: Dict[str, Any], start = time() if enable_log: - async_enable_logging(hass, verbose, log_rotate_days, log_file) + async_enable_logging(hass, verbose, log_rotate_days, log_file, + log_no_color) core_config = config.get(core.DOMAIN, {}) @@ -164,7 +167,8 @@ def from_config_file(config_path: str, verbose: bool = False, skip_pip: bool = True, log_rotate_days: Any = None, - log_file: Any = None): + log_file: Any = None, + log_no_color: bool = False): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -176,7 +180,8 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days, log_file) + config_path, hass, verbose, skip_pip, + log_rotate_days, log_file, log_no_color) ) return hass @@ -188,7 +193,8 @@ def async_from_config_file(config_path: str, verbose: bool = False, skip_pip: bool = True, log_rotate_days: Any = None, - log_file: Any = None): + log_file: Any = None, + log_no_color: bool = False): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -199,7 +205,8 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days, log_file) + async_enable_logging(hass, verbose, log_rotate_days, log_file, + log_no_color) try: config_dict = yield from hass.async_add_job( @@ -216,40 +223,51 @@ def async_from_config_file(config_path: str, @core.callback -def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False, - log_rotate_days=None, log_file=None) -> None: +def async_enable_logging(hass: core.HomeAssistant, + verbose: bool = False, + log_rotate_days=None, + log_file=None, + log_no_color: bool = False) -> None: """Set up the logging. This method must be run in the event loop. """ - logging.basicConfig(level=logging.INFO) fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " "[%(name)s] %(message)s") - colorfmt = "%(log_color)s{}%(reset)s".format(fmt) datefmt = '%Y-%m-%d %H:%M:%S' + if not log_no_color: + try: + from colorlog import ColoredFormatter + # basicConfig must be called after importing colorlog in order to + # ensure that the handlers it sets up wraps the correct streams. + logging.basicConfig(level=logging.INFO) + + colorfmt = "%(log_color)s{}%(reset)s".format(fmt) + logging.getLogger().handlers[0].setFormatter(ColoredFormatter( + colorfmt, + datefmt=datefmt, + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + except ImportError: + pass + + # If the above initialization failed for any reason, setup the default + # formatting. If the above succeeds, this wil result in a no-op. + logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + # Suppress overly verbose logs from libraries that aren't helpful logging.getLogger('requests').setLevel(logging.WARNING) logging.getLogger('urllib3').setLevel(logging.WARNING) logging.getLogger('aiohttp.access').setLevel(logging.WARNING) - try: - from colorlog import ColoredFormatter - logging.getLogger().handlers[0].setFormatter(ColoredFormatter( - colorfmt, - datefmt=datefmt, - reset=True, - log_colors={ - 'DEBUG': 'cyan', - 'INFO': 'green', - 'WARNING': 'yellow', - 'ERROR': 'red', - 'CRITICAL': 'red', - } - )) - except ImportError: - pass - # Log errors to a file if we have write access to file or config dir if log_file is None: err_log_path = hass.config.path(ERROR_LOG_FILENAME) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 806acdb6d0900..12ddf49fca8bd 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -76,8 +76,6 @@ def __init__(self): """Initialize the Jabber Bot.""" super(SendNotificationBot, self).__init__(sender, password) - logging.basicConfig(level=logging.ERROR) - self.use_tls = use_tls self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) From c5cb28d41fcdf1b8cf4b7c8c852a5606ca8351da Mon Sep 17 00:00:00 2001 From: Kane610 Date: Wed, 18 Apr 2018 16:27:44 +0200 Subject: [PATCH 111/155] deCONZ migrate setup fully to config entry (#13679) * Initial working config entry with discovery * No need for else * Make sure that imported config doesnt exist as a config entry * Improve checks to make sure there is only instance of deconz * Fix tests and add new tests * Follow upstream changes Fix case when discovery started ongoing config entry and user completes setup from other path it was possible to complete discovered config entry as well * Add test to make sure link doesn't bypass any check for only allowing one config entry * Dont use len to determine an empty sequence * Cleanup * Allways get bridgeid to use as unique identifier for bridge --- .../components/deconz/.translations/en.json | 1 + homeassistant/components/deconz/__init__.py | 204 +++------------- .../components/deconz/config_flow.py | 139 +++++++++++ homeassistant/components/deconz/const.py | 8 + homeassistant/components/deconz/strings.json | 1 + homeassistant/components/discovery.py | 2 +- tests/components/deconz/__init__.py | 1 + tests/components/deconz/test_config_flow.py | 225 ++++++++++++++++++ tests/components/deconz/test_init.py | 69 ++++++ tests/components/test_deconz.py | 97 -------- 10 files changed, 480 insertions(+), 267 deletions(-) create mode 100644 homeassistant/components/deconz/config_flow.py create mode 100644 homeassistant/components/deconz/const.py create mode 100644 tests/components/deconz/__init__.py create mode 100644 tests/components/deconz/test_config_flow.py create mode 100644 tests/components/deconz/test_init.py delete mode 100644 tests/components/test_deconz.py diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 69165dbbbaf3f..7ea68af01c1dc 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -18,6 +18,7 @@ "no_key": "Couldn't get an API key" }, "abort": { + "already_configured": "Bridge is already configured", "no_bridges": "No deCONZ bridges discovered", "one_instance_only": "Component only supports one deCONZ instance" } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 0cf96576223df..064725eda9514 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -4,28 +4,20 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/deconz/ """ -import logging - import voluptuous as vol -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.discovery import SERVICE_DECONZ from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import discovery, aiohttp_client -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['pydeconz==36'] - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers import ( + aiohttp_client, discovery, config_validation as cv) +from homeassistant.util.json import load_json -DOMAIN = 'deconz' -DATA_DECONZ_ID = 'deconz_entities' +# Loading the config flow file will register the flow +from .config_flow import configured_hosts +from .const import CONFIG_FILE, DATA_DECONZ_ID, DOMAIN, _LOGGER -CONFIG_FILE = 'deconz.conf' +REQUIREMENTS = ['pydeconz==36'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -46,44 +38,36 @@ }) -CONFIG_INSTRUCTIONS = """ -Unlock your deCONZ gateway to register with Home Assistant. - -1. [Go to deCONZ system settings](http://{}:{}/edit_system.html) -2. Press "Unlock Gateway" button - -[deCONZ platform documentation](https://home-assistant.io/components/deconz/) -""" - - async def async_setup(hass, config): - """Set up services and configuration for deCONZ component.""" - result = False - config_file = await hass.async_add_job( - load_json, hass.config.path(CONFIG_FILE)) - - async def async_deconz_discovered(service, discovery_info): - """Call when deCONZ gateway has been found.""" - deconz_config = {} - deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) - deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) - await async_request_configuration(hass, config, deconz_config) + """Load configuration for deCONZ component. - if config_file: - result = await async_setup_deconz(hass, config, config_file) - - if not result and DOMAIN in config and CONF_HOST in config[DOMAIN]: - deconz_config = config[DOMAIN] - if CONF_API_KEY in deconz_config: - result = await async_setup_deconz(hass, config, deconz_config) - else: - await async_request_configuration(hass, config, deconz_config) - return True + Discovery has loaded the component if DOMAIN is not present in config. + """ + if DOMAIN in config: + deconz_config = None + config_file = await hass.async_add_job( + load_json, hass.config.path(CONFIG_FILE)) + if config_file: + deconz_config = config_file + elif CONF_HOST in config[DOMAIN]: + deconz_config = config[DOMAIN] + if deconz_config and not configured_hosts(hass): + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data=deconz_config + )) + return True - if not result: - discovery.async_listen(hass, SERVICE_DECONZ, async_deconz_discovered) - return True +async def async_setup_entry(hass, entry): + """Set up a deCONZ bridge for a config entry.""" + if DOMAIN in hass.data: + _LOGGER.error( + "Config entry failed since one deCONZ instance already exists") + return False + result = await async_setup_deconz(hass, None, entry.data) + if result: + return True + return False async def async_setup_deconz(hass, config, deconz_config): @@ -94,8 +78,8 @@ async def async_setup_deconz(hass, config, deconz_config): """ _LOGGER.debug("deCONZ config %s", deconz_config) from pydeconz import DeconzSession - websession = async_get_clientsession(hass) - deconz = DeconzSession(hass.loop, websession, **deconz_config) + session = aiohttp_client.async_get_clientsession(hass) + deconz = DeconzSession(hass.loop, session, **deconz_config) result = await deconz.async_load_parameters() if result is False: _LOGGER.error("Failed to communicate with deCONZ") @@ -152,121 +136,3 @@ def deconz_shutdown(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz_shutdown) return True - - -async def async_request_configuration(hass, config, deconz_config): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - async def async_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - from pydeconz.utils import async_get_api_key - websession = async_get_clientsession(hass) - api_key = await async_get_api_key(websession, **deconz_config) - if api_key: - deconz_config[CONF_API_KEY] = api_key - result = await async_setup_deconz(hass, config, deconz_config) - if result: - await hass.async_add_job( - save_json, hass.config.path(CONFIG_FILE), deconz_config) - configurator.async_request_done(request_id) - return - else: - configurator.async_notify_errors( - request_id, "Couldn't load configuration.") - else: - configurator.async_notify_errors( - request_id, "Couldn't get an API key.") - return - - instructions = CONFIG_INSTRUCTIONS.format( - deconz_config[CONF_HOST], deconz_config[CONF_PORT]) - - request_id = configurator.async_request_config( - "deCONZ", async_configuration_callback, - description=instructions, - entity_picture="/static/images/logo_deconz.jpeg", - submit_caption="I have unlocked the gateway", - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class DeconzFlowHandler(data_entry_flow.FlowHandler): - """Handle a deCONZ config flow.""" - - VERSION = 1 - - def __init__(self): - """Initialize the deCONZ flow.""" - self.bridges = [] - self.deconz_config = {} - - async def async_step_init(self, user_input=None): - """Handle a flow start.""" - from pydeconz.utils import async_discovery - - if DOMAIN in self.hass.data: - return self.async_abort( - reason='one_instance_only' - ) - - if user_input is not None: - for bridge in self.bridges: - if bridge[CONF_HOST] == user_input[CONF_HOST]: - self.deconz_config = bridge - return await self.async_step_link() - - session = aiohttp_client.async_get_clientsession(self.hass) - self.bridges = await async_discovery(session) - - if len(self.bridges) == 1: - self.deconz_config = self.bridges[0] - return await self.async_step_link() - elif len(self.bridges) > 1: - hosts = [] - for bridge in self.bridges: - hosts.append(bridge[CONF_HOST]) - return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - vol.Required(CONF_HOST): vol.In(hosts) - }) - ) - - return self.async_abort( - reason='no_bridges' - ) - - async def async_step_link(self, user_input=None): - """Attempt to link with the deCONZ bridge.""" - from pydeconz.utils import async_get_api_key - errors = {} - - if user_input is not None: - session = aiohttp_client.async_get_clientsession(self.hass) - api_key = await async_get_api_key(session, **self.deconz_config) - if api_key: - self.deconz_config[CONF_API_KEY] = api_key - return self.async_create_entry( - title='deCONZ', - data=self.deconz_config - ) - else: - errors['base'] = 'no_key' - - return self.async_show_form( - step_id='link', - errors=errors, - ) - - -async def async_setup_entry(hass, entry): - """Set up a bridge for a config entry.""" - if DOMAIN in hass.data: - _LOGGER.error( - "Config entry failed since one deCONZ instance already exists") - return False - result = await async_setup_deconz(hass, None, entry.data) - if result: - return True - return False diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py new file mode 100644 index 0000000000000..e900782ea658d --- /dev/null +++ b/homeassistant/components/deconz/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow to configure deCONZ component.""" + +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.helpers import aiohttp_client +from homeassistant.util.json import load_json + +from .const import CONFIG_FILE, DOMAIN + + +@callback +def configured_hosts(hass): + """Return a set of the configured hosts.""" + return set(entry.data['host'] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class DeconzFlowHandler(data_entry_flow.FlowHandler): + """Handle a deCONZ config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the deCONZ config flow.""" + self.bridges = [] + self.deconz_config = {} + + async def async_step_init(self, user_input=None): + """Handle a deCONZ config flow start.""" + from pydeconz.utils import async_discovery + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + + if user_input is not None: + for bridge in self.bridges: + if bridge[CONF_HOST] == user_input[CONF_HOST]: + self.deconz_config = bridge + return await self.async_step_link() + + session = aiohttp_client.async_get_clientsession(self.hass) + self.bridges = await async_discovery(session) + + if len(self.bridges) == 1: + self.deconz_config = self.bridges[0] + return await self.async_step_link() + elif len(self.bridges) > 1: + hosts = [] + for bridge in self.bridges: + hosts.append(bridge[CONF_HOST]) + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): vol.In(hosts) + }) + ) + + return self.async_abort( + reason='no_bridges' + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the deCONZ bridge.""" + from pydeconz.utils import async_get_api_key, async_get_bridgeid + errors = {} + + if user_input is not None: + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + session = aiohttp_client.async_get_clientsession(self.hass) + api_key = await async_get_api_key(session, **self.deconz_config) + if api_key: + self.deconz_config[CONF_API_KEY] = api_key + if 'bridgeid' not in self.deconz_config: + self.deconz_config['bridgeid'] = await async_get_bridgeid( + session, **self.deconz_config) + return self.async_create_entry( + title='deCONZ-' + self.deconz_config['bridgeid'], + data=self.deconz_config + ) + errors['base'] = 'no_key' + + return self.async_show_form( + step_id='link', + errors=errors, + ) + + async def async_step_discovery(self, discovery_info): + """Prepare configuration for a discovered deCONZ bridge. + + This flow is triggered by the discovery component. + """ + deconz_config = {} + deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) + deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) + deconz_config['bridgeid'] = discovery_info.get('serial') + + config_file = await self.hass.async_add_job( + load_json, self.hass.config.path(CONFIG_FILE)) + if config_file and \ + config_file[CONF_HOST] == deconz_config[CONF_HOST] and \ + CONF_API_KEY in config_file: + deconz_config[CONF_API_KEY] = config_file[CONF_API_KEY] + + return await self.async_step_import(deconz_config) + + async def async_step_import(self, import_config): + """Import a deCONZ bridge as a config entry. + + This flow is triggered by `async_setup` for configured bridges. + This flow is also triggered by `async_step_discovery`. + + This will execute for any bridge that does not have a + config entry yet (based on host). + + If an API key is provided, we will create an entry. + Otherwise we will delegate to `link` step which + will ask user to link the bridge. + """ + from pydeconz.utils import async_get_bridgeid + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + elif CONF_API_KEY not in import_config: + self.deconz_config = import_config + return await self.async_step_link() + + if 'bridgeid' not in import_config: + session = aiohttp_client.async_get_clientsession(self.hass) + import_config['bridgeid'] = await async_get_bridgeid( + session, **import_config) + return self.async_create_entry( + title='deCONZ-' + import_config['bridgeid'], + data=import_config + ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py new file mode 100644 index 0000000000000..c5820c971f6a9 --- /dev/null +++ b/homeassistant/components/deconz/const.py @@ -0,0 +1,8 @@ +"""Constants for the deCONZ component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.deconz') + +DOMAIN = 'deconz' +CONFIG_FILE = 'deconz.conf' +DATA_DECONZ_ID = 'deconz_entities' diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 69165dbbbaf3f..7ea68af01c1dc 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -18,6 +18,7 @@ "no_key": "Couldn't get an API key" }, "abort": { + "already_configured": "Bridge is already configured", "no_bridges": "No deCONZ bridges discovered", "one_instance_only": "Component only supports one deCONZ instance" } diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 31ec3f2f60a8f..f0ebcba836677 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -43,6 +43,7 @@ SERVICE_HOMEKIT = 'homekit' CONFIG_ENTRY_HANDLERS = { + SERVICE_DECONZ: 'deconz', SERVICE_HUE: 'hue', } @@ -57,7 +58,6 @@ SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - SERVICE_DECONZ: ('deconz', None), SERVICE_DAIKIN: ('daikin', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), 'google_cast': ('media_player', 'cast'), diff --git a/tests/components/deconz/__init__.py b/tests/components/deconz/__init__.py new file mode 100644 index 0000000000000..59b903e8900bf --- /dev/null +++ b/tests/components/deconz/__init__.py @@ -0,0 +1 @@ +"""Tests for the deCONZ component.""" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py new file mode 100644 index 0000000000000..d86475b35ef02 --- /dev/null +++ b/tests/components/deconz/test_config_flow.py @@ -0,0 +1,225 @@ +"""Tests for deCONZ config flow.""" +from unittest.mock import patch +import pytest + +import voluptuous as vol +from homeassistant.components.deconz import config_flow +from tests.common import MockConfigEntry + +import pydeconz + + +async def test_flow_works(hass, aioclient_mock): + """Test that config flow works.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} + ]) + aioclient_mock.post('http://1.2.3.4:80/api', json=[ + {"success": {"username": "1234567890ABCDEF"}} + ]) + + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + await flow.async_step_init() + result = await flow.async_step_link(user_input={}) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + } + + +async def test_flow_already_registered_bridge(hass): + """Test config flow don't allow more than one bridge to be registered.""" + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_no_discovered_bridges(hass, aioclient_mock): + """Test config flow discovers no bridges.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_one_bridge_discovered(hass, aioclient_mock): + """Test config flow discovers one bridge.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} + ]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_flow_two_bridges_discovered(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': 80}, + {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': 80} + ]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + with pytest.raises(vol.Invalid): + assert result['data_schema']({'host': '0.0.0.0'}) + + result['data_schema']({'host': '1.2.3.4'}) + result['data_schema']({'host': '5.6.7.8'}) + + +async def test_link_no_api_key(hass, aioclient_mock): + """Test config flow should abort if no API key was possible to retrieve.""" + aioclient_mock.post('http://1.2.3.4:80/api', json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'no_key'} + + +async def test_link_already_registered_bridge(hass): + """Test that link verifies to only allow one config entry to complete. + + This is possible with discovery which will allow the user to complete + a second config entry and then complete the discovered config entry. + """ + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'abort' + + +async def test_bridge_discovery(hass): + """Test a bridge being discovered with no additional config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', return_value={}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_config_file(hass): + """Test a bridge being discovered with a corresponding config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', + return_value={'host': '1.2.3.4', + 'port': 8080, + 'api_key': '1234567890ABCDEF'}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + } + + +async def test_bridge_discovery_other_config_file(hass): + """Test a bridge being discovered with another bridges config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', + return_value={'host': '5.6.7.8', 'api_key': '5678'}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_already_configured(hass): + """Test if a discovered bridge has already been configured.""" + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'serial': 'id' + }) + + assert result['type'] == 'abort' + + +async def test_import_without_api_key(hass): + """Test importing a host without an API key.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'host': '1.2.3.4', + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_import_with_api_key(hass): + """Test importing a host with an API key.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + } diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py new file mode 100644 index 0000000000000..cbc8a373972c9 --- /dev/null +++ b/tests/components/deconz/test_init.py @@ -0,0 +1,69 @@ +"""Test deCONZ component setup process.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import deconz + + +async def test_config_with_host_passed_to_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: { + deconz.CONF_HOST: '1.2.3.4', + deconz.CONF_PORT: 80 + } + }) is True + # Import flow started + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_file_passed_to_config_entry(hass): + """Test that configuration file for a host are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', + return_value={'host': '1.2.3.4'}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: {} + }) is True + # Import flow started + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_without_host_not_passed_to_config_entry(hass): + """Test that a configuration without a host does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: {} + }) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered host does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', + return_value=['1.2.3.4']), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: { + deconz.CONF_HOST: '1.2.3.4', + deconz.CONF_PORT: 80 + } + }) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_discovery(hass): + """Test that a discovered bridge does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries: + assert await async_setup_component(hass, deconz.DOMAIN, {}) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 diff --git a/tests/components/test_deconz.py b/tests/components/test_deconz.py deleted file mode 100644 index 2c7c656d56039..0000000000000 --- a/tests/components/test_deconz.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for deCONZ config flow.""" -import pytest - -import voluptuous as vol - -import homeassistant.components.deconz as deconz -import pydeconz - - -async def test_flow_works(hass, aioclient_mock): - """Test config flow.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ - {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': '80'} - ]) - aioclient_mock.post('http://1.2.3.4:80/api', json=[ - {"success": {"username": "1234567890ABCDEF"}} - ]) - - flow = deconz.DeconzFlowHandler() - flow.hass = hass - await flow.async_step_init() - result = await flow.async_step_link(user_input={}) - - assert result['type'] == 'create_entry' - assert result['title'] == 'deCONZ' - assert result['data'] == { - 'bridgeid': 'id', - 'host': '1.2.3.4', - 'port': '80', - 'api_key': '1234567890ABCDEF' - } - - -async def test_flow_already_registered_bridge(hass, aioclient_mock): - """Test config flow don't allow more than one bridge to be registered.""" - flow = deconz.DeconzFlowHandler() - flow.hass = hass - flow.hass.data[deconz.DOMAIN] = True - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -async def test_flow_no_discovered_bridges(hass, aioclient_mock): - """Test config flow discovers no bridges.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) - flow = deconz.DeconzFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -async def test_flow_one_bridge_discovered(hass, aioclient_mock): - """Test config flow discovers one bridge.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ - {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': '80'} - ]) - flow = deconz.DeconzFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'link' - - -async def test_flow_two_bridges_discovered(hass, aioclient_mock): - """Test config flow discovers two bridges.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ - {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': '80'}, - {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': '80'} - ]) - flow = deconz.DeconzFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'init' - - with pytest.raises(vol.Invalid): - assert result['data_schema']({'host': '0.0.0.0'}) - - result['data_schema']({'host': '1.2.3.4'}) - result['data_schema']({'host': '5.6.7.8'}) - - -async def test_flow_no_api_key(hass, aioclient_mock): - """Test config flow discovers no bridges.""" - aioclient_mock.post('http://1.2.3.4:80/api', json=[]) - flow = deconz.DeconzFlowHandler() - flow.hass = hass - flow.deconz_config = {'host': '1.2.3.4', 'port': 80} - - result = await flow.async_step_link(user_input={}) - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == {'base': 'no_key'} From 0eb3e49880f87408bcaee77ba5771ce98ba3de4d Mon Sep 17 00:00:00 2001 From: Michael Wei Date: Wed, 18 Apr 2018 11:19:05 -0700 Subject: [PATCH 112/155] Alexa thermostat fails to properly parse 'value' field for climate (#13958) * Fix thermostat payload issue * fix test payload * style issue * handle both string and value object --- homeassistant/components/alexa/smart_home.py | 1 + tests/components/alexa/test_smart_home.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 707f8d029587c..c5c68f1af40fa 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1471,6 +1471,7 @@ async def async_api_adjust_target_temp(hass, config, request, entity): async def async_api_set_thermostat_mode(hass, config, request, entity): """Process a set thermostat mode request.""" mode = request[API_PAYLOAD]['thermostatMode'] + mode = mode if isinstance(mode, str) else mode['value'] operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) # Work around a pylint false positive due to diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index dd404b7d57aee..afa4d19b5d91c 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -807,15 +807,23 @@ async def test_thermostat(hass): 'Alexa.ThermostatController', 'SetThermostatMode', 'climate#test_thermostat', 'climate.set_operation_mode', hass, - payload={'thermostatMode': 'HEAT'} + payload={'thermostatMode': {'value': 'HEAT'}} ) assert call.data['operation_mode'] == 'heat' + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': 'HEAT'} + ) + + assert call.data['operation_mode'] == 'heat' msg = await assert_request_fails( 'Alexa.ThermostatController', 'SetThermostatMode', 'climate#test_thermostat', 'climate.set_operation_mode', hass, - payload={'thermostatMode': 'INVALID'} + payload={'thermostatMode': {'value': 'INVALID'}} ) assert msg['event']['payload']['type'] == 'UNSUPPORTED_THERMOSTAT_MODE' From 45eb611007d001abb6571ee8abf597fd91eceb13 Mon Sep 17 00:00:00 2001 From: NovapaX Date: Wed, 18 Apr 2018 21:46:44 +0200 Subject: [PATCH 113/155] renaming icons (#13982) * renaming icons * remove mdi:robot-vacuum * fix other vacuums --- homeassistant/components/hdmi_cec.py | 2 +- homeassistant/components/vacuum/__init__.py | 1 - homeassistant/components/vacuum/demo.py | 7 +------ homeassistant/components/vacuum/dyson.py | 8 -------- homeassistant/components/vacuum/mqtt.py | 7 +------ homeassistant/components/vacuum/neato.py | 7 ------- homeassistant/components/vacuum/roomba.py | 6 ------ homeassistant/components/vacuum/xiaomi_miio.py | 7 ------- tests/components/vacuum/test_dyson.py | 1 - 9 files changed, 3 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 8e2464d0922b6..b5d64f48dc757 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -35,7 +35,7 @@ ICON_UNKNOWN = 'mdi:help' ICON_AUDIO = 'mdi:speaker' ICON_PLAYER = 'mdi:play' -ICON_TUNER = 'mdi:nest-thermostat' +ICON_TUNER = 'mdi:radio' ICON_RECORDER = 'mdi:microphone' ICON_TV = 'mdi:television' ICONS_BY_TYPE = { diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index d403a776ddfe6..1b7d568523160 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -76,7 +76,6 @@ } DEFAULT_NAME = 'Vacuum cleaner robot' -DEFAULT_ICON = 'mdi:roomba' SUPPORT_TURN_ON = 1 SUPPORT_TURN_OFF = 2 diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index 668e3ca37e6e0..bd501167ffa8d 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.vacuum import ( - ATTR_CLEANED_AREA, DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, + ATTR_CLEANED_AREA, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) @@ -66,11 +66,6 @@ def name(self): """Return the name of the vacuum.""" return self._name - @property - def icon(self): - """Return the icon for the vacuum.""" - return DEFAULT_ICON - @property def should_poll(self): """No polling needed for a demo vacuum.""" diff --git a/homeassistant/components/vacuum/dyson.py b/homeassistant/components/vacuum/dyson.py index aa05d004a35fe..d423a8dacf51d 100644 --- a/homeassistant/components/vacuum/dyson.py +++ b/homeassistant/components/vacuum/dyson.py @@ -24,8 +24,6 @@ DYSON_360_EYE_DEVICES = "dyson_360_eye_devices" -ICON = 'mdi:roomba' - SUPPORT_DYSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | SUPPORT_STATUS | \ SUPPORT_BATTERY | SUPPORT_STOP @@ -56,7 +54,6 @@ def __init__(self, device): """Dyson 360 Eye robot vacuum device.""" _LOGGER.debug("Creating device %s", device.name) self._device = device - self._icon = ICON @asyncio.coroutine def async_added_to_hass(self): @@ -82,11 +79,6 @@ def name(self): """Return the name of the device.""" return self._device.name - @property - def icon(self): - """Return the icon to use for device.""" - return self._icon - @property def status(self): """Return the status of the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index f4c640f1fc73c..ef3bb0f636b7e 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -12,7 +12,7 @@ import homeassistant.components.mqtt as mqtt from homeassistant.components.mqtt import MqttAvailability from homeassistant.components.vacuum import ( - DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) @@ -340,11 +340,6 @@ def name(self): """Return the name of the vacuum.""" return self._name - @property - def icon(self): - """Return the icon for the vacuum.""" - return DEFAULT_ICON - @property def should_poll(self): """No polling needed for an MQTT vacuum.""" diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 2a4eb2d5e7f3d..9eba34cea321b 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -24,8 +24,6 @@ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ SUPPORT_STATUS | SUPPORT_MAP -ICON = 'mdi:roomba' - ATTR_CLEAN_START = 'clean_start' ATTR_CLEAN_STOP = 'clean_stop' ATTR_CLEAN_AREA = 'clean_area' @@ -131,11 +129,6 @@ def name(self): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return ICON - @property def supported_features(self): """Flag vacuum cleaner robot features that are supported.""" diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index b983b20bd0c42..44d22e03f4162 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -43,7 +43,6 @@ DEFAULT_CONTINUOUS = True DEFAULT_NAME = 'Roomba' -ICON = 'mdi:roomba' PLATFORM = 'roomba' FAN_SPEED_AUTOMATIC = 'Automatic' @@ -165,11 +164,6 @@ def name(self): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return ICON - @property def device_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index b2451ed495cc1..620014a1baee7 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -24,7 +24,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' -ICON = 'mdi:roomba' DATA_KEY = 'vacuum.xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -142,7 +141,6 @@ class MiroboVacuum(VacuumDevice): def __init__(self, name, vacuum): """Initialize the Xiaomi vacuum cleaner robot handler.""" self._name = name - self._icon = ICON self._vacuum = vacuum self.vacuum_state = None @@ -158,11 +156,6 @@ def name(self): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return self._icon - @property def status(self): """Return the status of the vacuum cleaner.""" diff --git a/tests/components/vacuum/test_dyson.py b/tests/components/vacuum/test_dyson.py index 186a2271a73f3..8a4e6d57b9154 100644 --- a/tests/components/vacuum/test_dyson.py +++ b/tests/components/vacuum/test_dyson.py @@ -118,7 +118,6 @@ def test_properties(self): component3 = Dyson360EyeDevice(device3) self.assertEqual(component.name, "Device_Vacuum") self.assertTrue(component.is_on) - self.assertEqual(component.icon, "mdi:roomba") self.assertEqual(component.status, "Cleaning") self.assertEqual(component2.status, "Unknown") self.assertEqual(component.battery_level, 85) From b0a3d084fb7d44ab675e634fc36be39243d07587 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 Apr 2018 15:58:14 -0400 Subject: [PATCH 114/155] Version bump to 20180418.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 45d16ae5fb6b9..76403c0b44213 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180417.0'] +REQUIREMENTS = ['home-assistant-frontend==20180418.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 8839fb841ae22..f1edc22180a95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -380,7 +380,7 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180417.0 +home-assistant-frontend==20180418.0 # homeassistant.components.homekit_controller # homekit==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56a645d4fd941..7db4ead08569f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180417.0 +home-assistant-frontend==20180418.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From ccba858ae183deec8d01110a083f108f0fc67c09 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 18 Apr 2018 15:58:47 -0400 Subject: [PATCH 115/155] Fix for Lokalise backend misinterpretation of keys (#13986) The Lokalise server has a bug that the internal portion of key references was misinterpreted as a symfony key, and was getting auto converted by the convert placeholders feature. Since we don't use this we're turning it off to work around the bug. --- .travis.yml | 2 +- script/translations_upload | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index fce8634881779..bf2d05bb18513 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,7 +31,7 @@ script: travis_wait 30 tox --develop services: - docker before_deploy: - - docker pull lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 + - docker pull lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 deploy: skip_cleanup: true provider: script diff --git a/script/translations_upload b/script/translations_upload index 578cc8c0ccfc3..5bf9fe1e1217c 100755 --- a/script/translations_upload +++ b/script/translations_upload @@ -35,9 +35,10 @@ script/translations_upload_merge.py docker run \ -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \ - lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 lokalise \ --token ${LOKALISE_TOKEN} \ import ${PROJECT_ID} \ --file /opt/src/${LOCAL_FILE} \ --lang_iso ${LANG_ISO} \ + --convert_placeholders 0 \ --replace 1 From ba7fccba3472e2b4fe81ec64263e953c4df2431c Mon Sep 17 00:00:00 2001 From: thelittlefireman Date: Wed, 18 Apr 2018 21:59:48 +0200 Subject: [PATCH 116/155] Bump locationsharinglib to 1.2.1 (#13980) * Bump locationsharinglib to 1.2.1 * Bump locationsharinglib to 1.2.1 --- homeassistant/components/device_tracker/google_maps.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 9e25761636199..d1e59293365b9 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['locationsharinglib==0.4.0'] +REQUIREMENTS = ['locationsharinglib==1.2.1'] CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' diff --git a/requirements_all.txt b/requirements_all.txt index f1edc22180a95..71a3227882280 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -497,7 +497,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==0.4.0 +locationsharinglib==1.2.1 # homeassistant.components.sensor.luftdaten luftdaten==0.1.3 From 674682e88f5e2efef70a7760249dab88c337f212 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 19 Apr 2018 09:11:38 +0200 Subject: [PATCH 117/155] Support for multiple MAX!Cube LAN gateways added (#13517) --- .../components/binary_sensor/maxcube.py | 23 ++++----- homeassistant/components/climate/maxcube.py | 22 ++++----- homeassistant/components/maxcube.py | 49 ++++++++++++++----- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py index 1043004243a46..c131de5420a5e 100644 --- a/homeassistant/components/binary_sensor/maxcube.py +++ b/homeassistant/components/binary_sensor/maxcube.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.components.maxcube import DATA_KEY from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) @@ -15,16 +15,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Iterate through all MAX! Devices and add window shutters.""" - cube = hass.data[MAXCUBE_HANDLE].cube devices = [] + for handler in hass.data[DATA_KEY].values(): + cube = handler.cube + for device in cube.devices: + name = "{} {}".format( + cube.room_by_id(device.room_id).name, device.name) - for device in cube.devices: - name = "{} {}".format( - cube.room_by_id(device.room_id).name, device.name) - - # Only add Window Shutters - if cube.is_windowshutter(device): - devices.append(MaxCubeShutter(hass, name, device.rf_address)) + # Only add Window Shutters + if cube.is_windowshutter(device): + devices.append( + MaxCubeShutter(handler, name, device.rf_address)) if devices: add_devices(devices) @@ -33,12 +34,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MaxCubeShutter(BinarySensorDevice): """Representation of a MAX! Cube Binary Sensor device.""" - def __init__(self, hass, name, rf_address): + def __init__(self, handler, name, rf_address): """Initialize MAX! Cube BinarySensorDevice.""" self._name = name self._sensor_type = 'window' self._rf_address = rf_address - self._cubehandle = hass.data[MAXCUBE_HANDLE] + self._cubehandle = handler self._state = STATE_UNKNOWN @property diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py index 067d11437b245..712ebb4f4ce6e 100644 --- a/homeassistant/components/climate/maxcube.py +++ b/homeassistant/components/climate/maxcube.py @@ -10,7 +10,7 @@ from homeassistant.components.climate import ( ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) -from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.components.maxcube import DATA_KEY from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE _LOGGER = logging.getLogger(__name__) @@ -24,16 +24,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Iterate through all MAX! Devices and add thermostats.""" - cube = hass.data[MAXCUBE_HANDLE].cube - devices = [] + for handler in hass.data[DATA_KEY].values(): + cube = handler.cube + for device in cube.devices: + name = '{} {}'.format( + cube.room_by_id(device.room_id).name, device.name) - for device in cube.devices: - name = '{} {}'.format( - cube.room_by_id(device.room_id).name, device.name) - - if cube.is_thermostat(device) or cube.is_wallthermostat(device): - devices.append(MaxCubeClimate(hass, name, device.rf_address)) + if cube.is_thermostat(device) or cube.is_wallthermostat(device): + devices.append( + MaxCubeClimate(handler, name, device.rf_address)) if devices: add_devices(devices) @@ -42,14 +42,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MaxCubeClimate(ClimateDevice): """MAX! Cube ClimateDevice.""" - def __init__(self, hass, name, rf_address): + def __init__(self, handler, name, rf_address): """Initialize MAX! Cube ClimateDevice.""" self._name = name self._unit_of_measurement = TEMP_CELSIUS self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST, STATE_VACATION] self._rf_address = rf_address - self._cubehandle = hass.data[MAXCUBE_HANDLE] + self._cubehandle = handler @property def supported_features(self): diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py index a0a8db6ba4d1d..13d3e0b444b9e 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -22,12 +22,22 @@ DEFAULT_PORT = 62910 DOMAIN = 'maxcube' -MAXCUBE_HANDLE = 'maxcube' +DATA_KEY = 'maxcube' + +NOTIFICATION_ID = 'maxcube_notification' +NOTIFICATION_TITLE = 'Max!Cube gateway setup' + +CONF_GATEWAYS = 'gateways' + +CONFIG_GATEWAY = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_GATEWAYS, default={}): + vol.All(cv.ensure_list, [CONFIG_GATEWAY]) }), }, extra=vol.ALLOW_EXTRA) @@ -36,18 +46,33 @@ def setup(hass, config): """Establish connection to MAX! Cube.""" from maxcube.connection import MaxCubeConnection from maxcube.cube import MaxCube + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} - host = config.get(DOMAIN).get(CONF_HOST) - port = config.get(DOMAIN).get(CONF_PORT) - - try: - cube = MaxCube(MaxCubeConnection(host, port)) - except timeout: - _LOGGER.error("Connection to Max!Cube could not be established") - cube = None + if DOMAIN not in config: return False - hass.data[MAXCUBE_HANDLE] = MaxCubeHandle(cube) + connection_failed = 0 + gateways = config[DOMAIN][CONF_GATEWAYS] + for gateway in gateways: + host = gateway[CONF_HOST] + port = gateway[CONF_PORT] + + try: + cube = MaxCube(MaxCubeConnection(host, port)) + hass.data[DATA_KEY][host] = MaxCubeHandle(cube) + except timeout as ex: + _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart Home Assistant after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + connection_failed += 1 + + if connection_failed >= len(gateways): + return False load_platform(hass, 'climate', DOMAIN) load_platform(hass, 'binary_sensor', DOMAIN) From 3dc70436f1937ab6767f8f9dc5bbbe712352990e Mon Sep 17 00:00:00 2001 From: koolsb <14332595+koolsb@users.noreply.github.com> Date: Thu, 19 Apr 2018 04:31:50 -0500 Subject: [PATCH 118/155] Add additional receiver for Onkyo zone 2 (#13551) --- .../components/media_player/onkyo.py | 86 ++++++++++++++++++- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 432d9ce108fb5..587031653852d 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) CONF_SOURCES = 'sources' +CONF_ZONE2 = 'zone2' DEFAULT_NAME = 'Onkyo Receiver' @@ -40,6 +41,7 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): {cv.string: cv.string}, + vol.Optional(CONF_ZONE2, default=False): cv.boolean, }) @@ -57,6 +59,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): eiscp.eISCP(host), config.get(CONF_SOURCES), name=config.get(CONF_NAME))) KNOWN_HOSTS.append(host) + + # Add Zone2 if configured + if config.get(CONF_ZONE2): + _LOGGER.debug("Setting up zone 2") + hosts.append(OnkyoDeviceZone2(eiscp.eISCP(host), + config.get(CONF_SOURCES), + name=config.get(CONF_NAME) + + " Zone 2")) except OSError: _LOGGER.error("Unable to connect to receiver at %s", host) else: @@ -98,8 +108,9 @@ def command(self, command): return result def update(self): - """Get the latest details from the device.""" + """Get the latest state from the device.""" status = self.command('system-power query') + if not status: return if status[1] == 'on': @@ -107,9 +118,11 @@ def update(self): else: self._pwstate = STATE_OFF return + volume_raw = self.command('volume query') mute_raw = self.command('audio-muting query') current_source_raw = self.command('input-selector query') + if not (volume_raw and mute_raw and current_source_raw): return @@ -147,12 +160,12 @@ def volume_level(self): @property def is_volume_muted(self): - """Boolean if volume is currently muted.""" + """Return boolean indicating mute status.""" return self._muted @property def supported_features(self): - """Flag media player features that are supported.""" + """Return media player features that are supported.""" return SUPPORT_ONKYO @property @@ -166,7 +179,7 @@ def source_list(self): return self._source_list def turn_off(self): - """Turn off media player.""" + """Turn the media player off.""" self.command('system-power standby') def set_volume_level(self, volume): @@ -189,3 +202,68 @@ def select_source(self, source): if source in self._source_list: source = self._reverse_mapping[source] self.command('input-selector {}'.format(source)) + + +class OnkyoDeviceZone2(OnkyoDevice): + """Representation of an Onkyo device's zone 2.""" + + def update(self): + """Get the latest state from the device.""" + status = self.command('zone2.power=query') + + if not status: + return + if status[1] == 'on': + self._pwstate = STATE_ON + else: + self._pwstate = STATE_OFF + return + + volume_raw = self.command('zone2.volume=query') + mute_raw = self.command('zone2.muting=query') + current_source_raw = self.command('zone2.selector=query') + + if not (volume_raw and mute_raw and current_source_raw): + return + + # eiscp can return string or tuple. Make everything tuples. + if isinstance(current_source_raw[1], str): + current_source_tuples = \ + (current_source_raw[0], (current_source_raw[1],)) + else: + current_source_tuples = current_source_raw + + for source in current_source_tuples[1]: + if source in self._source_mapping: + self._current_source = self._source_mapping[source] + break + else: + self._current_source = '_'.join( + [i for i in current_source_tuples[1]]) + self._muted = bool(mute_raw[1] == 'on') + self._volume = volume_raw[1] / 80.0 + + def turn_off(self): + """Turn the media player off.""" + self.command('zone2.power=standby') + + def set_volume_level(self, volume): + """Set volume level, input is range 0..1. Onkyo ranges from 1-80.""" + self.command('zone2.volume={}'.format(int(volume*80))) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + if mute: + self.command('zone2.muting=on') + else: + self.command('zone2.muting=off') + + def turn_on(self): + """Turn the media player on.""" + self.command('zone2.power=on') + + def select_source(self, source): + """Set the input source.""" + if source in self._source_list: + source = self._reverse_mapping[source] + self.command('zone2.selector={}'.format(source)) From 37cd63ea5a7a680ca47da03e5d5a98d2f0823bbf Mon Sep 17 00:00:00 2001 From: koolsb <14332595+koolsb@users.noreply.github.com> Date: Thu, 19 Apr 2018 04:35:38 -0500 Subject: [PATCH 119/155] Add blackbird media player component (#13549) --- .../components/media_player/blackbird.py | 213 ++++++++++++ .../components/media_player/services.yaml | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../components/media_player/test_blackbird.py | 328 ++++++++++++++++++ 6 files changed, 558 insertions(+) create mode 100644 homeassistant/components/media_player/blackbird.py create mode 100644 tests/components/media_player/test_blackbird.py diff --git a/homeassistant/components/media_player/blackbird.py b/homeassistant/components/media_player/blackbird.py new file mode 100644 index 0000000000000..37b3c0ff819df --- /dev/null +++ b/homeassistant/components/media_player/blackbird.py @@ -0,0 +1,213 @@ +""" +Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.blackbird +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + DOMAIN, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_NAME, CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyblackbird==0.5'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BLACKBIRD = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_SELECT_SOURCE + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +SOURCE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +CONF_ZONES = 'zones' +CONF_SOURCES = 'sources' +CONF_TYPE = 'type' + +DATA_BLACKBIRD = 'blackbird' + +SERVICE_SETALLZONES = 'blackbird_set_all_zones' +ATTR_SOURCE = 'source' + +BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_SOURCE): cv.string +}) + + +# Valid zone ids: 1-8 +ZONE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8)) + +# Valid source ids: 1-8 +SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8)) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TYPE): vol.In(['serial', 'socket']), + vol.Optional(CONF_PORT): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}), + vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Monoprice Blackbird 4k 8x8 HDBaseT Matrix platform.""" + port = config.get(CONF_PORT) + host = config.get(CONF_HOST) + device_type = config.get(CONF_TYPE) + + import socket + from pyblackbird import get_blackbird + from serial import SerialException + + if device_type == 'serial': + if port is None: + _LOGGER.error("No port configured") + return + try: + blackbird = get_blackbird(port) + except SerialException: + _LOGGER.error("Error connecting to the Blackbird controller") + return + + elif device_type == 'socket': + try: + if host is None: + _LOGGER.error("No host configured") + return + blackbird = get_blackbird(host, False) + except socket.timeout: + _LOGGER.error("Error connecting to the Blackbird controller") + return + + else: + _LOGGER.error("Incorrect device type specified") + return + + sources = {source_id: extra[CONF_NAME] for source_id, extra + in config[CONF_SOURCES].items()} + + hass.data[DATA_BLACKBIRD] = [] + for zone_id, extra in config[CONF_ZONES].items(): + _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + hass.data[DATA_BLACKBIRD].append(BlackbirdZone( + blackbird, sources, zone_id, extra[CONF_NAME])) + + add_devices(hass.data[DATA_BLACKBIRD], True) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + source = service.data.get(ATTR_SOURCE) + if entity_ids: + devices = [device for device in hass.data[DATA_BLACKBIRD] + if device.entity_id in entity_ids] + + else: + devices = hass.data[DATA_BLACKBIRD] + + for device in devices: + if service.service == SERVICE_SETALLZONES: + device.set_all_zones(source) + + hass.services.register(DOMAIN, SERVICE_SETALLZONES, service_handle, + schema=BLACKBIRD_SETALLZONES_SCHEMA) + + +class BlackbirdZone(MediaPlayerDevice): + """Representation of a Blackbird matrix zone.""" + + def __init__(self, blackbird, sources, zone_id, zone_name): + """Initialize new zone.""" + self._blackbird = blackbird + # dict source_id -> source name + self._source_id_name = sources + # dict source name -> source_id + self._source_name_id = {v: k for k, v in sources.items()} + # ordered list of all source names + self._source_names = sorted(self._source_name_id.keys(), + key=lambda v: self._source_name_id[v]) + self._zone_id = zone_id + self._name = zone_name + self._state = None + self._source = None + + def update(self): + """Retrieve latest state.""" + state = self._blackbird.zone_status(self._zone_id) + if not state: + return False + self._state = STATE_ON if state.power else STATE_OFF + idx = state.av + if idx in self._source_id_name: + self._source = self._source_id_name[idx] + else: + self._source = None + return True + + @property + def name(self): + """Return the name of the zone.""" + return self._name + + @property + def state(self): + """Return the state of the zone.""" + return self._state + + @property + def supported_features(self): + """Return flag of media commands that are supported.""" + return SUPPORT_BLACKBIRD + + @property + def media_title(self): + """Return the current source as media title.""" + return self._source + + @property + def source(self): + """Return the current input source of the device.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + def set_all_zones(self, source): + """Set all zones to one source.""" + _LOGGER.debug("Setting all zones") + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + _LOGGER.debug("Setting all zones source to %s", idx) + self._blackbird.set_all_zone_source(idx) + + def select_source(self, source): + """Set input source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + _LOGGER.debug("Setting zone %d source to %s", self._zone_id, idx) + self._blackbird.set_zone_source(self._zone_id, idx) + + def turn_on(self): + """Turn the media player on.""" + _LOGGER.debug("Turning zone %d on", self._zone_id) + self._blackbird.set_zone_power(self._zone_id, True) + + def turn_off(self): + """Turn the media player off.""" + _LOGGER.debug("Turning zone %d off", self._zone_id) + self._blackbird.set_zone_power(self._zone_id, False) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 95072f0270c22..0a6c413a688b8 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -402,3 +402,13 @@ songpal_set_sound_setting: value: description: Value to set. example: 'on' + +blackbird_set_all_zones: + description: Set all Blackbird zones to a single source. + fields: + entity_id: + description: Name of any blackbird zone. + example: 'media_player.zone_1' + source: + description: Name of source to switch to. + example: 'Source 1' diff --git a/requirements_all.txt b/requirements_all.txt index 71a3227882280..be44da3c9b94f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -704,6 +704,9 @@ pyatv==0.3.9 # homeassistant.components.sensor.bbox pybbox==0.0.5-alpha +# homeassistant.components.media_player.blackbird +pyblackbird==0.5 + # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7db4ead08569f..f02b5fcdf2e40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -129,6 +129,9 @@ pushbullet.py==0.11.0 # homeassistant.components.canary py-canary==0.5.0 +# homeassistant.components.media_player.blackbird +pyblackbird==0.5 + # homeassistant.components.deconz pydeconz==36 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f15425063b47f..b5b636dc8745d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -68,6 +68,7 @@ 'prometheus_client', 'pushbullet.py', 'py-canary', + 'pyblackbird', 'pydeconz', 'pydispatcher', 'PyJWT', diff --git a/tests/components/media_player/test_blackbird.py b/tests/components/media_player/test_blackbird.py new file mode 100644 index 0000000000000..86bfdfb52c4af --- /dev/null +++ b/tests/components/media_player/test_blackbird.py @@ -0,0 +1,328 @@ +"""The tests for the Monoprice Blackbird media player platform.""" +import unittest +from unittest import mock +import voluptuous as vol + +from collections import defaultdict +from homeassistant.components.media_player import ( + DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_SELECT_SOURCE) +from homeassistant.const import STATE_ON, STATE_OFF + +import tests.common +from homeassistant.components.media_player.blackbird import ( + DATA_BLACKBIRD, PLATFORM_SCHEMA, SERVICE_SETALLZONES, setup_platform) + + +class AttrDict(dict): + """Helper clas for mocking attributes.""" + + def __setattr__(self, name, value): + """Set attribute.""" + self[name] = value + + def __getattr__(self, item): + """Get attribute.""" + return self[item] + + +class MockBlackbird(object): + """Mock for pyblackbird object.""" + + def __init__(self): + """Init mock object.""" + self.zones = defaultdict(lambda: AttrDict(power=True, + av=1)) + + def zone_status(self, zone_id): + """Get zone status.""" + status = self.zones[zone_id] + status.zone = zone_id + return AttrDict(status) + + def set_zone_source(self, zone_id, source_idx): + """Set source for zone.""" + self.zones[zone_id].av = source_idx + + def set_zone_power(self, zone_id, power): + """Turn zone on/off.""" + self.zones[zone_id].power = power + + def set_all_zone_source(self, source_idx): + """Set source for all zones.""" + self.zones[3].av = source_idx + + +class TestBlackbirdSchema(unittest.TestCase): + """Test Blackbird schema.""" + + def test_valid_serial_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'blackbird', + 'type': 'serial', + 'port': '/dev/ttyUSB0', + 'zones': {1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'}, + 7: {'name': 'a'}, + 8: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'}, + 7: {'name': 'a'}, + 8: {'name': 'a'}, + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_valid_socket_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'blackbird', + 'type': 'socket', + 'port': '192.168.1.50', + 'zones': {1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_invalid_schemas(self): + """Test invalid schemas.""" + schemas = ( + {}, # Empty + None, # None + # Missing type + { + 'platform': 'blackbird', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid zone number + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid source number + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {9: {'name': 'b'}}, + }, + # Zone missing name + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {}}, + 'sources': {1: {'name': 'b'}}, + }, + # Source missing name + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {}}, + }, + # Invalid type + { + 'platform': 'blackbird', + 'type': 'aaa', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + ) + for value in schemas: + with self.assertRaises(vol.MultipleInvalid): + PLATFORM_SCHEMA(value) + + +class TestBlackbirdMediaPlayer(unittest.TestCase): + """Test the media_player module.""" + + def setUp(self): + """Set up the test case.""" + self.blackbird = MockBlackbird() + self.hass = tests.common.get_test_home_assistant() + self.hass.start() + # Note, source dictionary is unsorted! + with mock.patch('pyblackbird.get_blackbird', + new=lambda *a: self.blackbird): + setup_platform(self.hass, { + 'platform': 'blackbird', + 'type': 'serial', + 'port': '/dev/ttyUSB0', + 'zones': {3: {'name': 'Zone name'}}, + 'sources': {1: {'name': 'one'}, + 3: {'name': 'three'}, + 2: {'name': 'two'}}, + }, lambda *args, **kwargs: None, {}) + self.hass.block_till_done() + self.media_player = self.hass.data[DATA_BLACKBIRD][0] + self.media_player.hass = self.hass + self.media_player.entity_id = 'media_player.zone_3' + + def tearDown(self): + """Tear down the test case.""" + self.hass.stop() + + def test_setup_platform(self, *args): + """Test setting up platform.""" + # One service must be registered + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_SETALLZONES)) + self.assertEqual(len(self.hass.data[DATA_BLACKBIRD]), 1) + self.assertEqual(self.hass.data[DATA_BLACKBIRD][0].name, 'Zone name') + + def test_setallzones_service_call_with_entity_id(self): + """Test set all zone source service call with entity id.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + # Call set all zones service + self.hass.services.call(DOMAIN, SERVICE_SETALLZONES, + {'entity_id': 'media_player.zone_3', + 'source': 'three'}, + blocking=True) + + # Check that source was changed + self.assertEqual(3, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('three', self.media_player.source) + + def test_setallzones_service_call_without_entity_id(self): + """Test set all zone source service call without entity id.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + # Call set all zones service + self.hass.services.call(DOMAIN, SERVICE_SETALLZONES, + {'source': 'three'}, blocking=True) + + # Check that source was changed + self.assertEqual(3, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('three', self.media_player.source) + + def test_update(self): + """Test updating values from blackbird.""" + self.assertIsNone(self.media_player.state) + self.assertIsNone(self.media_player.source) + + self.media_player.update() + + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + def test_name(self): + """Test name property.""" + self.assertEqual('Zone name', self.media_player.name) + + def test_state(self): + """Test state property.""" + self.assertIsNone(self.media_player.state) + + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.blackbird.zones[3].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_SELECT_SOURCE, + self.media_player.supported_features) + + def test_source(self): + """Test source property.""" + self.assertIsNone(self.media_player.source) + self.media_player.update() + self.assertEqual('one', self.media_player.source) + + def test_media_title(self): + """Test media title property.""" + self.assertIsNone(self.media_player.media_title) + self.media_player.update() + self.assertEqual('one', self.media_player.media_title) + + def test_source_list(self): + """Test source list property.""" + # Note, the list is sorted! + self.assertEqual(['one', 'two', 'three'], + self.media_player.source_list) + + def test_select_source(self): + """Test source selection methods.""" + self.media_player.update() + + self.assertEqual('one', self.media_player.source) + + self.media_player.select_source('two') + self.assertEqual(2, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + # Trying to set unknown source. + self.media_player.select_source('no name') + self.assertEqual(2, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + def test_turn_on(self): + """Testing turning on the zone.""" + self.blackbird.zones[3].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + self.media_player.turn_on() + self.assertTrue(self.blackbird.zones[3].power) + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + def test_turn_off(self): + """Testing turning off the zone.""" + self.blackbird.zones[3].power = True + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.media_player.turn_off() + self.assertFalse(self.blackbird.zones[3].power) + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) From 3180c8b0fbe8bee70a0487adc995c95f218cfde7 Mon Sep 17 00:00:00 2001 From: Viorel Stirbu Date: Thu, 19 Apr 2018 12:37:30 +0300 Subject: [PATCH 120/155] Add support for Sensirion SHT31 temperature/humidity sensor (#12952) --- .coveragerc | 1 + homeassistant/components/sensor/sht31.py | 152 +++++++++++++++++++++++ requirements_all.txt | 6 + 3 files changed, 159 insertions(+) create mode 100644 homeassistant/components/sensor/sht31.py diff --git a/.coveragerc b/.coveragerc index 1f86a13f6ae13..eae6498cd0a09 100644 --- a/.coveragerc +++ b/.coveragerc @@ -648,6 +648,7 @@ omit = homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/serial.py + homeassistant/components/sensor/sht31.py homeassistant/components/sensor/shodan.py homeassistant/components/sensor/sigfox.py homeassistant/components/sensor/simulated.py diff --git a/homeassistant/components/sensor/sht31.py b/homeassistant/components/sensor/sht31.py new file mode 100644 index 0000000000000..1ba6c8f90eb6c --- /dev/null +++ b/homeassistant/components/sensor/sht31.py @@ -0,0 +1,152 @@ +""" +Support for Sensirion SHT31 temperature and humidity sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sht31/ +""" + +from datetime import timedelta +import logging +import math + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.temperature import display_temp +from homeassistant.const import PRECISION_TENTHS +from homeassistant.util import Throttle + + +REQUIREMENTS = ['Adafruit-GPIO==1.0.3', + 'Adafruit-SHT31==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = 'i2c_address' + +DEFAULT_NAME = 'SHT31' +DEFAULT_I2C_ADDRESS = 0x44 + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' +SENSOR_TYPES = (SENSOR_TEMPERATURE, SENSOR_HUMIDITY) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): + vol.All(vol.Coerce(int), vol.Range(min=0x44, max=0x45)), + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the sensor platform.""" + from Adafruit_SHT31 import SHT31 + + i2c_address = config.get(CONF_I2C_ADDRESS) + sensor = SHT31(address=i2c_address) + + try: + if sensor.read_status() is None: + raise ValueError("CRC error while reading SHT31 status") + except (OSError, ValueError): + raise HomeAssistantError("SHT31 sensor not detected at address %s " % + hex(i2c_address)) + sensor_client = SHTClient(sensor) + + sensor_classes = { + SENSOR_TEMPERATURE: SHTSensorTemperature, + SENSOR_HUMIDITY: SHTSensorHumidity + } + + devs = [] + for sensor_type, sensor_class in sensor_classes.items(): + name = "{} {}".format(config.get(CONF_NAME), sensor_type.capitalize()) + devs.append(sensor_class(sensor_client, name)) + + add_devices(devs) + + +class SHTClient(object): + """Get the latest data from the SHT sensor.""" + + def __init__(self, adafruit_sht): + """Initialize the sensor.""" + self.adafruit_sht = adafruit_sht + self.temperature = None + self.humidity = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the SHT sensor.""" + temperature, humidity = self.adafruit_sht.read_temperature_humidity() + if math.isnan(temperature) or math.isnan(humidity): + _LOGGER.warning("Bad sample from sensor SHT31") + return + self.temperature = temperature + self.humidity = humidity + + +class SHTSensor(Entity): + """An abstract SHTSensor, can be either temperature or humidity.""" + + def __init__(self, sensor, name): + """Initialize the sensor.""" + self._sensor = sensor + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Fetch temperature and humidity from the sensor.""" + self._sensor.update() + + +class SHTSensorTemperature(SHTSensor): + """Representation of a temperature sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.temperature_unit + + def update(self): + """Fetch temperature from the sensor.""" + super().update() + temp_celsius = self._sensor.temperature + if temp_celsius is not None: + self._state = display_temp(self.hass, temp_celsius, + TEMP_CELSIUS, PRECISION_TENTHS) + + +class SHTSensorHumidity(SHTSensor): + """Representation of a humidity sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return '%' + + def update(self): + """Fetch humidity from the sensor.""" + super().update() + humidity = self._sensor.humidity + if humidity is not None: + self._state = round(humidity) diff --git a/requirements_all.txt b/requirements_all.txt index be44da3c9b94f..6b64dba2bc675 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -15,6 +15,12 @@ attrs==17.4.0 # homeassistant.components.nuimo_controller --only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 +# homeassistant.components.sensor.sht31 +Adafruit-GPIO==1.0.3 + +# homeassistant.components.sensor.sht31 +Adafruit-SHT31==1.0.2 + # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 From 0999129f48b6f29108f045f640d747caf0648417 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 19 Apr 2018 11:42:40 +0200 Subject: [PATCH 121/155] Useless code removed (#13996) --- homeassistant/components/maxcube.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py index 13d3e0b444b9e..cf5091fc30899 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -49,9 +49,6 @@ def setup(hass, config): if DATA_KEY not in hass.data: hass.data[DATA_KEY] = {} - if DOMAIN not in config: - return False - connection_failed = 0 gateways = config[DOMAIN][CONF_GATEWAYS] for gateway in gateways: From 9fcbe68facf96e358163ebaf40d6643088f7dbd0 Mon Sep 17 00:00:00 2001 From: Pascal Hahn Date: Thu, 19 Apr 2018 12:48:21 +0200 Subject: [PATCH 122/155] Add Homematic HmIP-SWO-PR weather sensor support (#13904) --- homeassistant/components/homematic/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 23fe9685418a7..1528943a7f966 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -69,7 +69,8 @@ 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', - 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat'], + 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', + 'IPWeatherSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -78,7 +79,7 @@ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP'], + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } @@ -89,7 +90,7 @@ ] HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { - 'ACTUAL_TEMPERATURE': ['IPAreaThermostat'], + 'ACTUAL_TEMPERATURE': ['IPAreaThermostat', 'IPWeatherSensor'], } HM_ATTRIBUTE_SUPPORT = { From 13e72f48a813d3b1fdf1420ed2109d90001856fc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Apr 2018 14:06:49 -0400 Subject: [PATCH 123/155] Disable ebox requirement (#14003) * Disable ebox requirement * Lint --- homeassistant/components/sensor/ebox.py | 3 ++- requirements_all.txt | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/ebox.py b/homeassistant/components/sensor/ebox.py index eee959fceba87..aca2d7bdb9aa5 100644 --- a/homeassistant/components/sensor/ebox.py +++ b/homeassistant/components/sensor/ebox.py @@ -19,7 +19,8 @@ CONF_NAME, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyebox==0.1.0'] +# pylint: disable=import-error +REQUIREMENTS = [] # ['pyebox==0.1.0'] - disabled because it breaks pip10 _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 6b64dba2bc675..bfff65c54f6da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,9 +747,6 @@ pydispatcher==2.0.5 # homeassistant.components.android_ip_webcam pydroid-ipcam==0.8 -# homeassistant.components.sensor.ebox -pyebox==0.1.0 - # homeassistant.components.climate.econet pyeconet==0.0.5 From 27f3081b7458e4a7eb28cd29e2c5338427a90e20 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 Apr 2018 22:16:48 -0400 Subject: [PATCH 124/155] Update frontend to 20180420.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 76403c0b44213..87ca8bd2a2804 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180418.0'] +REQUIREMENTS = ['home-assistant-frontend==20180420.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index bfff65c54f6da..87cb0ccf7e1de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180418.0 +home-assistant-frontend==20180420.0 # homeassistant.components.homekit_controller # homekit==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f02b5fcdf2e40..0d371996e361c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180418.0 +home-assistant-frontend==20180420.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 2372419d42bca1d62039383acb8e9971c1482d55 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Fri, 20 Apr 2018 08:43:44 +0200 Subject: [PATCH 125/155] Upgraded miflora library to version 0.4.0 (#14005) --- homeassistant/components/sensor/miflora.py | 8 ++++---- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 37976151190d4..98cc7731d4d80 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -16,7 +16,7 @@ ) -REQUIREMENTS = ['miflora==0.3.0'] +REQUIREMENTS = ['miflora==0.4.0'] _LOGGER = logging.getLogger(__name__) @@ -63,10 +63,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from miflora import miflora_poller try: import bluepy.btle # noqa: F401 # pylint: disable=unused-variable - from miflora.backends.bluepy import BluepyBackend + from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: - from miflora.backends.gatttool import GatttoolBackend + from btlewrap import GatttoolBackend backend = GatttoolBackend _LOGGER.debug('Miflora is using %s backend.', backend.__name__) @@ -138,7 +138,7 @@ def update(self): This uses a rolling median over 3 values to filter out outliers. """ - from miflora.backends import BluetoothBackendException + from btlewrap import BluetoothBackendException try: _LOGGER.debug("Polling data for %s", self.name) data = self.poller.parameter_value(self.parameter) diff --git a/requirements_all.txt b/requirements_all.txt index 87cb0ccf7e1de..ccfaf0b35a18e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -525,7 +525,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.sensor.miflora -miflora==0.3.0 +miflora==0.4.0 # homeassistant.components.sensor.mopar motorparts==1.0.2 From 8ef2abfca7294d34a07ad69a6534c000cc5499ce Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 20 Apr 2018 08:45:28 +0200 Subject: [PATCH 126/155] Log an error instead of raising an exception (#14006) --- homeassistant/components/sensor/sht31.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/sht31.py b/homeassistant/components/sensor/sht31.py index 1ba6c8f90eb6c..e1a7f3c9e5f6f 100644 --- a/homeassistant/components/sensor/sht31.py +++ b/homeassistant/components/sensor/sht31.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import display_temp @@ -58,8 +57,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if sensor.read_status() is None: raise ValueError("CRC error while reading SHT31 status") except (OSError, ValueError): - raise HomeAssistantError("SHT31 sensor not detected at address %s " % - hex(i2c_address)) + _LOGGER.error( + "SHT31 sensor not detected at address %s", hex(i2c_address)) + return sensor_client = SHTClient(sensor) sensor_classes = { From 825f94f47fd972340245cff6988c95cd1645ae45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 20 Apr 2018 11:45:11 +0200 Subject: [PATCH 127/155] Tibber available (#13865) * Tibber available * Tibber available * Tibber * Tibber --- homeassistant/components/sensor/tibber.py | 90 ++++++++++++++--------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index ca1c1922ab565..4fb378ac22786 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -19,6 +19,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util +from homeassistant.util import Throttle REQUIREMENTS = ['pyTibber==0.4.1'] @@ -30,6 +31,7 @@ ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=1) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) async def async_setup_platform(hass, config, async_add_devices, @@ -58,7 +60,9 @@ def __init__(self, tibber_home): """Initialize the sensor.""" self._tibber_home = tibber_home self._last_updated = None + self._newest_data_timestamp = None self._state = None + self._is_available = False self._device_state_attributes = {} self._unit_of_measurement = self._tibber_home.price_unit self._name = 'Electricity price {}'.format(tibber_home.info['viewer'] @@ -68,50 +72,27 @@ async def async_update(self): """Get the latest data and updates the states.""" now = dt_util.utcnow() if self._tibber_home.current_price_total and self._last_updated and \ - dt_util.as_utc(dt_util.parse_datetime(self._last_updated)).hour\ - == now.hour: + self._last_updated.hour == now.hour and self._newest_data_timestamp: return - def _find_current_price(): - state = None - max_price = None - min_price = None - for key, price_total in self._tibber_home.price_total.items(): - price_time = dt_util.as_utc(dt_util.parse_datetime(key)) - price_total = round(price_total, 3) - time_diff = (now - price_time).total_seconds()/60 - if time_diff >= 0 and time_diff < 60: - state = price_total - self._last_updated = key - if now.date() == price_time.date(): - if max_price is None or price_total > max_price: - max_price = price_total - if min_price is None or price_total < min_price: - min_price = price_total - self._state = state - self._device_state_attributes['max_price'] = max_price - self._device_state_attributes['min_price'] = min_price - return state is not None - - if _find_current_price(): - return + if (not self._newest_data_timestamp or + (self._newest_data_timestamp - now).total_seconds()/3600 < 12 + or not self._is_available): + _LOGGER.debug("Asking for new data.") + await self._fetch_data() - _LOGGER.debug("No cached data found, so asking for new data") - await self._tibber_home.update_info() - await self._tibber_home.update_price_info() - data = self._tibber_home.info['viewer']['home'] - self._device_state_attributes['app_nickname'] = data['appNickname'] - self._device_state_attributes['grid_company'] =\ - data['meteringPointData']['gridCompany'] - self._device_state_attributes['estimated_annual_consumption'] =\ - data['meteringPointData']['estimatedAnnualConsumption'] - _find_current_price() + self._is_available = self._update_current_price() @property def device_state_attributes(self): """Return the state attributes.""" return self._device_state_attributes + @property + def available(self): + """Return True if entity is available.""" + return self._is_available + @property def name(self): """Return the name of the sensor.""" @@ -137,3 +118,42 @@ def unique_id(self): """Return a unique ID.""" home = self._tibber_home.info['viewer']['home'] return home['meteringPointData']['consumptionEan'] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def _fetch_data(self): + try: + await self._tibber_home.update_info() + await self._tibber_home.update_price_info() + except (asyncio.TimeoutError, aiohttp.ClientError): + return + data = self._tibber_home.info['viewer']['home'] + self._device_state_attributes['app_nickname'] = data['appNickname'] + self._device_state_attributes['grid_company'] = \ + data['meteringPointData']['gridCompany'] + self._device_state_attributes['estimated_annual_consumption'] = \ + data['meteringPointData']['estimatedAnnualConsumption'] + + def _update_current_price(self): + state = None + max_price = None + min_price = None + now = dt_util.utcnow() + for key, price_total in self._tibber_home.price_total.items(): + price_time = dt_util.as_utc(dt_util.parse_datetime(key)) + price_total = round(price_total, 3) + time_diff = (now - price_time).total_seconds()/60 + if (not self._newest_data_timestamp or + price_time > self._newest_data_timestamp): + self._newest_data_timestamp = price_time + if 0 <= time_diff < 60: + state = price_total + self._last_updated = price_time + if now.date() == price_time.date(): + if max_price is None or price_total > max_price: + max_price = price_total + if min_price is None or price_total < min_price: + min_price = price_total + self._state = state + self._device_state_attributes['max_price'] = max_price + self._device_state_attributes['min_price'] = min_price + return state is not None From 8459b241a22ef9c522863a2a5ed9df0c3e28dab5 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Fri, 20 Apr 2018 06:35:56 -0700 Subject: [PATCH 128/155] Upgrade pylutron-caseta to 0.5.0 to reestablish connections (#14013) * Upgrade pylutron-caseta to 0.5.0 to reestablish connections * Upgrade pylutron-caseta to 0.5.0 in requirements_all.txt --- homeassistant/components/lutron_caseta.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 63f0315f35c9e..7b1b7417cfd98 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.3.0'] +REQUIREMENTS = ['pylutron-caseta==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ccfaf0b35a18e..aeb5b84811ef1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -833,7 +833,7 @@ pylitejet==0.1 pyloopenergy==0.0.18 # homeassistant.components.lutron_caseta -pylutron-caseta==0.3.0 +pylutron-caseta==0.5.0 # homeassistant.components.lutron pylutron==0.1.0 From 2a5fac3b9da4e3a93fcfea9b5175e374050c9ca0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Apr 2018 09:38:27 -0400 Subject: [PATCH 129/155] Add sensor device classes (#14010) --- homeassistant/components/sensor/__init__.py | 9 +++++++++ homeassistant/components/sensor/ecobee.py | 7 +++++++ homeassistant/components/sensor/linux_battery.py | 5 +++++ homeassistant/components/sensor/nest.py | 5 +++++ 4 files changed, 26 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e0bf3c86b058a..2bc35a034f46b 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -8,6 +8,8 @@ from datetime import timedelta import logging +import voluptuous as vol + from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -18,6 +20,13 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' SCAN_INTERVAL = timedelta(seconds=30) +DEVICE_CLASSES = [ + 'battery', # % of battery that is left + 'humidity', # % of humidity in the air + 'temperature', # temperature (C/F) +] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) async def async_setup(hass, config): diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index dad770d5bab0b..7274f421f157b 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -52,6 +52,13 @@ def name(self): """Return the name of the Ecobee sensor.""" return self._name + @property + def device_class(self): + """Return the device class of the sensor.""" + if self.type in ('temperature', 'humidity'): + return self.type + return None + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py index 3d28c44d606da..1f0e3e89e5c25 100644 --- a/homeassistant/components/sensor/linux_battery.py +++ b/homeassistant/components/sensor/linux_battery.py @@ -94,6 +94,11 @@ def name(self): """Return the name of the sensor.""" return self._name + @property + def device_class(self): + """Return the device class of the sensor.""" + return 'battery' + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index e2567fdf4ca9a..5ee4f7380514e 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -140,6 +140,11 @@ def state(self): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the device class of the sensor.""" + return 'temperature' + def update(self): """Retrieve latest state.""" if self.device.temperature_scale == 'C': From 2b537297084cbef78af8f5452871bae27b68e90d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Apr 2018 10:58:43 -0400 Subject: [PATCH 130/155] Version bump to 0.68.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 43380d00a2d88..56e37e5e0394d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 68 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 8cb1e17ad85f26e861e5e63f2c7067d34f551fc4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Apr 2018 23:18:28 -0400 Subject: [PATCH 131/155] Bump frontend to 20180425.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 87ca8bd2a2804..ba487a935a28b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180420.0'] +REQUIREMENTS = ['home-assistant-frontend==20180425.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index aeb5b84811ef1..571974437796c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180420.0 +home-assistant-frontend==20180425.0 # homeassistant.components.homekit_controller # homekit==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d371996e361c..23ffab2fc782c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180420.0 +home-assistant-frontend==20180425.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 44be80145b55deda65b2e655fe0bd2cedc2eb8ad Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 21 Apr 2018 08:34:42 +0200 Subject: [PATCH 132/155] Qwikswitch binary sensors (#14008) --- .../components/binary_sensor/qwikswitch.py | 70 ++++++++++++++++++ homeassistant/components/qwikswitch.py | 44 ++++++++---- homeassistant/components/sensor/qwikswitch.py | 12 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../{sensor => }/test_qwikswitch.py | 72 +++++++++++++------ 6 files changed, 161 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/binary_sensor/qwikswitch.py rename tests/components/{sensor => }/test_qwikswitch.py (55%) diff --git a/homeassistant/components/binary_sensor/qwikswitch.py b/homeassistant/components/binary_sensor/qwikswitch.py new file mode 100644 index 0000000000000..067021b0c7a84 --- /dev/null +++ b/homeassistant/components/binary_sensor/qwikswitch.py @@ -0,0 +1,70 @@ +""" +Support for Qwikswitch Binary Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.qwikswitch/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.qwikswitch import QSEntity, DOMAIN as QWIKSWITCH +from homeassistant.core import callback + +DEPENDENCIES = [QWIKSWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, _, add_devices, discovery_info=None): + """Add binary sensor from the main Qwikswitch component.""" + if discovery_info is None: + return + + qsusb = hass.data[QWIKSWITCH] + _LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s", + qsusb, discovery_info) + devs = [QSBinarySensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + add_devices(devs) + + +class QSBinarySensor(QSEntity, BinarySensorDevice): + """Sensor based on a Qwikswitch relay/dimmer module.""" + + _val = False + + def __init__(self, sensor): + """Initialize the sensor.""" + from pyqwikswitch import SENSORS + + super().__init__(sensor['id'], sensor['name']) + self.channel = sensor['channel'] + sensor_type = sensor['type'] + + self._decode, _ = SENSORS[sensor_type] + self._invert = not sensor.get('invert', False) + self._class = sensor.get('class', 'door') + + @callback + def update_packet(self, packet): + """Receive update packet from QSUSB.""" + val = self._decode(packet, channel=self.channel) + _LOGGER.debug("Update %s (%s:%s) decoded as %s: %s", + self.entity_id, self.qsid, self.channel, val, packet) + if val is not None: + self._val = bool(val) + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Check if device is on (non-zero).""" + return self._val == self._invert + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}:{}".format(self.qsid, self.channel) + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._class diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 3dc16f513dc06..f26318fa7a926 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -8,17 +8,18 @@ import voluptuous as vol +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_URL, - CONF_SENSORS, CONF_SWITCHES) + CONF_SENSORS, CONF_SWITCHES, CONF_URL, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity -from homeassistant.components.light import ATTR_BRIGHTNESS -import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyqwikswitch==0.71'] +REQUIREMENTS = ['pyqwikswitch==0.8'] _LOGGER = logging.getLogger(__name__) @@ -28,6 +29,7 @@ CONF_BUTTON_EVENTS = 'button_events' CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3)) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_URL, default='http://127.0.0.1:2020'): @@ -40,6 +42,8 @@ vol.Optional('channel', default=1): int, vol.Required('name'): str, vol.Required('type'): str, + vol.Optional('class'): DEVICE_CLASSES_SCHEMA, + vol.Optional('invert'): bool })]), vol.Optional(CONF_SWITCHES, default=[]): vol.All( cv.ensure_list, [str]) @@ -115,7 +119,7 @@ async def async_turn_off(self, **_): async def async_setup(hass, config): """Qwiskswitch component setup.""" from pyqwikswitch.async_ import QSUsb - from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType + from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType, SENSORS # Add cmd's to in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] @@ -143,22 +147,39 @@ def callback_value_changed(_qsd, qsid, _val): hass.data[DOMAIN] = qsusb - _new = {'switch': [], 'light': [], 'sensor': sensors} + comps = {'switch': [], 'light': [], 'sensor': [], 'binary_sensor': []} + + try: + for sens in sensors: + _, _type = SENSORS[sens['type']] + if _type is bool: + comps['binary_sensor'].append(sens) + continue + comps['sensor'].append(sens) + for _key in ('invert', 'class'): + if _key in sens: + _LOGGER.warning( + "%s should only be used for binary_sensors: %s", + _key, sens) + + except KeyError: + _LOGGER.warning("Sensor validation failed") + for qsid, dev in qsusb.devices.items(): if qsid in switches: if dev.qstype != QSType.relay: _LOGGER.warning( "You specified a switch that is not a relay %s", qsid) continue - _new['switch'].append(qsid) + comps['switch'].append(qsid) elif dev.qstype in (QSType.relay, QSType.dimmer): - _new['light'].append(qsid) + comps['light'].append(qsid) else: _LOGGER.warning("Ignored unknown QSUSB device: %s", dev) continue # Load platforms - for comp_name, comp_conf in _new.items(): + for comp_name, comp_conf in comps.items(): if comp_conf: load_platform(hass, comp_name, DOMAIN, {DOMAIN: comp_conf}, config) @@ -190,9 +211,8 @@ def async_start(_): @callback def async_stop(_): - """Stop the listener queue and clean up.""" + """Stop the listener.""" hass.data[DOMAIN].stop() - _LOGGER.info("Waiting for long poll to QSUSB to time out (max 30sec)") hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop) diff --git a/homeassistant/components/sensor/qwikswitch.py b/homeassistant/components/sensor/qwikswitch.py index ebd5f5254d446..1497b4ad5cc13 100644 --- a/homeassistant/components/sensor/qwikswitch.py +++ b/homeassistant/components/sensor/qwikswitch.py @@ -36,18 +36,18 @@ def __init__(self, sensor): super().__init__(sensor['id'], sensor['name']) self.channel = sensor['channel'] - self.sensor_type = sensor['type'] + sensor_type = sensor['type'] - self._decode, self.unit = SENSORS[self.sensor_type] + self._decode, self.unit = SENSORS[sensor_type] if isinstance(self.unit, type): - self.unit = "{}:{}".format(self.sensor_type, self.channel) + self.unit = "{}:{}".format(sensor_type, self.channel) @callback def update_packet(self, packet): """Receive update packet from QSUSB.""" - val = self._decode(packet.get('data'), channel=self.channel) - _LOGGER.debug("Update %s (%s) decoded as %s: %s: %s", - self.entity_id, self.qsid, val, self.channel, packet) + val = self._decode(packet, channel=self.channel) + _LOGGER.debug("Update %s (%s:%s) decoded as %s: %s", + self.entity_id, self.qsid, self.channel, val, packet) if val is not None: self._val = val self.async_schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 571974437796c..579d8914d6667 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -898,7 +898,7 @@ pyowm==2.8.0 pypollencom==1.1.2 # homeassistant.components.qwikswitch -pyqwikswitch==0.71 +pyqwikswitch==0.8 # homeassistant.components.rainbird pyrainbird==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23ffab2fc782c..779b304f490e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -149,7 +149,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.qwikswitch -pyqwikswitch==0.71 +pyqwikswitch==0.8 # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky diff --git a/tests/components/sensor/test_qwikswitch.py b/tests/components/test_qwikswitch.py similarity index 55% rename from tests/components/sensor/test_qwikswitch.py rename to tests/components/test_qwikswitch.py index d9dfe072fc0ab..76655f32816b9 100644 --- a/tests/components/sensor/test_qwikswitch.py +++ b/tests/components/test_qwikswitch.py @@ -13,17 +13,19 @@ class AiohttpClientMockResponseList(list): - """List that fires an event on empty pop, for aiohttp Mocker.""" + """Return multiple values for aiohttp Mocker. + + aoihttp mocker uses decode to fetch the next value. + """ def decode(self, _): """Return next item from list.""" try: - res = list.pop(self) + res = list.pop(self, 0) _LOGGER.debug("MockResponseList popped %s: %s", res, self) return res except IndexError: - _LOGGER.debug("MockResponseList empty") - return "" + raise AssertionError("MockResponseList empty") async def wait_till_empty(self, hass): """Wait until empty.""" @@ -52,8 +54,8 @@ def aioclient_mock(): yield mock_session -async def test_sensor_device(hass, aioclient_mock): - """Test a sensor device.""" +async def test_binary_sensor_device(hass, aioclient_mock): + """Test a binary sensor device.""" config = { 'qwikswitch': { 'sensors': { @@ -67,21 +69,49 @@ async def test_sensor_device(hass, aioclient_mock): await async_setup_component(hass, QWIKSWITCH, config) await hass.async_block_till_done() - state_obj = hass.states.get('sensor.s1') - assert state_obj - assert state_obj.state == 'None' + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'off' + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - LISTEN.append( # Close - """{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}""") + LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}') + LISTEN.append('') # Will cause a sleep await hass.async_block_till_done() - state_obj = hass.states.get('sensor.s1') - assert state_obj.state == 'True' - - # Causes a 30second delay: can be uncommented when upstream library - # allows cancellation of asyncio.sleep(30) on failed packet ("") - # LISTEN.append( # Open - # """{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}""") - # await LISTEN.wait_till_empty(hass) - # state_obj = hass.states.get('sensor.s1') - # assert state_obj.state == 'False' + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'on' + + LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}') + hass.data[QWIKSWITCH]._sleep_task.cancel() + await LISTEN.wait_till_empty(hass) + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'off' + + +async def test_sensor_device(hass, aioclient_mock): + """Test a sensor device.""" + config = { + 'qwikswitch': { + 'sensors': { + 'name': 'ss1', + 'id': '@a00001', + 'channel': 1, + 'type': 'qwikcord', + } + } + } + await async_setup_component(hass, QWIKSWITCH, config) + await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.ss1') + assert state_obj.state == 'None' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + LISTEN.append( + '{"id":"@a00001","name":"ss1","type":"rel",' + '"val":"4733800001a00000"}') + LISTEN.append('') # Will cause a sleep + await LISTEN.wait_till_empty(hass) # await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.ss1') + assert state_obj.state == 'None' From 2bc87bfcf0ec2dc1ea45c05325431153afae0517 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Apr 2018 13:47:06 -0400 Subject: [PATCH 133/155] Order the output of the automation editor (#14019) * Order the output of the automation editor * Lint --- homeassistant/components/config/automation.py | 34 +++++++- tests/components/config/test_automation.py | 83 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/components/config/test_automation.py diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 6ede91e9b6669..1e260854687f1 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,6 +1,8 @@ """Provide configuration end points for Automations.""" import asyncio +from collections import OrderedDict +from homeassistant.const import CONF_ID from homeassistant.components.config import EditIdBasedConfigView from homeassistant.components.automation import ( PLATFORM_SCHEMA, DOMAIN, async_reload) @@ -13,8 +15,38 @@ @asyncio.coroutine def async_setup(hass): """Set up the Automation config API.""" - hass.http.register_view(EditIdBasedConfigView( + hass.http.register_view(EditAutomationConfigView( DOMAIN, 'config', CONFIG_PATH, cv.string, PLATFORM_SCHEMA, post_write_hook=async_reload )) return True + + +class EditAutomationConfigView(EditIdBasedConfigView): + """Edit automation config.""" + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + index = None + for index, cur_value in enumerate(data): + if cur_value[CONF_ID] == config_key: + break + else: + cur_value = OrderedDict() + cur_value[CONF_ID] = config_key + index = len(data) + data.append(cur_value) + + # Iterate through some keys that we want to have ordered in the output + updated_value = OrderedDict() + for key in ('id', 'alias', 'trigger', 'condition', 'action'): + if key in cur_value: + updated_value[key] = cur_value[key] + if key in new_value: + updated_value[key] = new_value[key] + + # We cover all current fields above, but just in case we start + # supporting more fields in the future. + updated_value.update(cur_value) + updated_value.update(new_value) + data[index] = updated_value diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py new file mode 100644 index 0000000000000..327283e74aace --- /dev/null +++ b/tests/components/config/test_automation.py @@ -0,0 +1,83 @@ +"""Test Automation config panel.""" +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config + + +async def test_get_device_config(hass, aiohttp_client): + """Test getting device config.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await aiohttp_client(hass.http.app) + + def mock_read(path): + """Mock reading data.""" + return [ + { + 'id': 'sun', + }, + { + 'id': 'moon', + } + ] + + with patch('homeassistant.components.config._read', mock_read): + resp = await client.get( + '/api/config/automation/config/moon') + + assert resp.status == 200 + result = await resp.json() + + assert result == {'id': 'moon'} + + +async def test_update_device_config(hass, aiohttp_client): + """Test updating device config.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await aiohttp_client(hass.http.app) + + orig_data = [ + { + 'id': 'sun', + }, + { + 'id': 'moon', + } + ] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = await client.post( + '/api/config/automation/config/moon', data=json.dumps({ + 'trigger': [], + 'action': [], + 'condition': [], + })) + + assert resp.status == 200 + result = await resp.json() + assert result == {'result': 'ok'} + + assert list(orig_data[1]) == ['id', 'trigger', 'condition', 'action'] + assert orig_data[1] == { + 'id': 'moon', + 'trigger': [], + 'condition': [], + 'action': [], + } + assert written[0] == orig_data From cb839eff0fc8a36d8bd869c1830d424f567a4aeb Mon Sep 17 00:00:00 2001 From: Matt Schmitt Date: Sat, 21 Apr 2018 10:16:46 -0400 Subject: [PATCH 134/155] HomeKit Alarm Control Panel Code Exception Fix (#14025) * Catch exception for KeyError * Use get and added test --- .../components/homekit/type_security_systems.py | 2 +- tests/components/homekit/test_type_security_systems.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 6b8457a3aa54b..0762e0f25f9c6 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -30,7 +30,7 @@ class SecuritySystem(HomeAccessory): def __init__(self, *args, config): """Initialize a SecuritySystem accessory object.""" super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) - self._alarm_code = config[ATTR_CODE] + self._alarm_code = config.get(ATTR_CODE) self.flag_target_state = False serv_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM) diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index ec538ce4b503a..9c1ff0faf1ae2 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -109,8 +109,16 @@ def test_no_alarm_code(self): acc = SecuritySystem(self.hass, 'SecuritySystem', acp, 2, config={ATTR_CODE: None}) - acc.run() + # Set from HomeKit + acc.char_target_state.client_update_value(0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') + self.assertNotIn(ATTR_CODE, self.events[0].data[ATTR_SERVICE_DATA]) + self.assertEqual(acc.char_target_state.value, 0) + acc = SecuritySystem(self.hass, 'SecuritySystem', acp, + 2, config={}) # Set from HomeKit acc.char_target_state.client_update_value(0) self.hass.block_till_done() From fc1f6ee0f0a0fd70c2f35dc0282b9316fe8c25d6 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 22 Apr 2018 22:32:15 +0200 Subject: [PATCH 135/155] Revert cast platform polling mode (#14027) --- homeassistant/components/media_player/cast.py | 64 +++----------- tests/components/media_player/test_cast.py | 85 +------------------ 2 files changed, 13 insertions(+), 136 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 30d4bd166d0ca..632ab4214b819 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -288,8 +288,7 @@ def __init__(self, cast_info): self._chromecast = None # type: Optional[pychromecast.Chromecast] self.cast_status = None self.media_status = None - self.media_status_position = None - self.media_status_position_received = None + self.media_status_received = None self._available = False # type: bool self._status_listener = None # type: Optional[CastStatusListener] @@ -362,26 +361,10 @@ def _async_disconnect(self): self._chromecast = None self.cast_status = None self.media_status = None - self.media_status_position = None - self.media_status_position_received = None + self.media_status_received = None self._status_listener.invalidate() self._status_listener = None - def update(self): - """Periodically update the properties. - - Even though we receive callbacks for most state changes, some 3rd party - apps don't always send them. Better poll every now and then if the - chromecast is active (i.e. an app is running). - """ - if not self._available: - # Not connected or not available. - return - - if self._chromecast.media_controller.is_active: - # We can only update status if the media namespace is active - self._chromecast.media_controller.update_status() - # ========== Callbacks ========== def new_cast_status(self, cast_status): """Handle updates of the cast status.""" @@ -390,36 +373,8 @@ def new_cast_status(self, cast_status): def new_media_status(self, media_status): """Handle updates of the media status.""" - # Only use media position for playing/paused, - # and for normal playback rate - if (media_status is None or - abs(media_status.playback_rate - 1) > 0.01 or - not (media_status.player_is_playing or - media_status.player_is_paused)): - self.media_status_position = None - self.media_status_position_received = None - else: - # Avoid unnecessary state attribute updates if player_state and - # calculated position stay the same - now = dt_util.utcnow() - do_update = \ - (self.media_status is None or - self.media_status_position is None or - self.media_status.player_state != media_status.player_state) - if not do_update: - if media_status.player_is_playing: - elapsed = now - self.media_status_position_received - do_update = abs(media_status.current_time - - (self.media_status_position + - elapsed.total_seconds())) > 1 - else: - do_update = \ - self.media_status_position != media_status.current_time - if do_update: - self.media_status_position = media_status.current_time - self.media_status_position_received = now - self.media_status = media_status + self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() def new_connection_status(self, connection_status): @@ -496,8 +451,8 @@ def play_media(self, media_type, media_id, **kwargs): # ========== Properties ========== @property def should_poll(self): - """Polling needed for cast integration, see async_update.""" - return True + """No polling needed.""" + return False @property def name(self): @@ -625,7 +580,12 @@ def supported_features(self): @property def media_position(self): """Position of current playing media in seconds.""" - return self.media_status_position + if self.media_status is None or \ + not (self.media_status.player_is_playing or + self.media_status.player_is_paused or + self.media_status.player_is_idle): + return None + return self.media_status.current_time @property def media_position_updated_at(self): @@ -633,7 +593,7 @@ def media_position_updated_at(self): Returns value from homeassistant.util.dt.utcnow(). """ - return self.media_status_position_received + return self.media_status_received @property def unique_id(self) -> Optional[str]: diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 0c0f3906dc2ba..ee69ec1c85d37 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,7 +1,6 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access import asyncio -import datetime as dt from typing import Optional from unittest.mock import patch, MagicMock, Mock from uuid import UUID @@ -15,8 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ async_dispatcher_send -from homeassistant.components.media_player import cast, \ - ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT +from homeassistant.components.media_player import cast from homeassistant.setup import async_setup_component @@ -288,8 +286,6 @@ async def test_entity_media_states(hass: HomeAssistantType): assert entity.unique_id == full_info.uuid media_status = MagicMock(images=None) - media_status.current_time = 0 - media_status.playback_rate = 1 media_status.player_is_playing = True entity.new_media_status(media_status) await hass.async_block_till_done() @@ -324,85 +320,6 @@ async def test_entity_media_states(hass: HomeAssistantType): assert state.state == 'unknown' -async def test_entity_media_position(hass: HomeAssistantType): - """Test various entity media states.""" - info = get_fake_chromecast_info() - full_info = attr.evolve(info, model_name='google home', - friendly_name='Speaker', uuid=FakeUUID) - - with patch('pychromecast.dial.get_device_status', - return_value=full_info): - chromecast, entity = await async_setup_media_player_cast(hass, info) - - media_status = MagicMock(images=None) - media_status.current_time = 10 - media_status.playback_rate = 1 - media_status.player_is_playing = True - media_status.player_is_paused = False - media_status.player_is_idle = False - now = dt.datetime.now(dt.timezone.utc) - with patch('homeassistant.util.dt.utcnow', return_value=now): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert state.attributes[ATTR_MEDIA_POSITION] == 10 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now - - media_status.current_time = 15 - now_plus_5 = now + dt.timedelta(seconds=5) - with patch('homeassistant.util.dt.utcnow', return_value=now_plus_5): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert state.attributes[ATTR_MEDIA_POSITION] == 10 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now - - media_status.current_time = 20 - with patch('homeassistant.util.dt.utcnow', return_value=now_plus_5): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert state.attributes[ATTR_MEDIA_POSITION] == 20 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_5 - - media_status.current_time = 25 - now_plus_10 = now + dt.timedelta(seconds=10) - media_status.player_is_playing = False - media_status.player_is_paused = True - with patch('homeassistant.util.dt.utcnow', return_value=now_plus_10): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert state.attributes[ATTR_MEDIA_POSITION] == 25 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_10 - - now_plus_15 = now + dt.timedelta(seconds=15) - with patch('homeassistant.util.dt.utcnow', return_value=now_plus_15): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert state.attributes[ATTR_MEDIA_POSITION] == 25 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_10 - - media_status.current_time = 30 - now_plus_20 = now + dt.timedelta(seconds=20) - with patch('homeassistant.util.dt.utcnow', return_value=now_plus_20): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert state.attributes[ATTR_MEDIA_POSITION] == 30 - assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_20 - - media_status.player_is_paused = False - media_status.player_is_idle = True - with patch('homeassistant.util.dt.utcnow', return_value=now_plus_20): - entity.new_media_status(media_status) - await hass.async_block_till_done() - state = hass.states.get('media_player.speaker') - assert ATTR_MEDIA_POSITION not in state.attributes - assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes - - async def test_switched_host(hass: HomeAssistantType): """Test cast device listens for changed hosts and disconnects old cast.""" info = get_fake_chromecast_info() From 7566bb5aed499449446ecb0a6de45dd70d700b76 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Sun, 22 Apr 2018 13:38:01 -0700 Subject: [PATCH 136/155] Handle HomeKit configuration failure more cleanly (#14041) * Handle HomeKit configuration failure more cleanly Add support for handling cases where HomeKit configuration fails, and give the user more information about what to do. * Don't consume the exception for a homekit.UnknownError If we get an UnknownError then we should alert the user but also still generate the backtrace so there's actually something for them to file in a bug report. --- .../components/homekit_controller/__init__.py | 27 ++++++++++++++++--- requirements_all.txt | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index c33edd079188e..164e7d50e4d64 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['homekit==0.5'] +REQUIREMENTS = ['homekit==0.6'] DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' @@ -133,10 +133,31 @@ def device_config_callback(self, callback_data): import homekit pairing_id = str(uuid.uuid4()) code = callback_data.get('code').strip() - self.pairing_data = homekit.perform_pair_setup( - self.conn, code, pairing_id) + try: + self.pairing_data = homekit.perform_pair_setup(self.conn, code, + pairing_id) + except homekit.exception.UnavailableError: + error_msg = "This accessory is already paired to another device. \ + Please reset the accessory and try again." + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + return + except homekit.exception.AuthenticationError: + error_msg = "Incorrect HomeKit code for {}. Please check it and \ + try again.".format(self.model) + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + return + except homekit.exception.UnknownError: + error_msg = "Received an unknown error. Please file a bug." + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + raise + if self.pairing_data is not None: homekit.save_pairing(self.pairing_file, self.pairing_data) + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.request_done(_configurator) self.accessory_setup() else: error_msg = "Unable to pair, please try again" diff --git a/requirements_all.txt b/requirements_all.txt index 579d8914d6667..5070f904b4350 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -389,7 +389,7 @@ holidays==0.9.4 home-assistant-frontend==20180425.0 # homeassistant.components.homekit_controller -# homekit==0.5 +# homekit==0.6 # homeassistant.components.homematicip_cloud homematicip==0.8 From 2e3a27e418e9f149de99ed55cefe0cead7d4267f Mon Sep 17 00:00:00 2001 From: Mark Coombes Date: Mon, 23 Apr 2018 07:52:39 -0400 Subject: [PATCH 137/155] Update device classes for contact sensor HomeKit (#14051) --- homeassistant/components/homekit/const.py | 3 +++ homeassistant/components/homekit/type_sensors.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 1c498b4b3b9e5..59444c754213f 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -102,6 +102,8 @@ # #### Device Class #### DEVICE_CLASS_CO2 = 'co2' +DEVICE_CLASS_DOOR = 'door' +DEVICE_CLASS_GARAGE_DOOR = 'garage_door' DEVICE_CLASS_GAS = 'gas' DEVICE_CLASS_HUMIDITY = 'humidity' DEVICE_CLASS_LIGHT = 'light' @@ -112,3 +114,4 @@ DEVICE_CLASS_PM25 = 'pm25' DEVICE_CLASS_SMOKE = 'smoke' DEVICE_CLASS_TEMPERATURE = 'temperature' +DEVICE_CLASS_WINDOW = 'window' diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 6aa8d92c0afc2..7d7bbc5edd6d7 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -20,6 +20,7 @@ DEVICE_CLASS_MOTION, SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, DEVICE_CLASS_OCCUPANCY, SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, DEVICE_CLASS_OPENING, SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, + DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_WINDOW, DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) from .util import ( convert_to_float, temperature_to_homekit, density_to_air_quality) @@ -29,13 +30,16 @@ BINARY_SENSOR_SERVICE_MAP = { DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED), + DEVICE_CLASS_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_GARAGE_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED), DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED), DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED), DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED), DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), - DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED)} + DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED), + DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE)} @TYPES.register('TemperatureSensor') From c49751542fff78711727dd0a0beb524a4e4670b3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Apr 2018 23:19:33 -0400 Subject: [PATCH 138/155] Version bump to 0.68.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 56e37e5e0394d..eed6664bd0a87 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 68 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 1b71ce32e440075aa527d9b777a44befc86c01d2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Apr 2018 16:39:14 -0400 Subject: [PATCH 139/155] Bump frontend to 20180426 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ba487a935a28b..4a181c00c023f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180425.0'] +REQUIREMENTS = ['home-assistant-frontend==20180426.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 5070f904b4350..e48ee1ec29220 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180425.0 +home-assistant-frontend==20180426.0 # homeassistant.components.homekit_controller # homekit==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 779b304f490e3..876aba4574dcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180425.0 +home-assistant-frontend==20180426.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 833508fbbb4cdf3ce1382095293b09acfa7b3bee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Apr 2018 16:39:42 -0400 Subject: [PATCH 140/155] Version bump to 0.68.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index eed6664bd0a87..bc32c20f142fe 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 68 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 403a546bdc4566b8672532a70837afb0ab08c2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 25 Apr 2018 04:45:16 +0200 Subject: [PATCH 141/155] Upgrade broadlink lib (#14074) --- homeassistant/components/sensor/broadlink.py | 2 +- homeassistant/components/switch/broadlink.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 5182ba4530e88..9376687cf131f 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['broadlink==0.8.0'] +REQUIREMENTS = ['broadlink==0.9.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 50c334b1f09ad..460021121775d 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -22,7 +22,7 @@ from homeassistant.util import Throttle, slugify from homeassistant.util.dt import utcnow -REQUIREMENTS = ['broadlink==0.8.0'] +REQUIREMENTS = ['broadlink==0.9.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e48ee1ec29220..7cc644129b375 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -180,7 +180,7 @@ botocore==1.7.34 # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink -broadlink==0.8.0 +broadlink==0.9.0 # homeassistant.components.device_tracker.bluetooth_tracker bt_proximity==0.1.2 From 9d0251cfeb8c9131e6a703e7a337bb658f576b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 26 Apr 2018 09:49:35 +0200 Subject: [PATCH 142/155] Fix timezone issue when calculating min/max values in tibber #14009 (#14080) * fix timezone issue in tibber #14009 * remove debug print --- homeassistant/components/sensor/tibber.py | 30 +++++++++++------------ 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 4fb378ac22786..42568a6b9ada4 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -60,7 +60,7 @@ def __init__(self, tibber_home): """Initialize the sensor.""" self._tibber_home = tibber_home self._last_updated = None - self._newest_data_timestamp = None + self._last_data_timestamp = None self._state = None self._is_available = False self._device_state_attributes = {} @@ -70,13 +70,13 @@ def __init__(self, tibber_home): async def async_update(self): """Get the latest data and updates the states.""" - now = dt_util.utcnow() + now = dt_util.now() if self._tibber_home.current_price_total and self._last_updated and \ - self._last_updated.hour == now.hour and self._newest_data_timestamp: + self._last_updated.hour == now.hour and self._last_data_timestamp: return - if (not self._newest_data_timestamp or - (self._newest_data_timestamp - now).total_seconds()/3600 < 12 + if (not self._last_data_timestamp or + (self._last_data_timestamp - now).total_seconds()/3600 < 12 or not self._is_available): _LOGGER.debug("Asking for new data.") await self._fetch_data() @@ -135,24 +135,22 @@ async def _fetch_data(self): def _update_current_price(self): state = None - max_price = None - min_price = None - now = dt_util.utcnow() + max_price = 0 + min_price = 10000 + now = dt_util.now() for key, price_total in self._tibber_home.price_total.items(): - price_time = dt_util.as_utc(dt_util.parse_datetime(key)) + price_time = dt_util.as_local(dt_util.parse_datetime(key)) price_total = round(price_total, 3) time_diff = (now - price_time).total_seconds()/60 - if (not self._newest_data_timestamp or - price_time > self._newest_data_timestamp): - self._newest_data_timestamp = price_time + if (not self._last_data_timestamp or + price_time > self._last_data_timestamp): + self._last_data_timestamp = price_time if 0 <= time_diff < 60: state = price_total self._last_updated = price_time if now.date() == price_time.date(): - if max_price is None or price_total > max_price: - max_price = price_total - if min_price is None or price_total < min_price: - min_price = price_total + max_price = max(max_price, price_total) + min_price = min(min_price, price_total) self._state = state self._device_state_attributes['max_price'] = max_price self._device_state_attributes['min_price'] = min_price From c42c668815383168565c85db575b087aa755e8e9 Mon Sep 17 00:00:00 2001 From: GotoCode Date: Thu, 26 Apr 2018 20:35:29 +0300 Subject: [PATCH 143/155] Updated list of AWS regions for Amazon Polly (#14097) Fixes #14052 --- homeassistant/components/tts/amazon_polly.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index d7cf0f1f2d197..46c1a24caa0e8 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -20,7 +20,11 @@ ATTR_CREDENTIALS = 'credentials' DEFAULT_REGION = 'us-east-1' -SUPPORTED_REGIONS = ['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1'] +SUPPORTED_REGIONS = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', + 'ca-central-1', 'eu-west-1', 'eu-central-1', 'eu-west-2', + 'eu-west-3', 'ap-southeast-1', 'ap-southeast-2', + 'ap-northeast-2', 'ap-northeast-1', 'ap-south-1', + 'sa-east-1'] CONF_VOICE = 'voice' CONF_OUTPUT_FORMAT = 'output_format' From 9fb2bf72f9c969defbafcfb92b3207ff807a2a91 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Apr 2018 15:35:20 -0400 Subject: [PATCH 144/155] Version bump to 0.68.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bc32c20f142fe..0a69f166b4350 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 68 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 7da1d757073c0dd49e7b6e17102b725388e5ac85 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Fri, 27 Apr 2018 13:39:07 -0700 Subject: [PATCH 145/155] Change Eufy brightness handling (#14111) Eufy device state isn't reported if the bulb is off, so avoid stamping on the previous values if the bulb isn't going to give us useful information. In addition, improve handling of bulb turn on if we aren't provided with a brightness - this should avoid the bulb tending to end up with a brightness of 1 after power cycling. --- homeassistant/components/light/eufy.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py index a66e219c1a826..6f0a8816eea17 100644 --- a/homeassistant/components/light/eufy.py +++ b/homeassistant/components/light/eufy.py @@ -61,13 +61,14 @@ def __init__(self, device): def update(self): """Synchronise state from the bulb.""" self._bulb.update() - self._brightness = self._bulb.brightness - self._temp = self._bulb.temperature - if self._bulb.colors: - self._colormode = True - self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) - else: - self._colormode = False + if self._bulb.power: + self._brightness = self._bulb.brightness + self._temp = self._bulb.temperature + if self._bulb.colors: + self._colormode = True + self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) + else: + self._colormode = False self._state = self._bulb.power @property @@ -130,7 +131,9 @@ def turn_on(self, **kwargs): if brightness is not None: brightness = int(brightness * 100 / 255) else: - brightness = max(1, self._brightness) + if self._brightness is None: + self._brightness = 100 + brightness = self._brightness if colortemp is not None: self._colormode = False From a06f61034cb3a8debbc4da9817a6f7d1f87ef582 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 28 Apr 2018 23:12:11 +0200 Subject: [PATCH 146/155] Fix color setting of tplink lights (#14108) --- homeassistant/components/light/tplink.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 0bbec01028204..4101eab215029 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -11,8 +11,8 @@ from homeassistant.const import (CONF_HOST, CONF_NAME) from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, PLATFORM_SCHEMA) + Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_COLOR, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin @@ -90,15 +90,15 @@ def turn_on(self, **kwargs): if ATTR_COLOR_TEMP in kwargs: self.smartbulb.color_temp = \ mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - if ATTR_KELVIN in kwargs: - self.smartbulb.color_temp = kwargs[ATTR_KELVIN] - if ATTR_BRIGHTNESS in kwargs: - brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) - self.smartbulb.brightness = brightness_to_percentage(brightness) + + brightness = brightness_to_percentage( + kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255)) if ATTR_HS_COLOR in kwargs: hue, sat = kwargs.get(ATTR_HS_COLOR) - hsv = (hue, sat, 100) + hsv = (int(hue), int(sat), brightness) self.smartbulb.hsv = hsv + elif ATTR_BRIGHTNESS in kwargs: + self.smartbulb.brightness = brightness def turn_off(self, **kwargs): """Turn the light off.""" From 52a48b3ac9cb3584f0e3b97854381b0d8356fe50 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 27 Apr 2018 13:18:58 +0200 Subject: [PATCH 147/155] Improve precision of Hue color state (#14113) --- homeassistant/components/light/hue.py | 20 +++++--------------- tests/components/light/test_hue.py | 17 ++--------------- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 6eb8de99c9947..6b4908b02d4a6 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -242,26 +242,16 @@ def _color_mode(self): @property def hs_color(self): """Return the hs color value.""" - # pylint: disable=redefined-outer-name mode = self._color_mode - - if mode not in ('hs', 'xy'): - return - source = self.light.action if self.is_group else self.light.state - hue = source.get('hue') - sat = source.get('sat') + if mode == 'xy' and 'xy' in source: + return color.color_xy_to_hs(*source['xy']) - # Sometimes the state will not include valid hue/sat values. - # Reported as issue 13434 - if hue is not None and sat is not None: - return hue / 65535 * 360, sat / 255 * 100 - - if 'xy' not in source: - return None + if mode == 'hs' and 'hue' in source and 'sat' in source: + return source['hue'] / 65535 * 360, source['sat'] / 255 * 100 - return color.color_xy_to_hs(*source['xy']) + return None @property def color_temp(self): diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 712cd17a7c733..d36548e1e9141 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -237,7 +237,7 @@ async def test_lights(hass, mock_bridge): assert lamp_1 is not None assert lamp_1.state == 'on' assert lamp_1.attributes['brightness'] == 144 - assert lamp_1.attributes['hs_color'] == (71.896, 83.137) + assert lamp_1.attributes['hs_color'] == (36.067, 69.804) lamp_2 = hass.states.get('light.hue_lamp_2') assert lamp_2 is not None @@ -253,7 +253,7 @@ async def test_lights_color_mode(hass, mock_bridge): assert lamp_1 is not None assert lamp_1.state == 'on' assert lamp_1.attributes['brightness'] == 144 - assert lamp_1.attributes['hs_color'] == (71.896, 83.137) + assert lamp_1.attributes['hs_color'] == (36.067, 69.804) assert 'color_temp' not in lamp_1.attributes new_light1_on = LIGHT_1_ON.copy() @@ -668,19 +668,6 @@ def test_hs_color(): 'colormode': 'xy', 'hue': 1234, 'sat': 123, - }), - request_bridge_update=None, - bridge=Mock(), - is_group=False, - ) - - assert light.hs_color == (1234 / 65535 * 360, 123 / 255 * 100) - - light = hue_light.HueLight( - light=Mock(state={ - 'colormode': 'xy', - 'hue': None, - 'sat': 123, 'xy': [0.4, 0.5] }), request_bridge_update=None, From b5bae17c6640d201c407defa341cf3431fe9ba31 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 30 Apr 2018 00:49:19 +0200 Subject: [PATCH 148/155] Revert Hue color state to be xy-based (#14154) --- homeassistant/components/light/hue.py | 5 +---- tests/components/light/test_hue.py | 13 ------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 6b4908b02d4a6..9f66271851465 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -245,12 +245,9 @@ def hs_color(self): mode = self._color_mode source = self.light.action if self.is_group else self.light.state - if mode == 'xy' and 'xy' in source: + if mode in ('xy', 'hs'): return color.color_xy_to_hs(*source['xy']) - if mode == 'hs' and 'hue' in source and 'sat' in source: - return source['hue'] / 65535 * 360, source['sat'] / 255 * 100 - return None @property diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index d36548e1e9141..8f5b52ea6de75 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -650,19 +650,6 @@ def test_hs_color(): assert light.hs_color is None - light = hue_light.HueLight( - light=Mock(state={ - 'colormode': 'hs', - 'hue': 1234, - 'sat': 123, - }), - request_bridge_update=None, - bridge=Mock(), - is_group=False, - ) - - assert light.hs_color == (1234 / 65535 * 360, 123 / 255 * 100) - light = hue_light.HueLight( light=Mock(state={ 'colormode': 'xy', From f2a17a5462c7173f9002dad32444cf334ba4b678 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Sun, 29 Apr 2018 00:46:36 -0700 Subject: [PATCH 149/155] Fix Python 3.6 compatibility for HomeKit controller (#14160) Python 3.6's http client passes an additional argument to _send_output, so add that to the function definition. --- homeassistant/components/homekit_controller/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 164e7d50e4d64..e36e7439e09d6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) -def homekit_http_send(self, message_body=None): +def homekit_http_send(self, message_body=None, encode_chunked=False): r"""Send the currently buffered request and clear the buffer. Appends an extra \r\n to the buffer. From 03c34804bc04e98f3e5884b675cd3854429af34c Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 30 Apr 2018 14:58:17 +0200 Subject: [PATCH 150/155] Added CONF_IP_ADDRESS to HomeKit (#14163) --- homeassistant/components/homekit/__init__.py | 16 +++++++---- tests/components/homekit/test_homekit.py | 28 +++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 24c6dfa8a7686..6af470e80be3e 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -3,6 +3,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/homekit/ """ +import ipaddress import logging from zlib import adler32 @@ -12,8 +13,8 @@ SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - ATTR_DEVICE_CLASS, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + ATTR_DEVICE_CLASS, CONF_IP_ADDRESS, CONF_PORT, TEMP_CELSIUS, + TEMP_FAHRENHEIT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.util import get_local_ip @@ -35,6 +36,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): + vol.All(ipaddress.ip_address, cv.string), vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, @@ -48,11 +51,12 @@ async def async_setup(hass, config): conf = config[DOMAIN] port = conf[CONF_PORT] + ip_address = conf.get(CONF_IP_ADDRESS) auto_start = conf[CONF_AUTO_START] entity_filter = conf[CONF_FILTER] entity_config = conf[CONF_ENTITY_CONFIG] - homekit = HomeKit(hass, port, entity_filter, entity_config) + homekit = HomeKit(hass, port, ip_address, entity_filter, entity_config) homekit.setup() if auto_start: @@ -151,10 +155,11 @@ def generate_aid(entity_id): class HomeKit(): """Class to handle all actions between HomeKit and Home Assistant.""" - def __init__(self, hass, port, entity_filter, entity_config): + def __init__(self, hass, port, ip_address, entity_filter, entity_config): """Initialize a HomeKit object.""" self.hass = hass self._port = port + self._ip_address = ip_address self._filter = entity_filter self._config = entity_config self.started = False @@ -169,9 +174,10 @@ def setup(self): self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self.stop) + ip_addr = self._ip_address or get_local_ip() path = self.hass.config.path(HOMEKIT_FILE) self.bridge = HomeBridge(self.hass) - self.driver = HomeDriver(self.bridge, self._port, get_local_ip(), path) + self.driver = HomeDriver(self.bridge, self._port, ip_addr, path) def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index d1ad232d27935..7ae37becbd5f6 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -11,7 +11,8 @@ DEFAULT_PORT, SERVICE_HOMEKIT_START) from homeassistant.helpers.entityfilter import generate_filter from homeassistant.const import ( - CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + CONF_IP_ADDRESS, CONF_PORT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from tests.common import get_test_home_assistant from tests.components.homekit.test_accessories import patch_debounce @@ -59,7 +60,7 @@ def test_setup_min(self, mock_homekit): self.hass, DOMAIN, {DOMAIN: {}})) self.assertEqual(mock_homekit.mock_calls, [ - call(self.hass, DEFAULT_PORT, ANY, {}), + call(self.hass, DEFAULT_PORT, None, ANY, {}), call().setup()]) # Test auto start enabled @@ -74,7 +75,8 @@ def test_setup_auto_start_disabled(self, mock_homekit): """Test async_setup with auto start disabled and test service calls.""" mock_homekit.return_value = homekit = Mock() - config = {DOMAIN: {CONF_AUTO_START: False, CONF_PORT: 11111}} + config = {DOMAIN: {CONF_AUTO_START: False, CONF_PORT: 11111, + CONF_IP_ADDRESS: '172.0.0.0'}} self.assertTrue(setup.setup_component( self.hass, DOMAIN, config)) @@ -82,7 +84,7 @@ def test_setup_auto_start_disabled(self, mock_homekit): self.hass.block_till_done() self.assertEqual(mock_homekit.mock_calls, [ - call(self.hass, 11111, ANY, {}), + call(self.hass, 11111, '172.0.0.0', ANY, {}), call().setup()]) # Test start call with driver stopped. @@ -101,7 +103,7 @@ def test_setup_auto_start_disabled(self, mock_homekit): def test_homekit_setup(self): """Test setup of bridge and driver.""" - homekit = HomeKit(self.hass, DEFAULT_PORT, {}, {}) + homekit = HomeKit(self.hass, DEFAULT_PORT, None, {}, {}) with patch(PATH_HOMEKIT + '.accessories.HomeDriver') as mock_driver, \ patch('homeassistant.util.get_local_ip') as mock_ip: @@ -117,9 +119,17 @@ def test_homekit_setup(self): self.assertEqual( self.hass.bus.listeners.get(EVENT_HOMEASSISTANT_STOP), 1) + def test_homekit_setup_ip_address(self): + """Test setup with given IP address.""" + homekit = HomeKit(self.hass, DEFAULT_PORT, '172.0.0.0', {}, {}) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver') as mock_driver: + homekit.setup() + mock_driver.assert_called_with(ANY, DEFAULT_PORT, '172.0.0.0', ANY) + def test_homekit_add_accessory(self): """Add accessory if config exists and get_acc returns an accessory.""" - homekit = HomeKit(self.hass, None, lambda entity_id: True, {}) + homekit = HomeKit(self.hass, None, None, lambda entity_id: True, {}) homekit.bridge = HomeBridge(self.hass) with patch(PATH_HOMEKIT + '.accessories.HomeBridge.add_accessory') \ @@ -142,7 +152,7 @@ def test_homekit_add_accessory(self): def test_homekit_entity_filter(self): """Test the entity filter.""" entity_filter = generate_filter(['cover'], ['demo.test'], [], []) - homekit = HomeKit(self.hass, None, entity_filter, {}) + homekit = HomeKit(self.hass, None, None, entity_filter, {}) with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: mock_get_acc.return_value = None @@ -162,7 +172,7 @@ def test_homekit_entity_filter(self): @patch(PATH_HOMEKIT + '.HomeKit.add_bridge_accessory') def test_homekit_start(self, mock_add_bridge_acc, mock_show_setup_msg): """Test HomeKit start method.""" - homekit = HomeKit(self.hass, None, {}, {'cover.demo': {}}) + homekit = HomeKit(self.hass, None, None, {}, {'cover.demo': {}}) homekit.bridge = HomeBridge(self.hass) homekit.driver = Mock() @@ -184,7 +194,7 @@ def test_homekit_start(self, mock_add_bridge_acc, mock_show_setup_msg): def test_homekit_stop(self): """Test HomeKit stop method.""" - homekit = HomeKit(None, None, None, None) + homekit = HomeKit(None, None, None, None, None) homekit.driver = Mock() # Test if started = False From aba143ac9ff874452ffcb5e4c82dd45a15454623 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Apr 2018 09:18:18 -0400 Subject: [PATCH 151/155] Do not sync entities with an empty name (#14181) --- .../components/google_assistant/smart_home.py | 11 +++++--- .../google_assistant/test_smart_home.py | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 7e746d48bed3b..27d993aee76ab 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -102,18 +102,23 @@ def sync_serialize(self): if state.state == STATE_UNAVAILABLE: return None + entity_config = self.config.entity_config.get(state.entity_id, {}) + name = (entity_config.get(CONF_NAME) or state.name).strip() + + # If an empty string + if not name: + return None + traits = self.traits() # Found no supported traits for this entity if not traits: return None - entity_config = self.config.entity_config.get(state.entity_id, {}) - device = { 'id': state.entity_id, 'name': { - 'name': entity_config.get(CONF_NAME) or state.name + 'name': name }, 'attributes': {}, 'traits': [trait.name for trait in traits], diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index e284b026ad8b9..cdaf4200c974e 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -286,3 +286,29 @@ async def test_unavailable_state_doesnt_sync(hass): 'devices': [] } } + + +async def test_empty_name_doesnt_sync(hass): + """Test that an entity with empty name does not sync over.""" + light = DemoLight( + None, ' ', + state=False, + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [] + } + } From 7f1b591fbb79c9adc2bfe4fa8904071e624d84c5 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 30 Apr 2018 14:46:44 +0200 Subject: [PATCH 152/155] Improve chromecast disconnection logic (#14190) * Attempt Cast Fix * Cleanup --- homeassistant/components/media_player/cast.py | 26 +++++++++++++------ tests/components/media_player/test_cast.py | 16 +++++++++--- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 632ab4214b819..a9bea9e4c1d13 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -306,13 +306,18 @@ def async_cast_discovered(discover: ChromecastInfo): _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) self.hass.async_add_job(self.async_set_cast_info(discover)) + async def async_stop(event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() + async_dispatcher_connect(self.hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) self.hass.async_add_job(self.async_set_cast_info(self._cast_info)) async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" - self._async_disconnect() + await self._async_disconnect() if self._cast_info.uuid is not None: # Remove the entity from the added casts so that it can dynamically # be re-added again. @@ -328,7 +333,7 @@ async def async_set_cast_info(self, cast_info): if old_cast_info.host_port == cast_info.host_port: # Nothing connection-related updated return - self._async_disconnect() + await self._async_disconnect() # Failed connection will unfortunately never raise an exception, it # will instead just try connecting indefinitely. @@ -348,22 +353,27 @@ async def async_set_cast_info(self, cast_info): _LOGGER.debug("Connection successful!") self.async_schedule_update_ha_state() - @callback - def _async_disconnect(self): + async def _async_disconnect(self): """Disconnect Chromecast object if it is set.""" if self._chromecast is None: # Can't disconnect if not connected. return - _LOGGER.debug("Disconnecting from previous chromecast socket.") + _LOGGER.debug("Disconnecting from chromecast socket.") self._available = False - self._chromecast.disconnect(blocking=False) + self.async_schedule_update_ha_state() + + await self.hass.async_add_job(self._chromecast.disconnect) + # Invalidate some attributes self._chromecast = None self.cast_status = None self.media_status = None self.media_status_received = None - self._status_listener.invalidate() - self._status_listener = None + if self._status_listener is not None: + self._status_listener.invalidate() + self._status_listener = None + + self.async_schedule_update_ha_state() # ========== Callbacks ========== def new_cast_status(self, cast_status): diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index ee69ec1c85d37..41cf6749b7158 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -346,8 +346,16 @@ async def test_switched_host(hass: HomeAssistantType): async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, changed) await hass.async_block_till_done() assert get_chromecast.call_count == 1 - chromecast.disconnect.assert_called_once_with(blocking=False) + assert chromecast.disconnect.call_count == 1 - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - chromecast.disconnect.assert_called_once_with(blocking=False) + +async def test_disconnect_on_stop(hass: HomeAssistantType): + """Test cast device disconnects socket on stop.""" + info = get_fake_chromecast_info() + + with patch('pychromecast.dial.get_device_status', return_value=info): + chromecast, _ = await async_setup_media_player_cast(hass, info) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert chromecast.disconnect.call_count == 1 From daeccfe7643efaaceb19970180275a9d7b47a721 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Apr 2018 09:56:42 -0400 Subject: [PATCH 153/155] Fix poorly formatted automations (#14196) --- homeassistant/components/config/automation.py | 8 ++- tests/components/config/test_automation.py | 67 +++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 1e260854687f1..223159eb4158a 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,6 +1,7 @@ """Provide configuration end points for Automations.""" import asyncio from collections import OrderedDict +import uuid from homeassistant.const import CONF_ID from homeassistant.components.config import EditIdBasedConfigView @@ -29,7 +30,12 @@ def _write_value(self, hass, data, config_key, new_value): """Set value.""" index = None for index, cur_value in enumerate(data): - if cur_value[CONF_ID] == config_key: + # When people copy paste their automations to the config file, + # they sometimes forget to add IDs. Fix it here. + if CONF_ID not in cur_value: + cur_value[CONF_ID] = uuid.uuid4().hex + + elif cur_value[CONF_ID] == config_key: break else: cur_value = OrderedDict() diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 327283e74aace..2c888dd2dd25d 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -42,13 +42,13 @@ async def test_update_device_config(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) orig_data = [ - { - 'id': 'sun', - }, - { - 'id': 'moon', - } - ] + { + 'id': 'sun', + }, + { + 'id': 'moon', + } + ] def mock_read(path): """Mock reading data.""" @@ -81,3 +81,56 @@ def mock_write(path, data): 'action': [], } assert written[0] == orig_data + + +async def test_bad_formatted_automations(hass, aiohttp_client): + """Test that we handle automations without ID.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await aiohttp_client(hass.http.app) + + orig_data = [ + { + # No ID + 'action': { + 'event': 'hello' + } + }, + { + 'id': 'moon', + } + ] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = await client.post( + '/api/config/automation/config/moon', data=json.dumps({ + 'trigger': [], + 'action': [], + 'condition': [], + })) + + assert resp.status == 200 + result = await resp.json() + assert result == {'result': 'ok'} + + # Verify ID added to orig_data + assert 'id' in orig_data[0] + + assert orig_data[1] == { + 'id': 'moon', + 'trigger': [], + 'condition': [], + 'action': [], + } From c704ceaeb7c3d5b085721af4003ad560e79ea9fe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Apr 2018 13:37:12 -0400 Subject: [PATCH 154/155] Version bump to 0.68.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0a69f166b4350..4014a7199127f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 68 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From c23cc0e8271bab2dfa60f8c4096907780bf1362d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Apr 2018 20:15:00 -0400 Subject: [PATCH 155/155] Disable eliqonline requirement (#14156) * Disable eliqonline requirement * Disable pylint import error --- homeassistant/components/sensor/eliqonline.py | 3 ++- requirements_all.txt | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 3e736ed719f25..23c397053c5c5 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -14,7 +14,8 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['eliqonline==1.0.13'] +# pylint: disable=import-error, no-member +REQUIREMENTS = [] # ['eliqonline==1.0.13'] - package disappeared _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7cc644129b375..ff6e680051d42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,9 +276,6 @@ dsmr_parser==0.11 # homeassistant.components.sensor.dweet dweepy==0.3.0 -# homeassistant.components.sensor.eliqonline -eliqonline==1.0.13 - # homeassistant.components.enocean enocean==0.40