Do not block setup of TP-Link when device unreachable (#53770)

This commit is contained in:
Michael 2021-08-01 23:58:55 +02:00 committed by GitHub
parent 7d1324d66d
commit da1a9bcbf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 37 deletions

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import time import time
from typing import Any
from pyHS100.smartdevice import SmartDevice, SmartDeviceException from pyHS100.smartdevice import SmartDevice, SmartDeviceException
from pyHS100.smartplug import SmartPlug from pyHS100.smartplug import SmartPlug
@ -22,9 +23,9 @@ from homeassistant.const import (
CONF_STATE, CONF_STATE,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
import homeassistant.helpers.config_validation as cv 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.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import utc_from_timestamp from homeassistant.util.dt import utc_from_timestamp
@ -44,6 +45,8 @@ from .const import (
CONF_SWITCH, CONF_SWITCH,
COORDINATORS, COORDINATORS,
PLATFORMS, PLATFORMS,
UNAVAILABLE_DEVICES,
UNAVAILABLE_RETRY_DELAY,
) )
_LOGGER = logging.getLogger(__name__) _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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up TPLink from a config entry.""" """Set up TPLink from a config entry."""
config_data = hass.data[DOMAIN].get(ATTR_CONFIG) 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) device_registry = dr.async_get(hass)
tplink_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) tplink_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
device_count = len(tplink_devices) device_count = len(tplink_devices)
hass_data: dict[str, Any] = hass.data[DOMAIN]
# These will contain the initialized devices # These will contain the initialized devices
hass.data[DOMAIN][CONF_LIGHT] = [] hass_data[CONF_LIGHT] = []
hass.data[DOMAIN][CONF_SWITCH] = [] hass_data[CONF_SWITCH] = []
lights: list[SmartDevice] = hass.data[DOMAIN][CONF_LIGHT] hass_data[UNAVAILABLE_DEVICES] = []
switches: list[SmartPlug] = hass.data[DOMAIN][CONF_SWITCH] 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 # Add static devices
static_devices = SmartDevices() static_devices = SmartDevices()
@ -136,22 +146,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
", ".join(d.host for d in switches), ", ".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 # prepare DataUpdateCoordinators
hass.data[DOMAIN][COORDINATORS] = {} hass_data[COORDINATORS] = {}
for switch in switches: for switch in switches:
try: try:
await hass.async_add_executor_job(switch.get_sysinfo) await hass.async_add_executor_job(switch.get_sysinfo)
except SmartDeviceException as ex: except SmartDeviceException:
_LOGGER.debug(ex) _LOGGER.warning(
raise ConfigEntryNotReady from ex "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 switch.mac
] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch)
await coordinator.async_config_entry_first_refresh() 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) hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True 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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """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: if unload_ok:
hass.data[DOMAIN].clear() hass_data.clear()
return unload_ok return unload_ok

View File

@ -5,6 +5,8 @@ import datetime
DOMAIN = "tplink" DOMAIN = "tplink"
COORDINATORS = "coordinators" COORDINATORS = "coordinators"
UNAVAILABLE_DEVICES = "unavailable_devices"
UNAVAILABLE_RETRY_DELAY = datetime.timedelta(seconds=300)
MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8) MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8)
MAX_DISCOVERY_RETRIES = 4 MAX_DISCOVERY_RETRIES = 4

View File

