From 257ae4d8d342b81c4146a654d19ba3dd9f422221 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Tue, 11 Oct 2022 06:01:31 +1100 Subject: [PATCH] Add support for the Flame and Morph effects for Tile and Candle (#80014) --- homeassistant/components/lifx/coordinator.py | 38 ++++- homeassistant/components/lifx/light.py | 20 ++- homeassistant/components/lifx/manager.py | 109 +++++++++++++- homeassistant/components/lifx/manifest.json | 6 +- homeassistant/components/lifx/services.yaml | 86 ++++++++++- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/lifx/__init__.py | 9 ++ tests/components/lifx/test_light.py | 146 ++++++++++++++++++- 9 files changed, 405 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index e3a66261fb2..8e9eed34ab3 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -7,7 +7,12 @@ from enum import IntEnum from functools import partial from typing import Any, cast -from aiolifx.aiolifx import Light, MultiZoneDirection, MultiZoneEffectType +from aiolifx.aiolifx import ( + Light, + MultiZoneDirection, + MultiZoneEffectType, + TileEffectType, +) from aiolifx.connection import LIFXConnection from homeassistant.const import Platform @@ -279,7 +284,11 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] async def async_set_multizone_effect( - self, effect: str, speed: float, direction: str, power_on: bool = True + self, + effect: str, + speed: float = 3, + direction: str = "RIGHT", + power_on: bool = True, ) -> None: """Control the firmware-based Move effect on a multizone device.""" if lifx_features(self.device)["multizone"] is True: @@ -296,6 +305,31 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): ) self.active_effect = FirmwareEffect[effect.upper()] + async def async_set_matrix_effect( + self, + effect: str, + palette: list[tuple[int, int, int, int]] | None = None, + speed: float = 3, + power_on: bool = True, + ) -> None: + """Control the firmware-based effects on a matrix device.""" + if lifx_features(self.device)["matrix"] is True: + if power_on and self.device.power_level == 0: + await self.async_set_power(True, 0) + + if palette is None: + palette = [] + + await async_execute_lifx( + partial( + self.device.set_tile_effect, + effect=TileEffectType[effect.upper()].value, + speed=speed, + palette=palette, + ) + ) + self.active_effect = FirmwareEffect[effect.upper()] + def async_get_active_effect(self) -> int: """Return the enum value of the currently active firmware effect.""" return self.active_effect.value diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index b8128df100e..3b9b83cd1fc 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -40,6 +40,8 @@ from .coordinator import FirmwareEffect, LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import ( SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP, @@ -93,8 +95,10 @@ async def async_setup_entry( LIFX_SET_HEV_CYCLE_STATE_SCHEMA, "set_hev_cycle_state", ) - if lifx_features(device)["extended_multizone"]: - entity: LIFXLight = LIFXExtendedMultiZone(coordinator, manager, entry) + if lifx_features(device)["matrix"]: + entity: LIFXLight = LIFXMatrix(coordinator, manager, entry) + elif lifx_features(device)["extended_multizone"]: + entity = LIFXExtendedMultiZone(coordinator, manager, entry) elif lifx_features(device)["multizone"]: entity = LIFXMultiZone(coordinator, manager, entry) elif lifx_features(device)["color"]: @@ -471,3 +475,15 @@ class LIFXExtendedMultiZone(LIFXMultiZone): # set_extended_color_zones does not update the # state of the device, so we need to do that await self.get_color() + + +class LIFXMatrix(LIFXColor): + """Representation of a LIFX matrix device.""" + + _attr_effect_list = [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_STOP, + ] diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 2b4536656d8..d6ae45c1edc 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any import aiolifx_effects +from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol from homeassistant.components.light import ( @@ -34,9 +35,11 @@ from .util import convert_8_to_16, find_hsbk SCAN_INTERVAL = timedelta(seconds=10) -SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_COLORLOOP = "effect_colorloop" +SERVICE_EFFECT_FLAME = "effect_flame" +SERVICE_EFFECT_MORPH = "effect_morph" SERVICE_EFFECT_MOVE = "effect_move" +SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_STOP = "effect_stop" ATTR_POWER_OFF = "power_off" @@ -47,11 +50,20 @@ ATTR_SPREAD = "spread" ATTR_CHANGE = "change" ATTR_DIRECTION = "direction" ATTR_SPEED = "speed" +ATTR_PALETTE = "palette" +ATTR_THEME = "theme" +EFFECT_FLAME = "FLAME" +EFFECT_MORPH = "MORPH" EFFECT_MOVE = "MOVE" EFFECT_OFF = "OFF" -EFFECT_MOVE_DEFAULT_SPEED = 3.0 +EFFECT_FLAME_DEFAULT_SPEED = 3 + +EFFECT_MORPH_DEFAULT_SPEED = 3 +EFFECT_MORPH_DEFAULT_THEME = "exciting" + +EFFECT_MOVE_DEFAULT_SPEED = 3 EFFECT_MOVE_DEFAULT_DIRECTION = "right" EFFECT_MOVE_DIRECTION_RIGHT = "right" EFFECT_MOVE_DIRECTION_LEFT = "left" @@ -128,6 +140,37 @@ SERVICES = ( SERVICE_EFFECT_COLORLOOP, ) +LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)), + } +) + +HSBK_SCHEMA = vol.All( + vol.Coerce(tuple), + vol.ExactSequence( + ( + vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), + vol.All(vol.Coerce(float), vol.Clamp(min=0, max=100)), + vol.All(vol.Coerce(int), vol.Clamp(min=1500, max=9000)), + ) + ), +) + +LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)), + vol.Exclusive(ATTR_THEME, COLOR_GROUP): vol.Optional( + vol.In(ThemeLibrary().themes) + ), + vol.Exclusive(ATTR_PALETTE, COLOR_GROUP): vol.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( { @@ -192,6 +235,20 @@ class LIFXManager: schema=LIFX_EFFECT_COLORLOOP_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + SERVICE_EFFECT_FLAME, + service_handler, + schema=LIFX_EFFECT_FLAME_SCHEMA, + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_EFFECT_MORPH, + service_handler, + schema=LIFX_EFFECT_MORPH_SCHEMA, + ) + self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_MOVE, @@ -222,7 +279,43 @@ class LIFXManager: coordinators.append(coordinator) bulbs.append(coordinator.device) - if service == SERVICE_EFFECT_MOVE: + if service == SERVICE_EFFECT_FLAME: + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_FLAME, + speed=kwargs.get(ATTR_SPEED, EFFECT_FLAME_DEFAULT_SPEED), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + elif service == SERVICE_EFFECT_MORPH: + + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE, None) + + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + else: + theme = ThemeLibrary().get_theme(theme_name) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_MORPH, + speed=kwargs.get(ATTR_SPEED, EFFECT_MORPH_DEFAULT_SPEED), + palette=theme.colors, + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + for coordinator in coordinators + ) + ) + + elif service == SERVICE_EFFECT_MOVE: await asyncio.gather( *( coordinator.async_set_multizone_effect( @@ -269,9 +362,9 @@ class LIFXManager: await self.effects_conductor.stop(bulbs) for coordinator in coordinators: - await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, - speed=EFFECT_MOVE_DEFAULT_SPEED, - direction=EFFECT_MOVE_DEFAULT_DIRECTION, - power_on=False, + await coordinator.async_set_matrix_effect( + effect=EFFECT_OFF, power_on=False + ) + await coordinator.async_set_multizone_effect( + effect=EFFECT_OFF, power_on=False ) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 95718b3ee83..730eceb2afa 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,11 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.8.6", "aiolifx_effects==0.2.2"], + "requirements": [ + "aiolifx==0.8.6", + "aiolifx_effects==0.2.2", + "aiolifx_themes==0.1.1" + ], "quality_scale": "platinum", "dependencies": ["network"], "homekit": { diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index fc2e522dcd4..ced5bacf513 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -205,7 +205,91 @@ effect_move: default: true selector: boolean: - +effect_flame: + name: Flame effect + description: Start the firmware-based Flame effect on LIFX Tiles or Candle. + target: + entity: + integration: lifx + domain: light + fields: + speed: + name: Speed + description: How fast the flames will move. + default: 3 + selector: + number: + min: 1 + max: 25 + step: 1 + unit_of_measurement: seconds + power_on: + name: Power on + description: Powered off lights will be turned on before starting the effect. + default: true + selector: + boolean: +effect_morph: + name: Morph effect + description: Start the firmware-based Morph effect on LIFX Tiles on Candle. + target: + entity: + integration: lifx + domain: light + fields: + speed: + name: Speed + description: How fast the colors will move. + default: 3 + selector: + number: + min: 1 + max: 25 + step: 1 + unit_of_measurement: seconds + palette: + name: Palette + description: List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute. + example: + - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" + selector: + object: + theme: + name: Theme + description: Predefined color theme to use for the effect. Overridden by the palette attribute. + selector: + select: + options: + - "autumn" + - "blissful" + - "cheerful" + - "dream" + - "energizing" + - "epic" + - "exciting" + - "focusing" + - "halloween" + - "hanukkah" + - "holly" + - "independence day" + - "intense" + - "mellow" + - "peaceful" + - "powerful" + - "relaxing" + - "santa" + - "serene" + - "soothing" + - "sports" + - "spring" + - "tranquil" + - "warming" + power_on: + name: Power on + description: Powered off lights will be turned on before starting the effect. + default: true + selector: + boolean: effect_stop: name: Stop effect description: Stop a running effect. diff --git a/requirements_all.txt b/requirements_all.txt index c81a14c8914..278192e78b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -198,6 +198,9 @@ aiolifx==0.8.6 # homeassistant.components.lifx aiolifx_effects==0.2.2 +# homeassistant.components.lifx +aiolifx_themes==0.1.1 + # homeassistant.components.lookin aiolookin==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd0b48b3d52..8b9df29d143 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -176,6 +176,9 @@ aiolifx==0.8.6 # homeassistant.components.lifx aiolifx_effects==0.2.2 +# homeassistant.components.lifx +aiolifx_themes==0.1.1 + # homeassistant.components.lookin aiolookin==0.1.1 diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index acfe8f69b02..df3c41ccaca 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -156,6 +156,15 @@ def _mocked_light_strip() -> Light: return bulb +def _mocked_tile() -> Light: + bulb = _mocked_bulb() + bulb.product = 55 # LIFX Tile + bulb.effect = {"effect": "OFF"} + bulb.get_tile_effect = MockLifxCommand(bulb) + bulb.set_tile_effect = MockLifxCommand(bulb) + return bulb + + def _mocked_bulb_new_firmware() -> Light: bulb = _mocked_bulb() bulb.host_firmware_version = "3.90" diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 1c424f354e3..cba5ba4636c 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -12,8 +12,11 @@ from homeassistant.components.lifx.const import ATTR_POWER from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.lifx.manager import ( ATTR_DIRECTION, + ATTR_PALETTE, ATTR_SPEED, + ATTR_THEME, SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, ) from homeassistant.components.light import ( @@ -55,6 +58,7 @@ from . import ( _mocked_bulb_new_firmware, _mocked_clean_bulb, _mocked_light_strip, + _mocked_tile, _mocked_white_bulb, _patch_config_flow_try_connect, _patch_device, @@ -650,6 +654,146 @@ async def test_extended_multizone_messages(hass: HomeAssistant) -> None: ) +async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None: + """Test the firmware flame and morph effects on a matrix device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_tile() + bulb.power_level = 0 + bulb.color = [65535, 65535, 65535, 65535] + 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" + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_flame"}, + blocking=True, + ) + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 3, + "speed": 3, + "palette": [], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MORPH, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = { + "effect": "MORPH", + "speed": 4.0, + "palette": [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ], + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 2, + "speed": 4, + "palette": [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MORPH, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SPEED: 6, + ATTR_PALETTE: [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ], + }, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = { + "effect": "MORPH", + "speed": 6, + "palette": [ + (0, 65535, 65535, 3500), + (10922, 65535, 65535, 3500), + (21845, 65535, 65535, 3500), + (32768, 65535, 65535, 3500), + (43690, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + ], + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_tile_effect.calls) == 1 + call_dict = bulb.set_tile_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 2, + "speed": 6, + "palette": [ + (0, 65535, 65535, 3500), + (10922, 65535, 65535, 3500), + (21845, 65535, 65535, 3500), + (32768, 65535, 65535, 3500), + (43690, 65535, 65535, 3500), + (54613, 65535, 65535, 3500), + ], + } + bulb.get_tile_effect.reset_mock() + bulb.set_tile_effect.reset_mock() + bulb.set_power.reset_mock() + + async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: """Test the firmware move effect on a light strip.""" config_entry = MockConfigEntry( @@ -697,7 +841,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: ) bulb.power_level = 65535 - bulb.effect = {"name": "effect_move", "enable": 1} + bulb.effect = {"name": "MOVE", "speed": 4.5, "direction": "Left"} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done()