From 5ffffd8dbc0d8b0e27d1532404998f8683766edf Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 20 Jul 2023 01:06:16 -0700 Subject: [PATCH] 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 --- homeassistant/components/wemo/__init__.py | 142 +++++++++++------- .../components/wemo/binary_sensor.py | 15 +- homeassistant/components/wemo/fan.py | 15 +- homeassistant/components/wemo/light.py | 12 +- homeassistant/components/wemo/models.py | 43 ++++++ homeassistant/components/wemo/sensor.py | 15 +- homeassistant/components/wemo/switch.py | 15 +- homeassistant/components/wemo/wemo_device.py | 38 ++++- tests/components/wemo/conftest.py | 3 - tests/components/wemo/test_init.py | 71 ++++++++- 10 files changed, 245 insertions(+), 124 deletions(-) create mode 100644 homeassistant/components/wemo/models.py diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 4488e881938..a58169aa6e5 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,9 +1,10 @@ """Support for WeMo device discovery.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Coroutine, Sequence from datetime import datetime import logging +from typing import Any import pywemo 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.core import CALLBACK_TYPE, Event, HomeAssistant, callback 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.typing import ConfigType from homeassistant.util.async_ import gather_with_concurrency 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 # avoid tying up too many executor threads with WeMo device setup. @@ -42,6 +43,7 @@ WEMO_MODEL_DISPATCH = { _LOGGER = logging.getLogger(__name__) +DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]] HostPortTuple = tuple[str, int | None] @@ -81,11 +83,26 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up for WeMo devices.""" - hass.data[DOMAIN] = { - "config": config.get(DOMAIN, {}), - "registry": None, - "pending": {}, - } + # Keep track of WeMo device subscriptions for push updates + registry = pywemo.SubscriptionRegistry() + await hass.async_add_executor_job(registry.start) + + # 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: 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: """Set up a wemo config entry.""" - config = hass.data[DOMAIN].pop("config") - - # Keep track of WeMo device subscriptions for push updates - registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry() - await hass.async_add_executor_job(registry.start) - - # Respond to discovery requests from WeMo devices. - 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) + wemo_data = async_wemo_data(hass) + dispatcher = WemoDispatcher(entry) + discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config) + wemo_data.config_entry_data = WemoConfigEntryData( + device_coordinators={}, + discovery=discovery, + dispatcher=dispatcher, ) - entry.async_on_unload(async_stop_wemo) # 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): - await wemo_discovery.async_discover_and_schedule() + if wemo_data.discovery_enabled: + await discovery.async_discover_and_schedule() return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a wemo config entry.""" - # This makes sure that `entry.async_on_unload` routines run correctly on unload - return True + _LOGGER.debug("Unloading WeMo") + 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: @@ -148,7 +168,8 @@ class WemoDispatcher: self._config_entry = config_entry self._added_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( self, hass: HomeAssistant, wemo: pywemo.WeMoDevice @@ -171,32 +192,47 @@ class WemoDispatcher: platforms.add(Platform.SENSOR) for platform in platforms: # 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 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: - hass.data[DOMAIN]["pending"][platform] = [coordinator] - self._loaded_platforms.add(platform) + if platform in self._dispatch_callbacks: + await self._dispatch_callbacks[platform](coordinator) + elif platform in self._dispatch_backlog: + self._dispatch_backlog[platform].append(coordinator) + else: + self._dispatch_backlog[platform] = [coordinator] hass.async_create_task( hass.config_entries.async_forward_entry_setup( 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._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: """Use SSDP to discover WeMo devices.""" diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index ce7dfc2fa11..396a555e4f4 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -1,22 +1,20 @@ """Support for WeMo binary sensors.""" -import asyncio from pywemo import Insight, Maker, StandbyState from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 .wemo_device import DeviceCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" @@ -30,14 +28,7 @@ async def async_setup_entry( else: async_add_entities([WemoBinarySensor(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) class WemoBinarySensor(WemoBinaryStateEntity, BinarySensorEntity): diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 1d2c2c9252d..aaa85455c56 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -1,7 +1,6 @@ """Support for WeMo humidifier.""" from __future__ import annotations -import asyncio from datetime import timedelta import math from typing import Any @@ -13,7 +12,6 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, @@ -21,8 +19,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) +from . import async_wemo_dispatcher_connect from .const import ( - DOMAIN as WEMO_DOMAIN, SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY, ) @@ -50,7 +48,7 @@ SET_HUMIDITY_SCHEMA = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" @@ -59,14 +57,7 @@ async def async_setup_entry( """Handle a discovered Wemo device.""" async_add_entities([WemoHumidifier(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("fan") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 2767d44032c..fb01d117c08 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,7 +1,6 @@ """Support for Belkin WeMo lights.""" from __future__ import annotations -import asyncio from typing import Any, cast from pywemo import Bridge, BridgeLight, Dimmer @@ -18,11 +17,11 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback 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_platform import AddEntitiesCallback import homeassistant.util.color as color_util +from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN from .entity import WemoBinaryStateEntity, WemoEntity from .wemo_device import DeviceCoordinator @@ -45,14 +44,7 @@ async def async_setup_entry( else: async_add_entities([WemoDimmer(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("light") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) @callback diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py new file mode 100644 index 00000000000..ee12ccbf846 --- /dev/null +++ b/homeassistant/components/wemo/models.py @@ -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]) diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 15e396cc660..2547dc0ad0d 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,7 +1,6 @@ """Support for power sensors in WeMo Insight devices.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -15,11 +14,10 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN as WEMO_DOMAIN +from . import async_wemo_dispatcher_connect from .entity import WemoEntity from .wemo_device import DeviceCoordinator @@ -59,7 +57,7 @@ ATTRIBUTE_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo sensors.""" @@ -72,14 +70,7 @@ async def async_setup_entry( if hasattr(coordinator.wemo, description.key) ) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) class AttributeSensor(WemoEntity, SensorEntity): diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 6d5e6b678b4..508621ba415 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -1,7 +1,6 @@ """Support for WeMo switches.""" from __future__ import annotations -import asyncio from datetime import datetime, timedelta from typing import Any @@ -11,10 +10,9 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 .wemo_device import DeviceCoordinator @@ -36,7 +34,7 @@ MAKER_SWITCH_TOGGLE = "toggle" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo switches.""" @@ -45,14 +43,7 @@ async def async_setup_entry( """Handle a discovered Wemo device.""" async_add_entities([WemoSwitch(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("switch") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) class WemoSwitch(WemoBinaryStateEntity, SwitchEntity): diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 65431fb7657..abb8aa186c9 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -9,7 +9,7 @@ from typing import Literal from pywemo import Insight, LongPressMixin, WeMoDevice 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.const import ( @@ -30,6 +30,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT +from .models import async_wemo_data _LOGGER = logging.getLogger(__name__) @@ -124,9 +125,21 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): updated = self.wemo.subscription_update(event_type, params) 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: """Turn on/off push updates from the device.""" - registry = self.hass.data[DOMAIN]["registry"] + registry = _async_registry(self.hass) if enable_subscription: registry.on(self.wemo, None, self.subscription_callback) 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. return True - registry = self.hass.data[DOMAIN]["registry"] - return not (registry.is_subscribed(self.wemo) and self.last_update_success) + return not ( + _async_registry(self.hass).is_subscribed(self.wemo) + and self.last_update_success + ) async def _async_update_data(self) -> None: """Update WeMo state.""" @@ -258,7 +273,7 @@ async def async_register_device( ) 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.add_update_listener(device.async_set_options) @@ -271,5 +286,14 @@ async def async_register_device( @callback def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: """Return DeviceCoordinator for device_id.""" - coordinator: DeviceCoordinator = hass.data[DOMAIN]["devices"][device_id] - return coordinator + return _async_coordinators(hass)[device_id] + + +@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 diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 5fe798004da..6c4d28ecae7 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -1,5 +1,4 @@ """Fixtures for pywemo.""" -import asyncio import contextlib 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.callbacks = {} - registry.semaphore = asyncio.Semaphore(value=0) def on_func(device, type_filter, callback): registry.callbacks[device.name] = callback - registry.semaphore.release() registry.on.side_effect = on_func registry.is_subscribed.return_value = False diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 0e9ba19af42..1d4271063f2 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -1,16 +1,24 @@ """Tests for the wemo component.""" +import asyncio from datetime import timedelta from unittest.mock import create_autospec, patch 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.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import entity_test_helpers from .conftest import ( MOCK_FIRMWARE_VERSION, MOCK_HOST, @@ -92,6 +100,54 @@ async def test_static_config_without_port(hass: HomeAssistant, pywemo_device) -> 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: """Component setup fails if a static host is invalid.""" 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 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)] # Setup the component and start discovery. with patch( "pywemo.discover_devices", return_value=pywemo_devices ) as mock_discovery, patch( "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( 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_discover_statics.assert_called() pywemo_devices.append(create_device(2))