From 2966f9ed8e13810302365d3a86a57651ce5d1b86 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 24 Oct 2022 03:28:17 +1100 Subject: [PATCH] Add themes for LIFX multi-zone devices via a new select entity (#80067) --- homeassistant/components/lifx/const.py | 2 + homeassistant/components/lifx/coordinator.py | 17 +++++- homeassistant/components/lifx/manager.py | 5 +- homeassistant/components/lifx/select.py | 58 +++++++++++++++++++- homeassistant/components/lifx/services.yaml | 37 ++++++++++++- homeassistant/components/lifx/util.py | 4 +- tests/components/lifx/test_light.py | 10 +++- tests/components/lifx/test_select.py | 38 +++++++++++++ 8 files changed, 162 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 8acfa35802e..a81cd7d59be 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -36,6 +36,8 @@ ATTR_POWER = "power" ATTR_REMAINING = "remaining" ATTR_ZONES = "zones" +ATTR_THEME = "theme" + HEV_CYCLE_STATE = "hev_cycle_state" INFRARED_BRIGHTNESS = "infrared_brightness" INFRARED_BRIGHTNESS_VALUES_MAP = { diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 8e9eed34ab3..b89fefb35fd 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -14,6 +14,7 @@ from aiolifx.aiolifx import ( TileEffectType, ) from aiolifx.connection import LIFXConnection +from aiolifx_themes.themes import ThemeLibrary, ThemePainter from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -69,6 +70,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.lock = asyncio.Lock() self.active_effect = FirmwareEffect.OFF update_interval = timedelta(seconds=10) + self.last_used_theme: str = "" super().__init__( hass, @@ -286,8 +288,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): async def async_set_multizone_effect( self, effect: str, - speed: float = 3, + speed: float = 3.0, direction: str = "RIGHT", + theme_name: str | None = None, power_on: bool = True, ) -> None: """Control the firmware-based Move effect on a multizone device.""" @@ -295,6 +298,12 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): if power_on and self.device.power_level == 0: await self.async_set_power(True, 0) + if theme_name is not None: + theme = ThemeLibrary().get_theme(theme_name) + await ThemePainter(self.hass.loop).paint( + theme, [self.device], round(speed) + ) + await async_execute_lifx( partial( self.device.set_multizone_effect, @@ -345,3 +354,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): """Set infrared brightness.""" infrared_brightness = infrared_brightness_option_to_value(option) await async_execute_lifx(partial(self.device.set_infrared, infrared_brightness)) + + async def async_apply_theme(self, theme_name: str) -> None: + """Apply the selected theme to the device.""" + self.last_used_theme = theme_name + theme = ThemeLibrary().get_theme(theme_name) + await ThemePainter(self.hass.loop).paint(theme, [self.device]) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index d6ae45c1edc..f91ed761e44 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids -from .const import DATA_LIFX_MANAGER, DOMAIN +from .const import ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN from .coordinator import LIFXUpdateCoordinator, Light from .util import convert_8_to_16, find_hsbk @@ -51,7 +51,6 @@ ATTR_CHANGE = "change" ATTR_DIRECTION = "direction" ATTR_SPEED = "speed" ATTR_PALETTE = "palette" -ATTR_THEME = "theme" EFFECT_FLAME = "FLAME" EFFECT_MORPH = "MORPH" @@ -177,6 +176,7 @@ LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( **LIFX_EFFECT_SCHEMA, ATTR_SPEED: vol.All(vol.Coerce(float), vol.Clamp(min=0.1, max=60)), ATTR_DIRECTION: vol.In(EFFECT_MOVE_DIRECTIONS), + ATTR_THEME: vol.Optional(vol.In(ThemeLibrary().themes)), } ) @@ -324,6 +324,7 @@ class LIFXManager: direction=kwargs.get( ATTR_DIRECTION, EFFECT_MOVE_DEFAULT_DIRECTION ), + theme_name=kwargs.get(ATTR_THEME, None), power_on=kwargs.get(ATTR_POWER_ON, False), ) for coordinator in coordinators diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index a89159968b1..2abbde9aed2 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -1,17 +1,26 @@ """Select sensor entities for LIFX integration.""" from __future__ import annotations +from aiolifx_themes.themes import ThemeLibrary + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP +from .const import ( + ATTR_THEME, + DOMAIN, + INFRARED_BRIGHTNESS, + INFRARED_BRIGHTNESS_VALUES_MAP, +) from .coordinator import LIFXUpdateCoordinator from .entity import LIFXEntity from .util import lifx_features +THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] + INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription( key=INFRARED_BRIGHTNESS, name="Infrared brightness", @@ -19,6 +28,13 @@ INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription( options=list(INFRARED_BRIGHTNESS_VALUES_MAP.values()), ) +THEME_ENTITY = SelectEntityDescription( + key=ATTR_THEME, + name="Theme", + entity_category=EntityCategory.CONFIG, + options=THEME_NAMES, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -30,11 +46,16 @@ async def async_setup_entry( async_add_entities( [ LIFXInfraredBrightnessSelectEntity( - coordinator, description=INFRARED_BRIGHTNESS_ENTITY + coordinator=coordinator, description=INFRARED_BRIGHTNESS_ENTITY ) ] ) + if lifx_features(coordinator.device)["multizone"] is True: + async_add_entities( + [LIFXThemeSelectEntity(coordinator=coordinator, description=THEME_ENTITY)] + ) + class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" @@ -65,3 +86,36 @@ class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Update the infrared brightness value.""" await self.coordinator.async_set_infrared_brightness(option) + + +class LIFXThemeSelectEntity(LIFXEntity, SelectEntity): + """Theme entity for LIFX multizone devices.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, coordinator: LIFXUpdateCoordinator, description: SelectEntityDescription + ) -> None: + """Initialise the theme selection entity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_name = description.name + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + self._attr_current_option = None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from coordinator data.""" + self._attr_current_option = self.coordinator.last_used_theme + + async def async_select_option(self, option: str) -> None: + """Paint the selected theme onto the device.""" + await self.coordinator.async_apply_theme(option.lower()) diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index ced5bacf513..976d4ff5623 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -183,6 +183,7 @@ effect_move: name: Speed description: How long in seconds for the effect to move across the length of the light. default: 3.0 + example: 3.0 selector: number: min: 0.1 @@ -193,12 +194,46 @@ effect_move: name: Direction description: Direction the effect will move across the device. default: right + example: right selector: select: mode: dropdown options: - right - left + theme: + name: Theme + description: (Optional) set one of the predefined themes onto the device before starting the effect. + example: exciting + default: exciting + selector: + select: + mode: dropdown + 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. @@ -271,7 +306,7 @@ effect_morph: - "halloween" - "hanukkah" - "holly" - - "independence day" + - "independence_day" - "intense" - "mellow" - "peaceful" diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 4e811e6c366..46a087296f2 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr import homeassistant.util.color as color_util -from .const import _LOGGER, DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT +from .const import DOMAIN, INFRARED_BRIGHTNESS_VALUES_MAP, OVERALL_TIMEOUT FIX_MAC_FW = AwesomeVersion("3.70") @@ -154,7 +154,7 @@ async def async_execute_lifx(method: Callable) -> Message: # us by async_timeout when we hit the OVERALL_TIMEOUT future.set_result(message) - _LOGGER.debug("Sending LIFX command: %s", method) + # _LOGGER.debug("Sending LIFX command: %s", method) method(callb=_callback) result = None diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index cba5ba4636c..6fe63b14b6a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -801,6 +801,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() + bulb.product = 38 bulb.power_level = 0 bulb.color = [65535, 65535, 65535, 65535] with _patch_discovery(device=bulb), _patch_config_flow_try_connect( @@ -828,6 +829,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: "speed": 3.0, "direction": 0, } + bulb.get_multizone_effect.reset_mock() bulb.set_multizone_effect.reset_mock() bulb.set_power.reset_mock() @@ -836,7 +838,12 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_EFFECT_MOVE, - {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4.5, ATTR_DIRECTION: "left"}, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SPEED: 4.5, + ATTR_DIRECTION: "left", + ATTR_THEME: "sports", + }, blocking=True, ) @@ -849,6 +856,7 @@ async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_extended_color_zones.calls) == 1 assert len(bulb.set_multizone_effect.calls) == 1 call_dict = bulb.set_multizone_effect.calls[0][1] call_dict.pop("callb") diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index bc2d6f0fc1e..d190cbe6b10 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -17,6 +17,7 @@ from . import ( SERIAL, MockLifxCommand, _mocked_infrared_bulb, + _mocked_light_strip, _patch_config_flow_try_connect, _patch_device, _patch_discovery, @@ -25,6 +26,43 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +async def test_theme_select(hass: HomeAssistant) -> None: + """Test selecting a theme.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_light_strip() + bulb.product = 38 + bulb.power_level = 0 + bulb.color = [0, 0, 65535, 3500] + 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 = "select.my_bulb_theme" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert not entity.disabled + + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: entity_id, "option": "intense"}, + blocking=True, + ) + + assert len(bulb.set_extended_color_zones.calls) == 1 + bulb.set_extended_color_zones.reset_mock() + + async def test_infrared_brightness(hass: HomeAssistant) -> None: """Test getting and setting infrared brightness."""