""" Alexa Devices Lights. SPDX-License-Identifier: Apache-2.0 For more details about this platform, please refer to the documentation at https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639 """ import datetime import logging from math import sqrt from typing import Optional from alexapy import AlexaAPI, hide_serial from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, ) from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.color import ( color_hs_to_RGB, color_hsb_to_RGB, color_name_to_rgb, color_RGB_to_hs, ) from . import ( CONF_EMAIL, CONF_EXCLUDE_DEVICES, CONF_INCLUDE_DEVICES, DATA_ALEXAMEDIA, hide_email, ) from .alexa_entity import ( parse_brightness_from_coordinator, parse_color_from_coordinator, parse_color_temp_from_coordinator, parse_power_from_coordinator, ) from .const import CONF_EXTENDED_ENTITY_DISCOVERY from .helpers import add_devices, safe_get _LOGGER = logging.getLogger(__name__) LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Alexa sensor platform.""" devices: list[LightEntity] = [] account = None if config: account = config.get(CONF_EMAIL) if account is None and discovery_info: account = safe_get(discovery_info, ["config", CONF_EMAIL]) if account is None: raise ConfigEntryNotReady account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] include_filter = config.get(CONF_INCLUDE_DEVICES, []) exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) coordinator = account_dict["coordinator"] hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get( "components", set() ) light_entities = safe_get(account_dict, ["devices", "light"], []) if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY): for light_entity in light_entities: if not (light_entity["is_hue_v1"] and hue_emulated_enabled): _LOGGER.debug( "Creating entity %s for a light with name %s", hide_serial(light_entity["id"]), light_entity["name"], ) light = AlexaLight(coordinator, account_dict["login_obj"], light_entity) account_dict["entities"]["light"].append(light) devices.append(light) else: _LOGGER.debug( "Light '%s' has not been added because it may originate from emulated_hue", light_entity["name"], ) return await add_devices( hide_email(account), devices, add_devices_callback, include_filter, exclude_filter, ) async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the Alexa sensor platform by config_entry.""" return await async_setup_platform( hass, config_entry.data, async_add_devices, discovery_info=None ) async def async_unload_entry(hass, entry) -> bool: """Unload a config entry.""" account = entry.data[CONF_EMAIL] account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] _LOGGER.debug("Attempting to unload lights") for light in account_dict["entities"]["light"]: await light.async_remove() return True def color_modes(details) -> list: """Return list of color modes.""" if details["color"] and details["color_temperature"]: return [ColorMode.HS, ColorMode.COLOR_TEMP] if details["color"]: return [ColorMode.HS] if details["color_temperature"]: return [ColorMode.COLOR_TEMP] if details["brightness"]: return [ColorMode.BRIGHTNESS] return [ColorMode.ONOFF] class AlexaLight(CoordinatorEntity, LightEntity): """A light controlled by an Echo.""" def __init__(self, coordinator, login, details): """Initialize alexa light entity.""" super().__init__(coordinator) self.alexa_entity_id = details["id"] self._name = details["name"] self._login = login self._attr_supported_color_modes = color_modes(details) self._attr_min_color_temp_kelvin = 2200 self._attr_max_color_temp_kelvin = 6500 # Store the requested state from the last call to _set_state # This is so that no new network call is needed just to get values that are already known # This is useful because refreshing the full state can take a bit when many lights are in play. # Especially since Alexa actually polls the lights and that appears to be error-prone with some Zigbee lights. # That delay(1-5s in practice) causes the UI controls to jump all over the place after _set_state self._requested_state_at = None # When was state last set in UTC self._requested_power = None self._requested_ha_brightness = None self._requested_kelvin = None self._requested_hs = None @property def name(self): """Return name.""" return self._name @property def unique_id(self): """Return unique id.""" return self.alexa_entity_id @property def color_mode(self): """Return color mode.""" if ( ColorMode.HS in self._attr_supported_color_modes and ColorMode.COLOR_TEMP in self._attr_supported_color_modes ): hs_color = self.hs_color if hs_color is None or (hs_color[0] == 0 and hs_color[1] == 0): # (0,0) is white. When white, color temp is the better plan. return ColorMode.COLOR_TEMP return ColorMode.HS return self._attr_supported_color_modes[0] @property def is_on(self): """Return whether on.""" power = parse_power_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if power is None: return self._requested_power if self._requested_power is not None else False return power == "ON" @property def brightness(self): """Return brightness.""" bright = parse_brightness_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if bright is None: return self._requested_ha_brightness return alexa_brightness_to_ha(bright) @property def color_temp_kelvin(self): """Return color temperature.""" kelvin = parse_color_temp_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if kelvin is None: return self._requested_kelvin return kelvin_to_alexa(kelvin)[0] @property def hs_color(self): """Return hs color.""" hsb = parse_color_from_coordinator( self.coordinator, self.alexa_entity_id, self._requested_state_at ) if hsb is None: return self._requested_hs ( adjusted_hs, color_name, # pylint:disable=unused-variable ) = hsb_to_alexa_color(hsb) return adjusted_hs @property def assumed_state(self) -> bool: """Return whether state is assumed.""" last_refresh_success = ( self.coordinator.data and self.alexa_entity_id in self.coordinator.data ) return not last_refresh_success async def _set_state(self, power_on, brightness=None, kelvin=None, hs_color=None): # This is "rounding" on kelvin to the closest value Alexa is willing to acknowledge the existence of. # The alternative implementation would be to use effects instead. # That is far more non-standard, and would lock users out of things like the Flux integration. # The downsides to this approach is that the UI is giving the user a slider # When the user picks a slider value, the UI will "jump" to the closest possible value. # This trade-off doesn't feel as bad in practice as it sounds. adjusted_kelvin, color_temperature_name = kelvin_to_alexa(kelvin) if color_temperature_name is None: # This is "rounding" on HS color to closest value Alexa supports. # The alexa color list is short, but covers a pretty broad spectrum. # Like for kelvin above, this sounds bad but works ok in practice. adjusted_hs, color_name = hs_to_alexa_color(hs_color) else: # If a color temperature is being set, it is not possible to also adjust the color. adjusted_hs = None color_name = None response = await AlexaAPI.set_light_state( self._login, self.alexa_entity_id, power_on, brightness=ha_brightness_to_alexa(brightness), color_temperature_name=color_temperature_name, color_name=color_name, ) if not isinstance(response, dict): return await self.coordinator.async_request_refresh() control_responses = response.get("controlResponses", []) for response in control_responses: if not response.get("code") == "SUCCESS": # If something failed any state is possible, fallback to a full refresh return await self.coordinator.async_request_refresh() self._requested_power = power_on self._requested_ha_brightness = ( brightness if brightness is not None else self.brightness ) self._requested_kelvin = ( adjusted_kelvin if adjusted_kelvin is not None else self.color_temp_kelvin ) if adjusted_hs is not None: self._requested_hs = adjusted_hs elif adjusted_kelvin is not None: # If a kelvin value was set, it is critical that color is cleared out so that color mode is set properly self._requested_hs = None else: self._requested_hs = self.hs_color self._requested_state_at = datetime.datetime.now( datetime.timezone.utc ) # must be set last so that previous getters work properly self.schedule_update_ha_state() # Confirm quickly, but debounce to avoid spamming during slider drags. account = self.hass.data[DATA_ALEXAMEDIA]["accounts"].get(self._login.email) if account: debouncer = account.get("confirm_refresh_debouncer") if debouncer: await debouncer.async_call() async def async_turn_on(self, **kwargs): """Turn on.""" brightness = None kelvin = None hs_color = None if ( ColorMode.ONOFF not in self._attr_supported_color_modes and ATTR_BRIGHTNESS in kwargs ): brightness = kwargs[ATTR_BRIGHTNESS] if ( ColorMode.COLOR_TEMP in self._attr_supported_color_modes and ATTR_COLOR_TEMP_KELVIN in kwargs ): kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] if ColorMode.HS in self._attr_supported_color_modes and ATTR_HS_COLOR in kwargs: hs_color = kwargs[ATTR_HS_COLOR] await self._set_state(True, brightness, kelvin, hs_color) async def async_turn_off(self, **kwargs): # pylint:disable=unused-argument """Turn off.""" await self._set_state(False) def kelvin_to_alexa(kelvin: Optional[float]) -> tuple[Optional[float], Optional[str]]: """Convert a given color temperature in kelvin to the closest available value that Alexa has support for.""" if kelvin is None: return None, None if kelvin <= 2400: return 2200, "warm_white" if kelvin <= 3200: return 2700, "soft_white" if kelvin <= 4400: return 4000, "white" if kelvin <= 6000: return 5400, "daylight_white" return 6500, "cool_white" def ha_brightness_to_alexa(ha_brightness: Optional[float]) -> Optional[float]: """Convert HA brightness to alexa brightness.""" return (ha_brightness / 255 * 100) if ha_brightness is not None else None def alexa_brightness_to_ha(alexa: Optional[float]) -> Optional[float]: """Convert Alexa brightness to HA brightness.""" return (alexa / 100 * 255) if alexa is not None else None # This is a fairly complete list of all the colors that Alexa will respond to and their associated RGB value. ALEXA_COLORS = { "alice_blue": (240, 248, 255), "antique_white": (250, 235, 215), "aqua": (0, 255, 255), "aquamarine": (127, 255, 212), "azure": (240, 255, 255), "beige": (245, 245, 220), "bisque": (255, 228, 196), "black": (0, 0, 0), "blanched_almond": (255, 235, 205), "blue": (0, 0, 255), "blue_violet": (138, 43, 226), "brown": (165, 42, 42), "burlywood": (222, 184, 135), "cadet_blue": (95, 158, 160), "chartreuse": (127, 255, 0), "chocolate": (210, 105, 30), "coral": (255, 127, 80), "cornflower_blue": (100, 149, 237), "cornsilk": (255, 248, 220), "crimson": (220, 20, 60), "cyan": (0, 255, 255), "dark_blue": (0, 0, 139), "dark_cyan": (0, 139, 139), "dark_goldenrod": (184, 134, 11), "dark_green": (0, 100, 0), "dark_grey": (169, 169, 169), "dark_khaki": (189, 183, 107), "dark_magenta": (139, 0, 139), "dark_olive_green": (85, 107, 47), "dark_orange": (255, 140, 0), "dark_orchid": (153, 50, 204), "dark_red": (139, 0, 0), "dark_salmon": (233, 150, 122), "dark_sea_green": (143, 188, 143), "dark_slate_blue": (72, 61, 139), "dark_slate_grey": (47, 79, 79), "dark_turquoise": (0, 206, 209), "dark_violet": (148, 0, 211), "deep_pink": (255, 20, 147), "deep_sky_blue": (0, 191, 255), "dim_grey": (105, 105, 105), "dodger_blue": (30, 144, 255), "firebrick": (178, 34, 34), "floral_white": (255, 250, 240), "forest_green": (34, 139, 34), "fuchsia": (255, 0, 255), "gainsboro": (220, 220, 220), "ghost_white": (248, 248, 255), "gold": (255, 215, 0), "goldenrod": (218, 165, 32), "green": (0, 128, 0), "green_yellow": (173, 255, 47), "grey": (128, 128, 128), "honey_dew": (240, 255, 240), "hot_pink": (255, 105, 180), "indian_red": (205, 92, 92), "indigo": (75, 0, 130), "ivory": (255, 255, 240), "khaki": (240, 230, 140), "lavender": (230, 230, 250), "lavender_blush": (255, 240, 245), "lawn_green": (124, 252, 0), "lemon_chiffon": (255, 250, 205), "light_blue": (173, 216, 230), "light_coral": (240, 128, 128), "light_cyan": (224, 255, 255), "light_goldenrod_yellow": (250, 250, 210), "light_green": (144, 238, 144), "light_grey": (211, 211, 211), "light_pink": (255, 182, 193), "light_salmon": (255, 160, 122), "light_sea_green": (32, 178, 170), "light_sky_blue": (135, 206, 250), "light_slate_grey": (119, 136, 153), "light_steel_blue": (176, 196, 222), "light_yellow": (255, 255, 224), "lime": (0, 255, 0), "lime_green": (50, 205, 50), "linen": (250, 240, 230), "magenta": (255, 0, 255), "maroon": (128, 0, 0), "medium_aqua_marine": (102, 205, 170), "medium_blue": (0, 0, 205), "medium_orchid": (186, 85, 211), "medium_purple": (147, 112, 219), "medium_sea_green": (60, 179, 113), "medium_slate_blue": (123, 104, 238), "medium_spring_green": (0, 250, 154), "medium_turquoise": (72, 209, 204), "medium_violet_red": (199, 21, 133), "midnight_blue": (25, 25, 112), "mint_cream": (245, 255, 250), "misty_rose": (255, 228, 225), "moccasin": (255, 228, 181), "navajo_white": (255, 222, 173), "navy": (0, 0, 128), "old_lace": (253, 245, 230), "olive": (128, 128, 0), "olive_drab": (107, 142, 35), "orange": (255, 165, 0), "orange_red": (255, 69, 0), "orchid": (218, 112, 214), "pale_goldenrod": (238, 232, 170), "pale_green": (152, 251, 152), "pale_turquoise": (175, 238, 238), "pale_violet_red": (219, 112, 147), "papaya_whip": (255, 239, 213), "peach_puff": (255, 218, 185), "peru": (205, 133, 63), "pink": (255, 192, 203), "plum": (221, 160, 221), "powder_blue": (176, 224, 230), "purple": (128, 0, 128), "rebecca_purple": (102, 51, 153), "red": (255, 0, 0), "rosy_brown": (188, 143, 143), "royal_blue": (65, 105, 225), "saddle_brown": (139, 69, 19), "salmon": (250, 128, 114), "sandy_brown": (244, 164, 96), "sea_green": (46, 139, 87), "sea_shell": (255, 245, 238), "sienna": (160, 82, 45), "silver": (192, 192, 192), "sky_blue": (135, 206, 235), "slate_blue": (106, 90, 205), "slate_grey": (112, 128, 144), "snow": (255, 250, 250), "spring_green": (0, 255, 127), "steel_blue": (70, 130, 180), "tan": (210, 180, 140), "teal": (0, 128, 128), "thistle": (216, 191, 216), "tomato": (255, 99, 71), "turquoise": (64, 224, 208), "violet": (238, 130, 238), "wheat": (245, 222, 179), "white": (255, 255, 255), "white_smoke": (245, 245, 245), "yellow": (255, 255, 0), "yellow_green": (154, 205, 50), } def red_mean(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float: """Get an approximate 'distance' between two colors using red mean. Wikipedia says this method is "one of the better low-cost approximations". """ r_avg = (color2[0] + color1[0]) / 2 r_delta = color2[0] - color1[0] g_delta = color2[1] - color1[1] b_delta = color2[2] - color1[2] r_term = (2 + r_avg / 256) * pow(r_delta, 2) g_term = 4 * pow(g_delta, 2) b_term = (2 + (255 - r_avg) / 256) * pow(b_delta, 2) return sqrt(r_term + g_term + b_term) def alexa_color_name_to_rgb(color_name: str) -> tuple[int, int, int]: """Convert an alexa color name into RGB.""" return color_name_to_rgb(color_name.replace("_", "")) def rgb_to_alexa_color( rgb: tuple[int, int, int], ) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given RGB value into the closest Alexa color.""" name, alexa_rgb = min( ALEXA_COLORS.items(), key=lambda alexa_color: red_mean(alexa_color[1], rgb), ) red, green, blue = alexa_rgb return color_RGB_to_hs(red, green, blue), name def hs_to_alexa_color( hs_color: Optional[tuple[float, float]], ) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given hue/saturation value into the closest Alexa color.""" if hs_color is None: return None, None hue, saturation = hs_color return rgb_to_alexa_color(color_hs_to_RGB(hue, saturation)) def hsb_to_alexa_color( hsb: Optional[tuple[float, float, float]], ) -> tuple[Optional[tuple[float, float]], Optional[str]]: """Convert a given hue/saturation/brightness value into the closest Alexa color.""" if hsb is None: return None, None hue, saturation, brightness = hsb return rgb_to_alexa_color(color_hsb_to_RGB(hue, saturation, brightness))