core/homeassistant/components/lifx/light.py

908 lines
30 KiB
Python

"""Support for LIFX lights."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from functools import partial
from ipaddress import IPv4Address
import logging
import math
import aiolifx as aiolifx_module
from aiolifx.aiolifx import LifxDiscovery, Light
import aiolifx_effects as aiolifx_effects_module
from awesomeversion import AwesomeVersion
import voluptuous as vol
from homeassistant import util
from homeassistant.components import network
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_NAME,
ATTR_COLOR_TEMP,
ATTR_EFFECT,
ATTR_HS_COLOR,
ATTR_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
ATTR_XY_COLOR,
COLOR_GROUP,
DOMAIN,
LIGHT_TURN_ON_SCHEMA,
VALID_BRIGHTNESS,
VALID_BRIGHTNESS_PCT,
ColorMode,
LightEntity,
LightEntityFeature,
preprocess_turn_on_alternatives,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_MODE,
ATTR_MODEL,
ATTR_SW_VERSION,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.color as color_util
from . import (
CONF_BROADCAST,
CONF_PORT,
CONF_SERVER,
DATA_LIFX_MANAGER,
DOMAIN as LIFX_DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=10)
DISCOVERY_INTERVAL = 10
MESSAGE_TIMEOUT = 1
MESSAGE_RETRIES = 8
UNAVAILABLE_GRACE = 90
FIX_MAC_FW = AwesomeVersion("3.70")
SERVICE_LIFX_SET_STATE = "set_state"
ATTR_INFRARED = "infrared"
ATTR_ZONES = "zones"
ATTR_POWER = "power"
LIFX_SET_STATE_SCHEMA = cv.make_entity_service_schema(
{
**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_EFFECT_PULSE = "effect_pulse"
SERVICE_EFFECT_COLORLOOP = "effect_colorloop"
SERVICE_EFFECT_STOP = "effect_stop"
ATTR_POWER_ON = "power_on"
ATTR_PERIOD = "period"
ATTR_CYCLES = "cycles"
ATTR_SPREAD = "spread"
ATTR_CHANGE = "change"
PULSE_MODE_BLINK = "blink"
PULSE_MODE_BREATHE = "breathe"
PULSE_MODE_PING = "ping"
PULSE_MODE_STROBE = "strobe"
PULSE_MODE_SOLID = "solid"
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,
ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT: 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, COLOR_GROUP): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Exclusive(ATTR_KELVIN, 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,
ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT,
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({})
def aiolifx():
"""Return the aiolifx module."""
return aiolifx_module
def aiolifx_effects():
"""Return the aiolifx_effects module."""
return aiolifx_effects_module
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the LIFX light platform. Obsolete."""
_LOGGER.warning("LIFX no longer works with light platform configuration")
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
# Priority 1: manual config
if not (interfaces := hass.data[LIFX_DOMAIN].get(DOMAIN)):
# Priority 2: Home Assistant enabled interfaces
ip_addresses = (
source_ip
for source_ip in await network.async_get_enabled_source_ips(hass)
if isinstance(source_ip, IPv4Address) and not source_ip.is_loopback
)
interfaces = [{CONF_SERVER: str(ip)} for ip in ip_addresses]
platform = entity_platform.async_get_current_platform()
lifx_manager = LIFXManager(hass, platform, config_entry, async_add_entities)
hass.data[DATA_LIFX_MANAGER] = lifx_manager
for interface in interfaces:
lifx_manager.start_discovery(interface)
def lifx_features(bulb):
"""Return a feature map for this bulb, or a default map if unknown."""
return aiolifx().products.features_map.get(
bulb.product
) or aiolifx().products.features_map.get(1)
def find_hsbk(hass, **kwargs):
"""Find the desired color from a number of possible inputs."""
hue, saturation, brightness, kelvin = [None] * 4
preprocess_turn_on_alternatives(hass, kwargs)
if ATTR_HS_COLOR in kwargs:
hue, saturation = kwargs[ATTR_HS_COLOR]
elif ATTR_RGB_COLOR in kwargs:
hue, saturation = color_util.color_RGB_to_hs(*kwargs[ATTR_RGB_COLOR])
elif ATTR_XY_COLOR in kwargs:
hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR])
if hue is not None:
hue = int(hue / 360 * 65535)
saturation = int(saturation / 100 * 65535)
kelvin = 3500
if ATTR_COLOR_TEMP in kwargs:
kelvin = int(
color_util.color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
)
saturation = 0
if ATTR_BRIGHTNESS in kwargs:
brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
hsbk = [hue, saturation, brightness, kelvin]
return None if hsbk == [None] * 4 else hsbk
def merge_hsbk(base, change):
"""Copy change on top of base, except when None."""
if change is None:
return None
return [b if c is None else c for b, c in zip(base, change)]
@dataclass
class InFlightDiscovery:
"""Represent a LIFX device that is being discovered."""
device: Light
lock: asyncio.Lock
class LIFXManager:
"""Representation of all known LIFX entities."""
def __init__(
self,
hass: HomeAssistant,
platform: EntityPlatform,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize the light."""
self.entities: dict[str, LIFXLight] = {}
self.switch_devices: list[str] = []
self.hass = hass
self.platform = platform
self.config_entry = config_entry
self.async_add_entities = async_add_entities
self.effects_conductor = aiolifx_effects().Conductor(hass.loop)
self.discoveries: list[LifxDiscovery] = []
self.discoveries_inflight: dict[str, InFlightDiscovery] = {}
self.cleanup_unsub = self.hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP, self.cleanup
)
self.entity_registry_updated_unsub = self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED, self.entity_registry_updated
)
self.register_set_state()
self.register_effects()
def start_discovery(self, interface):
"""Start discovery on a network interface."""
kwargs = {"discovery_interval": DISCOVERY_INTERVAL}
if broadcast_ip := interface.get(CONF_BROADCAST):
kwargs["broadcast_ip"] = broadcast_ip
lifx_discovery = aiolifx().LifxDiscovery(self.hass.loop, self, **kwargs)
kwargs = {}
if listen_ip := interface.get(CONF_SERVER):
kwargs["listen_ip"] = listen_ip
if listen_port := interface.get(CONF_PORT):
kwargs["listen_port"] = listen_port
lifx_discovery.start(**kwargs)
self.discoveries.append(lifx_discovery)
@callback
def cleanup(self, event=None):
"""Release resources."""
self.cleanup_unsub()
self.entity_registry_updated_unsub()
for discovery in self.discoveries:
discovery.cleanup()
for service in (
SERVICE_LIFX_SET_STATE,
SERVICE_EFFECT_STOP,
SERVICE_EFFECT_PULSE,
SERVICE_EFFECT_COLORLOOP,
):
self.hass.services.async_remove(LIFX_DOMAIN, service)
def register_set_state(self):
"""Register the LIFX set_state service call."""
self.platform.async_register_entity_service(
SERVICE_LIFX_SET_STATE, LIFX_SET_STATE_SCHEMA, "set_state"
)
def register_effects(self):
"""Register the LIFX effects as hass service calls."""
async def service_handler(service: ServiceCall) -> None:
"""Apply a service, i.e. start an effect."""
entities = await self.platform.async_extract_from_service(service)
if entities:
await self.start_effect(entities, service.service, **service.data)
self.hass.services.async_register(
LIFX_DOMAIN,
SERVICE_EFFECT_PULSE,
service_handler,
schema=LIFX_EFFECT_PULSE_SCHEMA,
)
self.hass.services.async_register(
LIFX_DOMAIN,
SERVICE_EFFECT_COLORLOOP,
service_handler,
schema=LIFX_EFFECT_COLORLOOP_SCHEMA,
)
self.hass.services.async_register(
LIFX_DOMAIN,
SERVICE_EFFECT_STOP,
service_handler,
schema=LIFX_EFFECT_STOP_SCHEMA,
)
async def start_effect(self, entities, service, **kwargs):
"""Start a light effect on entities."""
bulbs = [light.bulb for light in entities]
if 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:
preprocess_turn_on_alternatives(self.hass, kwargs)
brightness = None
if ATTR_BRIGHTNESS in kwargs:
brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
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,
)
await self.effects_conductor.start(effect, bulbs)
elif service == SERVICE_EFFECT_STOP:
await self.effects_conductor.stop(bulbs)
def clear_inflight_discovery(self, inflight: InFlightDiscovery) -> None:
"""Clear in-flight discovery."""
self.discoveries_inflight.pop(inflight.device.mac_addr, None)
@callback
def register(self, bulb: Light) -> None:
"""Allow a single in-flight discovery per bulb."""
if bulb.mac_addr in self.switch_devices:
_LOGGER.debug(
"Skipping discovered LIFX Switch at %s (%s)",
bulb.ip_addr,
bulb.mac_addr,
)
return
# Try to bail out of discovery as early as possible
if bulb.mac_addr in self.entities:
entity = self.entities[bulb.mac_addr]
entity.registered = True
_LOGGER.debug("Reconnected to %s", entity.who)
return
if bulb.mac_addr not in self.discoveries_inflight:
inflight = InFlightDiscovery(bulb, asyncio.Lock())
self.discoveries_inflight[bulb.mac_addr] = inflight
_LOGGER.debug(
"First discovery response received from %s (%s)",
bulb.ip_addr,
bulb.mac_addr,
)
else:
_LOGGER.debug(
"Duplicate discovery response received from %s (%s)",
bulb.ip_addr,
bulb.mac_addr,
)
self.hass.async_create_task(
self._async_handle_discovery(self.discoveries_inflight[bulb.mac_addr])
)
async def _async_handle_discovery(self, inflight: InFlightDiscovery) -> None:
"""Handle LIFX bulb registration lifecycle."""
# only allow a single discovery process per discovered device
async with inflight.lock:
# Bail out if an entity was created by a previous discovery while
# this discovery was waiting for the asyncio lock to release.
if inflight.device.mac_addr in self.entities:
self.clear_inflight_discovery(inflight)
entity: LIFXLight = self.entities[inflight.device.mac_addr]
entity.registered = True
_LOGGER.debug("Reconnected to %s", entity.who)
return
# Determine the product info so that LIFX Switches
# can be skipped.
ack = AwaitAioLIFX().wait
if inflight.device.product is None:
if await ack(inflight.device.get_version) is None:
_LOGGER.debug(
"Failed to discover product information for %s (%s)",
inflight.device.ip_addr,
inflight.device.mac_addr,
)
self.clear_inflight_discovery(inflight)
return
if lifx_features(inflight.device)["relays"] is True:
_LOGGER.debug(
"Skipping discovered LIFX Switch at %s (%s)",
inflight.device.ip_addr,
inflight.device.mac_addr,
)
self.switch_devices.append(inflight.device.mac_addr)
self.clear_inflight_discovery(inflight)
return
await self._async_process_discovery(inflight=inflight)
async def _async_process_discovery(self, inflight: InFlightDiscovery) -> None:
"""Process discovery of a device."""
bulb = inflight.device
ack = AwaitAioLIFX().wait
bulb.timeout = MESSAGE_TIMEOUT
bulb.retry_count = MESSAGE_RETRIES
bulb.unregister_timeout = UNAVAILABLE_GRACE
# Read initial state
if bulb.color is None:
if await ack(bulb.get_color) is None:
_LOGGER.debug(
"Failed to determine current state of %s (%s)",
bulb.ip_addr,
bulb.mac_addr,
)
self.clear_inflight_discovery(inflight)
return
if lifx_features(bulb)["multizone"]:
entity: LIFXLight = LIFXStrip(bulb.mac_addr, bulb, self.effects_conductor)
elif lifx_features(bulb)["color"]:
entity = LIFXColor(bulb.mac_addr, bulb, self.effects_conductor)
else:
entity = LIFXWhite(bulb.mac_addr, bulb, self.effects_conductor)
self.entities[bulb.mac_addr] = entity
self.async_add_entities([entity], True)
_LOGGER.debug("Entity created for %s", entity.who)
self.clear_inflight_discovery(inflight)
@callback
def unregister(self, bulb: Light) -> None:
"""Mark unresponsive bulbs as unavailable in Home Assistant."""
if bulb.mac_addr in self.entities:
entity = self.entities[bulb.mac_addr]
entity.registered = False
entity.async_write_ha_state()
_LOGGER.debug("Disconnected from %s", entity.who)
@callback
def entity_registry_updated(self, event):
"""Handle entity registry updated."""
if event.data["action"] == "remove":
self.remove_empty_devices()
def remove_empty_devices(self):
"""Remove devices with no entities."""
entity_reg = er.async_get(self.hass)
device_reg = dr.async_get(self.hass)
device_list = dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
)
for device_entry in device_list:
if not er.async_entries_for_device(
entity_reg,
device_entry.id,
include_disabled_entities=True,
):
device_reg.async_update_device(
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
)
class AwaitAioLIFX:
"""Wait for an aiolifx callback and return the message."""
def __init__(self):
"""Initialize the wrapper."""
self.message = None
self.event = asyncio.Event()
@callback
def callback(self, bulb, message):
"""Handle responses."""
self.message = message
self.event.set()
async def wait(self, method):
"""Call an aiolifx method and wait for its response."""
self.message = None
self.event.clear()
method(callb=self.callback)
await self.event.wait()
return self.message
def convert_8_to_16(value):
"""Scale an 8 bit level into 16 bits."""
return (value << 8) | value
def convert_16_to_8(value):
"""Scale a 16 bit level into 8 bits."""
return value >> 8
class LIFXLight(LightEntity):
"""Representation of a LIFX light."""
_attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT
def __init__(
self,
mac_addr: str,
bulb: Light,
effects_conductor: aiolifx_effects_module.Conductor,
) -> None:
"""Initialize the light."""
self.mac_addr = mac_addr
self.bulb = bulb
self.effects_conductor = effects_conductor
self.registered = True
self.postponed_update = None
self.lock = asyncio.Lock()
def get_mac_addr(self):
"""Increment the last byte of the mac address by one for FW>3.70."""
if (
self.bulb.host_firmware_version
and AwesomeVersion(self.bulb.host_firmware_version) >= FIX_MAC_FW
):
octets = [int(octet, 16) for octet in self.mac_addr.split(":")]
octets[5] = (octets[5] + 1) % 256
return ":".join(f"{octet:02x}" for octet in octets)
return self.mac_addr
@property
def device_info(self) -> DeviceInfo:
"""Return information about the device."""
_map = aiolifx().products.product_map
info = DeviceInfo(
identifiers={(LIFX_DOMAIN, self.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, self.get_mac_addr())},
manufacturer="LIFX",
name=self.name,
)
if (model := (_map.get(self.bulb.product) or self.bulb.product)) is not None:
info[ATTR_MODEL] = str(model)
if (version := self.bulb.host_firmware_version) is not None:
info[ATTR_SW_VERSION] = version
return info
@property
def available(self):
"""Return the availability of the bulb."""
return self.registered
@property
def unique_id(self):
"""Return a unique ID."""
return self.mac_addr
@property
def name(self):
"""Return the name of the bulb."""
return self.bulb.label
@property
def who(self):
"""Return a string identifying the bulb by name and mac."""
return f"{self.name} ({self.mac_addr})"
@property
def min_mireds(self):
"""Return the coldest color_temp that this light supports."""
kelvin = lifx_features(self.bulb)["max_kelvin"]
return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin))
@property
def max_mireds(self):
"""Return the warmest color_temp that this light supports."""
kelvin = lifx_features(self.bulb)["min_kelvin"]
return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin))
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
bulb_features = lifx_features(self.bulb)
if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]:
return ColorMode.COLOR_TEMP
return ColorMode.BRIGHTNESS
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
return {self.color_mode}
@property
def brightness(self):
"""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[2]))
@property
def color_temp(self):
"""Return the color temperature."""
_, sat, _, kelvin = self.bulb.color
if sat:
return None
return color_util.color_temperature_kelvin_to_mired(kelvin)
@property
def is_on(self):
"""Return true if light is on."""
return self.bulb.power_level != 0
@property
def effect(self):
"""Return the name of the currently running effect."""
effect = self.effects_conductor.effect(self.bulb)
if effect:
return f"lifx_effect_{effect.name}"
return None
async def update_hass(self, now=None):
"""Request new status and push it to hass."""
self.postponed_update = None
await self.async_update()
self.async_write_ha_state()
async def update_during_transition(self, when):
"""Update state at the start and end of a transition."""
if self.postponed_update:
self.postponed_update()
# Transition has started
await self.update_hass()
# Transition has ended
if when > 0:
self.postponed_update = async_track_point_in_utc_time(
self.hass,
self.update_hass,
util.dt.utcnow() + timedelta(milliseconds=when),
)
async def async_turn_on(self, **kwargs):
"""Turn the light on."""
kwargs[ATTR_POWER] = True
self.hass.async_create_task(self.set_state(**kwargs))
async def async_turn_off(self, **kwargs):
"""Turn the light off."""
kwargs[ATTR_POWER] = False
self.hass.async_create_task(self.set_state(**kwargs))
async def set_state(self, **kwargs):
"""Set a color on the light and turn it on/off."""
async with self.lock:
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:
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)
# Send messages, waiting for ACK each time
ack = AwaitAioLIFX().wait
if not self.is_on:
if power_off:
await self.set_power(ack, False)
# If fading on with color, set color immediately
if hsbk and power_on:
await self.set_color(ack, hsbk, kwargs)
await self.set_power(ack, True, duration=fade)
elif hsbk:
await self.set_color(ack, hsbk, kwargs, duration=fade)
elif power_on:
await self.set_power(ack, True, duration=fade)
else:
if power_on:
await self.set_power(ack, True)
if hsbk:
await self.set_color(ack, hsbk, kwargs, duration=fade)
if power_off:
await self.set_power(ack, False, duration=fade)
# Avoid state ping-pong by holding off updates as the state settles
await asyncio.sleep(0.3)
# Update when the transition starts and ends
await self.update_during_transition(fade)
async def set_power(self, ack, pwr, duration=0):
"""Send a power change to the bulb."""
await ack(partial(self.bulb.set_power, pwr, duration=duration))
async def set_color(self, ack, hsbk, kwargs, duration=0):
"""Send a color change to the bulb."""
hsbk = merge_hsbk(self.bulb.color, hsbk)
await ack(partial(self.bulb.set_color, hsbk, duration=duration))
async def default_effect(self, **kwargs):
"""Start an effect with default parameters."""
service = kwargs[ATTR_EFFECT]
data = {ATTR_ENTITY_ID: self.entity_id}
await self.hass.services.async_call(
LIFX_DOMAIN, service, data, context=self._context
)
async def async_update(self):
"""Update bulb status."""
if self.available and not self.lock.locked():
await AwaitAioLIFX().wait(self.bulb.get_color)
class LIFXWhite(LIFXLight):
"""Representation of a white-only LIFX light."""
@property
def effect_list(self):
"""Return the list of supported effects for this light."""
return [SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP]
class LIFXColor(LIFXLight):
"""Representation of a color LIFX light."""
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
sat = self.bulb.color[1]
if sat:
return ColorMode.HS
return ColorMode.COLOR_TEMP
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
return {ColorMode.COLOR_TEMP, ColorMode.HS}
@property
def effect_list(self):
"""Return the list of supported effects for this light."""
return [SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_PULSE, SERVICE_EFFECT_STOP]
@property
def hs_color(self):
"""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 LIFXStrip(LIFXColor):
"""Representation of a LIFX light strip with multiple zones."""
async def set_color(self, ack, hsbk, kwargs, duration=0):
"""Send a color change to the bulb."""
bulb = self.bulb
num_zones = len(bulb.color_zones)
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.
if hsbk[2] is not None and hsbk[3] is not None:
await super().set_color(ack, hsbk, kwargs, duration)
return
zones = list(range(0, num_zones))
else:
zones = [x for x in set(zones) if x < num_zones]
# Zone brightness is not reported when powered off
if not self.is_on and hsbk[2] is None:
await self.set_power(ack, True)
await asyncio.sleep(0.3)
await self.update_color_zones()
await self.set_power(ack, False)
await asyncio.sleep(0.3)
# Send new color to each zone
for index, zone in enumerate(zones):
zone_hsbk = merge_hsbk(bulb.color_zones[zone], hsbk)
apply = 1 if (index == len(zones) - 1) else 0
set_zone = partial(
bulb.set_color_zones,
start_index=zone,
end_index=zone,
color=zone_hsbk,
duration=duration,
apply=apply,
)
await ack(set_zone)
async def async_update(self):
"""Update strip status."""
if self.available and not self.lock.locked():
await super().async_update()
await self.update_color_zones()
async def update_color_zones(self):
"""Get updated color information for each zone."""
zone = 0
top = 1
while self.available and zone < top:
# Each get_color_zones can update 8 zones at once
resp = await AwaitAioLIFX().wait(
partial(self.bulb.get_color_zones, start_index=zone)
)
if resp:
zone += 8
top = resp.count
# We only await multizone responses so don't ask for just one
if zone == top - 1:
zone -= 1