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
This commit is contained in:
Eric Severance 2021-08-21 11:14:55 -07:00 committed by GitHub
parent 6cefd558d8
commit 67d04b6082
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 507 additions and 629 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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