@ -1,6 +1,6 @@
"""Constants for the TP-Link component tests.""" """Constants for the TP-Link component tests."""
SMARTPLUGSWITCH_DATA = { SMARTPLUG_HS110_DATA = {
"sysinfo": { "sysinfo": {
"sw_ver": "1.0.4 Build 191111 Rel.143500", "sw_ver": "1.0.4 Build 191111 Rel.143500",
"hw_ver": "4.0", "hw_ver": "4.0",
@ -34,6 +34,33 @@ SMARTPLUGSWITCH_DATA = {
"err_code": 0, "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 = { SMARTSTRIPWITCH_DATA = {
"sysinfo": { "sysinfo": {
"sw_ver": "1.0.4 Build 191111 Rel.143500", "sw_ver": "1.0.4 Build 191111 Rel.143500",

View File

@ -11,6 +11,8 @@ import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components import tplink 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.common import SmartDevices
from homeassistant.components.tplink.const import ( from homeassistant.components.tplink.const import (
CONF_DIMMER, CONF_DIMMER,
@ -19,16 +21,21 @@ from homeassistant.components.tplink.const import (
CONF_SW_VERSION, CONF_SW_VERSION,
CONF_SWITCH, CONF_SWITCH,
COORDINATORS, COORDINATORS,
UNAVAILABLE_RETRY_DELAY,
) )
from homeassistant.components.tplink.sensor import ENERGY_SENSORS from homeassistant.components.tplink.sensor import ENERGY_SENSORS
from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_HOST from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component 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.common import MockConfigEntry, async_fire_time_changed, mock_coro
from tests.components.tplink.consts import SMARTPLUGSWITCH_DATA, SMARTSTRIPWITCH_DATA from tests.components.tplink.consts import (
SMARTPLUG_HS100_DATA,
SMARTPLUG_HS110_DATA,
SMARTSTRIPWITCH_DATA,
)
async def test_creating_entry_tries_discover(hass): 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") light = SmartBulb("123.123.123.123")
switch = SmartPlug("321.321.321.321") 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( switch.get_emeter_realtime = MagicMock(
return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"])
) )
switch.get_emeter_daily = MagicMock( switch.get_emeter_daily = MagicMock(
return_value={int(time.strftime("%e")): 1.123} return_value={int(time.strftime("%e")): 1.123}
@ -270,25 +277,22 @@ async def test_smartplug_without_consumption_sensors(hass: HomeAssistant):
), patch( ), patch(
"homeassistant.components.tplink.light.async_setup_entry", "homeassistant.components.tplink.light.async_setup_entry",
return_value=mock_coro(True), return_value=mock_coro(True),
), patch(
"homeassistant.components.tplink.switch.async_setup_entry",
return_value=mock_coro(True),
), patch( ), patch(
"homeassistant.components.tplink.common.SmartPlug.is_dimmable", False "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False
): ):
switch = SmartPlug("321.321.321.321") 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]) get_static_devices.return_value = SmartDevices([], [switch])
await async_setup_component(hass, tplink.DOMAIN, config) await async_setup_component(hass, tplink.DOMAIN, config)
await hass.async_block_till_done() await hass.async_block_till_done()
for description in ENERGY_SENSORS: entities = hass.states.async_entity_ids(SWITCH_DOMAIN)
state = hass.states.get( assert len(entities) == 1
f"sensor.{switch.alias}_{slugify(description.name)}"
) entities = hass.states.async_entity_ids(SENSOR_DOMAIN)
assert state is None assert len(entities) == 0
async def test_smartstrip_device(hass: HomeAssistant): 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 assert mock_setup.call_count == 0
async def test_not_ready(hass: HomeAssistant): async def test_not_available_at_startup(hass: HomeAssistant):
"""Test for not ready when configured devices are not available.""" """Test when configured devices are not available."""
config = { config = {
tplink.DOMAIN: { tplink.DOMAIN: {
CONF_DISCOVERY: False, CONF_DISCOVERY: False,
@ -362,9 +366,6 @@ async def test_not_ready(hass: HomeAssistant):
), patch( ), patch(
"homeassistant.components.tplink.light.async_setup_entry", "homeassistant.components.tplink.light.async_setup_entry",
return_value=mock_coro(True), return_value=mock_coro(True),
), patch(
"homeassistant.components.tplink.switch.async_setup_entry",
return_value=mock_coro(True),
), patch( ), patch(
"homeassistant.components.tplink.common.SmartPlug.is_dimmable", False "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()) switch.get_sysinfo = MagicMock(side_effect=SmartDeviceException())
get_static_devices.return_value = SmartDevices([], [switch]) get_static_devices.return_value = SmartDevices([], [switch])
# run setup while device unreachable
await async_setup_component(hass, tplink.DOMAIN, config) await async_setup_component(hass, tplink.DOMAIN, config)
await hass.async_block_till_done() await hass.async_block_till_done()
entries = hass.config_entries.async_entries(tplink.DOMAIN) entries = hass.config_entries.async_entries(tplink.DOMAIN)
assert len(entries) == 1 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"]) @pytest.mark.parametrize("platform", ["switch", "light"])
@ -406,9 +433,9 @@ async def test_unload(hass, platform):
light = SmartBulb("123.123.123.123") light = SmartBulb("123.123.123.123")
switch = SmartPlug("321.321.321.321") 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( switch.get_emeter_realtime = MagicMock(
return_value=EmeterStatus(SMARTPLUGSWITCH_DATA["realtime"]) return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"])
) )
if platform == "light": if platform == "light":
get_static_devices.return_value = SmartDevices([light], []) get_static_devices.return_value = SmartDevices([light], [])