From 205ce2bac51d3e7e701b513708b5b4d2a3e5abe2 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Sun, 2 Oct 2022 15:21:48 +1100 Subject: [PATCH] Refactor LIFX multizone devices to use extended messages (#79444) --- homeassistant/components/lifx/coordinator.py | 53 ++++- homeassistant/components/lifx/light.py | 55 ++++- tests/components/lifx/__init__.py | 3 +- tests/components/lifx/test_light.py | 237 +++++++++++++++++++ 4 files changed, 333 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index a6d61d91d28..e3a66261fb2 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -12,6 +12,7 @@ from aiolifx.connection import LIFXConnection from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -148,10 +149,14 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if self.device.mac_addr == TARGET_ANY: self.device.mac_addr = response.target_addr - # Update model-specific configuration - if lifx_features(self.device)["multizone"]: - await self.async_update_color_zones() - await self.async_update_multizone_effect() + # Update extended multizone devices + if lifx_features(self.device)["extended_multizone"]: + await self.async_get_extended_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() @@ -159,7 +164,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if lifx_features(self.device)["infrared"]: response = await async_execute_lifx(self.device.get_infrared) - async def async_update_color_zones(self) -> None: + async def async_get_color_zones(self) -> None: """Get updated color information for each zone.""" zone = 0 top = 1 @@ -175,6 +180,15 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if zone == top - 1: zone -= 1 + async def async_get_extended_color_zones(self) -> None: + """Get updated color information for all zones.""" + try: + await async_execute_lifx(self.device.get_extended_color_zones) + except asyncio.TimeoutError as ex: + raise HomeAssistantError( + f"Timeout getting color zones from {self.name}" + ) from ex + def async_get_hev_cycle_state(self) -> bool | None: """Return the current HEV cycle state.""" if self.device.hev_cycle is None: @@ -232,7 +246,34 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): ) ) - async def async_update_multizone_effect(self) -> None: + async def async_set_extended_color_zones( + self, + colors: list[tuple[int | float, int | float, int | float, int | float]], + colors_count: int | None = None, + duration: int = 0, + apply: int = 1, + ) -> None: + """Send a single set extended color zones message to the device.""" + + if colors_count is None: + colors_count = len(colors) + + # pad the color list with blanks if necessary + if len(colors) < 82: + for _ in range(82 - len(colors)): + colors.append((0, 0, 0, 0)) + + await async_execute_lifx( + partial( + self.device.set_extended_color_zones, + colors=colors, + colors_count=colors_count, + duration=duration, + apply=apply, + ) + ) + + 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")] diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index aa02e42a9bf..50e4593077a 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -95,8 +95,10 @@ async def async_setup_entry( LIFX_SET_HEV_CYCLE_STATE_SCHEMA, "set_hev_cycle_state", ) - if lifx_features(device)["multizone"]: - entity: LIFXLight = LIFXStrip(coordinator, manager, entry) + if lifx_features(device)["extended_multizone"]: + entity: LIFXLight = LIFXExtendedMultiZone(coordinator, manager, entry) + elif lifx_features(device)["multizone"]: + entity = LIFXMultiZone(coordinator, manager, entry) elif lifx_features(device)["color"]: entity = LIFXColor(coordinator, manager, entry) else: @@ -362,8 +364,8 @@ class LIFXColor(LIFXLight): return (hue, sat) if sat else None -class LIFXStrip(LIFXColor): - """Representation of a LIFX light strip with multiple zones.""" +class LIFXMultiZone(LIFXColor): + """Representation of a legacy LIFX multizone device.""" _attr_effect_list = [ SERVICE_EFFECT_COLORLOOP, @@ -426,16 +428,53 @@ class LIFXStrip(LIFXColor): ) from ex # set_color_zones does not update the - # state of the bulb, so we need to do that + # state of the device, so we need to do that await self.get_color() async def update_color_zones( self, ) -> None: - """Send a get color zones message to the bulb.""" + """Send a get color zones message to the device.""" try: - await self.coordinator.async_update_color_zones() + await self.coordinator.async_get_color_zones() except asyncio.TimeoutError as ex: raise HomeAssistantError( - f"Timeout setting updating color zones for {self.name}" + f"Timeout getting color zones from {self.name}" ) from ex + + +class LIFXExtendedMultiZone(LIFXMultiZone): + """Representation of a LIFX device that supports extended multizone messages.""" + + async def set_color( + self, hsbk: list[float | int | None], kwargs: dict[str, Any], duration: int = 0 + ) -> None: + """Set colors on all zones of the device.""" + + # trigger an update of all zone values before merging new values + await self.coordinator.async_get_extended_color_zones() + + color_zones = self.bulb.color_zones + if (zones := kwargs.get(ATTR_ZONES)) is None: + # merge the incoming hsbk across all zones + for index, zone in enumerate(color_zones): + color_zones[index] = merge_hsbk(zone, hsbk) + else: + # merge the incoming HSBK with only the specified zones + for index, zone in enumerate(color_zones): + if index in zones: + color_zones[index] = merge_hsbk(zone, hsbk) + + # send the updated color zones list to the device + try: + await self.coordinator.async_set_extended_color_zones( + color_zones, duration=duration + ) + except asyncio.TimeoutError as ex: + raise HomeAssistantError( + f"Timeout setting color zones on {self.name}" + ) from ex + + # set_extended_color_zones does not update the + # state of the device, so we need to do that + await self.get_color() diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 72a355877e1..acfe8f69b02 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -151,7 +151,8 @@ def _mocked_light_strip() -> Light: bulb.set_color_zones = MockLifxCommand(bulb) bulb.get_multizone_effect = MockLifxCommand(bulb) bulb.set_multizone_effect = MockLifxCommand(bulb) - + bulb.get_extended_color_zones = MockLifxCommand(bulb) + bulb.set_extended_color_zones = MockLifxCommand(bulb) return bulb diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index e7c18989767..c2f846b0a76 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -412,6 +412,243 @@ async def test_light_strip(hass: HomeAssistant) -> None: ) +async def test_extended_multizone_messages(hass: HomeAssistant) -> None: + """Test a light strip that supports extended multizone.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 # LIFX Beam + bulb.power_level = 65535 + bulb.color = [65535, 65535, 65535, 3500] + bulb.color_zones = [(65535, 65535, 65535, 3500)] * 8 + bulb.zones_count = 8 + with _patch_discovery(device=bulb), _patch_config_flow_try_connect( + device=bulb + ), _patch_device(device=bulb): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == "on" + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert attributes[ATTR_HS_COLOR] == (360.0, 100.0) + assert attributes[ATTR_RGB_COLOR] == (255, 0, 0) + assert attributes[ATTR_XY_COLOR] == (0.701, 0.299) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is False + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert bulb.set_power.calls[0][0][0] is True + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 10, 30)}, + blocking=True, + ) + # always use a set_extended_color_zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_XY_COLOR: (0.3, 0.7)}, + blocking=True, + ) + # Single color uses the fast path + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.color_zones = [ + (0, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ] + + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + # always use set_extended_color_zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [0, 2], + }, + blocking=True, + ) + # set a two zones + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_color.reset_mock() + bulb.set_color_zones.reset_mock() + bulb.set_extended_color_zones.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (255, 255, 255), ATTR_ZONES: [3]}, + blocking=True, + ) + # set a one zone + assert len(bulb.set_power.calls) == 2 + assert len(bulb.get_color_zones.calls) == 0 + assert len(bulb.set_color.calls) == 0 + assert len(bulb.set_color_zones.calls) == 0 + + bulb.get_color_zones.reset_mock() + bulb.set_power.reset_mock() + bulb.set_color_zones.reset_mock() + + bulb.set_extended_color_zones = MockFailingLifxCommand(bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [3], + }, + blocking=True, + ) + + bulb.set_extended_color_zones = MockLifxCommand(bulb) + bulb.get_extended_color_zones = MockFailingLifxCommand(bulb) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_state", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGB_COLOR: (255, 255, 255), + ATTR_ZONES: [3], + }, + blocking=True, + ) + + async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: """Test the firmware move effect on a light strip.""" config_entry = MockConfigEntry(