mirror of https://github.com/home-assistant/core
520 lines
17 KiB
Python
520 lines
17 KiB
Python
"""Support for LIFX lights."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
import aiolifx_effects as aiolifx_effects_module
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.light import (
|
|
ATTR_EFFECT,
|
|
ATTR_TRANSITION,
|
|
LIGHT_TURN_ON_SCHEMA,
|
|
ColorMode,
|
|
LightEntity,
|
|
LightEntityFeature,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import entity_platform
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.event import async_call_later
|
|
from homeassistant.helpers.typing import VolDictType
|
|
|
|
from .const import (
|
|
_LOGGER,
|
|
ATTR_DURATION,
|
|
ATTR_INFRARED,
|
|
ATTR_POWER,
|
|
ATTR_ZONES,
|
|
DATA_LIFX_MANAGER,
|
|
DOMAIN,
|
|
INFRARED_BRIGHTNESS,
|
|
LIFX_CEILING_PRODUCT_IDS,
|
|
)
|
|
from .coordinator import FirmwareEffect, LIFXUpdateCoordinator
|
|
from .entity import LIFXEntity
|
|
from .manager import (
|
|
SERVICE_EFFECT_COLORLOOP,
|
|
SERVICE_EFFECT_FLAME,
|
|
SERVICE_EFFECT_MORPH,
|
|
SERVICE_EFFECT_MOVE,
|
|
SERVICE_EFFECT_PULSE,
|
|
SERVICE_EFFECT_SKY,
|
|
SERVICE_EFFECT_STOP,
|
|
LIFXManager,
|
|
)
|
|
from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk
|
|
|
|
LIFX_STATE_SETTLE_DELAY = 0.3
|
|
|
|
SERVICE_LIFX_SET_STATE = "set_state"
|
|
|
|
LIFX_SET_STATE_SCHEMA: VolDictType = {
|
|
**LIGHT_TURN_ON_SCHEMA,
|
|
ATTR_INFRARED: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)),
|
|
ATTR_ZONES: vol.All(cv.ensure_list, [cv.positive_int]),
|
|
ATTR_POWER: cv.boolean,
|
|
}
|
|
|
|
|
|
SERVICE_LIFX_SET_HEV_CYCLE_STATE = "set_hev_cycle_state"
|
|
|
|
LIFX_SET_HEV_CYCLE_STATE_SCHEMA: VolDictType = {
|
|
ATTR_POWER: vol.Required(cv.boolean),
|
|
ATTR_DURATION: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=86400)),
|
|
}
|
|
|
|
HSBK_HUE = 0
|
|
HSBK_SATURATION = 1
|
|
HSBK_BRIGHTNESS = 2
|
|
HSBK_KELVIN = 3
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up LIFX from a config entry."""
|
|
domain_data = hass.data[DOMAIN]
|
|
coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id]
|
|
manager: LIFXManager = domain_data[DATA_LIFX_MANAGER]
|
|
device = coordinator.device
|
|
platform = entity_platform.async_get_current_platform()
|
|
platform.async_register_entity_service(
|
|
SERVICE_LIFX_SET_STATE,
|
|
LIFX_SET_STATE_SCHEMA,
|
|
"set_state",
|
|
)
|
|
platform.async_register_entity_service(
|
|
SERVICE_LIFX_SET_HEV_CYCLE_STATE,
|
|
LIFX_SET_HEV_CYCLE_STATE_SCHEMA,
|
|
"set_hev_cycle_state",
|
|
)
|
|
if lifx_features(device)["matrix"]:
|
|
if device.product in LIFX_CEILING_PRODUCT_IDS:
|
|
entity: LIFXLight = LIFXCeiling(coordinator, manager, entry)
|
|
else:
|
|
entity = LIFXMatrix(coordinator, manager, entry)
|
|
elif lifx_features(device)["extended_multizone"]:
|
|
entity = LIFXExtendedMultiZone(coordinator, manager, entry)
|
|
elif lifx_features(device)["multizone"]:
|
|
entity = LIFXMultiZone(coordinator, manager, entry)
|
|
elif lifx_features(device)["color"]:
|
|
entity = LIFXColor(coordinator, manager, entry)
|
|
else:
|
|
entity = LIFXWhite(coordinator, manager, entry)
|
|
async_add_entities([entity])
|
|
|
|
|
|
class LIFXLight(LIFXEntity, LightEntity):
|
|
"""Representation of a LIFX light."""
|
|
|
|
_attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
|
|
_attr_name = None
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: LIFXUpdateCoordinator,
|
|
manager: LIFXManager,
|
|
entry: ConfigEntry,
|
|
) -> None:
|
|
"""Initialize the light."""
|
|
super().__init__(coordinator)
|
|
|
|
self.mac_addr = self.bulb.mac_addr
|
|
bulb_features = lifx_features(self.bulb)
|
|
self.manager = manager
|
|
self.effects_conductor: aiolifx_effects_module.Conductor = (
|
|
manager.effects_conductor
|
|
)
|
|
self.postponed_update: CALLBACK_TYPE | None = None
|
|
self.entry = entry
|
|
self._attr_unique_id = self.coordinator.serial_number
|
|
self._attr_min_color_temp_kelvin = bulb_features["min_kelvin"]
|
|
self._attr_max_color_temp_kelvin = bulb_features["max_kelvin"]
|
|
if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]:
|
|
color_mode = ColorMode.COLOR_TEMP
|
|
else:
|
|
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:
|
|
"""Return the brightness of this light between 0..255."""
|
|
fade = self.bulb.power_level / 65535
|
|
return convert_16_to_8(int(fade * self.bulb.color[HSBK_BRIGHTNESS]))
|
|
|
|
@property
|
|
def color_temp_kelvin(self) -> int | None:
|
|
"""Return the color temperature of this light in kelvin."""
|
|
return int(self.bulb.color[HSBK_KELVIN])
|
|
|
|
@property
|
|
def is_on(self) -> bool:
|
|
"""Return true if light is on."""
|
|
return bool(self.bulb.power_level != 0)
|
|
|
|
@property
|
|
def effect(self) -> str | None:
|
|
"""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:
|
|
"""Update state at the start and end of a transition."""
|
|
self._cancel_postponed_update()
|
|
|
|
# Transition has started
|
|
self.async_write_ha_state()
|
|
|
|
# The state reply we get back may be stale so we also request
|
|
# a refresh to get a fresh state
|
|
# https://lan.developer.lifx.com/docs/changing-a-device
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
# Transition has ended
|
|
if when > 0:
|
|
|
|
async def _async_refresh(now: datetime) -> None:
|
|
"""Refresh the state."""
|
|
await self.coordinator.async_refresh()
|
|
|
|
self.postponed_update = async_call_later(
|
|
self.hass,
|
|
timedelta(milliseconds=when),
|
|
_async_refresh,
|
|
)
|
|
|
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
|
"""Turn the light on."""
|
|
await self.set_state(**{**kwargs, ATTR_POWER: True})
|
|
|
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
|
"""Turn the light off."""
|
|
await self.set_state(**{**kwargs, ATTR_POWER: False})
|
|
|
|
async def set_state(self, **kwargs: Any) -> None:
|
|
"""Set a color on the light and turn it on/off."""
|
|
self.coordinator.async_set_updated_data(None)
|
|
# Cancel any pending refreshes
|
|
bulb = self.bulb
|
|
|
|
await self.effects_conductor.stop([bulb])
|
|
|
|
if ATTR_EFFECT in kwargs:
|
|
await self.default_effect(**kwargs)
|
|
return
|
|
|
|
if ATTR_INFRARED in kwargs:
|
|
infrared_entity_id = self.coordinator.async_get_entity_id(
|
|
Platform.SELECT, INFRARED_BRIGHTNESS
|
|
)
|
|
_LOGGER.warning(
|
|
(
|
|
"The 'infrared' attribute of 'lifx.set_state' is deprecated:"
|
|
" call 'select.select_option' targeting '%s' instead"
|
|
),
|
|
infrared_entity_id,
|
|
)
|
|
bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))
|
|
|
|
if ATTR_TRANSITION in kwargs:
|
|
fade = int(kwargs[ATTR_TRANSITION] * 1000)
|
|
else:
|
|
fade = 0
|
|
|
|
# These are both False if ATTR_POWER is not set
|
|
power_on = kwargs.get(ATTR_POWER, False)
|
|
power_off = not kwargs.get(ATTR_POWER, True)
|
|
|
|
hsbk = find_hsbk(self.hass, **kwargs)
|
|
|
|
if not self.is_on:
|
|
if power_off:
|
|
await self.set_power(False)
|
|
# If fading on with color, set color immediately
|
|
if hsbk and power_on:
|
|
await self.set_color(hsbk, kwargs)
|
|
await self.set_power(True, duration=fade)
|
|
elif hsbk:
|
|
await self.set_color(hsbk, kwargs, duration=fade)
|
|
elif power_on:
|
|
await self.set_power(True, duration=fade)
|
|
else:
|
|
if power_on:
|
|
await self.set_power(True)
|
|
if hsbk:
|
|
await self.set_color(hsbk, kwargs, duration=fade)
|
|
if power_off:
|
|
await self.set_power(False, duration=fade)
|
|
|
|
# Avoid state ping-pong by holding off updates as the state settles
|
|
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
|
|
|
|
# Update when the transition starts and ends
|
|
await self.update_during_transition(fade)
|
|
|
|
async def set_hev_cycle_state(
|
|
self, power: bool, duration: int | None = None
|
|
) -> None:
|
|
"""Set the state of the HEV LEDs on a LIFX Clean bulb."""
|
|
if lifx_features(self.bulb)["hev"] is False:
|
|
raise HomeAssistantError(
|
|
"This device does not support setting HEV cycle state"
|
|
)
|
|
|
|
await self.coordinator.async_set_hev_cycle_state(power, duration or 0)
|
|
await self.update_during_transition(duration or 0)
|
|
|
|
async def set_power(
|
|
self,
|
|
pwr: bool,
|
|
duration: int = 0,
|
|
) -> None:
|
|
"""Send a power change to the bulb."""
|
|
try:
|
|
await self.coordinator.async_set_power(pwr, duration)
|
|
except TimeoutError as ex:
|
|
raise HomeAssistantError(f"Timeout setting power for {self.name}") from ex
|
|
|
|
async def set_color(
|
|
self,
|
|
hsbk: list[float | int | None],
|
|
kwargs: dict[str, Any],
|
|
duration: int = 0,
|
|
) -> None:
|
|
"""Send a color change to the bulb."""
|
|
merged_hsbk = merge_hsbk(self.bulb.color, hsbk)
|
|
try:
|
|
await self.coordinator.async_set_color(merged_hsbk, duration)
|
|
except TimeoutError as ex:
|
|
raise HomeAssistantError(f"Timeout setting color for {self.name}") from ex
|
|
|
|
async def get_color(
|
|
self,
|
|
) -> None:
|
|
"""Send a get color message to the bulb."""
|
|
try:
|
|
await self.coordinator.async_get_color()
|
|
except TimeoutError as ex:
|
|
raise HomeAssistantError(
|
|
f"Timeout setting getting color for {self.name}"
|
|
) from ex
|
|
|
|
async def default_effect(self, **kwargs: Any) -> None:
|
|
"""Start an effect with default parameters."""
|
|
await self.hass.services.async_call(
|
|
DOMAIN,
|
|
kwargs[ATTR_EFFECT],
|
|
{ATTR_ENTITY_ID: self.entity_id},
|
|
context=self._context,
|
|
)
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Register callbacks."""
|
|
self.async_on_remove(
|
|
self.manager.async_register_entity(self.entity_id, self.entry.entry_id)
|
|
)
|
|
return await super().async_added_to_hass()
|
|
|
|
def _cancel_postponed_update(self) -> None:
|
|
"""Cancel postponed update, if applicable."""
|
|
if self.postponed_update:
|
|
self.postponed_update()
|
|
self.postponed_update = None
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
"""Run when entity will be removed from hass."""
|
|
self._cancel_postponed_update()
|
|
return await super().async_will_remove_from_hass()
|
|
|
|
|
|
class LIFXWhite(LIFXLight):
|
|
"""Representation of a white-only LIFX light."""
|
|
|
|
_attr_effect_list = [SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP]
|
|
|
|
|
|
class LIFXColor(LIFXLight):
|
|
"""Representation of a color LIFX light."""
|
|
|
|
_attr_effect_list = [
|
|
SERVICE_EFFECT_COLORLOOP,
|
|
SERVICE_EFFECT_PULSE,
|
|
SERVICE_EFFECT_STOP,
|
|
]
|
|
|
|
@property
|
|
def supported_color_modes(self) -> set[ColorMode]:
|
|
"""Return the supported color modes."""
|
|
return {ColorMode.COLOR_TEMP, ColorMode.HS}
|
|
|
|
@property
|
|
def color_mode(self) -> ColorMode:
|
|
"""Return the color mode of the light."""
|
|
has_sat = self.bulb.color[HSBK_SATURATION]
|
|
return ColorMode.HS if has_sat else ColorMode.COLOR_TEMP
|
|
|
|
@property
|
|
def hs_color(self) -> tuple[float, float] | None:
|
|
"""Return the hs value."""
|
|
hue, sat, _, _ = self.bulb.color
|
|
hue = hue / 65535 * 360
|
|
sat = sat / 65535 * 100
|
|
return (hue, sat) if sat else None
|
|
|
|
|
|
class LIFXMultiZone(LIFXColor):
|
|
"""Representation of a legacy LIFX multizone device."""
|
|
|
|
_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],
|
|
kwargs: dict[str, Any],
|
|
duration: int = 0,
|
|
) -> None:
|
|
"""Send a color change to the bulb."""
|
|
bulb = self.bulb
|
|
color_zones = bulb.color_zones
|
|
num_zones = self.coordinator.get_number_of_zones()
|
|
|
|
# Zone brightness is not reported when powered off
|
|
if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None:
|
|
await self.set_power(True)
|
|
await asyncio.sleep(LIFX_STATE_SETTLE_DELAY)
|
|
await self.update_color_zones()
|
|
await self.set_power(False)
|
|
|
|
if (zones := kwargs.get(ATTR_ZONES)) is None:
|
|
# Fast track: setting all zones to the same brightness and color
|
|
# can be treated as a single-zone bulb.
|
|
first_zone = color_zones[0]
|
|
first_zone_brightness = first_zone[HSBK_BRIGHTNESS]
|
|
all_zones_have_same_brightness = all(
|
|
color_zones[zone][HSBK_BRIGHTNESS] == first_zone_brightness
|
|
for zone in range(num_zones)
|
|
)
|
|
all_zones_are_the_same = all(
|
|
color_zones[zone] == first_zone for zone in range(num_zones)
|
|
)
|
|
if (
|
|
all_zones_have_same_brightness or hsbk[HSBK_BRIGHTNESS] is not None
|
|
) and (all_zones_are_the_same or hsbk[HSBK_KELVIN] is not None):
|
|
await super().set_color(hsbk, kwargs, duration)
|
|
return
|
|
|
|
zones = list(range(num_zones))
|
|
else:
|
|
zones = [x for x in set(zones) if x < num_zones]
|
|
|
|
# Send new color to each zone
|
|
for index, zone in enumerate(zones):
|
|
zone_hsbk = merge_hsbk(color_zones[zone], hsbk)
|
|
apply = 1 if (index == len(zones) - 1) else 0
|
|
try:
|
|
await self.coordinator.async_set_color_zones(
|
|
zone, zone, zone_hsbk, duration, apply
|
|
)
|
|
except TimeoutError as ex:
|
|
raise HomeAssistantError(
|
|
f"Timeout setting color zones for {self.name}"
|
|
) from ex
|
|
|
|
# set_color_zones does not update the
|
|
# state of the device, so we need to do that
|
|
await self.get_color()
|
|
|
|
async def update_color_zones(
|
|
self,
|
|
) -> None:
|
|
"""Send a get color zones message to the device."""
|
|
try:
|
|
await self.coordinator.async_get_color_zones()
|
|
except TimeoutError as ex:
|
|
raise HomeAssistantError(
|
|
f"Timeout getting color zones from {self.name}"
|
|
) from ex
|
|
|
|
|
|
class LIFXExtendedMultiZone(LIFXMultiZone):
|
|
"""Representation of a LIFX device that supports extended multizone messages."""
|
|
|
|
async def set_color(
|
|
self, hsbk: list[float | int | None], kwargs: dict[str, Any], duration: int = 0
|
|
) -> None:
|
|
"""Set colors on all zones of the device."""
|
|
|
|
# trigger an update of all zone values before merging new values
|
|
await self.coordinator.async_get_extended_color_zones()
|
|
|
|
color_zones = self.bulb.color_zones
|
|
if (zones := kwargs.get(ATTR_ZONES)) is None:
|
|
# merge the incoming hsbk across all zones
|
|
for index, zone in enumerate(color_zones):
|
|
color_zones[index] = merge_hsbk(zone, hsbk)
|
|
else:
|
|
# merge the incoming HSBK with only the specified zones
|
|
for index, zone in enumerate(color_zones):
|
|
if index in zones:
|
|
color_zones[index] = merge_hsbk(zone, hsbk)
|
|
|
|
# send the updated color zones list to the device
|
|
try:
|
|
await self.coordinator.async_set_extended_color_zones(
|
|
color_zones, duration=duration
|
|
)
|
|
except TimeoutError as ex:
|
|
raise HomeAssistantError(
|
|
f"Timeout setting color zones on {self.name}"
|
|
) from ex
|
|
|
|
# set_extended_color_zones does not update the
|
|
# state of the device, so we need to do that
|
|
await self.get_color()
|
|
|
|
|
|
class LIFXMatrix(LIFXColor):
|
|
"""Representation of a LIFX matrix device."""
|
|
|
|
_attr_effect_list = [
|
|
SERVICE_EFFECT_COLORLOOP,
|
|
SERVICE_EFFECT_FLAME,
|
|
SERVICE_EFFECT_PULSE,
|
|
SERVICE_EFFECT_MORPH,
|
|
SERVICE_EFFECT_STOP,
|
|
]
|
|
|
|
|
|
class LIFXCeiling(LIFXMatrix):
|
|
"""Representation of a LIFX Ceiling device."""
|
|
|
|
_attr_effect_list = [
|
|
SERVICE_EFFECT_COLORLOOP,
|
|
SERVICE_EFFECT_FLAME,
|
|
SERVICE_EFFECT_PULSE,
|
|
SERVICE_EFFECT_MORPH,
|
|
SERVICE_EFFECT_SKY,
|
|
SERVICE_EFFECT_STOP,
|
|
]
|