From 7f0dd442fddb8478c14d0060b7a28631b8fa706c Mon Sep 17 00:00:00 2001 From: Adam Belebczuk Date: Wed, 19 Dec 2018 02:12:32 -0500 Subject: [PATCH] Various enhancements for WeMo component/platforms (#19419) * WeMo - Various fixes and improvements Various fixes & improvements to the WeMo components, including: -- Fixes to rediscovery -- New reset filter service for the WeMo Humidifier -- Switched the remainder of the WeMo components to async IO -- Removed any remaining IO in entity properties and moved them to the polling/subscription update process * WeMo - Fix pywemo version and remove test code from WeMo fan component * WeMo Humidifier - Add services.yaml entry for reset filter life service * WeMo - Update binary_sensor component to use asyncio * WeMo - Add available property to binary_sensor component * WeMo - Fixed line length issue * WeMo - Fix issue with discovering the same device multiple times * WeMo - Fix for the fix for discovering devices multiple times * WeMo - Fix long lines * WeMo - Fixes from code review * WeMo - Breaking Change - entity_ids is now required on wemo_set_humidity * WeMo - Code review fixes * WeMo - Code review fixes * WeMo - Code review fixes --- .../components/binary_sensor/wemo.py | 99 ++++++++--- homeassistant/components/fan/services.yaml | 9 +- homeassistant/components/fan/wemo.py | 51 +++--- homeassistant/components/light/wemo.py | 158 ++++++++++++++---- homeassistant/components/switch/wemo.py | 21 +-- homeassistant/components/wemo.py | 18 +- 6 files changed, 256 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/binary_sensor/wemo.py b/homeassistant/components/binary_sensor/wemo.py index 1071aae50dd..304ea2e733f 100644 --- a/homeassistant/components/binary_sensor/wemo.py +++ b/homeassistant/components/binary_sensor/wemo.py @@ -4,7 +4,10 @@ Support for WeMo sensors. For more details about this component, please refer to the documentation at https://home-assistant.io/components/binary_sensor.wemo/ """ +import asyncio import logging + +import async_timeout import requests from homeassistant.components.binary_sensor import BinarySensorDevice @@ -41,48 +44,90 @@ class WemoBinarySensor(BinarySensorDevice): """Initialize the WeMo sensor.""" self.wemo = device self._state = None + self._available = True + self._update_lock = None + self._model_name = self.wemo.model_name + self._name = self.wemo.name + self._serialnumber = self.wemo.serialnumber - wemo = hass.components.wemo - wemo.SUBSCRIPTION_REGISTRY.register(self.wemo) - wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback) - - def _update_callback(self, _device, _type, _params): - """Handle state changes.""" - _LOGGER.info("Subscription update for %s", _device) + def _subscription_callback(self, _device, _type, _params): + """Update the state by the Wemo sensor.""" + _LOGGER.debug("Subscription update for %s", self.name) updated = self.wemo.subscription_update(_type, _params) - self._update(force_update=(not updated)) + self.hass.add_job( + self._async_locked_subscription_callback(not updated)) - if not hasattr(self, 'hass'): + async def _async_locked_subscription_callback(self, force_update): + """Handle an update from a subscription.""" + # If an update is in progress, we don't do anything + if self._update_lock.locked(): return - self.schedule_update_ha_state() - @property - def should_poll(self): - """No polling needed with subscriptions.""" - return False + await self._async_locked_update(force_update) + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Wemo sensor added to HASS.""" + # Define inside async context so we know our event loop + self._update_lock = asyncio.Lock() + + registry = self.hass.components.wemo.SUBSCRIPTION_REGISTRY + await self.hass.async_add_executor_job(registry.register, self.wemo) + registry.on(self.wemo, None, self._subscription_callback) + + async def async_update(self): + """Update WeMo state. + + Wemo has an aggressive retry logic that sometimes can take over a + minute to return. If we don't get a state after 5 seconds, assume the + Wemo sensor is unreachable. If update goes through, it will be made + available again. + """ + # If an update is in progress, we don't do anything + if self._update_lock.locked(): + return + + try: + with async_timeout.timeout(5): + await asyncio.shield(self._async_locked_update(True)) + except asyncio.TimeoutError: + _LOGGER.warning('Lost connection to %s', self.name) + self._available = False + + async def _async_locked_update(self, force_update): + """Try updating within an async lock.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update, force_update) + + def _update(self, force_update=True): + """Update the sensor state.""" + try: + self._state = self.wemo.get_state(force_update) + + if not self._available: + _LOGGER.info('Reconnected to %s', self.name) + self._available = True + except AttributeError as err: + _LOGGER.warning("Could not update status for %s (%s)", + self.name, err) + self._available = False @property def unique_id(self): - """Return the id of this WeMo device.""" - return self.wemo.serialnumber + """Return the id of this WeMo sensor.""" + return self._serialnumber @property def name(self): """Return the name of the service if any.""" - return self.wemo.name + return self._name @property def is_on(self): """Return true if sensor is on.""" return self._state - def update(self): - """Update WeMo state.""" - self._update(force_update=True) - - def _update(self, force_update=True): - try: - self._state = self.wemo.get_state(force_update) - except AttributeError as err: - _LOGGER.warning( - "Could not update status for %s (%s)", self.name, err) + @property + def available(self): + """Return true if sensor is available.""" + return self._available diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index b183c6049ee..35a81c7c934 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -209,8 +209,15 @@ wemo_set_humidity: description: Set the target humidity of WeMo humidifier devices. fields: entity_id: - description: Names of the WeMo humidifier entities (0 or more entities, if no entity_id is provided, all WeMo humidifiers will have the target humidity set). + description: Names of the WeMo humidifier entities (1 or more entity_ids are required). example: 'fan.wemo_humidifier' target_humidity: description: Target humidity. This is a float value between 0 and 100, but will be mapped to the humidity levels that WeMo humidifiers support (45, 50, 55, 60, and 100/Max) by rounding the value down to the nearest supported value. example: 56.5 + +wemo_reset_filter_life: + description: Reset the WeMo Humidifier's filter life to 100%. + fields: + entity_id: + description: Names of the WeMo humidifier entities (1 or more entity_ids are required). + example: 'fan.wemo_humidifier' diff --git a/homeassistant/components/fan/wemo.py b/homeassistant/components/fan/wemo.py index 0c570465f4d..fbf72185ac2 100644 --- a/homeassistant/components/fan/wemo.py +++ b/homeassistant/components/fan/wemo.py @@ -78,11 +78,17 @@ HASS_FAN_SPEED_TO_WEMO = {v: k for (k, v) in WEMO_FAN_SPEED_TO_HASS.items() SERVICE_SET_HUMIDITY = 'wemo_set_humidity' SET_HUMIDITY_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_TARGET_HUMIDITY): vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) }) +SERVICE_RESET_FILTER_LIFE = 'wemo_reset_filter_life' + +RESET_FILTER_LIFE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids +}) + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up discovered WeMo humidifiers.""" @@ -111,22 +117,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def service_handle(service): """Handle the WeMo humidifier services.""" entity_ids = service.data.get(ATTR_ENTITY_ID) - target_humidity = service.data.get(ATTR_TARGET_HUMIDITY) - if entity_ids: - humidifiers = [device for device in hass.data[DATA_KEY].values() if - device.entity_id in entity_ids] - else: - humidifiers = hass.data[DATA_KEY].values() + humidifiers = [device for device in + hass.data[DATA_KEY].values() if + device.entity_id in entity_ids] - for humidifier in humidifiers: - humidifier.set_humidity(target_humidity) + if service.service == SERVICE_SET_HUMIDITY: + target_humidity = service.data.get(ATTR_TARGET_HUMIDITY) + + for humidifier in humidifiers: + humidifier.set_humidity(target_humidity) + elif service.service == SERVICE_RESET_FILTER_LIFE: + for humidifier in humidifiers: + humidifier.reset_filter_life() # Register service(s) hass.services.register( DOMAIN, SERVICE_SET_HUMIDITY, service_handle, schema=SET_HUMIDITY_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_RESET_FILTER_LIFE, service_handle, + schema=RESET_FILTER_LIFE_SCHEMA) + class WemoHumidifier(FanEntity): """Representation of a WeMo humidifier.""" @@ -137,7 +150,6 @@ class WemoHumidifier(FanEntity): self._state = None self._available = True self._update_lock = None - self._fan_mode = None self._target_humidity = None self._current_humidity = None @@ -145,9 +157,6 @@ class WemoHumidifier(FanEntity): self._filter_life = None self._filter_expired = None self._last_fan_on_mode = WEMO_FAN_MEDIUM - - # look up model name, name, and serial number - # once as it incurs network traffic self._model_name = self.wemo.model_name self._name = self.wemo.name self._serialnumber = self.wemo.serialnumber @@ -211,12 +220,12 @@ class WemoHumidifier(FanEntity): return WEMO_FAN_SPEED_TO_HASS.get(self._fan_mode) @property - def speed_list(self: FanEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" return SUPPORTED_SPEEDS @property - def supported_features(self: FanEntity) -> int: + def supported_features(self) -> int: """Flag supported features.""" return SUPPORTED_FEATURES @@ -276,22 +285,22 @@ class WemoHumidifier(FanEntity): self.name, err) self._available = False - def turn_on(self: FanEntity, speed: str = None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn the switch on.""" if speed is None: self.wemo.set_state(self._last_fan_on_mode) else: self.set_speed(speed) - def turn_off(self: FanEntity, **kwargs) -> None: + def turn_off(self, **kwargs) -> None: """Turn the switch off.""" self.wemo.set_state(WEMO_FAN_OFF) - def set_speed(self: FanEntity, speed: str) -> None: + def set_speed(self, speed: str) -> None: """Set the fan_mode of the Humidifier.""" self.wemo.set_state(HASS_FAN_SPEED_TO_WEMO.get(speed)) - def set_humidity(self: FanEntity, humidity: float) -> None: + def set_humidity(self, humidity: float) -> None: """Set the target humidity level for the Humidifier.""" if humidity < 50: self.wemo.set_humidity(WEMO_HUMIDITY_45) @@ -303,3 +312,7 @@ class WemoHumidifier(FanEntity): self.wemo.set_humidity(WEMO_HUMIDITY_60) elif humidity >= 100: self.wemo.set_humidity(WEMO_HUMIDITY_100) + + def reset_filter_life(self) -> None: + """Reset the filter life to 100%.""" + self.wemo.reset_filter_life() diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index 55a4836e148..38044b7a736 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -4,9 +4,12 @@ Support for Belkin WeMo lights. For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.wemo/ """ +import asyncio import logging from datetime import timedelta + import requests +import async_timeout from homeassistant import util from homeassistant.components.light import ( @@ -74,40 +77,52 @@ class WemoLight(Light): def __init__(self, device, update_lights): """Initialize the WeMo light.""" - self.light_id = device.name self.wemo = device - self.update_lights = update_lights + self._state = None + self._update_lights = update_lights + self._available = True + self._update_lock = None + self._brightness = None + self._hs_color = None + self._color_temp = None + self._is_on = None + self._name = self.wemo.name + self._unique_id = self.wemo.uniqueID + + async def async_added_to_hass(self): + """Wemo light added to HASS.""" + # Define inside async context so we know our event loop + self._update_lock = asyncio.Lock() @property def unique_id(self): """Return the ID of this light.""" - return self.wemo.uniqueID + return self._unique_id @property def name(self): """Return the name of the light.""" - return self.wemo.name + return self._name @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self.wemo.state.get('level', 255) + return self._brightness @property def hs_color(self): """Return the hs color values of this light.""" - xy_color = self.wemo.state.get('color_xy') - return color_util.color_xy_to_hs(*xy_color) if xy_color else None + return self._hs_color @property def color_temp(self): """Return the color temperature of this light in mireds.""" - return self.wemo.state.get('temperature_mireds') + return self._color_temp @property def is_on(self): """Return true if device is on.""" - return self.wemo.state['onoff'] != 0 + return self._is_on @property def supported_features(self): @@ -117,7 +132,7 @@ class WemoLight(Light): @property def available(self): """Return if light is available.""" - return self.wemo.state['available'] + return self._available def turn_on(self, **kwargs): """Turn the light on.""" @@ -145,9 +160,40 @@ class WemoLight(Light): transitiontime = int(kwargs.get(ATTR_TRANSITION, 0)) self.wemo.turn_off(transition=transitiontime) - def update(self): + def _update(self, force_update=True): """Synchronize state with bridge.""" - self.update_lights(no_throttle=True) + self._update_lights(no_throttle=force_update) + self._state = self.wemo.state + + self._is_on = self._state.get('onoff') != 0 + self._brightness = self._state.get('level', 255) + self._color_temp = self._state.get('temperature_mireds') + self._available = True + + xy_color = self._state.get('color_xy') + + if xy_color: + self._hs_color = color_util.color_xy_to_hs(*xy_color) + else: + self._hs_color = None + + async def async_update(self): + """Synchronize state with bridge.""" + # If an update is in progress, we don't do anything + if self._update_lock.locked(): + return + + try: + with async_timeout.timeout(5): + await asyncio.shield(self._async_locked_update(True)) + except asyncio.TimeoutError: + _LOGGER.warning('Lost connection to %s', self.name) + self._available = False + + async def _async_locked_update(self, force_update): + """Try updating within an async lock.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update, force_update) class WemoDimmer(Light): @@ -156,46 +202,79 @@ class WemoDimmer(Light): def __init__(self, device): """Initialize the WeMo dimmer.""" self.wemo = device - self._brightness = None self._state = None + self._available = True + self._update_lock = None + self._brightness = None + self._model_name = self.wemo.model_name + self._name = self.wemo.name + self._serialnumber = self.wemo.serialnumber + + def _subscription_callback(self, _device, _type, _params): + """Update the state by the Wemo device.""" + _LOGGER.debug("Subscription update for %s", self.name) + updated = self.wemo.subscription_update(_type, _params) + self.hass.add_job( + self._async_locked_subscription_callback(not updated)) + + async def _async_locked_subscription_callback(self, force_update): + """Handle an update from a subscription.""" + # If an update is in progress, we don't do anything + if self._update_lock.locked(): + return + + await self._async_locked_update(force_update) + self.async_schedule_update_ha_state() async def async_added_to_hass(self): - """Register update callback.""" - wemo = self.hass.components.wemo - # The register method uses a threading condition, so call via executor. - # and await to wait until the task is done. - await self.hass.async_add_job( - wemo.SUBSCRIPTION_REGISTRY.register, self.wemo) - # The on method just appends to a defaultdict list. - wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback) + """Wemo dimmer added to HASS.""" + # Define inside async context so we know our event loop + self._update_lock = asyncio.Lock() - def _update_callback(self, _device, _type, _params): - """Update the state by the Wemo device.""" - _LOGGER.debug("Subscription update for %s", _device) - updated = self.wemo.subscription_update(_type, _params) - self._update(force_update=(not updated)) - self.schedule_update_ha_state() + registry = self.hass.components.wemo.SUBSCRIPTION_REGISTRY + await self.hass.async_add_executor_job(registry.register, self.wemo) + registry.on(self.wemo, None, self._subscription_callback) + + async def async_update(self): + """Update WeMo state. + + Wemo has an aggressive retry logic that sometimes can take over a + minute to return. If we don't get a state after 5 seconds, assume the + Wemo dimmer is unreachable. If update goes through, it will be made + available again. + """ + # If an update is in progress, we don't do anything + if self._update_lock.locked(): + return + + try: + with async_timeout.timeout(5): + await asyncio.shield(self._async_locked_update(True)) + except asyncio.TimeoutError: + _LOGGER.warning('Lost connection to %s', self.name) + self._available = False + self.wemo.reconnect_with_device() + + async def _async_locked_update(self, force_update): + """Try updating within an async lock.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update, force_update) @property def unique_id(self): """Return the ID of this WeMo dimmer.""" - return self.wemo.serialnumber + return self._serialnumber @property def name(self): """Return the name of the dimmer if any.""" - return self.wemo.name + return self._name @property def supported_features(self): """Flag supported features.""" return SUPPORT_BRIGHTNESS - @property - def should_poll(self): - """No polling needed with subscriptions.""" - return False - @property def brightness(self): """Return the brightness of this light between 1 and 100.""" @@ -210,11 +289,17 @@ class WemoDimmer(Light): """Update the device state.""" try: self._state = self.wemo.get_state(force_update) + wemobrightness = int(self.wemo.get_brightness(force_update)) self._brightness = int((wemobrightness * 255) / 100) + + if not self._available: + _LOGGER.info('Reconnected to %s', self.name) + self._available = True except AttributeError as err: _LOGGER.warning("Could not update status for %s (%s)", self.name, err) + self._available = False def turn_on(self, **kwargs): """Turn the dimmer on.""" @@ -232,3 +317,8 @@ class WemoDimmer(Light): def turn_off(self, **kwargs): """Turn the dimmer off.""" self.wemo.off() + + @property + def available(self): + """Return if dimmer is available.""" + return self._available diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 94b86377b8d..5cb08ef5916 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -64,10 +64,12 @@ class WemoSwitch(SwitchDevice): self.maker_params = None self.coffeemaker_mode = None self._state = None + self._mode_string = None self._available = True self._update_lock = None - # look up model name once as it incurs network traffic self._model_name = self.wemo.model_name + self._name = self.wemo.name + self._serialnumber = self.wemo.serialnumber def _subscription_callback(self, _device, _type, _params): """Update the state by the Wemo device.""" @@ -85,24 +87,15 @@ class WemoSwitch(SwitchDevice): await self._async_locked_update(force_update) self.async_schedule_update_ha_state() - @property - def should_poll(self): - """Device should poll. - - Subscriptions push the state, however it won't detect if a device - is no longer available. Use polling to detect if a device is available. - """ - return True - @property def unique_id(self): """Return the ID of this WeMo switch.""" - return self.wemo.serialnumber + return self._serialnumber @property def name(self): """Return the name of the switch if any.""" - return self.wemo.name + return self._name @property def device_state_attributes(self): @@ -169,7 +162,7 @@ class WemoSwitch(SwitchDevice): def detail_state(self): """Return the state of the device.""" if self.coffeemaker_mode is not None: - return self.wemo.mode_string + return self._mode_string if self.insight_params: standby_state = int(self.insight_params['state']) if standby_state == WEMO_ON: @@ -242,6 +235,7 @@ class WemoSwitch(SwitchDevice): """Update the device state.""" try: self._state = self.wemo.get_state(force_update) + if self._model_name == 'Insight': self.insight_params = self.wemo.insight_params self.insight_params['standby_state'] = ( @@ -250,6 +244,7 @@ class WemoSwitch(SwitchDevice): self.maker_params = self.wemo.maker_params elif self._model_name == 'CoffeeMaker': self.coffeemaker_mode = self.wemo.mode + self._mode_string = self.wemo.mode_string if not self._available: _LOGGER.info('Reconnected to %s', self.name) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 1d0133739c3..f471608da1c 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -96,6 +96,8 @@ def setup(hass, config): # Only register a device once if serial in KNOWN_DEVICES: + _LOGGER.debug('Ignoring known device %s %s', + service, discovery_info) return _LOGGER.debug('Discovered unique device %s', serial) KNOWN_DEVICES.append(serial) @@ -123,6 +125,7 @@ def setup(hass, config): devices = [] + _LOGGER.debug("Scanning statically configured WeMo devices...") for host, port in config.get(DOMAIN, {}).get(CONF_STATIC, []): url = setup_url_for_address(host, port) @@ -139,16 +142,19 @@ def setup(hass, config): _LOGGER.error('Unable to access %s (%s)', url, err) continue - devices.append((url, device)) + if not [d[1] for d in devices + if d[1].serialnumber == device.serialnumber]: + devices.append((url, device)) if config.get(DOMAIN, {}).get(CONF_DISCOVERY): - _LOGGER.debug("Scanning for WeMo devices.") - devices.extend( - (setup_url_for_device(device), device) - for device in pywemo.discover_devices()) + _LOGGER.debug("Scanning for WeMo devices...") + for device in pywemo.discover_devices(): + if not [d[1] for d in devices + if d[1].serialnumber == device.serialnumber]: + devices.append((setup_url_for_device(device), device)) for url, device in devices: - _LOGGER.debug('Adding wemo at %s:%i', device.host, device.port) + _LOGGER.debug('Adding WeMo device at %s:%i', device.host, device.port) discovery_info = { 'model_name': device.model_name,