"""Support for LimitlessLED bulbs.""" from __future__ import annotations import logging from typing import Any from limitlessled import Color from limitlessled.bridge import Bridge from limitlessled.group import Group from limitlessled.group.dimmer import DimmerGroup from limitlessled.group.rgbw import RgbwGroup from limitlessled.group.rgbww import RgbwwGroup from limitlessled.group.white import WhiteGroup from limitlessled.pipeline import Pipeline from limitlessled.presets import COLORLOOP import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG, PLATFORM_SCHEMA, ColorMode, LightEntity, LightEntityFeature, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin _LOGGER = logging.getLogger(__name__) CONF_BRIDGES = "bridges" CONF_GROUPS = "groups" CONF_NUMBER = "number" CONF_VERSION = "version" CONF_FADE = "fade" DEFAULT_LED_TYPE = "rgbw" DEFAULT_PORT = 5987 DEFAULT_TRANSITION = 0 DEFAULT_VERSION = 6 DEFAULT_FADE = False LED_TYPE = ["rgbw", "rgbww", "white", "bridge-led", "dimmer"] EFFECT_NIGHT = "night" MIN_SATURATION = 10 WHITE = (0, 0) COLOR_MODES_LIMITLESS_WHITE = {ColorMode.COLOR_TEMP} SUPPORT_LIMITLESSLED_WHITE = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION COLOR_MODES_LIMITLESS_DIMMER = {ColorMode.BRIGHTNESS} SUPPORT_LIMITLESSLED_DIMMER = LightEntityFeature.TRANSITION COLOR_MODES_LIMITLESS_RGB = {ColorMode.HS} SUPPORT_LIMITLESSLED_RGB = ( LightEntityFeature.EFFECT | LightEntityFeature.FLASH | LightEntityFeature.TRANSITION ) COLOR_MODES_LIMITLESS_RGBWW = {ColorMode.COLOR_TEMP, ColorMode.HS} SUPPORT_LIMITLESSLED_RGBWW = ( LightEntityFeature.EFFECT | LightEntityFeature.FLASH | LightEntityFeature.TRANSITION ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_BRIDGES): vol.All( cv.ensure_list, [ { vol.Required(CONF_HOST): cv.string, vol.Optional( CONF_VERSION, default=DEFAULT_VERSION ): cv.positive_int, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(CONF_GROUPS): vol.All( cv.ensure_list, [ { vol.Required(CONF_NAME): cv.string, vol.Optional( CONF_TYPE, default=DEFAULT_LED_TYPE ): vol.In(LED_TYPE), vol.Required(CONF_NUMBER): cv.positive_int, vol.Optional( CONF_FADE, default=DEFAULT_FADE ): cv.boolean, } ], ), } ], ) } ) def rewrite_legacy(config: ConfigType) -> ConfigType: """Rewrite legacy configuration to new format.""" bridges = config.get(CONF_BRIDGES, [config]) new_bridges = [] for bridge_conf in bridges: groups = [] if "groups" in bridge_conf: groups = bridge_conf["groups"] else: _LOGGER.warning("Legacy configuration format detected") for i in range(1, 5): name_key = "group_%d_name" % i if name_key in bridge_conf: groups.append( { "number": i, "type": bridge_conf.get( "group_%d_type" % i, DEFAULT_LED_TYPE ), "name": bridge_conf.get(name_key), } ) new_bridges.append( { "host": bridge_conf.get(CONF_HOST), "version": bridge_conf.get(CONF_VERSION), "port": bridge_conf.get(CONF_PORT), "groups": groups, } ) return {"bridges": new_bridges} def setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the LimitlessLED lights.""" # Two legacy configuration formats are supported to maintain backwards # compatibility. config = rewrite_legacy(config) # Use the expanded configuration format. lights = [] bridge_conf: dict[str, Any] group_conf: dict[str, Any] for bridge_conf in config[CONF_BRIDGES]: bridge = Bridge( bridge_conf.get(CONF_HOST), port=bridge_conf.get(CONF_PORT, DEFAULT_PORT), version=bridge_conf.get(CONF_VERSION, DEFAULT_VERSION), ) for group_conf in bridge_conf[CONF_GROUPS]: group = bridge.add_group( group_conf.get(CONF_NUMBER), group_conf.get(CONF_NAME), group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE), ) lights.append(LimitlessLEDGroup(group, {"fade": group_conf[CONF_FADE]})) add_entities(lights) def state(new_state): """State decorator. Specify True (turn on) or False (turn off). """ def decorator(function): """Set up the decorator function.""" def wrapper(self: LimitlessLEDGroup, **kwargs: Any) -> None: """Wrap a group state change.""" # pylint: disable=protected-access pipeline = Pipeline() transition_time = DEFAULT_TRANSITION if self.effect == EFFECT_COLORLOOP: self.group.stop() self._attr_effect = None # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) # Do group type-specific work. function(self, transition_time, pipeline, **kwargs) # Update state. self._attr_is_on = new_state self.group.enqueue(pipeline) self.schedule_update_ha_state() return wrapper return decorator class LimitlessLEDGroup(LightEntity, RestoreEntity): """Representation of a LimitessLED group.""" _attr_assumed_state = True _attr_max_mireds = 370 _attr_min_mireds = 154 _attr_should_poll = False def __init__(self, group: Group, config: dict[str, Any]) -> None: """Initialize a group.""" if isinstance(group, WhiteGroup): self._attr_supported_color_modes = COLOR_MODES_LIMITLESS_WHITE self._attr_supported_features = SUPPORT_LIMITLESSLED_WHITE self._attr_effect_list = [EFFECT_NIGHT] elif isinstance(group, DimmerGroup): self._attr_supported_color_modes = COLOR_MODES_LIMITLESS_DIMMER self._attr_supported_features = SUPPORT_LIMITLESSLED_DIMMER self._attr_effect_list = [] elif isinstance(group, RgbwGroup): self._attr_supported_color_modes = COLOR_MODES_LIMITLESS_RGB self._attr_supported_features = SUPPORT_LIMITLESSLED_RGB self._attr_effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] elif isinstance(group, RgbwwGroup): self._attr_supported_color_modes = COLOR_MODES_LIMITLESS_RGBWW self._attr_supported_features = SUPPORT_LIMITLESSLED_RGBWW self._attr_effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] self._fixed_color_mode = None if self.supported_color_modes and len(self.supported_color_modes) == 1: self._fixed_color_mode = next(iter(self.supported_color_modes)) else: assert self._attr_supported_color_modes == { ColorMode.COLOR_TEMP, ColorMode.HS, } self.group = group self._attr_name = group.name self.config = config self._attr_is_on = False async def async_added_to_hass(self) -> None: """Handle entity about to be added to hass event.""" await super().async_added_to_hass() if last_state := await self.async_get_last_state(): self._attr_is_on = last_state.state == STATE_ON self._attr_brightness = last_state.attributes.get("brightness") self._attr_color_temp = last_state.attributes.get("color_temp") self._attr_hs_color = last_state.attributes.get("hs_color") @property def brightness(self) -> int | None: """Return the brightness property.""" if self.effect == EFFECT_NIGHT: return 1 return self._attr_brightness @property def color_mode(self) -> str | None: """Return the color mode of the light.""" if self._fixed_color_mode: return self._fixed_color_mode # The light supports both hs and white with adjustable color temperature if ( self.effect == EFFECT_NIGHT or self.hs_color is None or self.hs_color[1] == 0 ): return ColorMode.COLOR_TEMP return ColorMode.HS # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time: int, pipeline: Pipeline, **kwargs: Any) -> None: """Turn off a group.""" if self.config[CONF_FADE]: pipeline.transition(transition_time, brightness=0.0) pipeline.off() # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time: int, pipeline: Pipeline, **kwargs: Any) -> None: """Turn on (or adjust property of) a group.""" # The night effect does not need a turned on light if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT: if self.effect_list and EFFECT_NIGHT in self.effect_list: pipeline.night_light() self._attr_effect = EFFECT_NIGHT return pipeline.on() # Set up transition. args = {} if self.config[CONF_FADE] and not self.is_on and self.brightness: args["brightness"] = self.limitlessled_brightness() if ATTR_BRIGHTNESS in kwargs: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] args["brightness"] = self.limitlessled_brightness() if ATTR_HS_COLOR in kwargs: self._attr_hs_color = kwargs[ATTR_HS_COLOR] # White is a special case. assert self.hs_color is not None if self.hs_color[1] < MIN_SATURATION: pipeline.white() self._attr_hs_color = WHITE else: args["color"] = self.limitlessled_color() if ATTR_COLOR_TEMP in kwargs: assert self.supported_color_modes if ColorMode.HS in self.supported_color_modes: pipeline.white() self._attr_hs_color = WHITE self._attr_color_temp = kwargs[ATTR_COLOR_TEMP] args["temperature"] = self.limitlessled_temperature() if args: pipeline.transition(transition_time, **args) # Flash. if ATTR_FLASH in kwargs and self.supported_features & LightEntityFeature.FLASH: duration = 0 if kwargs[ATTR_FLASH] == FLASH_LONG: duration = 1 pipeline.flash(duration=duration) # Add effects. if ATTR_EFFECT in kwargs and self.effect_list: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: self._attr_effect = EFFECT_COLORLOOP pipeline.append(COLORLOOP) if kwargs[ATTR_EFFECT] == EFFECT_WHITE: pipeline.white() self._attr_hs_color = WHITE def limitlessled_temperature(self) -> float: """Convert Home Assistant color temperature units to percentage.""" max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds) min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds) width = max_kelvin - min_kelvin assert self.color_temp is not None kelvin = color_temperature_mired_to_kelvin(self.color_temp) temperature = (kelvin - min_kelvin) / width return max(0, min(1, temperature)) def limitlessled_brightness(self) -> float: """Convert Home Assistant brightness units to percentage.""" assert self.brightness is not None return self.brightness / 255 def limitlessled_color(self) -> Color: """Convert Home Assistant HS list to RGB Color tuple.""" assert self.hs_color is not None return Color(*color_hs_to_RGB(*self.hs_color))