"""Platform to locally control Tuya-based light devices.""" import base64 import logging import textwrap import homeassistant.util.color as color_util import voluptuous as vol from dataclasses import dataclass from functools import partial from homeassistant.helpers import selector from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_WHITE, ColorMode, DOMAIN, LightEntity, LightEntityFeature, ) from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE from .config_flow import col_to_select from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_BRIGHTNESS_LOWER, CONF_BRIGHTNESS_UPPER, CONF_COLOR, CONF_COLOR_MODE, CONF_COLOR_MODE_SET, CONF_COLOR_TEMP_MAX_KELVIN, CONF_COLOR_TEMP_MIN_KELVIN, CONF_COLOR_TEMP_REVERSE, CONF_MUSIC_MODE, CONF_SCENE_VALUES, DictSelector, ) _LOGGER = logging.getLogger(__name__) DEFAULT_MIN_KELVIN = 2700 # MIRED 370 DEFAULT_MAX_KELVIN = 6500 # MIRED 153 DEFAULT_COLOR_TEMP_REVERSE = False DEFAULT_LOWER_BRIGHTNESS = 10 DEFAULT_UPPER_BRIGHTNESS = 1000 MODE_MANUAL = "manual" MODE_COLOR = "colour" MODE_MUSIC = "music" MODE_SCENE = "scene" MODE_WHITE = "white" SCENE_MUSIC = "Music" MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1} # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-10-scene_data SCENE_LIST_RGBW_255 = { "Night": "bd76000168ffff", "Read": "fffcf70168ffff", "Meeting": "cf38000168ffff", "Leisure": "3855b40168ffff", "Scenario 1": "scene_1", "Scenario 2": "scene_2", "Scenario 3": "scene_3", "Scenario 4": "scene_4", } # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-11-scene_data_v2 SCENE_LIST_RGBW_1000 = { "Night 1": "000e0d0000000000000000c80000", "Night 2": "000e0d00002e03e802cc00000000", "Read 1": "010e0d0000000000000003e801f4", "Read 2": "010e0d000084000003e800000000", "Meeting": "020e0d0000000000000003e803e8", "Working": "020e0d00001403e803e800000000", "Leisure 1": "030e0d0000000000000001f401f4", "Leisure 2": "030e0d0000e80383031c00000000", "Soft": "04464602007803e803e800000000464602007803e8000a00000000", "Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803" + "e800000000", "Colorful": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + "e800000000", "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" + "3e800000000", "Forest": "19464601007803e803e800000000464602006e0320025800000000464602005a038403e8" + "00000000", "Dream": "1c4646020104032003e800000000464602011802bc03e800000000464602011303e803e80" + "0000000", "F Style": "1e323201015e01f403e800000000323202003201f403e80000000032320200a001f403e" + "800000000", "A Style": "1f46460100dc02bc03e800000000464602006e03200258000000004646020014038403e" + "800000000464601012703e802ee0000000046460100000384028a00000000", "Halloween": "28464601011303e803e800000000464601001e03e803e800000000", "Christmas": "225a5a0100f003e803e8000000005a5a01003d03e803e800000000464601000003e80" + "3e8000000005a5a0100ae03e803e8000000005a5a01011303e803e800000000464601007803e803e" + "800000000", "Birthday": "20646401003d03e803e800000000646401007803e803e8000000005a5a01011303e803" + "e8000000005a5a0100ae03e803e800000000646401003201f403e800000000646401000003e803e8" + "00000000", "Wedding Anniversary": "21323202015e01f403e800000000323202011303e803e800000000", } # Same format as SCENE_LIST_RGBW_1000 SCENE_LIST_RGB_1000 = { "Night": "000e0d00002e03e802cc00000000", "Read": "010e0d000084000003e800000000", "Working": "020e0d00001403e803e800000000", "Leisure": "030e0d0000e80383031c00000000", "Soft": "04464602007803e803e800000000464602007803e8000a00000000", "Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80" + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + "e800000000", "Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + "3e800000000", "Gorgeous": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" + "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80" + "0000000", } SCENE_LIST_RGBW_BLE = { "Good Night": "AAAAAGQKQA==", "Reading": "AQAAAGRkCA==", "Work": "AgAAAGRkAQ==", "Leisure": "AwAAAGQ8BA==", "White Breath": "BgACADxkAYA=", "White Flashing": "BwABADJkAYA=", "Warm Breath": "BwABADJkAYA=", "Warm Flashing": "CQABADJkQIA=", "Rainbow": "CgACAUtkAQIEECAI", "Blue & Green Gradient": "CwACAUtkAgQ=", "Red & Green Gradient": "DAACAUtkAQQ=", "Red & Blue Gradient": "DQACAUtkAQI=", "Red & Blue & Green Gradient": "DgACATxkAYACgASA", "Red Breath": "DwACATxkAYA=", "Flash": "FAABATJkAQIEECAI", } @dataclass(frozen=True) class Mode: color: str = MODE_COLOR music: str = MODE_MUSIC scene: str = MODE_SCENE white: str = MODE_WHITE def as_list(self) -> list: return [self.color, self.music, self.scene, self.white] def as_dict(self) -> dict[str, str]: default = {"Default": self.white} return {**default, "Mode Color": self.color, "Mode Scene": self.scene} MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)} def map_range( value: int, from_min: int, from_max: int, to_min=0, to_max=255, reverse=False ): """Maps a value from one range to another.""" if reverse: value = from_max - (value - from_min) scale = (to_max - to_min) / (from_max - from_min) mapped_value = to_min + (value - from_min) * scale return min(max(round(mapped_value), to_min), to_max) def map_value_by_percent(value: int, value_max: int, target_max: int, percentage=100): """Convert a value from one range to another based on percentage.""" raw_percent = (value / value_max) * percentage return round((raw_percent / percentage) * target_max) def flow_schema(dps): """Return schema used in config flow.""" return { vol.Optional(CONF_BRIGHTNESS): col_to_select(dps, is_dps=True), vol.Optional(CONF_COLOR_TEMP): col_to_select(dps, is_dps=True), vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All( vol.Coerce(int), vol.Range(min=0, max=10000) ), vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All( vol.Coerce(int), vol.Range(min=0, max=10000) ), vol.Optional(CONF_COLOR_MODE): col_to_select(dps, is_dps=True), vol.Required(CONF_COLOR_MODE_SET, default="0"): col_to_select(MODES_SET), vol.Optional(CONF_COLOR): col_to_select(dps, is_dps=True), vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All( vol.Coerce(int), vol.Range(min=1500, max=8000) ), vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All( vol.Coerce(int), vol.Range(min=1500, max=8000) ), vol.Optional(CONF_COLOR_TEMP_REVERSE, default=DEFAULT_COLOR_TEMP_REVERSE): bool, vol.Optional(CONF_SCENE): col_to_select(dps, is_dps=True), vol.Optional(CONF_SCENE_VALUES, default={}): selector.ObjectSelector(), vol.Optional(CONF_MUSIC_MODE, default=False): selector.BooleanSelector(), } class LocalTuyaLight(LocalTuyaEntity, LightEntity): """Representation of a Tuya light.""" def __init__( self, device, config_entry, lightid, **kwargs, ): """Initialize the Tuya light.""" super().__init__(device, config_entry, lightid, _LOGGER, **kwargs) # Light is an active device (mains powered). It should be able # to respond at any time. But Tuya BLE bulbs are write-only. self._write_only = self._device.is_write_only self._state = None self._color_temp = None self._lower_brightness = int( self._config.get(CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS) ) self._upper_brightness = int( self._config.get(CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS) ) self._brightness = None if not self._write_only else self._upper_brightness self._upper_color_temp = self._upper_brightness self._color_temp_reverse = self._config.get(CONF_COLOR_TEMP_REVERSE, False) self._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))] self._hs = None self._effect = None self._effect_list = [] self._scenes = DictSelector({}) self._cached_status = {} if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) self._attr_min_color_temp_kelvin = int( self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) self._attr_max_color_temp_kelvin = int( self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self.__to_color = self.__to_color_common self.__from_color = self.__from_color_common def connection_made(self): """The connection has made with the device and status retrieved, Configure the entity based on its reserved status.""" super().connection_made() is_write_only = self._write_only if self.has_config(CONF_SCENE): if (cf_scenes := self._config.get(CONF_SCENE_VALUES)) and len(cf_scenes): scenes = {v: k for k, v in cf_scenes.items()} else: scene_value = self.dp_value(CONF_SCENE) if is_write_only and not scene_value: scenes = SCENE_LIST_RGBW_BLE elif scene_value and len(scene_value) <= 20: scenes = SCENE_LIST_RGBW_255 elif self._config.get(CONF_BRIGHTNESS) is None: scenes = SCENE_LIST_RGB_1000 else: scenes = SCENE_LIST_RGBW_1000 scenes = {**self._modes.as_dict(), **scenes} self._scenes = DictSelector(scenes, reverse=True) self._effect_list = list(scenes.keys()) + self._effect_list if self.has_config(CONF_COLOR): color_data = self.dp_value(CONF_COLOR) if is_write_only and not color_data: self.__to_color = self.__to_color_raw self.__from_color = self.__from_color_raw else: self.__to_color = self.__to_color_common self.__from_color = self.__from_color_common if is_write_only and self._cached_status: self._status.update(self._cached_status) @property def extra_state_attributes(self): """Return entity specific state attributes to be saved. These attributes are then available for restore when the entity is restored at startup. """ attributes = super().extra_state_attributes extra_attrs = (CONF_COLOR_MODE, CONF_COLOR, CONF_BRIGHTNESS, CONF_COLOR_TEMP) for attr in extra_attrs: dp = self._config.get(attr) if dp is not None and (state := self._status.get(dp)) is not None: attributes[f"raw_{attr}"] = state return attributes @property def is_on(self): """Check if Tuya light is on.""" return self._state @property def brightness(self): """Return the brightness of the light.""" brightness = self._brightness if brightness is not None and (self.is_color_mode or self.is_white_mode): return map_value_by_percent(brightness, self._upper_brightness, 255) return None @property def hs_color(self): """Return the hs color value.""" if self.is_color_mode: return self._hs if ( ColorMode.HS in self.supported_color_modes and not ColorMode.COLOR_TEMP in self.supported_color_modes ): return [0, 0] return None @property def color_temp_kelvin(self): """Return the color_temp of the light.""" if self._color_temp is not None: return map_range( self._color_temp, self._lower_brightness, self._upper_brightness, self.min_color_temp_kelvin, self.max_color_temp_kelvin, self._color_temp_reverse, ) @property def effect(self): """Return the current effect for this light.""" if self.is_scene_mode or self.is_music_mode: return self._effect elif (color_mode := self.__get_color_mode()) in self._scenes.values: return self.__find_scene_by_scene_data(color_mode) return None @property def effect_list(self): """Return the list of supported effects for this light.""" if len(self._effect_list) > 0: return self._effect_list return None @property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: """Flag supported color modes.""" color_modes: set[ColorMode] = set() if self.has_config(CONF_COLOR_TEMP): color_modes.add(ColorMode.COLOR_TEMP) elif self.has_config(CONF_BRIGHTNESS): color_modes.add(ColorMode.WHITE) if self.has_config(CONF_COLOR): color_modes.add(ColorMode.HS) if self.has_config(CONF_COLOR): color_modes.add(ColorMode.HS) if color_modes == {ColorMode.WHITE}: return {ColorMode.BRIGHTNESS} if not color_modes: return {ColorMode.ONOFF} return color_modes @property def supported_features(self) -> LightEntityFeature: """Flag supported features.""" supports = LightEntityFeature(0) if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE): supports |= LightEntityFeature.EFFECT return supports @property def is_white_mode(self): """Return true if the light is in white mode.""" color_mode = self.__get_color_mode() return color_mode is None or color_mode == self._modes.white @property def is_color_mode(self): """Return true if the light is in color mode.""" color_mode = self.__get_color_mode() return color_mode is not None and color_mode == self._modes.color @property def is_scene_mode(self): """Return true if the light is in scene mode.""" color_mode = self.__get_color_mode() return color_mode is not None and color_mode.startswith(self._modes.scene) @property def is_music_mode(self): """Return true if the light is in music mode.""" color_mode = self.__get_color_mode() return color_mode is not None and color_mode == self._modes.music @property def color_mode(self) -> ColorMode: """Return the color_mode of the light.""" if len(self.supported_color_modes) == 1: return next(iter(self.supported_color_modes)) if self.is_color_mode: return ColorMode.HS if self.is_white_mode: if self.has_config(CONF_COLOR_TEMP): return ColorMode.COLOR_TEMP else: return ColorMode.WHITE if self._brightness: return ColorMode.BRIGHTNESS return ColorMode.ONOFF def __is_color_rgb_encoded(self): # for now we will prefer non encoded if color is none "added by manual or cloud pull dp" color = self.dp_value(CONF_COLOR) return False if color is None else len(color) > 12 def __find_scene_by_scene_data(self, data): return ( next( (i for i in self._effect_list if self._scenes.to_tuya(i) == data), None, ) if data is not None else None ) def __get_color_mode(self): return ( self.dp_value(CONF_COLOR_MODE) if self.has_config(CONF_COLOR_MODE) else self._modes.white ) def __to_color_raw(self, hs, brightness): return base64.b64encode( # BASE64-encoded 4-byte value: HHSL bytes( [ round(hs[0]) // 256, round(hs[0]) % 256, round(hs[1]), round(brightness * 100 / self._upper_brightness), ] ) ).decode("ascii") def __to_color_(self, hs, brightness): # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-8-colour_data return "{:04x}{:02x}{:02x}".format( round(hs[0]), round(hs[1] * 255 / 100), round(brightness * 255 / self._upper_brightness), ) def __to_color_v2(self, hs, brightness): # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-9-colour_data_v2 return "{:04x}{:04x}{:04x}".format( round(hs[0]), round(hs[1] * 10.0), brightness ) def __to_color_common(self, hs, brightness): """Converts HSB values to a string.""" if self.__is_color_rgb_encoded(): # Not documented format rgb = color_util.color_hsv_to_RGB( hs[0], hs[1], int(brightness * 100 / self._upper_brightness) ) return "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( round(rgb[0]), round(rgb[1]), round(rgb[2]), round(hs[0]), round(hs[1] * 255 / 100), brightness, ) else: return self.__to_color_v2(hs, brightness) def __from_color_raw(self, color): # BASE64-encoded 4-byte value: HHSL hsl = int.from_bytes(base64.b64decode(color), byteorder="big", signed=False) hue = hsl // 65536 sat = (hsl // 256) % 256 value = (hsl % 256) * self._upper_brightness / 100 self._hs = [hue, sat] self._brightness = value def __from_color_(self, color): # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-8-colour_data hue, sat, value = [int(value, 16) for value in textwrap.wrap(color, 4)] self._hs = [hue, sat * 100 / 255] self._brightness = value * self._upper_brightness / 100 def __from_color_v2(self, color): # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-9-colour_data_v2 hue, sat, value = [int(value, 16) for value in textwrap.wrap(color, 4)] self._hs = [hue, sat / 10.0] self._brightness = value def __from_color_common(self, color): """Convert a string to HSL values.""" if self.__is_color_rgb_encoded(): hue = int(color[6:10], 16) sat = int(color[10:12], 16) value = int(color[12:14], 16) self._hs = [hue, (sat * 100 / 255)] self._brightness = value else: self.__from_color_v2(color) async def async_turn_on(self, **kwargs): """Turn on or control the light.""" states = {} if not self.is_on or self._write_only: states[self._dp_id] = True features = self.supported_features color_modes = self.supported_color_modes brightness = None color_mode = None if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT): effect = kwargs[ATTR_EFFECT] scene = self._scenes.to_tuya(effect) if scene is not None: if scene.startswith(self._modes.scene) or scene in ( self._modes.white, self._modes.color, ): color_mode = scene else: color_mode = self._modes.scene states[self._config.get(CONF_SCENE)] = scene elif effect in self._modes.as_list(): color_mode = effect elif effect == self._modes.music: color_mode = self._modes.music if ATTR_BRIGHTNESS in kwargs and ( ColorMode.BRIGHTNESS in color_modes or self.has_config(CONF_BRIGHTNESS) or self.has_config(CONF_COLOR) ): brightness = map_value_by_percent( int(kwargs[ATTR_BRIGHTNESS]), 255, self._upper_brightness ) brightness = max(brightness, self._lower_brightness) if self.is_color_mode and self._hs is not None: states[self._config.get(CONF_COLOR)] = self.__to_color( self._hs, brightness ) color_mode = self._modes.color else: states[self._config.get(CONF_BRIGHTNESS)] = brightness color_mode = self._modes.white if ATTR_HS_COLOR in kwargs and ColorMode.HS in color_modes: if brightness is None: brightness = self._brightness hs = kwargs[ATTR_HS_COLOR] if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS): states[self._config.get(CONF_BRIGHTNESS)] = brightness color_mode = self._modes.white else: states[self._config.get(CONF_COLOR)] = self.__to_color(hs, brightness) color_mode = self._modes.color if ATTR_COLOR_TEMP_KELVIN in kwargs and ColorMode.COLOR_TEMP in color_modes: if brightness is None: brightness = self._brightness color_temp = map_range( int(kwargs[ATTR_COLOR_TEMP_KELVIN]), self.min_color_temp_kelvin, self.max_color_temp_kelvin, self._lower_brightness, self._upper_color_temp, self._color_temp_reverse, ) color_mode = self._modes.white states[self._config.get(CONF_BRIGHTNESS)] = brightness states[self._config.get(CONF_COLOR_TEMP)] = color_temp if ATTR_WHITE in kwargs and ColorMode.WHITE in color_modes: if brightness is None: brightness = self._brightness color_mode = self._modes.white states[self._config.get(CONF_BRIGHTNESS)] = brightness if color_mode is not None: states[self._config.get(CONF_COLOR_MODE)] = color_mode await self._device.set_dps(states) async def async_turn_off(self, **kwargs): """Turn Tuya light off.""" await self._device.set_dp(False, self._dp_id) def status_updated(self): """Device status was updated.""" self._state = self.dp_value(self._dp_id) supported = self.supported_features self._effect = None if (brightness_dp_value := self.dp_value(CONF_BRIGHTNESS)) is not None: self._brightness = brightness_dp_value if ColorMode.HS in self.supported_color_modes: color = self.dp_value(CONF_COLOR) if color is not None and not self.is_white_mode: self.__from_color(color) elif self._brightness is None: self._brightness = self._upper_brightness if ColorMode.COLOR_TEMP in self.supported_color_modes: self._color_temp = self.dp_value(CONF_COLOR_TEMP) if self.is_scene_mode and supported & LightEntityFeature.EFFECT: color_mode = self.dp_value(CONF_COLOR_MODE) if color_mode != self._modes.scene: self._effect = self.__find_scene_by_scene_data(color_mode) else: self._effect = self.__find_scene_by_scene_data( self.dp_value(CONF_SCENE) ) if self._effect is None: self._effect = self.__find_scene_by_scene_data(color_mode) if self.is_music_mode and supported & LightEntityFeature.EFFECT: self._effect = SCENE_MUSIC def status_restored(self, stored_state) -> None: """Device status was restored.""" restore_attrs = (CONF_COLOR_MODE, CONF_COLOR, CONF_BRIGHTNESS, CONF_COLOR_TEMP) if self._write_only: for attr in restore_attrs: dp = self._config.get(attr) restored_value = stored_state.attributes.get(f"raw_{attr}") if None in (dp, restored_value): continue self._cached_status[dp] = restored_value self._state = self._last_state async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLight, flow_schema)