Enable the move firmware effect on multizone lights (#78918)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Avi Miller 2022-09-26 17:08:36 +10:00 committed by GitHub
parent 75104159c6
commit 691028dfb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 268 additions and 18 deletions

View File

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

View File

@ -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],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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