Files
Home-Assistant/custom_components/alexa_media/light.py
T
2026-07-17 10:12:15 -04:00

531 lines
19 KiB
Python

"""
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))