"""Support for LIFX lights.""" from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta from typing import Any import aiolifx_effects from aiolifx_themes.themes import Theme, ThemeLibrary import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, COLOR_GROUP, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, ) from homeassistant.const import ATTR_MODE 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 ATTR_THEME, 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_COLORLOOP = "effect_colorloop" SERVICE_EFFECT_FLAME = "effect_flame" SERVICE_EFFECT_MORPH = "effect_morph" SERVICE_EFFECT_MOVE = "effect_move" SERVICE_EFFECT_PULSE = "effect_pulse" SERVICE_EFFECT_STOP = "effect_stop" ATTR_CHANGE = "change" ATTR_CYCLES = "cycles" ATTR_DIRECTION = "direction" ATTR_PALETTE = "palette" ATTR_PERIOD = "period" ATTR_POWER_OFF = "power_off" ATTR_POWER_ON = "power_on" ATTR_SATURATION_MAX = "saturation_max" ATTR_SATURATION_MIN = "saturation_min" ATTR_SPEED = "speed" ATTR_SPREAD = "spread" EFFECT_FLAME = "FLAME" EFFECT_MORPH = "MORPH" EFFECT_MOVE = "MOVE" EFFECT_OFF = "OFF" EFFECT_FLAME_DEFAULT_SPEED = 3 EFFECT_MORPH_DEFAULT_SPEED = 3 EFFECT_MORPH_DEFAULT_THEME = "exciting" EFFECT_MOVE_DEFAULT_SPEED = 3 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" PULSE_MODE_PING = "ping" PULSE_MODE_SOLID = "solid" PULSE_MODE_STROBE = "strobe" PULSE_MODES = [ PULSE_MODE_BLINK, PULSE_MODE_BREATHE, PULSE_MODE_PING, PULSE_MODE_STROBE, PULSE_MODE_SOLID, ] LIFX_EFFECT_SCHEMA = { vol.Optional(ATTR_POWER_ON, default=True): cv.boolean, } LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( { **LIFX_EFFECT_SCHEMA, vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence((cv.byte, cv.byte, cv.byte)) ), vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence((cv.small_float, cv.small_float)) ), vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence( ( vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), ) ), ), vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): vol.All( vol.Coerce(int), vol.Range(min=1500, max=9000) ), vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int, ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), ATTR_MODE: vol.In(PULSE_MODES), } ) LIFX_EFFECT_COLORLOOP_SCHEMA = cv.make_entity_service_schema( { **LIFX_EFFECT_SCHEMA, vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, ATTR_SATURATION_MAX: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)), ATTR_SATURATION_MIN: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)), ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Clamp(min=0.05)), ATTR_CHANGE: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)), ATTR_SPREAD: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)), ATTR_TRANSITION: cv.positive_float, } ) LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({}) SERVICES = ( SERVICE_EFFECT_STOP, SERVICE_EFFECT_PULSE, SERVICE_EFFECT_MOVE, SERVICE_EFFECT_COLORLOOP, ) LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( { **LIFX_EFFECT_SCHEMA, ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)), } ) HSBK_SCHEMA = vol.All( vol.Coerce(tuple), vol.ExactSequence( ( vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), vol.All(vol.Coerce(float), vol.Clamp(min=0, max=100)), vol.All(vol.Coerce(int), vol.Clamp(min=1500, max=9000)), ) ), ) LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( { **LIFX_EFFECT_SCHEMA, ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=25)), 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] ), } ) 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)), } ) class LIFXManager: """Representation of all known LIFX entities.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize the manager.""" self.hass = hass self.effects_conductor = aiolifx_effects.Conductor(hass.loop) self.entry_id_to_entity_id: dict[str, str] = {} @callback def async_unload(self) -> None: """Release resources.""" for service in SERVICES: self.hass.services.async_remove(DOMAIN, service) @callback def async_register_entity( self, entity_id: str, entry_id: str ) -> Callable[[], None]: """Register an entity to the config entry id.""" self.entry_id_to_entity_id[entry_id] = entity_id @callback def unregister_entity() -> None: """Unregister entity when it is being destroyed.""" self.entry_id_to_entity_id.pop(entry_id) return unregister_entity @callback def async_setup(self) -> None: """Register the LIFX effects as hass service calls.""" async def service_handler(service: ServiceCall) -> None: """Apply a service, i.e. start an effect.""" referenced = async_extract_referenced_entity_ids(self.hass, service) all_referenced = referenced.referenced | referenced.indirectly_referenced if all_referenced: await self.start_effect(all_referenced, service.service, **service.data) self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_PULSE, service_handler, schema=LIFX_EFFECT_PULSE_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_COLORLOOP, service_handler, schema=LIFX_EFFECT_COLORLOOP_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_FLAME, service_handler, schema=LIFX_EFFECT_FLAME_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_EFFECT_MORPH, service_handler, schema=LIFX_EFFECT_MORPH_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, service_handler, schema=LIFX_EFFECT_STOP_SCHEMA, ) async def start_effect( self, entity_ids: set[str], service: str, **kwargs: Any ) -> None: """Start a light effect on entities.""" 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_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, None) 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, None), 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_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 )