From da1a9bcbf04efd4a902477c85f164a46a64a9df2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 1 Aug 2021 23:58:55 +0200 Subject: [PATCH] Do not block setup of TP-Link when device unreachable (#53770) --- homeassistant/components/tplink/__init__.py | 70 ++++++++++++++++---- homeassistant/components/tplink/const.py | 2 + tests/components/tplink/consts.py | 29 +++++++- tests/components/tplink/test_init.py | 73 ++++++++++++++------- 4 files changed, 137 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e309d2c5082..88160722669 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging import time +from typing import Any from pyHS100.smartdevice import SmartDevice, SmartDeviceException from pyHS100.smartplug import SmartPlug @@ -22,9 +23,9 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import utc_from_timestamp @@ -44,6 +45,8 @@ from .const import ( CONF_SWITCH, COORDINATORS, PLATFORMS, + UNAVAILABLE_DEVICES, + UNAVAILABLE_RETRY_DELAY, ) _LOGGER = logging.getLogger(__name__) @@ -96,16 +99,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" config_data = hass.data[DOMAIN].get(ATTR_CONFIG) + if config_data is None and entry.data: + config_data = entry.data + elif config_data is not None: + hass.config_entries.async_update_entry(entry, data=config_data) device_registry = dr.async_get(hass) tplink_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) device_count = len(tplink_devices) + hass_data: dict[str, Any] = hass.data[DOMAIN] # These will contain the initialized devices - hass.data[DOMAIN][CONF_LIGHT] = [] - hass.data[DOMAIN][CONF_SWITCH] = [] - lights: list[SmartDevice] = hass.data[DOMAIN][CONF_LIGHT] - switches: list[SmartPlug] = hass.data[DOMAIN][CONF_SWITCH] + hass_data[CONF_LIGHT] = [] + hass_data[CONF_SWITCH] = [] + hass_data[UNAVAILABLE_DEVICES] = [] + lights: list[SmartDevice] = hass_data[CONF_LIGHT] + switches: list[SmartPlug] = hass_data[CONF_SWITCH] + unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] # Add static devices static_devices = SmartDevices() @@ -136,22 +146,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ", ".join(d.host for d in switches), ) + async def async_retry_devices(self) -> None: + """Retry unavailable devices.""" + unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] + _LOGGER.debug( + "retry during setup unavailable devices: %s", + [d.host for d in unavailable_devices], + ) + + for device in unavailable_devices: + try: + device.get_sysinfo() + except SmartDeviceException: + continue + _LOGGER.debug( + "at least one device is available again, so reload integration" + ) + await hass.config_entries.async_reload(entry.entry_id) + break + # prepare DataUpdateCoordinators - hass.data[DOMAIN][COORDINATORS] = {} + hass_data[COORDINATORS] = {} for switch in switches: try: await hass.async_add_executor_job(switch.get_sysinfo) - except SmartDeviceException as ex: - _LOGGER.debug(ex) - raise ConfigEntryNotReady from ex + except SmartDeviceException: + _LOGGER.warning( + "Device at '%s' not reachable during setup, will retry later", + switch.host, + ) + unavailable_devices.append(switch) + continue - hass.data[DOMAIN][COORDINATORS][ + hass_data[COORDINATORS][ switch.mac ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) await coordinator.async_config_entry_first_refresh() + if unavailable_devices: + entry.async_on_unload( + async_track_time_interval( + hass, async_retry_devices, UNAVAILABLE_RETRY_DELAY + ) + ) + unavailable_devices_hosts = [d.host for d in unavailable_devices] + hass_data[CONF_SWITCH] = [ + s for s in switches if s.host not in unavailable_devices_hosts + ] + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -159,10 +203,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - platforms = [platform for platform in PLATFORMS if hass.data[DOMAIN].get(platform)] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + hass_data: dict[str, Any] = hass.data[DOMAIN] if unload_ok: - hass.data[DOMAIN].clear() + hass_data.clear() return unload_ok diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 888d671096d..60e06fd1ffe 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -5,6 +5,8 @@ import datetime DOMAIN = "tplink" COORDINATORS = "coordinators" +UNAVAILABLE_DEVICES = "unavailable_devices" +UNAVAILABLE_RETRY_DELAY = datetime.timedelta(seconds=300) MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8) MAX_DISCOVERY_RETRIES = 4 diff --git a/tests/components/tplink/consts.py b/tests/components/tplink/consts.py index de134ddbe07..95177a12a9c 100644 --- a/tests/components/tplink/consts.py +++ b/tests/components/tplink/consts.py @@ -1,6 +1,6 @@ """Constants for the TP-Link component tests.""" -SMARTPLUGSWITCH_DATA = { +SMARTPLUG_HS110_DATA = { "sysinfo": { "sw_ver": "1.0.4 Build 191111 Rel.143500", "hw_ver": "4.0", @@ -34,6 +34,33 @@ SMARTPLUGSWITCH_DATA = { "err_code": 0, }, } +SMARTPLUG_HS100_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "hw_ver": "4.0", + "model": "HS100(EU)", + "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", + "oemId": "40F54B43071E9436B6395611E9D91CEA", + "hwId": "A6C77E4FDD238B53D824AC8DA361F043", + "rssi": -24, + "longitude_i": 130793, + "latitude_i": 480582, + "alias": "SmartPlug", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM:", + "mac": "A9:F4:3D:A4:E3:47", + "updating": 0, + "led_off": 0, + "relay_state": 0, + "on_time": 0, + "active_mode": "none", + "icon_hash": "", + "dev_name": "Smart Wi-Fi Plug", + "next_action": {"type": -1}, + "err_code": 0, + } +} SMARTSTRIPWITCH_DATA = { "sysinfo": { "sw_ver": "1.0.4 Build 191111 Rel.143500", diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index fb3f44709fc..a201788f35b 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -11,6 +11,8 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.tplink.common import SmartDevices from homeassistant.components.tplink.const import ( CONF_DIMMER, @@ -19,16 +21,21 @@ from homeassistant.components.tplink.const import ( CONF_SW_VERSION, CONF_SWITCH, COORDINATORS, + UNAVAILABLE_RETRY_DELAY, ) from homeassistant.components.tplink.sensor import ENERGY_SENSORS from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import slugify +from homeassistant.util import dt, slugify -from tests.common import MockConfigEntry, mock_coro -from tests.components.tplink.consts import SMARTPLUGSWITCH_DATA, SMARTSTRIPWITCH_DATA +from tests.common import MockConfigEntry, async_fire_time_changed, mock_coro +from tests.components.tplink.consts import ( + SMARTPLUG_HS100_DATA, + SMARTPLUG_HS110_DATA, + SMARTSTRIPWITCH_DATA, +) async def test_creating_entry_tries_discover(hass): @@ -220,9 +227,9 @@ async def test_platforms_are_initialized(hass: HomeAssistant): light = SmartBulb("123.123.123.123") switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) switch.get_emeter_realtime = MagicMock( - return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) + return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) ) switch.get_emeter_daily = MagicMock( return_value={int(time.strftime("%e")): 1.123} @@ -270,25 +277,22 @@ async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): ), patch( "homeassistant.components.tplink.light.async_setup_entry", return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.switch.async_setup_entry", - return_value=mock_coro(True), ), patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False ): switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) get_static_devices.return_value = SmartDevices([], [switch]) await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - for description in ENERGY_SENSORS: - state = hass.states.get( - f"sensor.{switch.alias}_{slugify(description.name)}" - ) - assert state is None + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 1 + + entities = hass.states.async_entity_ids(SENSOR_DOMAIN) + assert len(entities) == 0 async def test_smartstrip_device(hass: HomeAssistant): @@ -346,8 +350,8 @@ async def test_no_config_creates_no_entry(hass): assert mock_setup.call_count == 0 -async def test_not_ready(hass: HomeAssistant): - """Test for not ready when configured devices are not available.""" +async def test_not_available_at_startup(hass: HomeAssistant): + """Test when configured devices are not available.""" config = { tplink.DOMAIN: { CONF_DISCOVERY: False, @@ -362,9 +366,6 @@ async def test_not_ready(hass: HomeAssistant): ), patch( "homeassistant.components.tplink.light.async_setup_entry", return_value=mock_coro(True), - ), patch( - "homeassistant.components.tplink.switch.async_setup_entry", - return_value=mock_coro(True), ), patch( "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False ): @@ -373,13 +374,39 @@ async def test_not_ready(hass: HomeAssistant): switch.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) get_static_devices.return_value = SmartDevices([], [switch]) + # run setup while device unreachable await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() entries = hass.config_entries.async_entries(tplink.DOMAIN) - assert len(entries) == 1 - assert entries[0].state is config_entries.ConfigEntryState.SETUP_RETRY + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 0 + + # retrying with still unreachable device + async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 0 + + # retrying with now reachable device + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) + async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 1 @pytest.mark.parametrize("platform", ["switch", "light"]) @@ -406,9 +433,9 @@ async def test_unload(hass, platform): light = SmartBulb("123.123.123.123") switch = SmartPlug("321.321.321.321") - switch.get_sysinfo = MagicMock(return_value=SMARTPLUGSWITCH_DATA["sysinfo"]) + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) switch.get_emeter_realtime = MagicMock( - return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) + return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) ) if platform == "light": get_static_devices.return_value = SmartDevices([light], [])