diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json index 58a7c89e266..c37d7641717 100644 --- a/homeassistant/components/lifx/icons.json +++ b/homeassistant/components/lifx/icons.json @@ -26,6 +26,9 @@ }, "effect_stop": { "service": "mdi:stop" + }, + "paint_theme": { + "service": "mdi:palette" } } } diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 27e62717e96..3d1b61c4b58 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -8,6 +8,7 @@ from datetime import timedelta from typing import Any import aiolifx_effects +from aiolifx_themes.painter import ThemePainter from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol @@ -42,6 +43,7 @@ SERVICE_EFFECT_MOVE = "effect_move" SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_SKY = "effect_sky" SERVICE_EFFECT_STOP = "effect_stop" +SERVICE_PAINT_THEME = "paint_theme" ATTR_CHANGE = "change" ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" @@ -83,6 +85,8 @@ EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180 EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] +PAINT_THEME_DEFAULT_TRANSITION = 1 + PULSE_MODE_BLINK = "blink" PULSE_MODE_BREATHE = "breathe" PULSE_MODE_PING = "ping" @@ -201,6 +205,18 @@ LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( } ) +LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_TRANSITION: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3600)), + 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] + ), + } +) SERVICES = ( SERVICE_EFFECT_COLORLOOP, @@ -210,6 +226,7 @@ SERVICES = ( SERVICE_EFFECT_PULSE, SERVICE_EFFECT_SKY, SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, ) @@ -302,6 +319,222 @@ class LIFXManager: schema=LIFX_EFFECT_STOP_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + SERVICE_PAINT_THEME, + service_handler, + schema=LIFX_PAINT_THEME_SCHEMA, + ) + + @staticmethod + def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: + """Either return the predefined theme or build one from the palette.""" + 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) + + return theme + + async def _start_effect_flame( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Flame effect.""" + + 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 + ) + ) + + async def _start_paint_theme( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Paint a theme across one or more LIFX bulbs.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + await ThemePainter(self.hass.loop).paint( + theme, + bulbs, + duration=kwargs.get(ATTR_TRANSITION, PAINT_THEME_DEFAULT_TRANSITION), + power_on=kwargs.get(ATTR_POWER_ON, True), + ) + + async def _start_effect_morph( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Morph effect.""" + theme_name = kwargs.get(ATTR_THEME, "exciting") + palette = kwargs.get(ATTR_PALETTE) + + theme = self.build_theme(theme_name, palette) + + 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 + ) + ) + + async def _start_effect_move( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Move effect.""" + await asyncio.gather( + *( + coordinator.async_set_multizone_effect( + effect=EFFECT_MOVE, + speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), + direction=kwargs.get(ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION), + theme_name=kwargs.get(ATTR_THEME), + power_on=kwargs.get(ATTR_POWER_ON, False), + ) + for coordinator in coordinators + ) + ) + + async def _start_effect_pulse( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software-based Pulse effect.""" + effect = aiolifx_effects.EffectPulse( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + cycles=kwargs.get(ATTR_CYCLES), + mode=kwargs.get(ATTR_MODE), + hsbk=find_hsbk(self.hass, **kwargs), + ) + await self.effects_conductor.start(effect, bulbs) + + async def _start_effect_colorloop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the software based Color Loop effect.""" + brightness = None + saturation_max = None + saturation_min = None + + if ATTR_BRIGHTNESS in kwargs: + brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) + elif ATTR_BRIGHTNESS_PCT in kwargs: + brightness = convert_8_to_16(round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100)) + + if ATTR_SATURATION_MAX in kwargs: + saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) + + if ATTR_SATURATION_MIN in kwargs: + saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) + + effect = aiolifx_effects.EffectColorloop( + power_on=bool(kwargs.get(ATTR_POWER_ON)), + period=kwargs.get(ATTR_PERIOD), + change=kwargs.get(ATTR_CHANGE), + spread=kwargs.get(ATTR_SPREAD), + transition=kwargs.get(ATTR_TRANSITION), + brightness=brightness, + saturation_max=saturation_max, + saturation_min=saturation_min, + ) + await self.effects_conductor.start(effect, bulbs) + + async def _start_effect_sky( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Start the firmware-based Sky effect.""" + palette = kwargs.get(ATTR_PALETTE) + if palette is not None: + theme = Theme() + for hsbk in palette: + theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) + + speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) + sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) + + cloud_saturation_min = kwargs.get( + ATTR_CLOUD_SATURATION_MIN, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, + ) + cloud_saturation_max = kwargs.get( + ATTR_CLOUD_SATURATION_MAX, + EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, + ) + + await asyncio.gather( + *( + coordinator.async_set_matrix_effect( + effect=EFFECT_SKY, + speed=speed, + sky_type=sky_type, + cloud_saturation_min=cloud_saturation_min, + cloud_saturation_max=cloud_saturation_max, + palette=theme.colors, + ) + for coordinator in coordinators + ) + ) + + async def _start_effect_stop( + self, + bulbs: list[Light], + coordinators: list[LIFXUpdateCoordinator], + **kwargs: Any, + ) -> None: + """Stop any running software or firmware effect.""" + await self.effects_conductor.stop(bulbs) + + for coordinator in coordinators: + await coordinator.async_set_matrix_effect(effect=EFFECT_OFF, power_on=False) + await coordinator.async_set_multizone_effect( + effect=EFFECT_OFF, power_on=False + ) + + _effect_dispatch = { + SERVICE_EFFECT_COLORLOOP: _start_effect_colorloop, + SERVICE_EFFECT_FLAME: _start_effect_flame, + SERVICE_EFFECT_MORPH: _start_effect_morph, + SERVICE_EFFECT_MOVE: _start_effect_move, + SERVICE_EFFECT_PULSE: _start_effect_pulse, + SERVICE_EFFECT_SKY: _start_effect_sky, + SERVICE_EFFECT_STOP: _start_effect_stop, + SERVICE_PAINT_THEME: _start_paint_theme, + } + async def start_effect( self, entity_ids: set[str], service: str, **kwargs: Any ) -> None: @@ -318,137 +551,5 @@ class LIFXManager: coordinators.append(coordinator) bulbs.append(coordinator.device) - 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) - - 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( - effect=EFFECT_MOVE, - speed=kwargs.get(ATTR_SPEED, EFFECT_MOVE_DEFAULT_SPEED), - direction=kwargs.get( - ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION - ), - theme_name=kwargs.get(ATTR_THEME), - power_on=kwargs.get(ATTR_POWER_ON, False), - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_PULSE: - effect = aiolifx_effects.EffectPulse( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - cycles=kwargs.get(ATTR_CYCLES), - mode=kwargs.get(ATTR_MODE), - hsbk=find_hsbk(self.hass, **kwargs), - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_COLORLOOP: - brightness = None - saturation_max = None - saturation_min = None - - if ATTR_BRIGHTNESS in kwargs: - brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS]) - elif ATTR_BRIGHTNESS_PCT in kwargs: - brightness = convert_8_to_16( - round(255 * kwargs[ATTR_BRIGHTNESS_PCT] / 100) - ) - - if ATTR_SATURATION_MAX in kwargs: - saturation_max = int(kwargs[ATTR_SATURATION_MAX] / 100 * 65535) - - if ATTR_SATURATION_MIN in kwargs: - saturation_min = int(kwargs[ATTR_SATURATION_MIN] / 100 * 65535) - - effect = aiolifx_effects.EffectColorloop( - power_on=kwargs.get(ATTR_POWER_ON), - period=kwargs.get(ATTR_PERIOD), - change=kwargs.get(ATTR_CHANGE), - spread=kwargs.get(ATTR_SPREAD), - transition=kwargs.get(ATTR_TRANSITION), - brightness=brightness, - saturation_max=saturation_max, - saturation_min=saturation_min, - ) - await self.effects_conductor.start(effect, bulbs) - - elif service == SERVICE_EFFECT_SKY: - palette = kwargs.get(ATTR_PALETTE) - if palette is not None: - theme = Theme() - for hsbk in palette: - theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) - - speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED) - sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE) - - cloud_saturation_min = kwargs.get( - ATTR_CLOUD_SATURATION_MIN, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN, - ) - cloud_saturation_max = kwargs.get( - ATTR_CLOUD_SATURATION_MAX, - EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX, - ) - - await asyncio.gather( - *( - coordinator.async_set_matrix_effect( - effect=EFFECT_SKY, - speed=speed, - sky_type=sky_type, - cloud_saturation_min=cloud_saturation_min, - cloud_saturation_max=cloud_saturation_max, - palette=theme.colors, - ) - for coordinator in coordinators - ) - ) - - elif service == SERVICE_EFFECT_STOP: - await self.effects_conductor.stop(bulbs) - - for coordinator in coordinators: - await coordinator.async_set_matrix_effect( - effect=EFFECT_OFF, power_on=False - ) - await coordinator.async_set_multizone_effect( - effect=EFFECT_OFF, power_on=False - ) + if start_effect_func := self._effect_dispatch.get(service): + await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index c2eb2e249cb..ac4fbfc15af 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -186,28 +186,46 @@ effect_move: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -255,28 +273,46 @@ effect_morph: options: - "autumn" - "blissful" + - "bias_lighting" + - "calaveras" - "cheerful" + - "christmas" - "dream" - "energizing" - "epic" + - "evening" - "exciting" + - "fantasy" - "focusing" + - "gentle" - "halloween" - "hanukkah" - "holly" - - "independence_day" + - "hygge" + - "independence" - "intense" + - "love" + - "kwanzaa" - "mellow" + - "party" - "peaceful" - "powerful" + - "proud" + - "pumpkin" - "relaxing" + - "romance" - "santa" - "serene" + - "shamrock" - "soothing" + - "spacey" - "sports" - "spring" + - "stardust" + - "thanksgiving" - "tranquil" - "warming" + - "zombie" power_on: default: true selector: @@ -338,3 +374,73 @@ effect_stop: entity: integration: lifx domain: light +paint_theme: + target: + entity: + integration: lifx + domain: light + fields: + palette: + example: + - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" + selector: + object: + theme: + example: exciting + default: exciting + selector: + select: + mode: dropdown + options: + - "autumn" + - "blissful" + - "bias_lighting" + - "calaveras" + - "cheerful" + - "christmas" + - "dream" + - "energizing" + - "epic" + - "evening" + - "exciting" + - "fantasy" + - "focusing" + - "gentle" + - "halloween" + - "hanukkah" + - "holly" + - "hygge" + - "independence" + - "intense" + - "love" + - "kwanzaa" + - "mellow" + - "party" + - "peaceful" + - "powerful" + - "proud" + - "pumpkin" + - "relaxing" + - "romance" + - "santa" + - "serene" + - "shamrock" + - "soothing" + - "spacey" + - "sports" + - "spring" + - "stardust" + - "thanksgiving" + - "tranquil" + - "warming" + - "zombie" + transition: + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds + power_on: + default: true + selector: + boolean: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 19d86e57f09..39102d904d5 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -209,7 +209,7 @@ }, "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." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", @@ -254,6 +254,28 @@ "effect_stop": { "name": "Stop effect", "description": "Stops a running effect." + }, + "paint_theme": { + "name": "Paint Theme", + "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "fields": { + "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-9000) values to paint across the target lights. Overrides the theme attribute." + }, + "theme": { + "name": "[%key:component::lifx::entity::select::theme::name%]", + "description": "Predefined color theme to paint. Overridden by the palette attribute." + }, + "transition": { + "name": "Transition", + "description": "Duration in seconds to paint the theme." + }, + "power_on": { + "name": "Power on", + "description": "Powered off lights will be turned on before painting the theme." + } + } } } } diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index ffe819fa2cb..58843d63f9a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -25,6 +25,7 @@ from homeassistant.components.lifx.manager import ( SERVICE_EFFECT_MORPH, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_SKY, + SERVICE_PAINT_THEME, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -1045,6 +1046,104 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: bulb.set_power.reset_mock() +@pytest.mark.usefixtures("mock_discovery") +async def test_paint_theme_service(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_bulb() + 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" + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 4, ATTR_THEME: "autumn"}, + blocking=True, + ) + + bulb.power_level = 65535 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + assert call_dict["value"] in [ + (5643, 65535, 32768, 3500), + (15109, 65535, 32768, 3500), + (8920, 65535, 32768, 3500), + (10558, 65535, 32768, 3500), + ] + assert call_dict["duration"] == 4000 + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_PAINT_THEME, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TRANSITION: 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 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_color.calls) == 1 + call_dict = bulb.set_color.calls[0][1] + call_dict.pop("callb") + hue = round(call_dict["value"][0] / 65535 * 360) + sat = round(call_dict["value"][1] / 65535 * 100) + bri = call_dict["value"][2] >> 8 + kel = call_dict["value"][3] + assert (hue, sat, bri, kel) in [ + (0, 100, 255, 3500), + (60, 100, 255, 3500), + (120, 100, 255, 3500), + (180, 100, 255, 3500), + (240, 100, 255, 3500), + (300, 100, 255, 3500), + ] + assert call_dict["duration"] == 6000 + + bulb.set_color.reset_mock() + bulb.set_power.reset_mock() + + async def test_color_light_with_temp( hass: HomeAssistant, mock_effect_conductor ) -> None: