Automatically retry lost/timed out LIFX requests (#91157)

This commit is contained in:
J. Nick Koston 2023-04-16 02:27:17 -10:00 committed by GitHub
parent 3ff03eef46
commit 9625444989
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 278 additions and 140 deletions

View File

@ -18,12 +18,19 @@ from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.typing import DiscoveryInfoType
from .const import _LOGGER, CONF_SERIAL, DOMAIN, TARGET_ANY from .const import (
_LOGGER,
CONF_SERIAL,
DEFAULT_ATTEMPTS,
DOMAIN,
OVERALL_TIMEOUT,
TARGET_ANY,
)
from .discovery import async_discover_devices from .discovery import async_discover_devices
from .util import ( from .util import (
async_entry_is_legacy, async_entry_is_legacy,
async_execute_lifx,
async_get_legacy_entry, async_get_legacy_entry,
async_multi_execute_lifx_with_retries,
formatted_serial, formatted_serial,
lifx_features, lifx_features,
mac_matches_serial_number, mac_matches_serial_number,
@ -225,13 +232,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# get_version required for lifx_features() # get_version required for lifx_features()
# get_label required to log the name of the device # get_label required to log the name of the device
# get_group required to populate suggested areas # get_group required to populate suggested areas
messages = await asyncio.gather( messages = await async_multi_execute_lifx_with_retries(
*[ [
async_execute_lifx(device.get_hostfirmware), device.get_hostfirmware,
async_execute_lifx(device.get_version), device.get_version,
async_execute_lifx(device.get_label), device.get_label,
async_execute_lifx(device.get_group), device.get_group,
] ],
DEFAULT_ATTEMPTS,
OVERALL_TIMEOUT,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
return None return None

View File

@ -7,11 +7,22 @@ DOMAIN = "lifx"
TARGET_ANY = "00:00:00:00:00:00" TARGET_ANY = "00:00:00:00:00:00"
DISCOVERY_INTERVAL = 10 DISCOVERY_INTERVAL = 10
MESSAGE_TIMEOUT = 1.65 # The number of seconds before we will no longer accept a response
MESSAGE_RETRIES = 5 # to a message and consider it invalid
OVERALL_TIMEOUT = 9 MESSAGE_TIMEOUT = 18
# Disable the retries in the library since they are not spaced out
# enough to account for WiFi and UDP dropouts
MESSAGE_RETRIES = 1
OVERALL_TIMEOUT = 15
UNAVAILABLE_GRACE = 90 UNAVAILABLE_GRACE = 90
# The number of times to retry a request message
DEFAULT_ATTEMPTS = 5
# The maximum time to wait for a bulb to respond to an update
MAX_UPDATE_TIME = 90
# The number of tries to send each request message to a bulb during an update
MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE = 5
CONF_LABEL = "label" CONF_LABEL = "label"
CONF_SERIAL = "serial" CONF_SERIAL = "serial"
@ -50,4 +61,5 @@ INFRARED_BRIGHTNESS_VALUES_MAP = {
} }
DATA_LIFX_MANAGER = "lifx_manager" DATA_LIFX_MANAGER = "lifx_manager"
_LOGGER = logging.getLogger(__package__) _LOGGER = logging.getLogger(__package__)

View File

@ -28,20 +28,25 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import ( from .const import (
_LOGGER, _LOGGER,
ATTR_REMAINING, ATTR_REMAINING,
DEFAULT_ATTEMPTS,
DOMAIN, DOMAIN,
IDENTIFY_WAVEFORM, IDENTIFY_WAVEFORM,
MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE,
MAX_UPDATE_TIME,
MESSAGE_RETRIES, MESSAGE_RETRIES,
MESSAGE_TIMEOUT, MESSAGE_TIMEOUT,
OVERALL_TIMEOUT,
TARGET_ANY, TARGET_ANY,
UNAVAILABLE_GRACE, UNAVAILABLE_GRACE,
) )
from .util import ( from .util import (
async_execute_lifx, async_execute_lifx,
async_multi_execute_lifx_with_retries,
get_real_mac_addr, get_real_mac_addr,
infrared_brightness_option_to_value, infrared_brightness_option_to_value,
infrared_brightness_value_to_option, infrared_brightness_value_to_option,
@ -49,7 +54,6 @@ from .util import (
) )
LIGHT_UPDATE_INTERVAL = 10 LIGHT_UPDATE_INTERVAL = 10
SENSOR_UPDATE_INTERVAL = 30
REQUEST_REFRESH_DELAY = 0.35 REQUEST_REFRESH_DELAY = 0.35
LIFX_IDENTIFY_DELAY = 3.0 LIFX_IDENTIFY_DELAY = 3.0
RSSI_DBM_FW = AwesomeVersion("2.77") RSSI_DBM_FW = AwesomeVersion("2.77")
@ -186,60 +190,84 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
platform, DOMAIN, f"{self.serial_number}_{key}" platform, DOMAIN, f"{self.serial_number}_{key}"
) )
async def _async_populate_device_info(self) -> None:
"""Populate device info."""
methods: list[Callable] = []
device = self.device
if self.device.host_firmware_version is None:
methods.append(device.get_hostfirmware)
if self.device.product is None:
methods.append(device.get_version)
if self.device.group is None:
methods.append(device.get_group)
assert methods, "Device info already populated"
await async_multi_execute_lifx_with_retries(
methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT
)
@callback
def _async_build_color_zones_update_requests(self) -> list[Callable]:
"""Build a color zones update request."""
device = self.device
return [
partial(device.get_color_zones, start_index=zone)
for zone in range(0, len(device.color_zones), 8)
]
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Fetch all device data from the api.""" """Fetch all device data from the api."""
async with self.lock: device = self.device
if self.device.host_firmware_version is None: if (
self.device.get_hostfirmware() device.host_firmware_version is None
if self.device.product is None: or device.product is None
self.device.get_version() or device.group is None
if self.device.group is None: ):
self.device.get_group() await self._async_populate_device_info()
response = await async_execute_lifx(self.device.get_color) num_zones = len(device.color_zones) if device.color_zones is not None else 0
features = lifx_features(self.device)
is_extended_multizone = features["extended_multizone"]
is_legacy_multizone = not is_extended_multizone and features["multizone"]
update_rssi = self._update_rssi
methods: list[Callable] = [self.device.get_color]
if update_rssi:
methods.append(self.device.get_wifiinfo)
if is_extended_multizone:
methods.append(self.device.get_extended_color_zones)
elif is_legacy_multizone:
methods.extend(self._async_build_color_zones_update_requests())
if is_extended_multizone or is_legacy_multizone:
methods.append(self.device.get_multizone_effect)
if features["hev"]:
methods.append(self.device.get_hev_cycle)
if features["infrared"]:
methods.append(self.device.get_infrared)
if self.device.product is None: responses = await async_multi_execute_lifx_with_retries(
raise UpdateFailed( methods, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME
f"Failed to fetch get version from device: {self.device.ip_addr}" )
) # device.mac_addr is not the mac_address, its the serial number
if device.mac_addr == TARGET_ANY:
device.mac_addr = responses[0].target_addr
# device.mac_addr is not the mac_address, its the serial number if update_rssi:
if self.device.mac_addr == TARGET_ANY: # We always send the rssi request second
self.device.mac_addr = response.target_addr self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5))
if self._update_rssi is True: if is_extended_multizone or is_legacy_multizone:
await self.async_update_rssi() self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
if is_legacy_multizone and num_zones != len(device.color_zones):
# Update extended multizone devices # The number of zones has changed so we need
if lifx_features(self.device)["extended_multizone"]: # to update the zones again. This happens rarely.
await self.async_get_extended_color_zones() await self.async_get_color_zones()
await self.async_get_multizone_effect()
# use legacy methods for older devices
elif lifx_features(self.device)["multizone"]:
await self.async_get_color_zones()
await self.async_get_multizone_effect()
if lifx_features(self.device)["hev"]:
await self.async_get_hev_cycle()
if lifx_features(self.device)["infrared"]:
await async_execute_lifx(self.device.get_infrared)
async def async_get_color_zones(self) -> None: async def async_get_color_zones(self) -> None:
"""Get updated color information for each zone.""" """Get updated color information for each zone."""
zone = 0 await async_multi_execute_lifx_with_retries(
top = 1 self._async_build_color_zones_update_requests(),
while zone < top: DEFAULT_ATTEMPTS,
# Each get_color_zones can update 8 zones at once OVERALL_TIMEOUT,
resp = await async_execute_lifx( )
partial(self.device.get_color_zones, start_index=zone)
)
zone += 8
top = resp.count
# We only await multizone responses so don't ask for just one
if zone == top - 1:
zone -= 1
async def async_get_extended_color_zones(self) -> None: async def async_get_extended_color_zones(self) -> None:
"""Get updated color information for all zones.""" """Get updated color information for all zones."""
@ -323,11 +351,6 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
) )
) )
async def async_get_multizone_effect(self) -> None:
"""Update the device firmware effect running state."""
await async_execute_lifx(self.device.get_multizone_effect)
self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
async def async_set_multizone_effect( async def async_set_multizone_effect(
self, self,
effect: str, effect: str,
@ -415,22 +438,12 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
self._update_rssi = True self._update_rssi = True
return _async_disable_rssi_updates return _async_disable_rssi_updates
async def async_update_rssi(self) -> None:
"""Update RSSI value."""
resp = await async_execute_lifx(self.device.get_wifiinfo)
self._rssi = int(floor(10 * log10(resp.signal) + 0.5))
def async_get_hev_cycle_state(self) -> bool | None: def async_get_hev_cycle_state(self) -> bool | None:
"""Return the current HEV cycle state.""" """Return the current HEV cycle state."""
if self.device.hev_cycle is None: if self.device.hev_cycle is None:
return None return None
return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0) return bool(self.device.hev_cycle.get(ATTR_REMAINING, 0) > 0)
async def async_get_hev_cycle(self) -> None:
"""Update the HEV cycle status from a LIFX Clean bulb."""
if lifx_features(self.device)["hev"]:
await async_execute_lifx(self.device.get_hev_cycle)
async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None:
"""Start or stop an HEV cycle on a LIFX Clean bulb.""" """Start or stop an HEV cycle on a LIFX Clean bulb."""
if lifx_features(self.device)["hev"]: if lifx_features(self.device)["hev"]:

View File

@ -206,61 +206,60 @@ class LIFXLight(LIFXEntity, LightEntity):
async def set_state(self, **kwargs: Any) -> None: async def set_state(self, **kwargs: Any) -> None:
"""Set a color on the light and turn it on/off.""" """Set a color on the light and turn it on/off."""
self.coordinator.async_set_updated_data(None) self.coordinator.async_set_updated_data(None)
async with self.coordinator.lock: # Cancel any pending refreshes
# Cancel any pending refreshes bulb = self.bulb
bulb = self.bulb
await self.effects_conductor.stop([bulb]) await self.effects_conductor.stop([bulb])
if ATTR_EFFECT in kwargs: if ATTR_EFFECT in kwargs:
await self.default_effect(**kwargs) await self.default_effect(**kwargs)
return return
if ATTR_INFRARED in kwargs: if ATTR_INFRARED in kwargs:
infrared_entity_id = self.coordinator.async_get_entity_id( infrared_entity_id = self.coordinator.async_get_entity_id(
Platform.SELECT, INFRARED_BRIGHTNESS Platform.SELECT, INFRARED_BRIGHTNESS
) )
_LOGGER.warning( _LOGGER.warning(
( (
"The 'infrared' attribute of 'lifx.set_state' is deprecated:" "The 'infrared' attribute of 'lifx.set_state' is deprecated:"
" call 'select.select_option' targeting '%s' instead" " call 'select.select_option' targeting '%s' instead"
), ),
infrared_entity_id, infrared_entity_id,
) )
bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED])) bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))
if ATTR_TRANSITION in kwargs: if ATTR_TRANSITION in kwargs:
fade = int(kwargs[ATTR_TRANSITION] * 1000) fade = int(kwargs[ATTR_TRANSITION] * 1000)
else: else:
fade = 0 fade = 0
# These are both False if ATTR_POWER is not set # These are both False if ATTR_POWER is not set
power_on = kwargs.get(ATTR_POWER, False) power_on = kwargs.get(ATTR_POWER, False)
power_off = not kwargs.get(ATTR_POWER, True) power_off = not kwargs.get(ATTR_POWER, True)
hsbk = find_hsbk(self.hass, **kwargs) hsbk = find_hsbk(self.hass, **kwargs)
if not self.is_on: if not self.is_on:
if power_off: if power_off:
await self.set_power(False) await self.set_power(False)
# If fading on with color, set color immediately # If fading on with color, set color immediately
if hsbk and power_on: if hsbk and power_on:
await self.set_color(hsbk, kwargs) await self.set_color(hsbk, kwargs)
await self.set_power(True, duration=fade) await self.set_power(True, duration=fade)
elif hsbk: elif hsbk:
await self.set_color(hsbk, kwargs, duration=fade) await self.set_color(hsbk, kwargs, duration=fade)
elif power_on: elif power_on:
await self.set_power(True, duration=fade) await self.set_power(True, duration=fade)
else: else:
if power_on: if power_on:
await self.set_power(True) await self.set_power(True)
if hsbk: if hsbk:
await self.set_color(hsbk, kwargs, duration=fade) await self.set_color(hsbk, kwargs, duration=fade)
if power_off: if power_off:
await self.set_power(False, duration=fade) await self.set_power(False, duration=fade)
# Avoid state ping-pong by holding off updates as the state settles # Avoid state ping-pong by holding off updates as the state settles
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY) await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
# Update when the transition starts and ends # Update when the transition starts and ends
await self.update_during_transition(fade) await self.update_during_transition(fade)

View File

@ -4,12 +4,12 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from functools import partial
from typing import Any from typing import Any
from aiolifx import products from aiolifx import products
from aiolifx.aiolifx import Light from aiolifx.aiolifx import Light
from aiolifx.message import Message from aiolifx.message import Message
import async_timeout
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from homeassistant.components.light import ( from homeassistant.components.light import (
@ -28,7 +28,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
import homeassistant.util.color as color_util import homeassistant.util.color as color_util
from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT from .const import (
_LOGGER,
DEFAULT_ATTEMPTS,
DOMAIN,
INFRARED_BRIGHTNESS_VALUES_MAP,
OVERALL_TIMEOUT,
)
FIX_MAC_FW = AwesomeVersion("3.70") FIX_MAC_FW = AwesomeVersion("3.70")
@ -177,21 +183,61 @@ def mac_matches_serial_number(mac_addr: str, serial_number: str) -> bool:
async def async_execute_lifx(method: Callable) -> Message: async def async_execute_lifx(method: Callable) -> Message:
"""Execute a lifx coroutine and wait for a response.""" """Execute a lifx callback method and wait for a response."""
future: asyncio.Future[Message] = asyncio.Future() return (
await async_multi_execute_lifx_with_retries(
[method], DEFAULT_ATTEMPTS, OVERALL_TIMEOUT
)
)[0]
def _callback(bulb: Light, message: Message) -> None:
if not future.done(): async def async_multi_execute_lifx_with_retries(
# The future will get canceled out from under methods: list[Callable], attempts: int, overall_timeout: int
# us by async_timeout when we hit the OVERALL_TIMEOUT ) -> list[Message]:
"""Execute multiple lifx callback methods with retries and wait for a response.
This functional will the overall timeout by the number of attempts and
wait for each method to return a result. If we don't get a result
within the split timeout, we will send all methods that did not generate
a response again.
If we don't get a result after all attempts, we will raise an
asyncio.TimeoutError exception.
"""
loop = asyncio.get_running_loop()
futures: list[asyncio.Future] = [loop.create_future() for _ in methods]
def _callback(
bulb: Light, message: Message | None, future: asyncio.Future[Message]
) -> None:
if message and not future.done():
future.set_result(message) future.set_result(message)
method(callb=_callback) timeout_per_attempt = overall_timeout / attempts
result = None
async with async_timeout.timeout(OVERALL_TIMEOUT): for _ in range(attempts):
result = await future for idx, method in enumerate(methods):
future = futures[idx]
if not future.done():
method(callb=partial(_callback, future=future))
if result is None: _, pending = await asyncio.wait(futures, timeout=timeout_per_attempt)
raise asyncio.TimeoutError("No response from LIFX bulb") if not pending:
return result break
results: list[Message] = []
failed: list[str] = []
for idx, future in enumerate(futures):
if not future.done() or not (result := future.result()):
method = methods[idx]
failed.append(str(getattr(method, "__name__", method)))
else:
results.append(result)
if failed:
failed_methods = ", ".join(failed)
raise asyncio.TimeoutError(
f"{failed_methods} timed out after {attempts} attempts"
)
return results

View File

@ -3,6 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from homeassistant.components.lifx import config_flow, coordinator, util
from tests.common import mock_device_registry, mock_registry from tests.common import mock_device_registry, mock_registry
@ -34,6 +36,17 @@ def lifx_mock_get_source_ip(mock_get_source_ip):
"""Mock network util's async_get_source_ip.""" """Mock network util's async_get_source_ip."""
@pytest.fixture(autouse=True)
def lifx_no_wait_for_timeouts():
"""Avoid waiting for timeouts in tests."""
with patch.object(util, "OVERALL_TIMEOUT", 0), patch.object(
config_flow, "OVERALL_TIMEOUT", 0
), patch.object(coordinator, "OVERALL_TIMEOUT", 0), patch.object(
coordinator, "MAX_UPDATE_TIME", 0
):
yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def lifx_mock_async_get_ipv4_broadcast_addresses(): def lifx_mock_async_get_ipv4_broadcast_addresses():
"""Mock network util's async_get_ipv4_broadcast_addresses.""" """Mock network util's async_get_ipv4_broadcast_addresses."""

View File

@ -546,9 +546,11 @@ async def test_suggested_area(hass: HomeAssistant) -> None:
self.bulb = bulb self.bulb = bulb
self.lifx_group = kwargs.get("lifx_group") self.lifx_group = kwargs.get("lifx_group")
def __call__(self, *args, **kwargs): def __call__(self, callb=None, *args, **kwargs):
"""Call command.""" """Call command."""
self.bulb.group = self.lifx_group self.bulb.group = self.lifx_group
if callb:
callb(self.bulb, self.lifx_group)
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, unique_id=SERIAL

View File

@ -363,7 +363,7 @@ async def test_light_strip(hass: HomeAssistant) -> None:
) )
# set a one zone # set a one zone
assert len(bulb.set_power.calls) == 2 assert len(bulb.set_power.calls) == 2
assert len(bulb.get_color_zones.calls) == 2 assert len(bulb.get_color_zones.calls) == 1
assert len(bulb.set_color.calls) == 0 assert len(bulb.set_color.calls) == 0
call_dict = bulb.set_color_zones.calls[0][1] call_dict = bulb.set_color_zones.calls[0][1]
call_dict.pop("callb") call_dict.pop("callb")
@ -1124,7 +1124,7 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None:
entity_id = "light.my_bulb" entity_id = "light.my_bulb"
class MockFailingLifxCommand: class MockFailingLifxCommand:
"""Mock a lifx command that fails on the 3rd try.""" """Mock a lifx command that fails on the 2nd try."""
def __init__(self, bulb, **kwargs): def __init__(self, bulb, **kwargs):
"""Init command.""" """Init command."""
@ -1134,7 +1134,7 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None:
def __call__(self, callb=None, *args, **kwargs): def __call__(self, callb=None, *args, **kwargs):
"""Call command.""" """Call command."""
self.call_count += 1 self.call_count += 1
response = None if self.call_count >= 3 else MockMessage() response = None if self.call_count >= 2 else MockMessage()
if callb: if callb:
callb(self.bulb, response) callb(self.bulb, response)
@ -1152,6 +1152,50 @@ async def test_config_zoned_light_strip_fails(hass: HomeAssistant) -> None:
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
async def test_legacy_zoned_light_strip(hass: HomeAssistant) -> None:
"""Test we handle failure to update zones."""
already_migrated_config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=SERIAL
)
already_migrated_config_entry.add_to_hass(hass)
light_strip = _mocked_light_strip()
entity_id = "light.my_bulb"
class MockPopulateLifxZonesCommand:
"""Mock populating the number of zones."""
def __init__(self, bulb, **kwargs):
"""Init command."""
self.bulb = bulb
self.call_count = 0
def __call__(self, callb=None, *args, **kwargs):
"""Call command."""
self.call_count += 1
self.bulb.color_zones = [None] * 12
if callb:
callb(self.bulb, MockMessage())
get_color_zones_mock = MockPopulateLifxZonesCommand(light_strip)
light_strip.get_color_zones = get_color_zones_mock
with _patch_discovery(device=light_strip), _patch_device(device=light_strip):
await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}})
await hass.async_block_till_done()
entity_registry = er.async_get(hass)
assert entity_registry.async_get(entity_id).unique_id == SERIAL
assert hass.states.get(entity_id).state == STATE_OFF
# 1 to get the number of zones
# 2 get populate the zones
assert get_color_zones_mock.call_count == 3
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
# 2 get populate the zones
assert get_color_zones_mock.call_count == 5
async def test_white_light_fails(hass: HomeAssistant) -> None: async def test_white_light_fails(hass: HomeAssistant) -> None:
"""Test we handle failure to power on off.""" """Test we handle failure to power on off."""
already_migrated_config_entry = MockConfigEntry( already_migrated_config_entry = MockConfigEntry(