"""Support for FluxLED/MagicHome lights.""" from __future__ import annotations import ast import logging import random from typing import Any, Final, cast from flux_led.const import ( COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, COLOR_MODE_DIM as FLUX_COLOR_MODE_DIM, COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB, COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW, ) from flux_led.utils import ( color_temp_to_white_levels, rgbcw_brightness, rgbcw_to_rgbwc, rgbw_brightness, rgbww_brightness, ) import voluptuous as vol from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_RGBWW_COLOR, ATTR_WHITE, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, COLOR_MODE_ONOFF, COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_WHITE, EFFECT_RANDOM, PLATFORM_SCHEMA, SUPPORT_EFFECT, SUPPORT_TRANSITION, LightEntity, ) from homeassistant.const import ( ATTR_MODE, CONF_DEVICES, CONF_HOST, CONF_MAC, CONF_MODE, CONF_NAME, CONF_PROTOCOL, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.color import ( color_hs_to_RGB, color_RGB_to_hs, color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, ) from . import FluxLedUpdateCoordinator from .const import ( CONF_AUTOMATIC_ADD, CONF_COLORS, CONF_CUSTOM_EFFECT, CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, CONF_SPEED_PCT, CONF_TRANSITION, DEFAULT_EFFECT_SPEED, DOMAIN, FLUX_HOST, FLUX_LED_DISCOVERY, FLUX_MAC, MODE_AUTO, MODE_RGB, MODE_RGBW, MODE_WHITE, TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE, ) from .entity import FluxEntity _LOGGER = logging.getLogger(__name__) SUPPORT_FLUX_LED: Final = SUPPORT_TRANSITION FLUX_COLOR_MODE_TO_HASS: Final = { FLUX_COLOR_MODE_RGB: COLOR_MODE_RGB, FLUX_COLOR_MODE_RGBW: COLOR_MODE_RGBW, FLUX_COLOR_MODE_RGBWW: COLOR_MODE_RGBWW, FLUX_COLOR_MODE_CCT: COLOR_MODE_COLOR_TEMP, } EFFECT_SUPPORT_MODES = {COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW} # Constant color temp values for 2 flux_led special modes # Warm-white and Cool-white modes COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: Final = 285 EFFECT_CUSTOM: Final = "custom" SERVICE_CUSTOM_EFFECT: Final = "set_custom_effect" CUSTOM_EFFECT_DICT: Final = { vol.Required(CONF_COLORS): vol.All( cv.ensure_list, vol.Length(min=1, max=16), [vol.All(vol.Coerce(tuple), vol.ExactSequence((cv.byte, cv.byte, cv.byte)))], ), vol.Optional(CONF_SPEED_PCT, default=50): vol.All( vol.Range(min=0, max=100), vol.Coerce(int) ), vol.Optional(CONF_TRANSITION, default=TRANSITION_GRADUAL): vol.All( cv.string, vol.In([TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE]) ), } CUSTOM_EFFECT_SCHEMA: Final = vol.Schema(CUSTOM_EFFECT_DICT) DEVICE_SCHEMA: Final = vol.Schema( { vol.Optional(CONF_NAME): cv.string, vol.Optional(ATTR_MODE, default=MODE_AUTO): vol.All( cv.string, vol.In([MODE_AUTO, MODE_RGBW, MODE_RGB, MODE_WHITE]) ), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(["ledenet"])), vol.Optional(CONF_CUSTOM_EFFECT): CUSTOM_EFFECT_SCHEMA, } ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, } ) def _flux_color_mode_to_hass(flux_color_mode: str, flux_color_modes: set[str]) -> str: """Map the flux color mode to Home Assistant color mode.""" if flux_color_mode == FLUX_COLOR_MODE_DIM: if len(flux_color_modes) > 1: return COLOR_MODE_WHITE return COLOR_MODE_BRIGHTNESS return FLUX_COLOR_MODE_TO_HASS.get(flux_color_mode, COLOR_MODE_ONOFF) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: """Set up the flux led platform.""" domain_data = hass.data[DOMAIN] discovered_mac_by_host = { device[FLUX_HOST]: device[FLUX_MAC] for device in domain_data[FLUX_LED_DISCOVERY] } for host, device_config in config.get(CONF_DEVICES, {}).items(): _LOGGER.warning( "Configuring flux_led via yaml is deprecated; the configuration for" " %s has been migrated to a config entry and can be safely removed", host, ) custom_effects = device_config.get(CONF_CUSTOM_EFFECT, {}) custom_effect_colors = None if CONF_COLORS in custom_effects: custom_effect_colors = str(custom_effects[CONF_COLORS]) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ CONF_HOST: host, CONF_MAC: discovered_mac_by_host.get(host), CONF_NAME: device_config[CONF_NAME], CONF_PROTOCOL: device_config.get(CONF_PROTOCOL), CONF_MODE: device_config.get(ATTR_MODE, MODE_AUTO), CONF_CUSTOM_EFFECT_COLORS: custom_effect_colors, CONF_CUSTOM_EFFECT_SPEED_PCT: custom_effects.get( CONF_SPEED_PCT, DEFAULT_EFFECT_SPEED ), CONF_CUSTOM_EFFECT_TRANSITION: custom_effects.get( CONF_TRANSITION, TRANSITION_GRADUAL ), }, ) ) return True async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Flux lights.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_CUSTOM_EFFECT, CUSTOM_EFFECT_DICT, "async_set_custom_effect", ) options = entry.options try: custom_effect_colors = ast.literal_eval( options.get(CONF_CUSTOM_EFFECT_COLORS) or "[]" ) except (ValueError, TypeError, SyntaxError, MemoryError) as ex: _LOGGER.warning( "Could not parse custom effect colors for %s: %s", entry.unique_id, ex ) custom_effect_colors = [] async_add_entities( [ FluxLight( coordinator, entry.unique_id, entry.data[CONF_NAME], list(custom_effect_colors), options.get(CONF_CUSTOM_EFFECT_SPEED_PCT, DEFAULT_EFFECT_SPEED), options.get(CONF_CUSTOM_EFFECT_TRANSITION, TRANSITION_GRADUAL), ) ] ) class FluxLight(FluxEntity, CoordinatorEntity, LightEntity): """Representation of a Flux light.""" def __init__( self, coordinator: FluxLedUpdateCoordinator, unique_id: str | None, name: str, custom_effect_colors: list[tuple[int, int, int]], custom_effect_speed_pct: int, custom_effect_transition: str, ) -> None: """Initialize the light.""" super().__init__(coordinator, unique_id, name) self._attr_supported_features = SUPPORT_FLUX_LED self._attr_min_mireds = ( color_temperature_kelvin_to_mired(self._device.max_temp) + 1 ) # for rounding self._attr_max_mireds = color_temperature_kelvin_to_mired(self._device.min_temp) self._attr_supported_color_modes = { _flux_color_mode_to_hass(mode, self._device.color_modes) for mode in self._device.color_modes } if self._attr_supported_color_modes.intersection(EFFECT_SUPPORT_MODES): self._attr_supported_features |= SUPPORT_EFFECT self._attr_effect_list = [*self._device.effect_list, EFFECT_RANDOM] if custom_effect_colors: self._attr_effect_list.append(EFFECT_CUSTOM) self._custom_effect_colors = custom_effect_colors self._custom_effect_speed_pct = custom_effect_speed_pct self._custom_effect_transition = custom_effect_transition @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return cast(int, self._device.brightness) @property def color_temp(self) -> int: """Return the kelvin value of this light in mired.""" return color_temperature_kelvin_to_mired(self._device.color_temp) @property def rgb_color(self) -> tuple[int, int, int]: """Return the rgb color value.""" # Note that we call color_RGB_to_hs and not color_RGB_to_hsv # to get the unscaled value since this is what the frontend wants # https://github.com/home-assistant/frontend/blob/e797c017614797bb11671496d6bd65863de22063/src/dialogs/more-info/controls/more-info-light.ts#L263 rgb: tuple[int, int, int] = color_hs_to_RGB(*color_RGB_to_hs(*self._device.rgb)) return rgb @property def rgbw_color(self) -> tuple[int, int, int, int]: """Return the rgbw color value.""" rgbw: tuple[int, int, int, int] = self._device.rgbw return rgbw @property def rgbww_color(self) -> tuple[int, int, int, int, int]: """Return the rgbww aka rgbcw color value.""" rgbcw: tuple[int, int, int, int, int] = self._device.rgbcw return rgbcw @property def rgbwc_color(self) -> tuple[int, int, int, int, int]: """Return the rgbwc color value.""" rgbwc: tuple[int, int, int, int, int] = self._device.rgbww return rgbwc @property def color_mode(self) -> str: """Return the color mode of the light.""" return _flux_color_mode_to_hass( self._device.color_mode, self._device.color_modes ) @property def effect(self) -> str | None: """Return the current effect.""" effect = self._device.effect if effect is None: return None return cast(str, effect) async def _async_turn_on(self, **kwargs: Any) -> None: """Turn the specified or all lights on.""" if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None: brightness = self.brightness if not self.is_on: await self._device.async_turn_on() if not kwargs: return # If the brightness was previously 0, the light # will not turn on unless brightness is at least 1 if not brightness: brightness = 1 elif not brightness: # If the device was on and brightness was not # set, it means it was masked by an effect brightness = 255 # Handle switch to CCT Color Mode if ATTR_COLOR_TEMP in kwargs: color_temp_mired = kwargs[ATTR_COLOR_TEMP] color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) if self.color_mode != COLOR_MODE_RGBWW: await self._device.async_set_white_temp(color_temp_kelvin, brightness) return # When switching to color temp from RGBWW mode, # we do not want the overall brightness, we only # want the brightness of the white channels brightness = kwargs.get( ATTR_BRIGHTNESS, self._device.getWhiteTemperature()[1] ) cold, warm = color_temp_to_white_levels(color_temp_kelvin, brightness) await self._device.async_set_levels(r=0, b=0, g=0, w=warm, w2=cold) return # Handle switch to RGB Color Mode if ATTR_RGB_COLOR in kwargs: await self._device.async_set_levels( *kwargs[ATTR_RGB_COLOR], brightness=brightness ) return # Handle switch to RGBW Color Mode if ATTR_RGBW_COLOR in kwargs: if ATTR_BRIGHTNESS in kwargs: rgbw = rgbw_brightness(kwargs[ATTR_RGBW_COLOR], brightness) else: rgbw = kwargs[ATTR_RGBW_COLOR] await self._device.async_set_levels(*rgbw) return # Handle switch to RGBWW Color Mode if ATTR_RGBWW_COLOR in kwargs: if ATTR_BRIGHTNESS in kwargs: rgbcw = rgbcw_brightness(kwargs[ATTR_RGBWW_COLOR], brightness) else: rgbcw = kwargs[ATTR_RGBWW_COLOR] await self._device.async_set_levels(*rgbcw_to_rgbwc(rgbcw)) return if ATTR_WHITE in kwargs: await self._device.async_set_levels(w=kwargs[ATTR_WHITE]) return if ATTR_EFFECT in kwargs: effect = kwargs[ATTR_EFFECT] # Random color effect if effect == EFFECT_RANDOM: await self._device.async_set_levels( random.randint(0, 255), random.randint(0, 255), random.randint(0, 255), ) return # Custom effect if effect == EFFECT_CUSTOM: if self._custom_effect_colors: await self._device.async_set_custom_pattern( self._custom_effect_colors, self._custom_effect_speed_pct, self._custom_effect_transition, ) return await self._device.async_set_effect(effect, DEFAULT_EFFECT_SPEED) return # Handle brightness adjustment in CCT Color Mode if self.color_mode == COLOR_MODE_COLOR_TEMP: await self._device.async_set_white_temp(self._device.color_temp, brightness) return # Handle brightness adjustment in RGB Color Mode if self.color_mode == COLOR_MODE_RGB: await self._device.async_set_levels(*self.rgb_color, brightness=brightness) return # Handle brightness adjustment in RGBW Color Mode if self.color_mode == COLOR_MODE_RGBW: await self._device.async_set_levels( *rgbw_brightness(self.rgbw_color, brightness) ) return # Handle brightness adjustment in RGBWW Color Mode if self.color_mode == COLOR_MODE_RGBWW: rgbwc = self.rgbwc_color await self._device.async_set_levels(*rgbww_brightness(rgbwc, brightness)) return # Handle Brightness Only Color Mode if self.color_mode in {COLOR_MODE_WHITE, COLOR_MODE_BRIGHTNESS}: await self._device.async_set_levels(w=brightness) return raise ValueError(f"Unsupported color mode {self.color_mode}") async def async_set_custom_effect( self, colors: list[tuple[int, int, int]], speed_pct: int, transition: str ) -> None: """Set a custom effect on the bulb.""" await self._device.async_set_custom_pattern( colors, speed_pct, transition, )