Fully unload wemo config entry (#96620)

* Fully unload wemo config entity

* Test reloading the config entry

* Encapsulate data with dataclasses

* Fix missing test coverage

* Replace if with assert for options that are always set

* Move WemoData/WemoConfigEntryData to models.py

* Use _ to indicate unused argument

* Test that the entry and entity work after reloading

* Nit: Slight test reordering

* Reset the correct mock (get_state)

* from .const import DOMAIN

* Nit: _async_wemo_data -> async_wemo_data; not module private
This commit is contained in:
Eric Severance 2023-07-20 01:06:16 -07:00 committed by GitHub
parent 0349e47372
commit 5ffffd8dbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 245 additions and 124 deletions

View File

@ -1,9 +1,10 @@
"""Support for WeMo device discovery.""" """Support for WeMo device discovery."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Callable, Coroutine, Sequence
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any
import pywemo import pywemo
import voluptuous as vol import voluptuous as vol
@ -13,13 +14,13 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import gather_with_concurrency from homeassistant.util.async_ import gather_with_concurrency
from .const import DOMAIN from .const import DOMAIN
from .wemo_device import async_register_device from .models import WemoConfigEntryData, WemoData, async_wemo_data
from .wemo_device import DeviceCoordinator, async_register_device
# Max number of devices to initialize at once. This limit is in place to # Max number of devices to initialize at once. This limit is in place to
# avoid tying up too many executor threads with WeMo device setup. # avoid tying up too many executor threads with WeMo device setup.
@ -42,6 +43,7 @@ WEMO_MODEL_DISPATCH = {
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]]
HostPortTuple = tuple[str, int | None] HostPortTuple = tuple[str, int | None]
@ -81,11 +83,26 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up for WeMo devices.""" """Set up for WeMo devices."""
hass.data[DOMAIN] = { # Keep track of WeMo device subscriptions for push updates
"config": config.get(DOMAIN, {}), registry = pywemo.SubscriptionRegistry()
"registry": None, await hass.async_add_executor_job(registry.start)
"pending": {},
} # Respond to discovery requests from WeMo devices.
discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port)
await hass.async_add_executor_job(discovery_responder.start)
async def _on_hass_stop(_: Event) -> None:
await hass.async_add_executor_job(discovery_responder.stop)
await hass.async_add_executor_job(registry.stop)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop)
yaml_config = config.get(DOMAIN, {})
hass.data[DOMAIN] = WemoData(
discovery_enabled=yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY),
static_config=yaml_config.get(CONF_STATIC, []),
registry=registry,
)
if DOMAIN in config: if DOMAIN in config:
hass.async_create_task( hass.async_create_task(
@ -99,45 +116,48 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a wemo config entry.""" """Set up a wemo config entry."""
config = hass.data[DOMAIN].pop("config") wemo_data = async_wemo_data(hass)
dispatcher = WemoDispatcher(entry)
# Keep track of WeMo device subscriptions for push updates discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config)
registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry() wemo_data.config_entry_data = WemoConfigEntryData(
await hass.async_add_executor_job(registry.start) device_coordinators={},
discovery=discovery,
# Respond to discovery requests from WeMo devices. dispatcher=dispatcher,
discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port)
await hass.async_add_executor_job(discovery_responder.start)
static_conf: Sequence[HostPortTuple] = config.get(CONF_STATIC, [])
wemo_dispatcher = WemoDispatcher(entry)
wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf)
async def async_stop_wemo(_: Event | None = None) -> None:
"""Shutdown Wemo subscriptions and subscription thread on exit."""
_LOGGER.debug("Shutting down WeMo event subscriptions")
await hass.async_add_executor_job(registry.stop)
await hass.async_add_executor_job(discovery_responder.stop)
wemo_discovery.async_stop_discovery()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo)
) )
entry.async_on_unload(async_stop_wemo)
# Need to do this at least once in case statistics are defined and discovery is disabled # Need to do this at least once in case statistics are defined and discovery is disabled
await wemo_discovery.discover_statics() await discovery.discover_statics()
if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): if wemo_data.discovery_enabled:
await wemo_discovery.async_discover_and_schedule() await discovery.async_discover_and_schedule()
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a wemo config entry.""" """Unload a wemo config entry."""
# This makes sure that `entry.async_on_unload` routines run correctly on unload _LOGGER.debug("Unloading WeMo")
return True wemo_data = async_wemo_data(hass)
wemo_data.config_entry_data.discovery.async_stop_discovery()
dispatcher = wemo_data.config_entry_data.dispatcher
if unload_ok := await dispatcher.async_unload_platforms(hass):
assert not wemo_data.config_entry_data.device_coordinators
wemo_data.config_entry_data = None # type: ignore[assignment]
return unload_ok
async def async_wemo_dispatcher_connect(
hass: HomeAssistant,
dispatch: DispatchCallback,
) -> None:
"""Connect a wemo platform with the WemoDispatcher."""
module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch"
platform = Platform(module.rsplit(".", 1)[1])
dispatcher = async_wemo_data(hass).config_entry_data.dispatcher
await dispatcher.async_connect_platform(platform, dispatch)
class WemoDispatcher: class WemoDispatcher:
@ -148,7 +168,8 @@ class WemoDispatcher:
self._config_entry = config_entry self._config_entry = config_entry
self._added_serial_numbers: set[str] = set() self._added_serial_numbers: set[str] = set()
self._failed_serial_numbers: set[str] = set() self._failed_serial_numbers: set[str] = set()
self._loaded_platforms: set[Platform] = set() self._dispatch_backlog: dict[Platform, list[DeviceCoordinator]] = {}
self._dispatch_callbacks: dict[Platform, DispatchCallback] = {}
async def async_add_unique_device( async def async_add_unique_device(
self, hass: HomeAssistant, wemo: pywemo.WeMoDevice self, hass: HomeAssistant, wemo: pywemo.WeMoDevice
@ -171,32 +192,47 @@ class WemoDispatcher:
platforms.add(Platform.SENSOR) platforms.add(Platform.SENSOR)
for platform in platforms: for platform in platforms:
# Three cases: # Three cases:
# - First time we see platform, we need to load it and initialize the backlog # - Platform is loaded, dispatch discovery
# - Platform is being loaded, add to backlog # - Platform is being loaded, add to backlog
# - Platform is loaded, backlog is gone, dispatch discovery # - First time we see platform, we need to load it and initialize the backlog
if platform not in self._loaded_platforms: if platform in self._dispatch_callbacks:
hass.data[DOMAIN]["pending"][platform] = [coordinator] await self._dispatch_callbacks[platform](coordinator)
self._loaded_platforms.add(platform) elif platform in self._dispatch_backlog:
self._dispatch_backlog[platform].append(coordinator)
else:
self._dispatch_backlog[platform] = [coordinator]
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_forward_entry_setup( hass.config_entries.async_forward_entry_setup(
self._config_entry, platform self._config_entry, platform
) )
) )
elif platform in hass.data[DOMAIN]["pending"]:
hass.data[DOMAIN]["pending"][platform].append(coordinator)
else:
async_dispatcher_send(
hass,
f"{DOMAIN}.{platform}",
coordinator,
)
self._added_serial_numbers.add(wemo.serial_number) self._added_serial_numbers.add(wemo.serial_number)
self._failed_serial_numbers.discard(wemo.serial_number) self._failed_serial_numbers.discard(wemo.serial_number)
async def async_connect_platform(
self, platform: Platform, dispatch: DispatchCallback
) -> None:
"""Consider a platform as loaded and dispatch any backlog of discovered devices."""
self._dispatch_callbacks[platform] = dispatch
await gather_with_concurrency(
MAX_CONCURRENCY,
*(
dispatch(coordinator)
for coordinator in self._dispatch_backlog.pop(platform)
),
)
async def async_unload_platforms(self, hass: HomeAssistant) -> bool:
"""Forward the unloading of an entry to platforms."""
platforms: set[Platform] = set(self._dispatch_backlog.keys())
platforms.update(self._dispatch_callbacks.keys())
return await hass.config_entries.async_unload_platforms(
self._config_entry, platforms
)
class WemoDiscovery: class WemoDiscovery:
"""Use SSDP to discover WeMo devices.""" """Use SSDP to discover WeMo devices."""

View File

@ -1,22 +1,20 @@
"""Support for WeMo binary sensors.""" """Support for WeMo binary sensors."""
import asyncio
from pywemo import Insight, Maker, StandbyState from pywemo import Insight, Maker, StandbyState
from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as WEMO_DOMAIN from . import async_wemo_dispatcher_connect
from .entity import WemoBinaryStateEntity, WemoEntity from .entity import WemoBinaryStateEntity, WemoEntity
from .wemo_device import DeviceCoordinator from .wemo_device import DeviceCoordinator
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, _config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up WeMo binary sensors.""" """Set up WeMo binary sensors."""
@ -30,14 +28,7 @@ async def async_setup_entry(
else: else:
async_add_entities([WemoBinarySensor(coordinator)]) async_add_entities([WemoBinarySensor(coordinator)])
async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) await async_wemo_dispatcher_connect(hass, _discovered_wemo)
await asyncio.gather(
*(
_discovered_wemo(coordinator)
for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor")
)
)
class WemoBinarySensor(WemoBinaryStateEntity, BinarySensorEntity): class WemoBinarySensor(WemoBinaryStateEntity, BinarySensorEntity):

View File

@ -1,7 +1,6 @@
"""Support for WeMo humidifier.""" """Support for WeMo humidifier."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import math import math
from typing import Any from typing import Any
@ -13,7 +12,6 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
int_states_in_range, int_states_in_range,
@ -21,8 +19,8 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage, ranged_value_to_percentage,
) )
from . import async_wemo_dispatcher_connect
from .const import ( from .const import (
DOMAIN as WEMO_DOMAIN,
SERVICE_RESET_FILTER_LIFE, SERVICE_RESET_FILTER_LIFE,
SERVICE_SET_HUMIDITY, SERVICE_SET_HUMIDITY,
) )
@ -50,7 +48,7 @@ SET_HUMIDITY_SCHEMA = {
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, _config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up WeMo binary sensors.""" """Set up WeMo binary sensors."""
@ -59,14 +57,7 @@ async def async_setup_entry(
"""Handle a discovered Wemo device.""" """Handle a discovered Wemo device."""
async_add_entities([WemoHumidifier(coordinator)]) async_add_entities([WemoHumidifier(coordinator)])
async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) await async_wemo_dispatcher_connect(hass, _discovered_wemo)
await asyncio.gather(
*(
_discovered_wemo(coordinator)
for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("fan")
)
)
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()

View File

@ -1,7 +1,6 @@
"""Support for Belkin WeMo lights.""" """Support for Belkin WeMo lights."""
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any, cast from typing import Any, cast
from pywemo import Bridge, BridgeLight, Dimmer from pywemo import Bridge, BridgeLight, Dimmer
@ -18,11 +17,11 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from . import async_wemo_dispatcher_connect
from .const import DOMAIN as WEMO_DOMAIN from .const import DOMAIN as WEMO_DOMAIN
from .entity import WemoBinaryStateEntity, WemoEntity from .entity import WemoBinaryStateEntity, WemoEntity
from .wemo_device import DeviceCoordinator from .wemo_device import DeviceCoordinator
@ -45,14 +44,7 @@ async def async_setup_entry(
else: else:
async_add_entities([WemoDimmer(coordinator)]) async_add_entities([WemoDimmer(coordinator)])
async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) await async_wemo_dispatcher_connect(hass, _discovered_wemo)
await asyncio.gather(
*(
_discovered_wemo(coordinator)
for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("light")
)
)
@callback @callback

View File

@ -0,0 +1,43 @@
"""Common data structures and helpers for accessing them."""
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
import pywemo
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
if TYPE_CHECKING: # Avoid circular dependencies.
from . import HostPortTuple, WemoDiscovery, WemoDispatcher
from .wemo_device import DeviceCoordinator
@dataclass
class WemoConfigEntryData:
"""Config entry state data."""
device_coordinators: dict[str, "DeviceCoordinator"]
discovery: "WemoDiscovery"
dispatcher: "WemoDispatcher"
@dataclass
class WemoData:
"""Component state data."""
discovery_enabled: bool
static_config: Sequence["HostPortTuple"]
registry: pywemo.SubscriptionRegistry
# config_entry_data is set when the config entry is loaded and unset when it's
# unloaded. It's a programmer error if config_entry_data is accessed when the
# config entry is not loaded
config_entry_data: WemoConfigEntryData = None # type: ignore[assignment]
@callback
def async_wemo_data(hass: HomeAssistant) -> WemoData:
"""Fetch WemoData with proper typing."""
return cast(WemoData, hass.data[DOMAIN])

View File

@ -1,7 +1,6 @@
"""Support for power sensors in WeMo Insight devices.""" """Support for power sensors in WeMo Insight devices."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import cast
@ -15,11 +14,10 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from .const import DOMAIN as WEMO_DOMAIN from . import async_wemo_dispatcher_connect
from .entity import WemoEntity from .entity import WemoEntity
from .wemo_device import DeviceCoordinator from .wemo_device import DeviceCoordinator
@ -59,7 +57,7 @@ ATTRIBUTE_SENSORS = (
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, _config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up WeMo sensors.""" """Set up WeMo sensors."""
@ -72,14 +70,7 @@ async def async_setup_entry(
if hasattr(coordinator.wemo, description.key) if hasattr(coordinator.wemo, description.key)
) )
async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) await async_wemo_dispatcher_connect(hass, _discovered_wemo)
await asyncio.gather(
*(
_discovered_wemo(coordinator)
for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("sensor")
)
)
class AttributeSensor(WemoEntity, SensorEntity): class AttributeSensor(WemoEntity, SensorEntity):

View File

@ -1,7 +1,6 @@
"""Support for WeMo switches.""" """Support for WeMo switches."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@ -11,10 +10,9 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN as WEMO_DOMAIN from . import async_wemo_dispatcher_connect
from .entity import WemoBinaryStateEntity from .entity import WemoBinaryStateEntity
from .wemo_device import DeviceCoordinator from .wemo_device import DeviceCoordinator
@ -36,7 +34,7 @@ MAKER_SWITCH_TOGGLE = "toggle"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, _config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up WeMo switches.""" """Set up WeMo switches."""
@ -45,14 +43,7 @@ async def async_setup_entry(
"""Handle a discovered Wemo device.""" """Handle a discovered Wemo device."""
async_add_entities([WemoSwitch(coordinator)]) async_add_entities([WemoSwitch(coordinator)])
async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) await async_wemo_dispatcher_connect(hass, _discovered_wemo)
await asyncio.gather(
*(
_discovered_wemo(coordinator)
for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("switch")
)
)
class WemoSwitch(WemoBinaryStateEntity, SwitchEntity): class WemoSwitch(WemoBinaryStateEntity, SwitchEntity):

View File

@ -9,7 +9,7 @@ from typing import Literal
from pywemo import Insight, LongPressMixin, WeMoDevice from pywemo import Insight, LongPressMixin, WeMoDevice
from pywemo.exceptions import ActionException, PyWeMoException from pywemo.exceptions import ActionException, PyWeMoException
from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from pywemo.subscribe import EVENT_TYPE_LONG_PRESS, SubscriptionRegistry
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -30,6 +30,7 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT
from .models import async_wemo_data
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -124,9 +125,21 @@ class DeviceCoordinator(DataUpdateCoordinator[None]):
updated = self.wemo.subscription_update(event_type, params) updated = self.wemo.subscription_update(event_type, params)
self.hass.create_task(self._async_subscription_callback(updated)) self.hass.create_task(self._async_subscription_callback(updated))
async def async_shutdown(self) -> None:
"""Unregister push subscriptions and remove from coordinators dict."""
await super().async_shutdown()
del _async_coordinators(self.hass)[self.device_id]
assert self.options # Always set by async_register_device.
if self.options.enable_subscription:
await self._async_set_enable_subscription(False)
# Check that the device is available (last_update_success) before disabling long
# press. That avoids long shutdown times for devices that are no longer connected.
if self.options.enable_long_press and self.last_update_success:
await self._async_set_enable_long_press(False)
async def _async_set_enable_subscription(self, enable_subscription: bool) -> None: async def _async_set_enable_subscription(self, enable_subscription: bool) -> None:
"""Turn on/off push updates from the device.""" """Turn on/off push updates from the device."""
registry = self.hass.data[DOMAIN]["registry"] registry = _async_registry(self.hass)
if enable_subscription: if enable_subscription:
registry.on(self.wemo, None, self.subscription_callback) registry.on(self.wemo, None, self.subscription_callback)
await self.hass.async_add_executor_job(registry.register, self.wemo) await self.hass.async_add_executor_job(registry.register, self.wemo)
@ -199,8 +212,10 @@ class DeviceCoordinator(DataUpdateCoordinator[None]):
# this case so the Sensor entities are properly populated. # this case so the Sensor entities are properly populated.
return True return True
registry = self.hass.data[DOMAIN]["registry"] return not (
return not (registry.is_subscribed(self.wemo) and self.last_update_success) _async_registry(self.hass).is_subscribed(self.wemo)
and self.last_update_success
)
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Update WeMo state.""" """Update WeMo state."""
@ -258,7 +273,7 @@ async def async_register_device(
) )
device = DeviceCoordinator(hass, wemo, entry.id) device = DeviceCoordinator(hass, wemo, entry.id)
hass.data[DOMAIN].setdefault("devices", {})[entry.id] = device _async_coordinators(hass)[entry.id] = device
config_entry.async_on_unload( config_entry.async_on_unload(
config_entry.add_update_listener(device.async_set_options) config_entry.add_update_listener(device.async_set_options)
@ -271,5 +286,14 @@ async def async_register_device(
@callback @callback
def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator:
"""Return DeviceCoordinator for device_id.""" """Return DeviceCoordinator for device_id."""
coordinator: DeviceCoordinator = hass.data[DOMAIN]["devices"][device_id] return _async_coordinators(hass)[device_id]
return coordinator
@callback
def _async_coordinators(hass: HomeAssistant) -> dict[str, DeviceCoordinator]:
return async_wemo_data(hass).config_entry_data.device_coordinators
@callback
def _async_registry(hass: HomeAssistant) -> SubscriptionRegistry:
return async_wemo_data(hass).registry

View File

@ -1,5 +1,4 @@
"""Fixtures for pywemo.""" """Fixtures for pywemo."""
import asyncio
import contextlib import contextlib
from unittest.mock import create_autospec, patch from unittest.mock import create_autospec, patch
@ -33,11 +32,9 @@ async def async_pywemo_registry_fixture():
registry = create_autospec(pywemo.SubscriptionRegistry, instance=True) registry = create_autospec(pywemo.SubscriptionRegistry, instance=True)
registry.callbacks = {} registry.callbacks = {}
registry.semaphore = asyncio.Semaphore(value=0)
def on_func(device, type_filter, callback): def on_func(device, type_filter, callback):
registry.callbacks[device.name] = callback registry.callbacks[device.name] = callback
registry.semaphore.release()
registry.on.side_effect = on_func registry.on.side_effect = on_func
registry.is_subscribed.return_value = False registry.is_subscribed.return_value = False

View File

@ -1,16 +1,24 @@
"""Tests for the wemo component.""" """Tests for the wemo component."""
import asyncio
from datetime import timedelta from datetime import timedelta
from unittest.mock import create_autospec, patch from unittest.mock import create_autospec, patch
import pywemo import pywemo
from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, WemoDiscovery from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.wemo import (
CONF_DISCOVERY,
CONF_STATIC,
WemoDiscovery,
async_wemo_dispatcher_connect,
)
from homeassistant.components.wemo.const import DOMAIN from homeassistant.components.wemo.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import entity_test_helpers
from .conftest import ( from .conftest import (
MOCK_FIRMWARE_VERSION, MOCK_FIRMWARE_VERSION,
MOCK_HOST, MOCK_HOST,
@ -92,6 +100,54 @@ async def test_static_config_without_port(hass: HomeAssistant, pywemo_device) ->
assert len(entity_entries) == 1 assert len(entity_entries) == 1
async def test_reload_config_entry(
hass: HomeAssistant,
pywemo_device: pywemo.WeMoDevice,
pywemo_registry: pywemo.SubscriptionRegistry,
) -> None:
"""Config entry can be reloaded without errors."""
assert await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
CONF_DISCOVERY: False,
CONF_STATIC: [MOCK_HOST],
},
},
)
async def _async_test_entry_and_entity() -> tuple[str, str]:
await hass.async_block_till_done()
pywemo_device.get_state.assert_called()
pywemo_device.get_state.reset_mock()
pywemo_registry.register.assert_called_once_with(pywemo_device)
pywemo_registry.register.reset_mock()
entity_registry = er.async_get(hass)
entity_entries = list(entity_registry.entities.values())
assert len(entity_entries) == 1
await entity_test_helpers.test_turn_off_state(
hass, entity_entries[0], SWITCH_DOMAIN
)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
return entries[0].entry_id, entity_entries[0].entity_id
entry_id, entity_id = await _async_test_entry_and_entity()
pywemo_registry.unregister.assert_not_called()
assert await hass.config_entries.async_reload(entry_id)
ids = await _async_test_entry_and_entity()
pywemo_registry.unregister.assert_called_once_with(pywemo_device)
assert ids == (entry_id, entity_id)
async def test_static_config_with_invalid_host(hass: HomeAssistant) -> None: async def test_static_config_with_invalid_host(hass: HomeAssistant) -> None:
"""Component setup fails if a static host is invalid.""" """Component setup fails if a static host is invalid."""
setup_success = await async_setup_component( setup_success = await async_setup_component(
@ -146,17 +202,26 @@ async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None:
device.supports_long_press.return_value = False device.supports_long_press.return_value = False
return device return device
semaphore = asyncio.Semaphore(value=0)
async def async_connect(*args):
await async_wemo_dispatcher_connect(*args)
semaphore.release()
pywemo_devices = [create_device(0), create_device(1)] pywemo_devices = [create_device(0), create_device(1)]
# Setup the component and start discovery. # Setup the component and start discovery.
with patch( with patch(
"pywemo.discover_devices", return_value=pywemo_devices "pywemo.discover_devices", return_value=pywemo_devices
) as mock_discovery, patch( ) as mock_discovery, patch(
"homeassistant.components.wemo.WemoDiscovery.discover_statics" "homeassistant.components.wemo.WemoDiscovery.discover_statics"
) as mock_discover_statics: ) as mock_discover_statics, patch(
"homeassistant.components.wemo.binary_sensor.async_wemo_dispatcher_connect",
side_effect=async_connect,
):
assert await async_setup_component( assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}} hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}}
) )
await pywemo_registry.semaphore.acquire() # Returns after platform setup. await semaphore.acquire() # Returns after platform setup.
mock_discovery.assert_called() mock_discovery.assert_called()
mock_discover_statics.assert_called() mock_discover_statics.assert_called()
pywemo_devices.append(create_device(2)) pywemo_devices.append(create_device(2))