Files
rikku-home-assistant/custom_components/localtuya/light.py
T
2025-04-21 21:03:30 -04:00

673 lines
24 KiB
Python

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