From a0b0dc0aca204deff542e2d396076ee6ec7113be Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Sat, 11 Jan 2020 18:45:01 -0800 Subject: [PATCH] Optimistically set tplink light states (#30189) * Optimistically handling state changes. Using retries when command fail. * Fixing endless update loop. * Address PR comments. --- homeassistant/components/tplink/light.py | 362 ++++++++++++++++------- tests/components/tplink/test_light.py | 324 +++++++++++++------- 2 files changed, 475 insertions(+), 211 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index ec3307fc87e..0e7be471f43 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -1,6 +1,8 @@ """Support for TPLink lights.""" +from datetime import timedelta import logging import time +from typing import Any, Dict, NamedTuple, Tuple, cast from pyHS100 import SmartBulb, SmartDeviceException @@ -24,6 +26,7 @@ from . import CONF_LIGHT, DOMAIN as TPLINK_DOMAIN from .common import async_add_entities_retry PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -72,192 +75,327 @@ def brightness_from_percentage(percent): return (percent * 255.0) / 100.0 +LightState = NamedTuple( + "LightState", + ( + ("state", bool), + ("brightness", int), + ("color_temp", float), + ("hs", Tuple[int, int]), + ("emeter_params", dict), + ), +) + + +LightFeatures = NamedTuple( + "LightFeatures", + ( + ("sysinfo", Dict[str, Any]), + ("mac", str), + ("alias", str), + ("model", str), + ("supported_features", int), + ("min_mireds", float), + ("max_mireds", float), + ), +) + + class TPLinkSmartBulb(Light): """Representation of a TPLink Smart Bulb.""" def __init__(self, smartbulb: SmartBulb) -> None: """Initialize the bulb.""" self.smartbulb = smartbulb - self._sysinfo = None - self._state = None - self._available = False - self._color_temp = None - self._brightness = None - self._hs = None - self._supported_features = None - self._min_mireds = None - self._max_mireds = None - self._emeter_params = {} - - self._mac = None - self._alias = None - self._model = None + self._light_features = cast(LightFeatures, None) + self._light_state = cast(LightState, None) + self._is_available = True + self._is_setting_light_state = False @property def unique_id(self): """Return a unique ID.""" - return self._mac + return self._light_features.mac @property def name(self): """Return the name of the Smart Bulb.""" - return self._alias + return self._light_features.alias @property def device_info(self): """Return information about the device.""" return { - "name": self._alias, - "model": self._model, + "name": self._light_features.alias, + "model": self._light_features.model, "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "sw_version": self._sysinfo["sw_ver"], + "connections": {(dr.CONNECTION_NETWORK_MAC, self._light_features.mac)}, + "sw_version": self._light_features.sysinfo["sw_ver"], } @property def available(self) -> bool: """Return if bulb is available.""" - return self._available + return self._is_available @property def device_state_attributes(self): """Return the state attributes of the device.""" - return self._emeter_params + return self._light_state.emeter_params - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - self._state = True - self.smartbulb.state = SmartBulb.BULB_STATE_ON + brightness = ( + int(kwargs[ATTR_BRIGHTNESS]) + if ATTR_BRIGHTNESS in kwargs + else self._light_state.brightness + if self._light_state.brightness is not None + else 255 + ) + color_tmp = ( + int(kwargs[ATTR_COLOR_TEMP]) + if ATTR_COLOR_TEMP in kwargs + else self._light_state.color_temp + ) - if ATTR_COLOR_TEMP in kwargs: - self._color_temp = kwargs.get(ATTR_COLOR_TEMP) - self.smartbulb.color_temp = mired_to_kelvin(self._color_temp) + await self.async_set_light_state_retry( + self._light_state, + LightState( + state=True, + brightness=brightness, + color_temp=color_tmp, + hs=tuple(kwargs.get(ATTR_HS_COLOR, self._light_state.hs or ())), + emeter_params=self._light_state.emeter_params, + ), + ) - brightness_value = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) - brightness_pct = brightness_to_percentage(brightness_value) - if ATTR_HS_COLOR in kwargs: - self._hs = kwargs.get(ATTR_HS_COLOR) - hue, sat = self._hs - hsv = (int(hue), int(sat), brightness_pct) - self.smartbulb.hsv = hsv - elif ATTR_BRIGHTNESS in kwargs: - self._brightness = brightness_value - self.smartbulb.brightness = brightness_pct - - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the light off.""" - self._state = False - self.smartbulb.state = SmartBulb.BULB_STATE_OFF + await self.async_set_light_state_retry( + self._light_state, + LightState( + state=False, + brightness=self._light_state.brightness, + color_temp=self._light_state.color_temp, + hs=self._light_state.hs, + emeter_params=self._light_state.emeter_params, + ), + ) @property def min_mireds(self): """Return minimum supported color temperature.""" - return self._min_mireds + return self._light_features.min_mireds @property def max_mireds(self): """Return maximum supported color temperature.""" - return self._max_mireds + return self._light_features.max_mireds @property def color_temp(self): """Return the color temperature of this light in mireds for HA.""" - return self._color_temp + return self._light_state.color_temp @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return self._light_state.brightness @property def hs_color(self): """Return the color.""" - return self._hs + return self._light_state.hs @property def is_on(self): """Return True if device is on.""" - return self._state + return self._light_state.state def update(self): """Update the TP-Link Bulb's state.""" - if self._supported_features is None: - # First run, update by blocking. - self.do_update() + # State is currently being set, ignore. + if self._is_setting_light_state: + return + + # Initial run, perform call blocking. + if not self._light_features: + self.do_update_retry(False) + # Subsequent runs should not block. else: - # Not first run, update in the background. - self.hass.add_job(self.do_update) + self.hass.add_job(self.do_update_retry, True) - def do_update(self): - """Update states.""" + def do_update_retry(self, update_state: bool) -> None: + """Update state data with retry.""" "" try: - if self._supported_features is None: - self.get_features() - - self._state = self.smartbulb.state == SmartBulb.BULB_STATE_ON - - if self._supported_features & SUPPORT_BRIGHTNESS: - self._brightness = brightness_from_percentage(self.smartbulb.brightness) - - if self._supported_features & SUPPORT_COLOR_TEMP: - if ( - self.smartbulb.color_temp is not None - and self.smartbulb.color_temp != 0 - ): - self._color_temp = kelvin_to_mired(self.smartbulb.color_temp) - - if self._supported_features & SUPPORT_COLOR: - hue, sat, _ = self.smartbulb.hsv - self._hs = (hue, sat) - - if self.smartbulb.has_emeter: - self._emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format( - self.smartbulb.current_consumption() - ) - daily_statistics = self.smartbulb.get_emeter_daily() - monthly_statistics = self.smartbulb.get_emeter_monthly() - try: - self._emeter_params[ATTR_DAILY_ENERGY_KWH] = "{:.3f}".format( - daily_statistics[int(time.strftime("%d"))] - ) - self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] = "{:.3f}".format( - monthly_statistics[int(time.strftime("%m"))] - ) - except KeyError: - # device returned no daily/monthly history - pass - - self._available = True - + # Update light features only once. + self._light_features = ( + self._light_features or self.get_light_features_retry() + ) + self._light_state = self.get_light_state_retry(self._light_features) + self._is_available = True except (SmartDeviceException, OSError) as ex: - if self._available: + if self._is_available: _LOGGER.warning( - "Could not read state for %s: %s", self.smartbulb.host, ex + "Could not read data for %s: %s", self.smartbulb.host, ex ) - self._available = False + self._is_available = False + + # The local variables were updates asyncronousally, + # we need the entity registry to poll this object's properties for + # updated information. Calling schedule_update_ha_state will only + # cause a loop. + if update_state: + self.schedule_update_ha_state() @property def supported_features(self): """Flag supported features.""" - return self._supported_features + return self._light_features.supported_features - def get_features(self): + def get_light_features_retry(self) -> LightFeatures: + """Retry the retrieval of the supported features.""" + try: + return self.get_light_features() + except (SmartDeviceException, OSError): + pass + + _LOGGER.debug("Retrying getting light features") + return self.get_light_features() + + def get_light_features(self): """Determine all supported features in one go.""" - self._sysinfo = self.smartbulb.sys_info - self._supported_features = 0 - self._mac = self.smartbulb.mac - self._alias = self.smartbulb.alias - self._model = self.smartbulb.model + sysinfo = self.smartbulb.sys_info + supported_features = 0 + mac = self.smartbulb.mac + alias = self.smartbulb.alias + model = self.smartbulb.model + min_mireds = None + max_mireds = None if self.smartbulb.is_dimmable: - self._supported_features += SUPPORT_BRIGHTNESS + supported_features += SUPPORT_BRIGHTNESS if getattr(self.smartbulb, "is_variable_color_temp", False): - self._supported_features += SUPPORT_COLOR_TEMP - self._min_mireds = kelvin_to_mired( - self.smartbulb.valid_temperature_range[1] - ) - self._max_mireds = kelvin_to_mired( - self.smartbulb.valid_temperature_range[0] - ) + supported_features += SUPPORT_COLOR_TEMP + min_mireds = kelvin_to_mired(self.smartbulb.valid_temperature_range[1]) + max_mireds = kelvin_to_mired(self.smartbulb.valid_temperature_range[0]) if getattr(self.smartbulb, "is_color", False): - self._supported_features += SUPPORT_COLOR + supported_features += SUPPORT_COLOR + + return LightFeatures( + sysinfo=sysinfo, + mac=mac, + alias=alias, + model=model, + supported_features=supported_features, + min_mireds=min_mireds, + max_mireds=max_mireds, + ) + + def get_light_state_retry(self, light_features: LightFeatures) -> LightState: + """Retry the retrieval of getting light states.""" + try: + return self.get_light_state(light_features) + except (SmartDeviceException, OSError): + pass + + _LOGGER.debug("Retrying getting light state") + return self.get_light_state(light_features) + + def get_light_state(self, light_features: LightFeatures) -> LightState: + """Get the light state.""" + emeter_params = {} + brightness = None + color_temp = None + hue_saturation = None + state = self.smartbulb.state == SmartBulb.BULB_STATE_ON + + if light_features.supported_features & SUPPORT_BRIGHTNESS: + brightness = brightness_from_percentage(self.smartbulb.brightness) + + if light_features.supported_features & SUPPORT_COLOR_TEMP: + if self.smartbulb.color_temp is not None and self.smartbulb.color_temp != 0: + color_temp = kelvin_to_mired(self.smartbulb.color_temp) + + if light_features.supported_features & SUPPORT_COLOR: + hue, sat, _ = self.smartbulb.hsv + hue_saturation = (hue, sat) + + if self.smartbulb.has_emeter: + emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format( + self.smartbulb.current_consumption() + ) + daily_statistics = self.smartbulb.get_emeter_daily() + monthly_statistics = self.smartbulb.get_emeter_monthly() + try: + emeter_params[ATTR_DAILY_ENERGY_KWH] = "{:.3f}".format( + daily_statistics[int(time.strftime("%d"))] + ) + emeter_params[ATTR_MONTHLY_ENERGY_KWH] = "{:.3f}".format( + monthly_statistics[int(time.strftime("%m"))] + ) + except KeyError: + # device returned no daily/monthly history + pass + + return LightState( + state=state, + brightness=brightness, + color_temp=color_temp, + hs=hue_saturation, + emeter_params=emeter_params, + ) + + async def async_set_light_state_retry( + self, old_light_state: LightState, new_light_state: LightState + ) -> None: + """Set the light state with retry.""" + # Optimistically setting the light state. + self._light_state = new_light_state + + # Tell the device to set the states. + self._is_setting_light_state = True + try: + await self.hass.async_add_executor_job( + self.set_light_state, old_light_state, new_light_state + ) + self._is_available = True + self._is_setting_light_state = False + return + except (SmartDeviceException, OSError): + pass + + try: + _LOGGER.debug("Retrying setting light state") + await self.hass.async_add_executor_job( + self.set_light_state, old_light_state, new_light_state + ) + self._is_available = True + except (SmartDeviceException, OSError) as ex: + self._is_available = False + _LOGGER.warning("Could not set data for %s: %s", self.smartbulb.host, ex) + + self._is_setting_light_state = False + + def set_light_state( + self, old_light_state: LightState, new_light_state: LightState + ) -> None: + """Set the light state.""" + # Calling the API with the new state information. + if new_light_state.state != old_light_state.state: + if new_light_state.state: + self.smartbulb.state = SmartBulb.BULB_STATE_ON + else: + self.smartbulb.state = SmartBulb.BULB_STATE_OFF + return + + if new_light_state.color_temp != old_light_state.color_temp: + self.smartbulb.color_temp = mired_to_kelvin(new_light_state.color_temp) + + brightness_pct = brightness_to_percentage(new_light_state.brightness) + if new_light_state.hs != old_light_state.hs and len(new_light_state.hs) > 1: + hue, sat = new_light_state.hs + hsv = (int(hue), int(sat), brightness_pct) + self.smartbulb.hsv = hsv + elif new_light_state.brightness != old_light_state.brightness: + self.smartbulb.brightness = brightness_pct diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 8d1d4d94738..8e5a2a775b9 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -1,9 +1,15 @@ """Tests for light platform.""" -from unittest.mock import patch +from typing import Callable, NamedTuple +from unittest.mock import Mock, patch -from pyHS100 import SmartBulb +from pyHS100 import SmartDeviceException +import pytest from homeassistant.components import tplink +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -20,9 +26,25 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +LightMockData = NamedTuple( + "LightMockData", + ( + ("sys_info", dict), + ("light_state", dict), + ("set_light_state", Callable[[dict], None]), + ("set_light_state_mock", Mock), + ("get_light_state_mock", Mock), + ("current_consumption_mock", Mock), + ("get_sysinfo_mock", Mock), + ("get_emeter_daily_mock", Mock), + ("get_emeter_monthly_mock", Mock), + ), +) -async def test_light(hass: HomeAssistant) -> None: - """Test function.""" + +@pytest.fixture(name="light_mock_data") +def light_mock_data_fixture() -> None: + """Create light mock data.""" sys_info = { "sw_ver": "1.2.3", "hw_ver": "2.3.4", @@ -44,22 +66,26 @@ async def test_light(hass: HomeAssistant) -> None: } light_state = { - "on_off": SmartBulb.BULB_STATE_ON, + "on_off": True, "dft_on_state": { "brightness": 12, "color_temp": 3200, - "hue": 100, - "saturation": 200, + "hue": 110, + "saturation": 90, }, "brightness": 13, "color_temp": 3300, "hue": 110, - "saturation": 210, + "saturation": 90, } - def set_light_state(state): + def set_light_state(state) -> None: nonlocal light_state + drt_on_state = light_state["dft_on_state"] + drt_on_state.update(state.get("dft_on_state", {})) + light_state.update(state) + light_state["dft_on_state"] = drt_on_state set_light_state_patch = patch( "homeassistant.components.tplink.common.SmartBulb.set_light_state", @@ -112,109 +138,209 @@ async def test_light(hass: HomeAssistant) -> None: }, ) - with set_light_state_patch, get_light_state_patch, current_consumption_patch, get_sysinfo_patch, get_emeter_daily_patch, get_emeter_monthly_patch: - await async_setup_component( - hass, - tplink.DOMAIN, - { - tplink.DOMAIN: { - CONF_DISCOVERY: False, - CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], - } - }, - ) - await hass.async_block_till_done() - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, + with set_light_state_patch as set_light_state_mock, get_light_state_patch as get_light_state_mock, current_consumption_patch as current_consumption_mock, get_sysinfo_patch as get_sysinfo_mock, get_emeter_daily_patch as get_emeter_daily_mock, get_emeter_monthly_patch as get_emeter_monthly_mock: + yield LightMockData( + sys_info=sys_info, + light_state=light_state, + set_light_state=set_light_state, + set_light_state_mock=set_light_state_mock, + get_light_state_mock=get_light_state_mock, + current_consumption_mock=current_consumption_mock, + get_sysinfo_mock=get_sysinfo_mock, + get_emeter_daily_mock=get_emeter_daily_mock, + get_emeter_monthly_mock=get_emeter_monthly_mock, ) - assert hass.states.get("light.light1").state == "off" - assert light_state["on_off"] == 0 - await hass.async_block_till_done() +async def update_entity(hass: HomeAssistant, entity_id: str) -> None: + """Run an update action for an entity.""" + await hass.services.async_call( + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id}, blocking=True, + ) + await hass.async_block_till_done() - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.light1", - ATTR_COLOR_TEMP: 312, - ATTR_BRIGHTNESS: 50, - }, - blocking=True, - ) - await hass.async_block_till_done() +async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> None: + """Test function.""" + light_state = light_mock_data.light_state + set_light_state = light_mock_data.set_light_state - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 48.45 - assert state.attributes["hs_color"] == (110, 210) - assert state.attributes["color_temp"] == 312 - assert light_state["on_off"] == 1 + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.async_block_till_done() - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.light1", - ATTR_BRIGHTNESS: 55, - ATTR_HS_COLOR: (23, 27), - }, - blocking=True, - ) + await async_setup_component( + hass, + tplink.DOMAIN, + { + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], + } + }, + ) + await hass.async_block_till_done() - await hass.async_block_till_done() + assert hass.states.get("light.light1") - state = hass.states.get("light.light1") - assert state.state == "on" - assert state.attributes["brightness"] == 53.55 - assert state.attributes["hs_color"] == (23, 27) - assert state.attributes["color_temp"] == 312 - assert light_state["brightness"] == 21 - assert light_state["hue"] == 23 - assert light_state["saturation"] == 27 + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "light.light1"}, blocking=True, + ) + await hass.async_block_till_done() + await update_entity(hass, "light.light1") - light_state["on_off"] = 0 - light_state["dft_on_state"]["on_off"] = 0 - light_state["brightness"] = 66 - light_state["dft_on_state"]["brightness"] = 66 - light_state["color_temp"] = 6400 - light_state["dft_on_state"]["color_temp"] = 123 - light_state["hue"] = 77 - light_state["dft_on_state"]["hue"] = 77 - light_state["saturation"] = 78 - light_state["dft_on_state"]["saturation"] = 78 + assert hass.states.get("light.light1").state == "off" + assert light_state["on_off"] == 0 - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light1", ATTR_COLOR_TEMP: 222, ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + await hass.async_block_till_done() + await update_entity(hass, "light.light1") - await hass.async_block_till_done() + state = hass.states.get("light.light1") + assert state.state == "on" + assert state.attributes["brightness"] == 48.45 + assert state.attributes["hs_color"] == (110, 90) + assert state.attributes["color_temp"] == 222 + assert light_state["on_off"] == 1 - state = hass.states.get("light.light1") - assert state.state == "off" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light1", ATTR_BRIGHTNESS: 55, ATTR_HS_COLOR: (23, 27)}, + blocking=True, + ) + await hass.async_block_till_done() + await update_entity(hass, "light.light1") - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light1"}, - blocking=True, - ) + state = hass.states.get("light.light1") + assert state.state == "on" + assert state.attributes["brightness"] == 53.55 + assert state.attributes["hs_color"] == (23, 27) + assert light_state["brightness"] == 21 + assert light_state["hue"] == 23 + assert light_state["saturation"] == 27 - await hass.async_block_till_done() + light_state["on_off"] = 0 + light_state["dft_on_state"]["on_off"] = 0 + light_state["brightness"] = 66 + light_state["dft_on_state"]["brightness"] = 66 + light_state["color_temp"] = 6400 + light_state["dft_on_state"]["color_temp"] = 123 + light_state["hue"] = 77 + light_state["dft_on_state"]["hue"] = 77 + light_state["saturation"] = 78 + light_state["dft_on_state"]["saturation"] = 78 - state = hass.states.get("light.light1") - assert state.attributes["brightness"] == 168.3 - assert state.attributes["hs_color"] == (77, 78) - assert state.attributes["color_temp"] == 156 - assert light_state["brightness"] == 66 - assert light_state["hue"] == 77 - assert light_state["saturation"] == 78 + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "light.light1"}, blocking=True, + ) + await hass.async_block_till_done() + await update_entity(hass, "light.light1") + + state = hass.states.get("light.light1") + assert state.state == "off" + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "light.light1"}, blocking=True, + ) + await hass.async_block_till_done() + await update_entity(hass, "light.light1") + + state = hass.states.get("light.light1") + assert state.state == "on" + assert state.attributes["brightness"] == 168.3 + assert state.attributes["hs_color"] == (77, 78) + assert state.attributes["color_temp"] == 156 + assert light_state["brightness"] == 66 + assert light_state["hue"] == 77 + assert light_state["saturation"] == 78 + + set_light_state({"brightness": 91, "dft_on_state": {"brightness": 91}}) + await update_entity(hass, "light.light1") + + state = hass.states.get("light.light1") + assert state.attributes["brightness"] == 232.05 + + +async def test_get_light_state_retry( + hass: HomeAssistant, light_mock_data: LightMockData +) -> None: + """Test function.""" + # Setup test for retries for sysinfo. + get_sysinfo_call_count = 0 + + def get_sysinfo_side_effect(): + nonlocal get_sysinfo_call_count + get_sysinfo_call_count += 1 + + # Need to fail on the 2nd call because the first call is used to + # determine if the device is online during the light platform's + # setup hook. + if get_sysinfo_call_count == 2: + raise SmartDeviceException() + + return light_mock_data.sys_info + + light_mock_data.get_sysinfo_mock.side_effect = get_sysinfo_side_effect + + # Setup test for retries of getting state information. + get_state_call_count = 0 + + def get_light_state_side_effect(): + nonlocal get_state_call_count + get_state_call_count += 1 + + if get_state_call_count == 1: + raise SmartDeviceException() + + return light_mock_data.light_state + + light_mock_data.get_light_state_mock.side_effect = get_light_state_side_effect + + # Setup test for retries of setting state information. + set_state_call_count = 0 + + def set_light_state_side_effect(state_data: dict): + nonlocal set_state_call_count, light_mock_data + set_state_call_count += 1 + + if set_state_call_count == 1: + raise SmartDeviceException() + + light_mock_data.set_light_state(state_data) + + light_mock_data.set_light_state_mock.side_effect = set_light_state_side_effect + + # Setup component. + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.async_block_till_done() + + await async_setup_component( + hass, + tplink.DOMAIN, + { + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}], + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "light.light1"}, blocking=True, + ) + await hass.async_block_till_done() + await update_entity(hass, "light.light1") + + assert light_mock_data.get_sysinfo_mock.call_count > 1 + assert light_mock_data.get_light_state_mock.call_count > 1 + assert light_mock_data.set_light_state_mock.call_count > 1 + + assert light_mock_data.get_sysinfo_mock.call_count < 40 + assert light_mock_data.get_light_state_mock.call_count < 40 + assert light_mock_data.set_light_state_mock.call_count < 10