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
This commit is contained in:
Adam Belebczuk 2018-12-19 02:12:32 -05:00 committed by Martin Hjelmare
parent ef6c39f911
commit 7f0dd442fd
6 changed files with 256 additions and 100 deletions

View File

@ -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

View File

@ -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'

View File

@ -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
humidifiers = [device for device in
hass.data[DATA_KEY].values() if
device.entity_id in entity_ids]
else:
humidifiers = hass.data[DATA_KEY].values()
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()

View File

@ -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

View File

@ -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)

View File

@ -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
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,