From 67d04b60824e35a3111c8391bf4ef369b661148a Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Sat, 21 Aug 2021 11:14:55 -0700 Subject: [PATCH] Use DataUpdateCoordinator for wemo (#54866) * Use DataUpdateCoordinator for wemo * Rename DeviceWrapper->DeviceCoordinator and make it a subclass of DataUpdateCoordinator * Rename async_update_data->_async_update_data to override base class * Rename: device -> coordinator --- homeassistant/components/wemo/__init__.py | 8 +- .../components/wemo/binary_sensor.py | 20 +- homeassistant/components/wemo/const.py | 1 - .../components/wemo/device_trigger.py | 6 +- homeassistant/components/wemo/entity.py | 184 +++-------------- homeassistant/components/wemo/fan.py | 67 +++---- homeassistant/components/wemo/light.py | 147 ++++++-------- homeassistant/components/wemo/sensor.py | 40 +--- homeassistant/components/wemo/switch.py | 97 +++++---- homeassistant/components/wemo/wemo_device.py | 82 ++++++-- tests/components/wemo/conftest.py | 1 + tests/components/wemo/entity_test_helpers.py | 185 ++++++------------ tests/components/wemo/test_binary_sensor.py | 6 - tests/components/wemo/test_fan.py | 19 +- tests/components/wemo/test_light_bridge.py | 78 ++++---- tests/components/wemo/test_light_dimmer.py | 19 +- tests/components/wemo/test_sensor.py | 39 +--- tests/components/wemo/test_switch.py | 19 +- tests/components/wemo/test_wemo_device.py | 118 ++++++++++- 19 files changed, 507 insertions(+), 629 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index aa7b5ff05c1..dd2ae173b51 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -152,7 +152,7 @@ class WemoDispatcher: if wemo.serialnumber in self._added_serial_numbers: return - device = await async_register_device(hass, self._config_entry, wemo) + coordinator = await async_register_device(hass, self._config_entry, wemo) for component in WEMO_MODEL_DISPATCH.get(wemo.model_name, [SWITCH_DOMAIN]): # Three cases: # - First time we see component, we need to load it and initialize the backlog @@ -160,7 +160,7 @@ class WemoDispatcher: # - Component is loaded, backlog is gone, dispatch discovery if component not in self._loaded_components: - hass.data[DOMAIN]["pending"][component] = [device] + hass.data[DOMAIN]["pending"][component] = [coordinator] self._loaded_components.add(component) hass.async_create_task( hass.config_entries.async_forward_entry_setup( @@ -169,13 +169,13 @@ class WemoDispatcher: ) elif component in hass.data[DOMAIN]["pending"]: - hass.data[DOMAIN]["pending"][component].append(device) + hass.data[DOMAIN]["pending"][component].append(coordinator) else: async_dispatcher_send( hass, f"{DOMAIN}.{component}", - device, + coordinator, ) self._added_serial_numbers.add(wemo.serialnumber) diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index f3ba5e0ec52..1f48a093cd6 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity _LOGGER = logging.getLogger(__name__) @@ -14,24 +14,24 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo binary sensors.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoBinarySensor(device)]) + async_add_entities([WemoBinarySensor(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") ) ) -class WemoBinarySensor(WemoSubscriptionEntity, BinarySensorEntity): +class WemoBinarySensor(WemoEntity, BinarySensorEntity): """Representation a WeMo binary sensor.""" - def _update(self, force_update=True): - """Update the sensor state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) + @property + def is_on(self) -> bool: + """Return true if the state is on. Standby is on.""" + return self.wemo.get_state() diff --git a/homeassistant/components/wemo/const.py b/homeassistant/components/wemo/const.py index 79972affa48..ec59e713b0d 100644 --- a/homeassistant/components/wemo/const.py +++ b/homeassistant/components/wemo/const.py @@ -3,6 +3,5 @@ DOMAIN = "wemo" SERVICE_SET_HUMIDITY = "set_humidity" SERVICE_RESET_FILTER_LIFE = "reset_filter_life" -SIGNAL_WEMO_STATE_PUSH = f"{DOMAIN}.state_push" WEMO_SUBSCRIPTION_EVENT = f"{DOMAIN}_subscription_event" diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index ba2ac08ed74..da9a157e1a4 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -7,7 +7,7 @@ from homeassistant.components.homeassistant.triggers import event as event_trigg from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .wemo_device import async_get_device +from .wemo_device import async_get_coordinator TRIGGER_TYPES = {EVENT_TYPE_LONG_PRESS} @@ -28,11 +28,11 @@ async def async_get_triggers(hass, device_id): CONF_DEVICE_ID: device_id, } - device = async_get_device(hass, device_id) + coordinator = async_get_coordinator(hass, device_id) triggers = [] # Check for long press support. - if device.supports_long_press: + if coordinator.supports_long_press: triggers.append( { # Required fields of TRIGGER_SCHEMA diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 19035367ae5..4571d8f5eaa 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -1,49 +1,30 @@ """Classes shared among Wemo entities.""" from __future__ import annotations -import asyncio from collections.abc import Generator import contextlib import logging -import async_timeout -from pywemo import WeMoDevice from pywemo.exceptions import ActionException -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.core import callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN as WEMO_DOMAIN, SIGNAL_WEMO_STATE_PUSH -from .wemo_device import DeviceWrapper +from .wemo_device import DeviceCoordinator _LOGGER = logging.getLogger(__name__) -class ExceptionHandlerStatus: - """Exit status from the _wemo_exception_handler context manager.""" +class WemoEntity(CoordinatorEntity): + """Common methods for Wemo entities.""" - # An exception if one was raised in the _wemo_exception_handler. - exception: Exception | None = None - - @property - def success(self) -> bool: - """Return True if the handler completed with no exception.""" - return self.exception is None - - -class WemoEntity(Entity): - """Common methods for Wemo entities. - - Requires that subclasses implement the _update method. - """ - - def __init__(self, wemo: WeMoDevice) -> None: + def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo device.""" - self.wemo = wemo - self._state = None + super().__init__(coordinator) + self.wemo = coordinator.wemo + self._device_info = coordinator.device_info self._available = True - self._update_lock = None - self._has_polled = False @property def name(self) -> str: @@ -52,81 +33,8 @@ class WemoEntity(Entity): @property def available(self) -> bool: - """Return true if switch is available.""" - return self._available - - @contextlib.contextmanager - def _wemo_exception_handler( - self, message: str - ) -> Generator[ExceptionHandlerStatus, None, None]: - """Wrap device calls to set `_available` when wemo exceptions happen.""" - status = ExceptionHandlerStatus() - try: - yield status - except ActionException as err: - status.exception = err - _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) - self._available = False - else: - if not self._available: - _LOGGER.info("Reconnected to %s", self.name) - self._available = True - - def _update(self, force_update: bool | None = True): - """Update the device state.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Wemo device added to Home Assistant.""" - # Define inside async context so we know our event loop - self._update_lock = asyncio.Lock() - - async def async_update(self) -> None: - """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 within the scan interval, - assume the Wemo switch 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: - async with async_timeout.timeout( - self.platform.scan_interval.total_seconds() - 0.1 - ) as timeout: - await asyncio.shield(self._async_locked_update(True, timeout)) - except asyncio.TimeoutError: - _LOGGER.warning("Lost connection to %s", self.name) - self._available = False - - async def _async_locked_update( - self, force_update: bool, timeout: async_timeout.timeout | None = None - ) -> None: - """Try updating within an async lock.""" - async with self._update_lock: - await self.hass.async_add_executor_job(self._update, force_update) - self._has_polled = True - # When the timeout expires HomeAssistant is no longer waiting for an - # update from the device. Instead, the state needs to be updated - # asynchronously. This also handles the case where an update came - # directly from the device (device push). In that case no polling - # update was involved and the state also needs to be updated - # asynchronously. - if not timeout or timeout.expired: - self.async_write_ha_state() - - -class WemoSubscriptionEntity(WemoEntity): - """Common methods for Wemo devices that register for update callbacks.""" - - def __init__(self, device: DeviceWrapper) -> None: - """Initialize WemoSubscriptionEntity.""" - super().__init__(device.wemo) - self._device_id = device.device_id - self._device_info = device.device_info + """Return true if the device is available.""" + return super().available and self._available @property def unique_id(self) -> str: @@ -138,59 +46,17 @@ class WemoSubscriptionEntity(WemoEntity): """Return the device info.""" return self._device_info - @property - def is_on(self) -> bool: - """Return true if the state is on. Standby is on.""" - return self._state + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._available = True + super()._handle_coordinator_update() - @property - def should_poll(self) -> bool: - """Return True if the the device requires local polling, False otherwise. - - It is desirable to allow devices to enter periods of polling when the - callback subscription (device push) is not working. To work with the - entity platform polling logic, this entity needs to report True for - should_poll initially. That is required to cause the entity platform - logic to start the polling task (see the discussion in #47182). - - Polling can be disabled if three conditions are met: - 1. The device has polled to get the initial state (self._has_polled) and - to satisfy the entity platform constraint mentioned above. - 2. The polling was successful and the device is in a healthy state - (self.available). - 3. The pywemo subscription registry reports that there is an active - subscription and the subscription has been confirmed by receiving an - initial event. This confirms that device push notifications are - working correctly (registry.is_subscribed - this method is async safe). - """ - registry = self.hass.data[WEMO_DOMAIN]["registry"] - return not ( - self.available and self._has_polled and registry.is_subscribed(self.wemo) - ) - - async def async_added_to_hass(self) -> None: - """Wemo device added to Home Assistant.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_WEMO_STATE_PUSH, self._async_subscription_callback - ) - ) - - async def _async_subscription_callback( - self, device_id: str, event_type: str, params: str - ) -> None: - """Update the state by the Wemo device.""" - # Only respond events for this device. - if device_id != self._device_id: - return - # If an update is in progress, we don't do anything - if self._update_lock.locked(): - return - - _LOGGER.debug("Subscription event (%s) for %s", event_type, self.name) - updated = await self.hass.async_add_executor_job( - self.wemo.subscription_update, event_type, params - ) - await self._async_locked_update(not updated) + @contextlib.contextmanager + def _wemo_exception_handler(self, message: str) -> Generator[None, None, None]: + """Wrap device calls to set `_available` when wemo exceptions happen.""" + try: + yield + except ActionException as err: + _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) + self._available = False diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 1582a0110cd..501011f841a 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -7,6 +7,7 @@ import math import voluptuous as vol from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity +from homeassistant.core import callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( @@ -20,7 +21,7 @@ from .const import ( SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY, ) -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -68,16 +69,16 @@ SET_HUMIDITY_SCHEMA = { async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo binary sensors.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoHumidifier(device)]) + async_add_entities([WemoHumidifier(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("fan") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("fan") ) ) @@ -94,20 +95,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class WemoHumidifier(WemoSubscriptionEntity, FanEntity): +class WemoHumidifier(WemoEntity, FanEntity): """Representation of a WeMo humidifier.""" - def __init__(self, device): + def __init__(self, coordinator): """Initialize the WeMo switch.""" - super().__init__(device) - self._fan_mode = WEMO_FAN_OFF - self._fan_mode_string = None - self._target_humidity = None - self._current_humidity = None - self._water_level = None - self._filter_life = None - self._filter_expired = None - self._last_fan_on_mode = WEMO_FAN_MEDIUM + super().__init__(coordinator) + if self.wemo.fan_mode != WEMO_FAN_OFF: + self._last_fan_on_mode = self.wemo.fan_mode + else: + self._last_fan_on_mode = WEMO_FAN_MEDIUM @property def icon(self): @@ -118,18 +115,18 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): def extra_state_attributes(self): """Return device specific state attributes.""" return { - ATTR_CURRENT_HUMIDITY: self._current_humidity, - ATTR_TARGET_HUMIDITY: self._target_humidity, - ATTR_FAN_MODE: self._fan_mode_string, - ATTR_WATER_LEVEL: self._water_level, - ATTR_FILTER_LIFE: self._filter_life, - ATTR_FILTER_EXPIRED: self._filter_expired, + ATTR_CURRENT_HUMIDITY: self.wemo.current_humidity_percent, + ATTR_TARGET_HUMIDITY: self.wemo.desired_humidity_percent, + ATTR_FAN_MODE: self.wemo.fan_mode_string, + ATTR_WATER_LEVEL: self.wemo.water_level_string, + ATTR_FILTER_LIFE: self.wemo.filter_life_percent, + ATTR_FILTER_EXPIRED: self.wemo.filter_expired, } @property def percentage(self) -> int: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._fan_mode) + return ranged_value_to_percentage(SPEED_RANGE, self.wemo.fan_mode) @property def speed_count(self) -> int: @@ -141,21 +138,17 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): """Flag supported features.""" return SUPPORTED_FEATURES - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.wemo.fan_mode != WEMO_FAN_OFF: + self._last_fan_on_mode = self.wemo.fan_mode + super()._handle_coordinator_update() - self._fan_mode = self.wemo.fan_mode - self._fan_mode_string = self.wemo.fan_mode_string - self._target_humidity = self.wemo.desired_humidity_percent - self._current_humidity = self.wemo.current_humidity_percent - self._water_level = self.wemo.water_level_string - self._filter_life = self.wemo.filter_life_percent - self._filter_expired = self.wemo.filter_expired - - if self.wemo.fan_mode != WEMO_FAN_OFF: - self._last_fan_on_mode = self.wemo.fan_mode + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return self.wemo.get_state() def turn_on( self, diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 8a098904bb0..ecb64296171 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,9 +1,9 @@ """Support for Belkin WeMo lights.""" import asyncio -from datetime import timedelta import logging -from homeassistant import util +from pywemo.ouimeaux_device import bridge + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -15,14 +15,13 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoEntity, WemoSubscriptionEntity - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +from .entity import WemoEntity +from .wemo_device import DeviceCoordinator _LOGGER = logging.getLogger(__name__) @@ -31,77 +30,75 @@ SUPPORT_WEMO = ( ) # The WEMO_ constants below come from pywemo itself -WEMO_ON = 1 WEMO_OFF = 0 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo lights.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator: DeviceCoordinator): """Handle a discovered Wemo device.""" - if device.wemo.model_name == "Dimmer": - async_add_entities([WemoDimmer(device)]) + if isinstance(coordinator.wemo, bridge.Bridge): + async_setup_bridge(hass, config_entry, async_add_entities, coordinator) else: - await hass.async_add_executor_job( - setup_bridge, hass, device.wemo, async_add_entities - ) + async_add_entities([WemoDimmer(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("light") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("light") ) ) -def setup_bridge(hass, bridge, async_add_entities): +@callback +def async_setup_bridge(hass, config_entry, async_add_entities, coordinator): """Set up a WeMo link.""" - lights = {} - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the WeMo led objects with latest info from the bridge.""" - bridge.bridge_update() + known_light_ids = set() + @callback + def async_update_lights(): + """Check to see if the bridge has any new lights.""" new_lights = [] - for light_id, device in bridge.Lights.items(): - if light_id not in lights: - lights[light_id] = WemoLight(device, update_lights) - new_lights.append(lights[light_id]) + for light_id, light in coordinator.wemo.Lights.items(): + if light_id not in known_light_ids: + known_light_ids.add(light_id) + new_lights.append(WemoLight(coordinator, light)) if new_lights: - hass.add_job(async_add_entities, new_lights) + async_add_entities(new_lights) - update_lights() + async_update_lights() + config_entry.async_on_unload(coordinator.async_add_listener(async_update_lights)) class WemoLight(WemoEntity, LightEntity): """Representation of a WeMo light.""" - def __init__(self, device, update_lights): + def __init__(self, coordinator: DeviceCoordinator, light: bridge.Light) -> None: """Initialize the WeMo light.""" - super().__init__(device) - self._update_lights = update_lights - self._brightness = None - self._hs_color = None - self._color_temp = None - self._is_on = None - self._unique_id = self.wemo.uniqueID - self._model_name = type(self.wemo).__name__ + super().__init__(coordinator) + self.light = light + self._unique_id = self.light.uniqueID + self._model_name = type(self.light).__name__ - async def async_added_to_hass(self): - """Wemo light added to Home Assistant.""" - # Define inside async context so we know our event loop - self._update_lock = asyncio.Lock() + @property + def name(self) -> str: + """Return the name of the device if any.""" + return self.light.name + + @property + def available(self) -> bool: + """Return true if the device is available.""" + return super().available and self.light.state.get("available") @property def unique_id(self): """Return the ID of this light.""" - return self.wemo.uniqueID + return self.light.uniqueID @property def device_info(self): @@ -116,22 +113,25 @@ class WemoLight(WemoEntity, LightEntity): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return self.light.state.get("level", 255) @property def hs_color(self): """Return the hs color values of this light.""" - return self._hs_color + xy_color = self.light.state.get("color_xy") + if xy_color: + return color_util.color_xy_to_hs(*xy_color) + return None @property def color_temp(self): """Return the color temperature of this light in mireds.""" - return self._color_temp + return self.light.state.get("temperature_mireds") @property def is_on(self): """Return true if device is on.""" - return self._is_on + return self.light.state.get("onoff") != WEMO_OFF @property def supported_features(self): @@ -158,13 +158,14 @@ class WemoLight(WemoEntity, LightEntity): with self._wemo_exception_handler("turn on"): if xy_color is not None: - self.wemo.set_color(xy_color, transition=transition_time) + self.light.set_color(xy_color, transition=transition_time) if color_temp is not None: - self.wemo.set_temperature(mireds=color_temp, transition=transition_time) + self.light.set_temperature( + mireds=color_temp, transition=transition_time + ) - if self.wemo.turn_on(**turn_on_kwargs): - self._state["onoff"] = WEMO_ON + self.light.turn_on(**turn_on_kwargs) self.schedule_update_ha_state() @@ -173,37 +174,14 @@ class WemoLight(WemoEntity, LightEntity): transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) with self._wemo_exception_handler("turn off"): - if self.wemo.turn_off(transition=transition_time): - self._state["onoff"] = WEMO_OFF + self.light.turn_off(transition=transition_time) self.schedule_update_ha_state() - def _update(self, force_update=True): - """Synchronize state with bridge.""" - with self._wemo_exception_handler("update status") as handler: - self._update_lights(no_throttle=force_update) - self._state = self.wemo.state - if handler.success: - self._is_on = self._state.get("onoff") != WEMO_OFF - self._brightness = self._state.get("level", 255) - self._color_temp = self._state.get("temperature_mireds") - 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 - - -class WemoDimmer(WemoSubscriptionEntity, LightEntity): +class WemoDimmer(WemoEntity, LightEntity): """Representation of a WeMo dimmer.""" - def __init__(self, device): - """Initialize the WeMo dimmer.""" - super().__init__(device) - self._brightness = None - @property def supported_features(self): """Flag supported features.""" @@ -212,15 +190,13 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): @property def brightness(self): """Return the brightness of this light between 1 and 100.""" - return self._brightness + wemo_brightness = int(self.wemo.get_brightness()) + return int((wemo_brightness * 255) / 100) - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) - - wemobrightness = int(self.wemo.get_brightness(force_update)) - self._brightness = int((wemobrightness * 255) / 100) + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return self.wemo.get_state() def turn_on(self, **kwargs): """Turn the dimmer on.""" @@ -231,18 +207,15 @@ class WemoDimmer(WemoSubscriptionEntity, LightEntity): brightness = int((brightness / 255) * 100) with self._wemo_exception_handler("set brightness"): self.wemo.set_brightness(brightness) - self._state = WEMO_ON else: with self._wemo_exception_handler("turn on"): self.wemo.on() - self._state = WEMO_ON self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the dimmer off.""" with self._wemo_exception_handler("turn off"): - if self.wemo.off(): - self._state = WEMO_OFF + self.wemo.off() self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 1fd55e4142e..5249ff8a4b9 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,7 +1,5 @@ """Support for power sensors in WeMo Insight devices.""" import asyncio -from datetime import timedelta -from typing import Callable from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, @@ -17,52 +15,35 @@ from homeassistant.const import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import StateType -from homeassistant.util import Throttle, convert +from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity -from .wemo_device import DeviceWrapper - -SCAN_INTERVAL = timedelta(seconds=10) +from .entity import WemoEntity +from .wemo_device import DeviceCoordinator async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo sensors.""" - async def _discovered_wemo(device: DeviceWrapper): + async def _discovered_wemo(coordinator: DeviceCoordinator): """Handle a discovered Wemo device.""" - - @Throttle(SCAN_INTERVAL) - def update_insight_params(): - device.wemo.update_insight_params() - async_add_entities( - [ - InsightCurrentPower(device, update_insight_params), - InsightTodayEnergy(device, update_insight_params), - ] + [InsightCurrentPower(coordinator), InsightTodayEnergy(coordinator)] ) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") ) ) -class InsightSensor(WemoSubscriptionEntity, SensorEntity): +class InsightSensor(WemoEntity, SensorEntity): """Common base for WeMo Insight power sensors.""" - _name_suffix: str - - def __init__(self, device: DeviceWrapper, update_insight_params: Callable) -> None: - """Initialize the WeMo Insight power sensor.""" - super().__init__(device) - self._update_insight_params = update_insight_params - @property def name(self) -> str: """Return the name of the entity if any.""" @@ -81,11 +62,6 @@ class InsightSensor(WemoSubscriptionEntity, SensorEntity): and super().available ) - def _update(self, force_update=True) -> None: - with self._wemo_exception_handler("update status"): - if force_update or not self.wemo.insight_params: - self._update_insight_params() - class InsightCurrentPower(InsightSensor): """Current instantaineous power consumption.""" diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index a7031d669a4..46e143902f9 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -3,13 +3,15 @@ import asyncio from datetime import datetime, timedelta import logging +from pywemo import CoffeeMaker, Insight, Maker + from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoSubscriptionEntity +from .entity import WemoEntity SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -33,63 +35,61 @@ WEMO_STANDBY = 8 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up WeMo switches.""" - async def _discovered_wemo(device): + async def _discovered_wemo(coordinator): """Handle a discovered Wemo device.""" - async_add_entities([WemoSwitch(device)]) + async_add_entities([WemoSwitch(coordinator)]) async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) await asyncio.gather( *( - _discovered_wemo(device) - for device in hass.data[WEMO_DOMAIN]["pending"].pop("switch") + _discovered_wemo(coordinator) + for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("switch") ) ) -class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): +class WemoSwitch(WemoEntity, SwitchEntity): """Representation of a WeMo switch.""" - def __init__(self, device): - """Initialize the WeMo switch.""" - super().__init__(device) - self.insight_params = None - self.maker_params = None - self.coffeemaker_mode = None - self._mode_string = None - @property def extra_state_attributes(self): """Return the state attributes of the device.""" attr = {} - if self.maker_params: + if isinstance(self.wemo, Maker): # Is the maker sensor on or off. - if self.maker_params["hassensor"]: + if self.wemo.maker_params["hassensor"]: # Note a state of 1 matches the WeMo app 'not triggered'! - if self.maker_params["sensorstate"]: + if self.wemo.maker_params["sensorstate"]: attr[ATTR_SENSOR_STATE] = STATE_OFF else: attr[ATTR_SENSOR_STATE] = STATE_ON # Is the maker switch configured as toggle(0) or momentary (1). - if self.maker_params["switchmode"]: + if self.wemo.maker_params["switchmode"]: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_MOMENTARY else: attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_TOGGLE - if self.insight_params or (self.coffeemaker_mode is not None): + if isinstance(self.wemo, (Insight, CoffeeMaker)): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state - if self.insight_params: - attr["on_latest_time"] = WemoSwitch.as_uptime(self.insight_params["onfor"]) - attr["on_today_time"] = WemoSwitch.as_uptime(self.insight_params["ontoday"]) - attr["on_total_time"] = WemoSwitch.as_uptime(self.insight_params["ontotal"]) + if isinstance(self.wemo, Insight): + attr["on_latest_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["onfor"] + ) + attr["on_today_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["ontoday"] + ) + attr["on_total_time"] = WemoSwitch.as_uptime( + self.wemo.insight_params["ontotal"] + ) attr["power_threshold_w"] = ( - convert(self.insight_params["powerthreshold"], float, 0.0) / 1000.0 + convert(self.wemo.insight_params["powerthreshold"], float, 0.0) / 1000.0 ) - if self.coffeemaker_mode is not None: - attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode + if isinstance(self.wemo, CoffeeMaker): + attr[ATTR_COFFEMAKER_MODE] = self.wemo.mode return attr @@ -104,23 +104,25 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): @property def current_power_w(self): """Return the current power usage in W.""" - if self.insight_params: - return convert(self.insight_params["currentpower"], float, 0.0) / 1000.0 + if isinstance(self.wemo, Insight): + return ( + convert(self.wemo.insight_params["currentpower"], float, 0.0) / 1000.0 + ) @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" - if self.insight_params: - miliwatts = convert(self.insight_params["todaymw"], float, 0.0) + if isinstance(self.wemo, Insight): + miliwatts = convert(self.wemo.insight_params["todaymw"], float, 0.0) return round(miliwatts / (1000.0 * 1000.0 * 60), 2) @property def detail_state(self): """Return the state of the device.""" - if self.coffeemaker_mode is not None: - return self._mode_string - if self.insight_params: - standby_state = int(self.insight_params["state"]) + if isinstance(self.wemo, CoffeeMaker): + return self.wemo.mode_string + if isinstance(self.wemo, Insight): + standby_state = int(self.wemo.insight_params["state"]) if standby_state == WEMO_ON: return STATE_ON if standby_state == WEMO_OFF: @@ -132,36 +134,25 @@ class WemoSwitch(WemoSubscriptionEntity, SwitchEntity): @property def icon(self): """Return the icon of device based on its type.""" - if self.wemo.model_name == "CoffeeMaker": + if isinstance(self.wemo, CoffeeMaker): return "mdi:coffee" return None + @property + def is_on(self) -> bool: + """Return true if the state is on. Standby is on.""" + return self.wemo.get_state() + def turn_on(self, **kwargs): """Turn the switch on.""" with self._wemo_exception_handler("turn on"): - if self.wemo.on(): - self._state = WEMO_ON + self.wemo.on() self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the switch off.""" with self._wemo_exception_handler("turn off"): - if self.wemo.off(): - self._state = WEMO_OFF + self.wemo.off() self.schedule_update_ha_state() - - def _update(self, force_update=True): - """Update the device state.""" - with self._wemo_exception_handler("update status"): - self._state = self.wemo.get_state(force_update) - - if self.wemo.model_name == "Insight": - self.insight_params = self.wemo.insight_params - self.insight_params["standby_state"] = self.wemo.get_standby_state - elif self.wemo.model_name == "Maker": - self.maker_params = self.wemo.maker_params - elif self.wemo.model_name == "CoffeeMaker": - self.coffeemaker_mode = self.wemo.mode - self._mode_string = self.wemo.mode_string diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 6fd1f4d5512..9423d0b8d1c 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -1,7 +1,10 @@ """Home Assistant wrapper for a pyWeMo device.""" +import asyncio +from datetime import timedelta import logging from pywemo import WeMoDevice +from pywemo.exceptions import ActionException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.config_entries import ConfigEntry @@ -14,28 +17,36 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SIGNAL_WEMO_STATE_PUSH, WEMO_SUBSCRIPTION_EVENT +from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT _LOGGER = logging.getLogger(__name__) -class DeviceWrapper: +class DeviceCoordinator(DataUpdateCoordinator): """Home Assistant wrapper for a pyWeMo device.""" def __init__(self, hass: HomeAssistant, wemo: WeMoDevice, device_id: str) -> None: - """Initialize DeviceWrapper.""" + """Initialize DeviceCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=wemo.name, + update_interval=timedelta(seconds=30), + ) self.hass = hass self.wemo = wemo self.device_id = device_id self.device_info = _device_info(wemo) self.supports_long_press = wemo.supports_long_press() + self.update_lock = asyncio.Lock() def subscription_callback( self, _device: WeMoDevice, event_type: str, params: str ) -> None: """Receives push notifications from WeMo devices.""" + _LOGGER.debug("Subscription event (%s) for %s", event_type, self.wemo.name) if event_type == EVENT_TYPE_LONG_PRESS: self.hass.bus.fire( WEMO_SUBSCRIPTION_EVENT, @@ -48,9 +59,50 @@ class DeviceWrapper: }, ) else: - dispatcher_send( - self.hass, SIGNAL_WEMO_STATE_PUSH, self.device_id, event_type, params - ) + updated = self.wemo.subscription_update(event_type, params) + self.hass.add_job(self._async_subscription_callback(updated)) + + async def _async_subscription_callback(self, updated: bool) -> None: + """Update the state by the Wemo device.""" + # If an update is in progress, we don't do anything. + if self.update_lock.locked(): + return + try: + await self._async_locked_update(not updated) + except UpdateFailed as err: + self.last_exception = err + if self.last_update_success: + _LOGGER.exception("Subscription callback failed") + self.last_update_success = False + except Exception as err: # pylint: disable=broad-except + self.last_exception = err + self.last_update_success = False + _LOGGER.exception("Unexpected error fetching %s data: %s", self.name, err) + else: + self.async_set_updated_data(None) + + async def _async_update_data(self) -> None: + """Update WeMo state.""" + # No need to poll if the device will push updates. + registry = self.hass.data[DOMAIN]["registry"] + if registry.is_subscribed(self.wemo) and self.last_update_success: + return + + # If an update is in progress, we don't do anything. + if self.update_lock.locked(): + return + + await self._async_locked_update(True) + + async def _async_locked_update(self, force_update: bool) -> None: + """Try updating within an async lock.""" + async with self.update_lock: + try: + await self.hass.async_add_executor_job( + self.wemo.get_state, force_update + ) + except ActionException as err: + raise UpdateFailed("WeMo update failed") from err def _device_info(wemo: WeMoDevice): @@ -64,19 +116,21 @@ def _device_info(wemo: WeMoDevice): async def async_register_device( hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice -) -> DeviceWrapper: +) -> DeviceCoordinator: """Register a device with home assistant and enable pywemo event callbacks.""" + # Ensure proper communication with the device and get the initial state. + await hass.async_add_executor_job(wemo.get_state, True) + device_registry = async_get_device_registry(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, **_device_info(wemo) ) - registry = hass.data[DOMAIN]["registry"] - await hass.async_add_executor_job(registry.register, wemo) - - device = DeviceWrapper(hass, wemo, entry.id) + device = DeviceCoordinator(hass, wemo, entry.id) hass.data[DOMAIN].setdefault("devices", {})[entry.id] = device + registry = hass.data[DOMAIN]["registry"] registry.on(wemo, None, device.subscription_callback) + await hass.async_add_executor_job(registry.register, wemo) if device.supports_long_press: try: @@ -93,6 +147,6 @@ async def async_register_device( @callback -def async_get_device(hass: HomeAssistant, device_id: str) -> DeviceWrapper: - """Return DeviceWrapper for device_id.""" +def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: + """Return DeviceCoordinator for device_id.""" return hass.data[DOMAIN]["devices"][device_id] diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index bf69318706c..6c597d51df4 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -35,6 +35,7 @@ async def async_pywemo_registry_fixture(): registry.semaphore.release() registry.on.side_effect = on_func + registry.is_subscribed.return_value = False with patch("pywemo.SubscriptionRegistry", return_value=registry): yield registry diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index 3d1a73941e6..6836f87a4a0 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -4,196 +4,133 @@ This is not a test module. These test methods are used by the platform test modu """ import asyncio import threading -from unittest.mock import patch -import async_timeout -import pywemo -from pywemo.ouimeaux_device.api.service import ActionException - -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN +from homeassistant.components.wemo import wemo_device +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, ) -from homeassistant.components.wemo.const import SIGNAL_WEMO_STATE_PUSH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -def _perform_registry_callback(hass, pywemo_registry, pywemo_device): +def _perform_registry_callback(coordinator): """Return a callable method to trigger a state callback from the device.""" async def async_callback(): - event = asyncio.Event() - - async def event_callback(e, *args): - event.set() - - stop_dispatcher_listener = async_dispatcher_connect( - hass, SIGNAL_WEMO_STATE_PUSH, event_callback + await coordinator.hass.async_add_executor_job( + coordinator.subscription_callback, coordinator.wemo, "", "" ) - # Cause a state update callback to be triggered by the device. - await hass.async_add_executor_job( - pywemo_registry.callbacks[pywemo_device.name], pywemo_device, "", "" - ) - await event.wait() - stop_dispatcher_listener() return async_callback -def _perform_async_update(hass, wemo_entity): +def _perform_async_update(coordinator): """Return a callable method to cause hass to update the state of the entity.""" - @callback - def async_callback(): - return hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) + async def async_callback(): + await coordinator._async_update_data() return async_callback -async def _async_multiple_call_helper( - hass, - pywemo_registry, - wemo_entity, - pywemo_device, - call1, - call2, - update_polling_method=None, -): +async def _async_multiple_call_helper(hass, pywemo_device, call1, call2): """Create two calls (call1 & call2) in parallel; verify only one polls the device. - The platform entity should only perform one update poll on the device at a time. - Any parallel updates that happen at the same time should be ignored. This is - verified by blocking in the update polling method. The polling method should - only be called once as a result of calling call1 & call2 simultaneously. + There should only be one poll on the device at a time. Any parallel updates + # that happen at the same time should be ignored. This is verified by blocking + in the get_state method. The polling method should only be called once as a + result of calling call1 & call2 simultaneously. """ - # get_state is called outside the event loop. Use non-async Python Event. event = threading.Event() waiting = asyncio.Event() + call_count = 0 - def get_update(force_update=True): + def get_state(force_update=None): + if force_update is None: + return + nonlocal call_count + call_count += 1 hass.add_job(waiting.set) event.wait() - update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = get_update + # Danger! Do not use a Mock side_effect here. The test will deadlock. When + # called though hass.async_add_executor_job, Mock objects !surprisingly! + # run in the same thread as the asyncio event loop. + # https://github.com/home-assistant/core/blob/1ba5c1c9fb1e380549cb655986b5f4d3873d7352/tests/common.py#L179 + pywemo_device.get_state = get_state # One of these two calls will block on `event`. The other will return right # away because the `_update_lock` is held. - _, pending = await asyncio.wait( + done, pending = await asyncio.wait( [call1(), call2()], return_when=asyncio.FIRST_COMPLETED ) + _ = [d.result() for d in done] # Allow any exceptions to be raised. # Allow the blocked call to return. await waiting.wait() event.set() + if pending: - await asyncio.wait(pending) + done, _ = await asyncio.wait(pending) + _ = [d.result() for d in done] # Allow any exceptions to be raised. # Make sure the state update only happened once. - update_polling_method.assert_called_once() + assert call_count == 1 async def test_async_update_locked_callback_and_update( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs + hass, pywemo_device, wemo_entity ): """Test that a callback and a state update request can't both happen at the same time. When a state update is received via a callback from the device at the same time as hass is calling `async_update`, verify that only one of the updates proceeds. """ + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - callback = _perform_registry_callback(hass, pywemo_registry, pywemo_device) - update = _perform_async_update(hass, wemo_entity) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, callback, update, **kwargs - ) + callback = _perform_registry_callback(coordinator) + update = _perform_async_update(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, callback, update) -async def test_async_update_locked_multiple_updates( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs -): +async def test_async_update_locked_multiple_updates(hass, pywemo_device, wemo_entity): """Test that two hass async_update state updates do not proceed at the same time.""" + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - update = _perform_async_update(hass, wemo_entity) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, update, update, **kwargs - ) + update = _perform_async_update(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, update, update) -async def test_async_update_locked_multiple_callbacks( - hass, pywemo_registry, wemo_entity, pywemo_device, **kwargs -): +async def test_async_update_locked_multiple_callbacks(hass, pywemo_device, wemo_entity): """Test that two device callback state updates do not proceed at the same time.""" + coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) - callback = _perform_registry_callback(hass, pywemo_registry, pywemo_device) - await _async_multiple_call_helper( - hass, pywemo_registry, wemo_entity, pywemo_device, callback, callback, **kwargs - ) + callback = _perform_registry_callback(coordinator) + await _async_multiple_call_helper(hass, pywemo_device, callback, callback) -async def test_async_locked_update_with_exception( - hass, - wemo_entity, - pywemo_device, - update_polling_method=None, - expected_state=STATE_OFF, +async def test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, domain ): - """Test that the entity becomes unavailable when communication is lost.""" - assert hass.states.get(wemo_entity.entity_id).state == expected_state - await async_setup_component(hass, HA_DOMAIN, {}) - update_polling_method = update_polling_method or pywemo_device.get_state - update_polling_method.side_effect = ActionException + """Test the avaliability when an On call fails and after an update. + + This test expects that the pywemo_device Mock has been setup to raise an + ActionException when the SERVICE_TURN_ON method is called and that the + state will be On after the update. + """ + await async_setup_component(hass, domain, {}) await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, + domain, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, blocking=True, ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - -async def test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device, expected_state=STATE_OFF -): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - assert hass.states.get(wemo_entity.entity_id).state == expected_state - await async_setup_component(hass, HA_DOMAIN, {}) - - event = threading.Event() - - def get_state(*args): - event.wait() - return 0 - - if hasattr(pywemo_device, "bridge_update"): - pywemo_device.bridge_update.side_effect = get_state - elif isinstance(pywemo_device, pywemo.Insight): - pywemo_device.update_insight_params.side_effect = get_state - else: - pywemo_device.get_state.side_effect = get_state - timeout = async_timeout.timeout(0) - - with patch("async_timeout.timeout", return_value=timeout): - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, - blocking=True, - ) - - assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE - - # Check that the entity recovers and is available after the update succeeds. - event.set() + pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == expected_state + assert hass.states.get(wemo_entity.entity_id).state == STATE_ON diff --git a/tests/components/wemo/test_binary_sensor.py b/tests/components/wemo/test_binary_sensor.py index 1bf6f0f3bef..64e67162829 100644 --- a/tests/components/wemo/test_binary_sensor.py +++ b/tests/components/wemo/test_binary_sensor.py @@ -30,12 +30,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_binary_sensor_registry_state_callback( diff --git a/tests/components/wemo/test_fan.py b/tests/components/wemo/test_fan.py index 38055ba972c..dc450311e6a 100644 --- a/tests/components/wemo/test_fan.py +++ b/tests/components/wemo/test_fan.py @@ -1,7 +1,9 @@ """Tests for the Wemo fan entity.""" import pytest +from pywemo.exceptions import ActionException +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -32,12 +34,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_fan_registry_state_callback( @@ -82,6 +78,17 @@ async def test_fan_update_entity(hass, pywemo_registry, pywemo_device, wemo_enti assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.set_state.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, FAN_DOMAIN + ) + + async def test_fan_reset_filter_service(hass, pywemo_device, wemo_entity): """Verify that SERVICE_RESET_FILTER_LIFE is registered and works.""" assert await hass.services.async_call( diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 573f75a66d9..b00cfe30ef7 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -1,5 +1,5 @@ """Tests for the Wemo light entity via the bridge.""" -from unittest.mock import create_autospec, patch +from unittest.mock import create_autospec import pytest import pywemo @@ -8,10 +8,9 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.wemo.light import MIN_TIME_BETWEEN_SCANS +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from . import entity_test_helpers @@ -32,60 +31,53 @@ def pywemo_bridge_light_fixture(pywemo_device): light.uniqueID = pywemo_device.serialnumber light.name = pywemo_device.name light.bridge = pywemo_device - light.state = {"onoff": 0} + light.state = {"onoff": 0, "available": True} pywemo_device.Lights = {pywemo_device.serialnumber: light} return light -def _bypass_throttling(): - """Bypass the util.Throttle on the update_lights method.""" - utcnow = dt_util.utcnow() - - def increment_and_return_time(): - nonlocal utcnow - utcnow += MIN_TIME_BETWEEN_SCANS - return utcnow - - return patch("homeassistant.util.utcnow", side_effect=increment_and_return_time) +async def test_async_update_locked_callback_and_update( + hass, pywemo_bridge_light, wemo_entity, pywemo_device +): + """Test that a callback and a state update request can't both happen at the same time.""" + await entity_test_helpers.test_async_update_locked_callback_and_update( + hass, + pywemo_device, + wemo_entity, + ) async def test_async_update_locked_multiple_updates( - hass, pywemo_registry, pywemo_bridge_light, wemo_entity, pywemo_device + hass, pywemo_bridge_light, wemo_entity, pywemo_device ): """Test that two state updates do not proceed at the same time.""" - pywemo_device.bridge_update.reset_mock() - - with _bypass_throttling(): - await entity_test_helpers.test_async_update_locked_multiple_updates( - hass, - pywemo_registry, - wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.bridge_update, - ) + await entity_test_helpers.test_async_update_locked_multiple_updates( + hass, + pywemo_device, + wemo_entity, + ) -async def test_async_update_with_timeout_and_recovery( +async def test_async_update_locked_multiple_callbacks( hass, pywemo_bridge_light, wemo_entity, pywemo_device ): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - with _bypass_throttling(): - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device - ) + """Test that two device callback state updates do not proceed at the same time.""" + await entity_test_helpers.test_async_update_locked_multiple_callbacks( + hass, + pywemo_device, + wemo_entity, + ) -async def test_async_locked_update_with_exception( - hass, pywemo_bridge_light, wemo_entity, pywemo_device +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, pywemo_bridge_light, wemo_entity ): - """Test that the entity becomes unavailable when communication is lost.""" - with _bypass_throttling(): - await entity_test_helpers.test_async_locked_update_with_exception( - hass, - wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.bridge_update, - ) + """Test the avaliability when an On call fails and after an update.""" + pywemo_bridge_light.turn_on.side_effect = pywemo.exceptions.ActionException + pywemo_bridge_light.state["onoff"] = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN + ) async def test_light_update_entity( @@ -95,7 +87,7 @@ async def test_light_update_entity( await async_setup_component(hass, HA_DOMAIN, {}) # On state. - pywemo_bridge_light.state = {"onoff": 1} + pywemo_bridge_light.state["onoff"] = 1 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -105,7 +97,7 @@ async def test_light_update_entity( assert hass.states.get(wemo_entity.entity_id).state == STATE_ON # Off state. - pywemo_bridge_light.state = {"onoff": 0} + pywemo_bridge_light.state["onoff"] = 0 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, diff --git a/tests/components/wemo/test_light_dimmer.py b/tests/components/wemo/test_light_dimmer.py index 45fdd01a643..830eb6dbdf4 100644 --- a/tests/components/wemo/test_light_dimmer.py +++ b/tests/components/wemo/test_light_dimmer.py @@ -1,11 +1,13 @@ """Tests for the Wemo standalone/non-bridge light entity.""" import pytest +from pywemo.exceptions import ActionException from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -30,12 +32,17 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) + + +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.on.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN + ) async def test_light_registry_state_callback( diff --git a/tests/components/wemo/test_sensor.py b/tests/components/wemo/test_sensor.py index 3b8786131a7..a7f68429994 100644 --- a/tests/components/wemo/test_sensor.py +++ b/tests/components/wemo/test_sensor.py @@ -78,62 +78,33 @@ class InsightTestTemplate: # in the scope of this test module. They will run using the pywemo_model from # this test module (Insight). async def test_async_update_locked_multiple_updates( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that two hass async_update state updates do not proceed at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_multiple_updates( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, + wemo_entity, ) async def test_async_update_locked_multiple_callbacks( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that two device callback state updates do not proceed at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_multiple_callbacks( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, + wemo_entity, ) async def test_async_update_locked_callback_and_update( - self, hass, pywemo_registry, wemo_entity, pywemo_device + self, hass, pywemo_device, wemo_entity ): """Test that a callback and a state update request can't both happen at the same time.""" - pywemo_device.subscription_update.return_value = False await entity_test_helpers.test_async_update_locked_callback_and_update( hass, - pywemo_registry, - wemo_entity, pywemo_device, - update_polling_method=pywemo_device.update_insight_params, - ) - - async def test_async_locked_update_with_exception( - self, hass, wemo_entity, pywemo_device - ): - """Test that the entity becomes unavailable when communication is lost.""" - await entity_test_helpers.test_async_locked_update_with_exception( - hass, wemo_entity, - pywemo_device, - update_polling_method=pywemo_device.update_insight_params, - expected_state=self.EXPECTED_STATE_VALUE, - ) - - async def test_async_update_with_timeout_and_recovery( - self, hass, wemo_entity, pywemo_device - ): - """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - await entity_test_helpers.test_async_update_with_timeout_and_recovery( - hass, wemo_entity, pywemo_device, expected_state=self.EXPECTED_STATE_VALUE ) async def test_state_unavailable(self, hass, wemo_entity, pywemo_device): diff --git a/tests/components/wemo/test_switch.py b/tests/components/wemo/test_switch.py index 05151d38be8..1023498c792 100644 --- a/tests/components/wemo/test_switch.py +++ b/tests/components/wemo/test_switch.py @@ -1,11 +1,13 @@ """Tests for the Wemo switch entity.""" import pytest +from pywemo.exceptions import ActionException from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -30,12 +32,6 @@ test_async_update_locked_multiple_callbacks = ( test_async_update_locked_callback_and_update = ( entity_test_helpers.test_async_update_locked_callback_and_update ) -test_async_locked_update_with_exception = ( - entity_test_helpers.test_async_locked_update_with_exception -) -test_async_update_with_timeout_and_recovery = ( - entity_test_helpers.test_async_update_with_timeout_and_recovery -) async def test_switch_registry_state_callback( @@ -78,3 +74,14 @@ async def test_switch_update_entity(hass, pywemo_registry, pywemo_device, wemo_e blocking=True, ) assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + +async def test_available_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """Test the avaliability when an On call fails and after an update.""" + pywemo_device.on.side_effect = ActionException + pywemo_device.get_state.return_value = 1 + await entity_test_helpers.test_avaliable_after_update( + hass, pywemo_registry, pywemo_device, wemo_entity, SWITCH_DOMAIN + ) diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 38727a28424..6f3cc12a81a 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -1,16 +1,24 @@ """Tests for wemo_device.py.""" +import asyncio from unittest.mock import patch +import async_timeout import pytest -from pywemo import PyWeMoException +from pywemo.exceptions import ActionException, PyWeMoException +from pywemo.subscribe import EVENT_TYPE_LONG_PRESS +from homeassistant import runner from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, wemo_device -from homeassistant.components.wemo.const import DOMAIN +from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT +from homeassistant.core import callback from homeassistant.helpers import device_registry +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from .conftest import MOCK_HOST +asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(True)) + @pytest.fixture def pywemo_model(): @@ -36,5 +44,107 @@ async def test_async_register_device_longpress_fails(hass, pywemo_device): dr = device_registry.async_get(hass) device_entries = list(dr.devices.values()) assert len(device_entries) == 1 - device_wrapper = wemo_device.async_get_device(hass, device_entries[0].id) - assert device_wrapper.supports_long_press is False + device = wemo_device.async_get_coordinator(hass, device_entries[0].id) + assert device.supports_long_press is False + + +async def test_long_press_event(hass, pywemo_registry, wemo_entity): + """Device fires a long press event.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + got_event = asyncio.Event() + event_data = {} + + @callback + def async_event_received(event): + nonlocal event_data + event_data = event.data + got_event.set() + + hass.bus.async_listen_once(WEMO_SUBSCRIPTION_EVENT, async_event_received) + + await hass.async_add_executor_job( + pywemo_registry.callbacks[device.wemo.name], + device.wemo, + EVENT_TYPE_LONG_PRESS, + "testing_params", + ) + + async with async_timeout.timeout(8): + await got_event.wait() + + assert event_data == { + "device_id": wemo_entity.device_id, + "name": device.wemo.name, + "params": "testing_params", + "type": EVENT_TYPE_LONG_PRESS, + "unique_id": device.wemo.serialnumber, + } + + +async def test_subscription_callback(hass, pywemo_registry, wemo_entity): + """Device processes a registry subscription callback.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = False + + got_callback = asyncio.Event() + + @callback + def async_received_callback(): + got_callback.set() + + device.async_add_listener(async_received_callback) + + await hass.async_add_executor_job( + pywemo_registry.callbacks[device.wemo.name], device.wemo, "", "" + ) + + async with async_timeout.timeout(8): + await got_callback.wait() + assert device.last_update_success + + +async def test_subscription_update_action_exception(hass, pywemo_device, wemo_entity): + """Device handles ActionException on get_state properly.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = True + + pywemo_device.subscription_update.return_value = False + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.side_effect = ActionException + await hass.async_add_executor_job( + device.subscription_callback, pywemo_device, "", "" + ) + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called_once_with(True) + assert device.last_update_success is False + assert isinstance(device.last_exception, UpdateFailed) + + +async def test_subscription_update_exception(hass, pywemo_device, wemo_entity): + """Device handles Exception on get_state properly.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device.last_update_success = True + + pywemo_device.subscription_update.return_value = False + pywemo_device.get_state.reset_mock() + pywemo_device.get_state.side_effect = Exception + await hass.async_add_executor_job( + device.subscription_callback, pywemo_device, "", "" + ) + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called_once_with(True) + assert device.last_update_success is False + assert isinstance(device.last_exception, Exception) + + +async def test_async_update_data_subscribed( + hass, pywemo_registry, pywemo_device, wemo_entity +): + """No update happens when the device is subscribed.""" + device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + pywemo_registry.is_subscribed.return_value = True + pywemo_device.get_state.reset_mock() + await device._async_update_data() + pywemo_device.get_state.assert_not_called()