New paint_theme service added to the LIFX integration (#135667)

* New paint_theme service added to the LIFX integration

Signed-off-by: Avi Miller <me@dje.li>
Co-authored-by: J. Nick Koston <nick@koston.org>

* Move effect selection into a dispatch table

Signed-off-by: Avi Miller <me@dje.li>

---------

Signed-off-by: Avi Miller <me@dje.li>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Avi Miller 2025-01-17 11:41:09 +11:00 committed by GitHub
parent 632c166201
commit 02ec1d1b71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 468 additions and 137 deletions

View File

@ -26,6 +26,9 @@
},
"effect_stop": {
"service": "mdi:stop"
},
"paint_theme": {
"service": "mdi:palette"
}
}
}

View File

@ -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)

View File

@ -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:

View File

@ -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."
}
}
}
}
}

View File

@ -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: