diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 7d3a51562d1..a6d61d91d28 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio from datetime import timedelta +from enum import IntEnum from functools import partial from typing import Any, cast -from aiolifx.aiolifx import Light +from aiolifx.aiolifx import Light, MultiZoneDirection, MultiZoneEffectType from aiolifx.connection import LIFXConnection from homeassistant.const import Platform @@ -37,6 +38,15 @@ REQUEST_REFRESH_DELAY = 0.35 LIFX_IDENTIFY_DELAY = 3.0 +class FirmwareEffect(IntEnum): + """Enumeration of LIFX firmware effects.""" + + OFF = 0 + MOVE = 1 + MORPH = 2 + FLAME = 3 + + class LIFXUpdateCoordinator(DataUpdateCoordinator): """DataUpdateCoordinator to gather data for a specific lifx device.""" @@ -51,7 +61,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): self.connection = connection self.device: Light = connection.device self.lock = asyncio.Lock() + self.active_effect = FirmwareEffect.OFF update_interval = timedelta(seconds=10) + super().__init__( hass, _LOGGER, @@ -139,6 +151,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): # Update model-specific configuration if lifx_features(self.device)["multizone"]: await self.async_update_color_zones() + await self.async_update_multizone_effect() if lifx_features(self.device)["hev"]: await self.async_get_hev_cycle() @@ -219,6 +232,33 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator): ) ) + async def async_update_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")] + + async def async_set_multizone_effect( + self, effect: str, speed: float, direction: str, power_on: bool = True + ) -> None: + """Control the firmware-based Move effect on a multizone device.""" + if lifx_features(self.device)["multizone"] is True: + if power_on and self.device.power_level == 0: + await self.async_set_power(True, 0) + + await async_execute_lifx( + partial( + self.device.set_multizone_effect, + effect=MultiZoneEffectType[effect.upper()].value, + speed=speed, + direction=MultiZoneDirection[direction.upper()].value, + ) + ) + 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 + async def async_set_hev_cycle_state(self, enable: bool, duration: int = 0) -> None: """Start or stop an HEV cycle on a LIFX Clean bulb.""" if lifx_features(self.device)["hev"]: diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 314f7bd915e..aa02e42a9bf 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -38,10 +38,11 @@ from .const import ( DOMAIN, INFRARED_BRIGHTNESS, ) -from .coordinator import LIFXUpdateCoordinator +from .coordinator import FirmwareEffect, LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import ( SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_MOVE, SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP, LIFXManager, @@ -139,6 +140,7 @@ class LIFXLight(LIFXEntity, LightEntity): color_mode = ColorMode.BRIGHTNESS self._attr_color_mode = color_mode self._attr_supported_color_modes = {color_mode} + self._attr_effect = None @property def brightness(self) -> int: @@ -163,6 +165,8 @@ class LIFXLight(LIFXEntity, LightEntity): """Return the name of the currently running effect.""" if effect := self.effects_conductor.effect(self.bulb): return f"effect_{effect.name}" + if effect := self.coordinator.async_get_active_effect(): + return f"effect_{FirmwareEffect(effect).name.lower()}" return None async def update_during_transition(self, when: int) -> None: @@ -361,6 +365,13 @@ class LIFXColor(LIFXLight): class LIFXStrip(LIFXColor): """Representation of a LIFX light strip with multiple zones.""" + _attr_effect_list = [ + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_STOP, + ] + async def set_color( self, hsbk: list[float | int | None], diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 28693ae6a60..c199ee8a9a1 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -1,6 +1,7 @@ """Support for LIFX lights.""" from __future__ import annotations +import asyncio from collections.abc import Callable from datetime import timedelta from typing import Any @@ -28,21 +29,35 @@ 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 _LOGGER, DATA_LIFX_MANAGER, DOMAIN +from .const import DATA_LIFX_MANAGER, DOMAIN +from .coordinator import LIFXUpdateCoordinator, Light 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_MOVE = "effect_move" SERVICE_EFFECT_STOP = "effect_stop" +ATTR_POWER_OFF = "power_off" ATTR_POWER_ON = "power_on" ATTR_PERIOD = "period" ATTR_CYCLES = "cycles" ATTR_SPREAD = "spread" ATTR_CHANGE = "change" +ATTR_DIRECTION = "direction" +ATTR_SPEED = "speed" + +EFFECT_MOVE = "MOVE" +EFFECT_OFF = "OFF" + +EFFECT_MOVE_DEFAULT_SPEED = 3.0 +EFFECT_MOVE_DEFAULT_DIRECTION = "right" +EFFECT_MOVE_DIRECTION_RIGHT = "right" +EFFECT_MOVE_DIRECTION_LEFT = "left" + +EFFECT_MOVE_DIRECTIONS = [EFFECT_MOVE_DIRECTION_LEFT, EFFECT_MOVE_DIRECTION_RIGHT] PULSE_MODE_BLINK = "blink" PULSE_MODE_BREATHE = "breathe" @@ -110,10 +125,20 @@ LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({}) SERVICES = ( SERVICE_EFFECT_STOP, SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_MOVE, SERVICE_EFFECT_COLORLOOP, ) +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), + } +) + + class LIFXManager: """Representation of all known LIFX entities.""" @@ -168,6 +193,13 @@ class LIFXManager: schema=LIFX_EFFECT_COLORLOOP_SCHEMA, ) + self.hass.services.async_register( + DOMAIN, + SERVICE_EFFECT_MOVE, + service_handler, + schema=LIFX_EFFECT_MOVE_SCHEMA, + ) + self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_STOP, @@ -179,15 +211,35 @@ class LIFXManager: self, entity_ids: set[str], service: str, **kwargs: Any ) -> None: """Start a light effect on entities.""" - bulbs = [ - coordinator.device - for entry_id, coordinator in self.hass.data[DOMAIN].items() - if entry_id != DATA_LIFX_MANAGER - and self.entry_id_to_entity_id[entry_id] in entity_ids - ] - _LOGGER.debug("Starting effect %s on %s", service, bulbs) - if service == SERVICE_EFFECT_PULSE: + coordinators: list[LIFXUpdateCoordinator] = [] + bulbs: list[Light] = [] + + for entry_id, coordinator in self.hass.data[DOMAIN].items(): + if ( + entry_id != DATA_LIFX_MANAGER + and self.entry_id_to_entity_id[entry_id] in entity_ids + ): + coordinators.append(coordinator) + bulbs.append(coordinator.device) + + if 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 + ), + 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), @@ -196,6 +248,7 @@ class LIFXManager: hsbk=find_hsbk(self.hass, **kwargs), ) await self.effects_conductor.start(effect, bulbs) + elif service == SERVICE_EFFECT_COLORLOOP: preprocess_turn_on_alternatives(self.hass, kwargs) @@ -212,5 +265,15 @@ class LIFXManager: brightness=brightness, ) await self.effects_conductor.start(effect, bulbs) + elif service == SERVICE_EFFECT_STOP: + 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, + ) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index bbc2e1bea15..45321f22b66 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.8.4", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.8.5", "aiolifx_effects==0.2.2"], "quality_scale": "platinum", "dependencies": ["network"], "homekit": { diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index 5208be89638..fc2e522dcd4 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -171,6 +171,40 @@ effect_colorloop: default: true selector: boolean: +effect_move: + name: Move effect + description: Start the firmware-based Move effect on a LIFX Z, Lightstrip or Beam. + target: + entity: + integration: lifx + domain: light + fields: + speed: + name: Speed + description: How long in seconds for the effect to move across the length of the light. + default: 3.0 + selector: + number: + min: 0.1 + max: 60 + step: 0.1 + unit_of_measurement: seconds + direction: + name: Direction + description: Direction the effect will move across the device. + default: right + selector: + select: + mode: dropdown + options: + - right + - left + 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 diff --git a/requirements_all.txt b/requirements_all.txt index 2f811e4d286..ea34b58493f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aiokafka==0.7.2 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.8.4 +aiolifx==0.8.5 # homeassistant.components.lifx aiolifx_effects==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b5586e63d9..dd9568217d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -171,7 +171,7 @@ aiohue==4.5.0 aiokafka==0.7.2 # homeassistant.components.lifx -aiolifx==0.8.4 +aiolifx==0.8.5 # homeassistant.components.lifx aiolifx_effects==0.2.2 diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 8f6e19188b6..72a355877e1 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -145,9 +145,13 @@ def _mocked_infrared_bulb() -> Light: def _mocked_light_strip() -> Light: bulb = _mocked_bulb() bulb.product = 31 # LIFX Z + bulb.color_zones = [MagicMock(), MagicMock()] + bulb.effect = {"effect": "MOVE", "speed": 3, "duration": 0, "direction": "RIGHT"} bulb.get_color_zones = MockLifxCommand(bulb) bulb.set_color_zones = MockLifxCommand(bulb) - bulb.color_zones = [MagicMock(), MagicMock()] + bulb.get_multizone_effect = MockLifxCommand(bulb) + bulb.set_multizone_effect = MockLifxCommand(bulb) + return bulb diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 6555e483f5f..e7c18989767 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -10,7 +10,12 @@ from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN from homeassistant.components.lifx.const import ATTR_POWER from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES -from homeassistant.components.lifx.manager import SERVICE_EFFECT_COLORLOOP +from homeassistant.components.lifx.manager import ( + ATTR_DIRECTION, + ATTR_SPEED, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_MOVE, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -24,7 +29,13 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, ColorMode, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, STATE_OFF, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -401,6 +412,93 @@ async def test_light_strip(hass: HomeAssistant) -> None: ) +async def test_lightstrip_move_effect(hass: HomeAssistant) -> None: + """Test the firmware move effect on a light strip.""" + 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.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_move"}, + blocking=True, + ) + + assert len(bulb.set_power.calls) == 1 + assert len(bulb.set_multizone_effect.calls) == 1 + + call_dict = bulb.set_multizone_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 1, + "speed": 3.0, + "direction": 0, + } + bulb.get_multizone_effect.reset_mock() + bulb.set_multizone_effect.reset_mock() + bulb.set_power.reset_mock() + + bulb.power_level = 0 + await hass.services.async_call( + DOMAIN, + SERVICE_EFFECT_MOVE, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 4.5, ATTR_DIRECTION: "left"}, + blocking=True, + ) + + bulb.power_level = 65535 + bulb.effect = {"name": "effect_move", "enable": 1} + 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_multizone_effect.calls) == 1 + call_dict = bulb.set_multizone_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 1, + "speed": 4.5, + "direction": 1, + } + bulb.get_multizone_effect.reset_mock() + bulb.set_multizone_effect.reset_mock() + bulb.set_power.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "effect_stop"}, + blocking=True, + ) + assert len(bulb.set_power.calls) == 0 + assert len(bulb.set_multizone_effect.calls) == 1 + call_dict = bulb.set_multizone_effect.calls[0][1] + call_dict.pop("callb") + assert call_dict == { + "effect": 0, + "speed": 3.0, + "direction": 0, + } + bulb.get_multizone_effect.reset_mock() + bulb.set_multizone_effect.reset_mock() + bulb.set_power.reset_mock() + + async def test_color_light_with_temp( hass: HomeAssistant, mock_effect_conductor ) -> None: