From 55482aadcb9349d283dcb33f88445ea918ff0900 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sun, 15 Dec 2024 17:58:36 +0300 Subject: [PATCH 01/23] Bug: CONF_SCENE_VALUES can be empty --- custom_components/localtuya/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 8818060b8..340a5fde2 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -189,7 +189,7 @@ def __init__( custom_scenes = False if self.has_config(CONF_SCENE): - if self.has_config(CONF_SCENE_VALUES): + if self.has_config(CONF_SCENE_VALUES) and len(self._config.get(CONF_SCENE_VALUES)): custom_scenes = True values_list = list(self._config.get(CONF_SCENE_VALUES)) values_name = list(self._config.get(CONF_SCENE_VALUES).values()) From ca3af1cbcbbb356ffe15f500ef8f44c9ecb6a843 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sun, 15 Dec 2024 18:00:05 +0300 Subject: [PATCH 02/23] Bug: Always prepend with general mode controls --- custom_components/localtuya/light.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 340a5fde2..d00de2f95 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -187,10 +187,8 @@ def __init__( self._effect_list = [] self._scenes = {} - custom_scenes = False if self.has_config(CONF_SCENE): if self.has_config(CONF_SCENE_VALUES) and len(self._config.get(CONF_SCENE_VALUES)): - custom_scenes = True values_list = list(self._config.get(CONF_SCENE_VALUES)) values_name = list(self._config.get(CONF_SCENE_VALUES).values()) self._scenes = dict(zip(values_name, values_list)) @@ -201,8 +199,7 @@ def __init__( else: self._scenes = SCENE_LIST_RGBW_1000 - if not custom_scenes: - self._scenes = {**self._modes.as_dict(), **self._scenes} + self._scenes = {**self._modes.as_dict(), **self._scenes} self._effect_list = list(self._scenes.keys()) From bc71204232d488d3d9a93bb472e2af71373e7be8 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sun, 15 Dec 2024 18:05:36 +0300 Subject: [PATCH 03/23] Support for write-only (BLE) bulbs --- custom_components/localtuya/const.py | 1 + custom_components/localtuya/light.py | 33 ++++++++++++++++++- .../localtuya/translations/en.json | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index bf74e5252..ee8e4c985 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -97,6 +97,7 @@ CONF_MUSIC_MODE = "music_mode" CONF_SCENE_VALUES = "scene_values" CONF_SCENE_VALUES_FRIENDLY = "scene_values_friendly" +CONF_WRITE_ONLY = "write_only" # switch CONF_CURRENT = "current" diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index d00de2f95..2f18c3a34 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -1,6 +1,7 @@ """Platform to locally control Tuya-based light devices.""" import logging +from typing import Any import textwrap import homeassistant.util.color as color_util import voluptuous as vol @@ -33,6 +34,7 @@ CONF_COLOR_TEMP_REVERSE, CONF_MUSIC_MODE, CONF_SCENE_VALUES, + CONF_WRITE_ONLY, ) _LOGGER = logging.getLogger(__name__) @@ -96,6 +98,14 @@ + "0000000", } +SCENE_LIST_RGBW_BLE = { + "Good Night": "AA==", + "Leisure": "Aw==", + "Gorgeous": "Bw==", + "Dream": "HA==", + "Sunflower": "GA==", + "Grassland": "BA==", +} @dataclass(frozen=True) class Mode: @@ -147,6 +157,7 @@ def flow_schema(dps): 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(), + vol.Optional(CONF_WRITE_ONLY, default=False): selector.BooleanSelector(), } @@ -162,7 +173,11 @@ def __init__( ): """Initialize the Tuya light.""" super().__init__(device, config_entry, lightid, _LOGGER, **kwargs) - self._state = False + # 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._config.get(CONF_WRITE_ONLY, False) + + self._state = False if not self._write_only else None self._brightness = None self._color_temp = None self._lower_brightness = int( @@ -192,6 +207,8 @@ def __init__( values_list = list(self._config.get(CONF_SCENE_VALUES)) values_name = list(self._config.get(CONF_SCENE_VALUES).values()) self._scenes = dict(zip(values_name, values_list)) + elif self._write_only: # BLE bulbs + self._scenes = SCENE_LIST_RGBW_BLE elif int(self._config.get(CONF_SCENE)) < 20: self._scenes = SCENE_LIST_RGBW_255 elif self._config.get(CONF_BRIGHTNESS) is None: @@ -202,6 +219,9 @@ def __init__( self._scenes = {**self._modes.as_dict(), **self._scenes} self._effect_list = list(self._scenes.keys()) + elif self._write_only: + # BLE bulbs with no scene value DP configured + self._scenes = self._modes.as_dict() if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) @@ -484,8 +504,19 @@ async def async_turn_off(self, **kwargs): """Turn Tuya light off.""" await self._device.set_dp(False, self._dp_id) + def dp_value(self, key, default=None) -> Any | None: + if self._write_only: + # Consider any DP value as not trusted + return None + else: + return super().dp_value(key, default) + def status_updated(self): """Device status was updated.""" + if self._write_only: + # Consider any DP value as not trusted + return + self._state = self.dp_value(self._dp_id) supported = self.supported_features self._effect = None diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index fed2f0ee7..9afc8b4dc 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -196,6 +196,7 @@ "music_mode": "Music mode available?", "scene": "Scene", "scene_values": "(Optional) Scene values", + "write_only": "Write-only DPs (usually BLE bulbs)", "select_options": "Select options values", "fan_speed_control": "Fan Speed Control DP", "fan_oscillating_control": "Fan Oscillating Control DP", From 2a8982b696b2b49153386d1674291ab8d828ec03 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sun, 15 Dec 2024 18:15:13 +0300 Subject: [PATCH 04/23] If BLE bulb has Music mode, it must have Scene and Color. --- custom_components/localtuya/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 2f18c3a34..377299d93 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -219,7 +219,7 @@ def __init__( self._scenes = {**self._modes.as_dict(), **self._scenes} self._effect_list = list(self._scenes.keys()) - elif self._write_only: + elif self._write_only and self._config.get(CONF_MUSIC_MODE): # BLE bulbs with no scene value DP configured self._scenes = self._modes.as_dict() From 93d7fd7f01fb721f6153a3e4d1b39ffd025836e1 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Mon, 16 Dec 2024 22:21:48 +0300 Subject: [PATCH 05/23] BLE bulbs: support for colors --- custom_components/localtuya/light.py | 76 ++++++++++++++-------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 377299d93..b2bc27101 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -1,5 +1,6 @@ """Platform to locally control Tuya-based light devices.""" +import base64 import logging from typing import Any import textwrap @@ -178,7 +179,6 @@ def __init__( self._write_only = self._config.get(CONF_WRITE_ONLY, False) self._state = False if not self._write_only else None - self._brightness = None self._color_temp = None self._lower_brightness = int( self._config.get(CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS) @@ -186,6 +186,7 @@ def __init__( 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._min_kelvin = int( self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) @@ -235,7 +236,9 @@ def is_on(self): 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): + if brightness is not None and ( + self.is_color_mode or self.is_white_mode or self._write_only + ): if self._upper_brightness >= 1000: # Round to the nearest 10th, since Tuya does that. # If the value is less than 5, it will round down to 0. @@ -391,6 +394,35 @@ def __get_color_mode(self): else self._modes.white ) + def __to_color(self, hs, brightness): + if self._write_only: # BLE bulbs + color = base64.b64encode( + bytes([ + round(hs[0]) // 256, + round(hs[0]) % 256, + round(hs[1]), + int(brightness * 100 / self._upper_brightness) + ]) + ).decode("ascii") + self._hs = hs + elif self.__is_color_rgb_encoded(): + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], int(brightness * 100 / self._upper_brightness) + ) + color = "{: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: + color = "{:04x}{:04x}{:04x}".format( + round(hs[0]), round(hs[1] * 10.0), brightness + ) + return color + async def async_turn_on(self, **kwargs): """Turn on or control the light.""" states = {} @@ -428,28 +460,12 @@ async def async_turn_on(self, **kwargs): self._lower_brightness, self._upper_brightness, ) + if self._write_only: # BLE bulbs + self._brightness = brightness if self.is_white_mode or self.dp_value(CONF_COLOR) is None: states[self._config.get(CONF_BRIGHTNESS)] = brightness else: - if self.__is_color_rgb_encoded(): - rgb = color_util.color_hsv_to_RGB( - self._hs[0], - self._hs[1], - int(brightness * 100 / self._upper_brightness), - ) - color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( - round(rgb[0]), - round(rgb[1]), - round(rgb[2]), - round(self._hs[0]), - round(self._hs[1] * 255 / 100), - brightness, - ) - else: - color = "{:04x}{:04x}{:04x}".format( - round(self._hs[0]), round(self._hs[1] * 10.0), brightness - ) - states[self._config.get(CONF_COLOR)] = color + states[self._config.get(CONF_COLOR)] = self.__to_color(self._hs, brightness) states[self._config.get(CONF_COLOR_MODE)] = self._modes.color if ATTR_HS_COLOR in kwargs and ColorMode.HS in color_modes: @@ -460,23 +476,7 @@ async def async_turn_on(self, **kwargs): states[self._config.get(CONF_BRIGHTNESS)] = brightness states[self._config.get(CONF_COLOR_MODE)] = self._modes.white else: - if self.__is_color_rgb_encoded(): - rgb = color_util.color_hsv_to_RGB( - hs[0], hs[1], int(brightness * 100 / self._upper_brightness) - ) - color = "{: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: - color = "{:04x}{:04x}{:04x}".format( - round(hs[0]), round(hs[1] * 10.0), brightness - ) - states[self._config.get(CONF_COLOR)] = color + states[self._config.get(CONF_COLOR)] = self.__to_color(hs, brightness) states[self._config.get(CONF_COLOR_MODE)] = self._modes.color if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in color_modes: From d2de2e6d3f7331c8c5605988f0f52daac474463e Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Tue, 17 Dec 2024 10:40:52 +0300 Subject: [PATCH 06/23] Comments regarding scene and color formats --- custom_components/localtuya/light.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index b2bc27101..b736bc01d 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -59,6 +59,7 @@ 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", @@ -69,6 +70,8 @@ "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": "000e0d0000000000000000c80000", "Read": "010e0d0000000000000003e801f4", @@ -99,13 +102,15 @@ + "0000000", } +# BASE64-encoded 1-byte numbers. +# Other numbers up to 0x10 were tested to no avail. SCENE_LIST_RGBW_BLE = { - "Good Night": "AA==", - "Leisure": "Aw==", - "Gorgeous": "Bw==", - "Dream": "HA==", - "Sunflower": "GA==", - "Grassland": "BA==", + "Good Night": "AA==", # 00 + "Leisure": "Aw==", # 01 + "Gorgeous": "Bw==", # 07 + "Dream": "HA==", # 1C + "Sunflower": "GA==", # 18 + "Grassland": "BA==", # 04 } @dataclass(frozen=True) @@ -395,8 +400,10 @@ def __get_color_mode(self): ) def __to_color(self, hs, brightness): + """Converts HSB values to a string.""" if self._write_only: # BLE bulbs color = base64.b64encode( +# BASE64-encoded 4-byte value: HHSL bytes([ round(hs[0]) // 256, round(hs[0]) % 256, @@ -406,6 +413,8 @@ def __to_color(self, hs, brightness): ).decode("ascii") self._hs = hs elif self.__is_color_rgb_encoded(): +# It is not +# https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-8-colour_data rgb = color_util.color_hsv_to_RGB( hs[0], hs[1], int(brightness * 100 / self._upper_brightness) ) @@ -418,6 +427,7 @@ def __to_color(self, hs, brightness): brightness, ) else: +# https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-9-colour_data_v2 color = "{:04x}{:04x}{:04x}".format( round(hs[0]), round(hs[1] * 10.0), brightness ) From cb20a6b2225a5c5f6a55ed101d8cf2f52b6a2610 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Wed, 18 Dec 2024 20:47:59 +0300 Subject: [PATCH 07/23] Fully functional BLE bulbs (from HA only) --- custom_components/localtuya/coordinator.py | 4 + custom_components/localtuya/light.py | 107 +++++++++++---------- 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/custom_components/localtuya/coordinator.py b/custom_components/localtuya/coordinator.py index 442440d8d..5da4f565a 100644 --- a/custom_components/localtuya/coordinator.py +++ b/custom_components/localtuya/coordinator.py @@ -70,6 +70,7 @@ def __init__( self._hass_entry: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] self._device_config = DeviceConfig(device_config.copy()) self.id = self._device_config.id + self.write_only = False # Used for BLE bulbs, see LocalTuyaLight self._status = {} self._interface = None @@ -414,6 +415,9 @@ async def set_status(self): payload, self._pending_status = self._pending_status.copy(), {} try: await self._interface.set_dps(payload, cid=self._node_id) + if self.write_only: + # The device never replies, process its status change now + self.status_updated(payload) except Exception as ex: # pylint: disable=broad-except self.debug(f"Failed to set values {payload} --> {ex}", force=True) elif not self.connected: diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index b736bc01d..60bfc14cb 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -2,7 +2,6 @@ import base64 import logging -from typing import Any import textwrap import homeassistant.util.color as color_util import voluptuous as vol @@ -54,7 +53,6 @@ MODE_SCENE = "scene" MODE_WHITE = "white" -SCENE_CUSTOM = "Custom" SCENE_MUSIC = "Music" MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1} @@ -182,8 +180,10 @@ def __init__( # 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._config.get(CONF_WRITE_ONLY, False) + if self._write_only: + self._device.write_only = self._write_only - self._state = False if not self._write_only else None + self._state = None self._color_temp = None self._lower_brightness = int( self._config.get(CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS) @@ -241,9 +241,7 @@ def is_on(self): 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 or self._write_only - ): + if brightness is not None and (self.is_color_mode or self.is_white_mode): if self._upper_brightness >= 1000: # Round to the nearest 10th, since Tuya does that. # If the value is less than 5, it will round down to 0. @@ -274,7 +272,7 @@ def hs_color(self): def color_temp(self): """Return the color_temp of the light.""" if self._color_temp is None: - return + return None if self.has_config(CONF_COLOR_TEMP): color_temp = ( self._upper_color_temp - self._color_temp @@ -389,7 +387,7 @@ def __is_color_rgb_encoded(self): def __find_scene_by_scene_data(self, data): return next( (item for item in self._effect_list if self._scenes.get(item) == data), - SCENE_CUSTOM, + None, ) def __get_color_mode(self): @@ -401,6 +399,7 @@ def __get_color_mode(self): def __to_color(self, hs, brightness): """Converts HSB values to a string.""" + # FIXME: the format should be selected by DP name, not a fuzzy logic if self._write_only: # BLE bulbs color = base64.b64encode( # BASE64-encoded 4-byte value: HHSL @@ -408,7 +407,7 @@ def __to_color(self, hs, brightness): round(hs[0]) // 256, round(hs[0]) % 256, round(hs[1]), - int(brightness * 100 / self._upper_brightness) + round(brightness * 100 / self._upper_brightness) ]) ).decode("ascii") self._hs = hs @@ -433,14 +432,39 @@ def __to_color(self, hs, brightness): ) return color + def __from_color(self, color): + """Convert a string to HSL values.""" + if self._write_only: # BLE bulbs + 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 + elif 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] + self._brightness = value + else: + hue, sat, value = [ + int(value, 16) for value in textwrap.wrap(color, 4) + ] + self._hs = [hue, sat / 10.0] + self._brightness = value + async def async_turn_on(self, **kwargs): """Turn on or control the light.""" states = {} - if not self.is_on: + 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.get(effect) @@ -449,14 +473,14 @@ async def async_turn_on(self, **kwargs): self._modes.white, self._modes.color, ): - states[self._config.get(CONF_COLOR_MODE)] = scene + color_mode = scene else: - states[self._config.get(CONF_COLOR_MODE)] = self._modes.scene + color_mode = self._modes.scene states[self._config.get(CONF_SCENE)] = scene elif effect in self._modes.as_list(): - states[self._config.get(CONF_COLOR_MODE)] = effect + color_mode = effect elif effect == self._modes.music: - states[self._config.get(CONF_COLOR_MODE)] = self._modes.music + color_mode = self._modes.music if ATTR_BRIGHTNESS in kwargs and ( ColorMode.BRIGHTNESS in color_modes @@ -470,13 +494,12 @@ async def async_turn_on(self, **kwargs): self._lower_brightness, self._upper_brightness, ) - if self._write_only: # BLE bulbs - self._brightness = brightness - if self.is_white_mode or self.dp_value(CONF_COLOR) is None: - states[self._config.get(CONF_BRIGHTNESS)] = brightness - else: + if self.is_color_mode and self._hs is not None: states[self._config.get(CONF_COLOR)] = self.__to_color(self._hs, brightness) - states[self._config.get(CONF_COLOR_MODE)] = self._modes.color + 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: @@ -484,10 +507,10 @@ async def async_turn_on(self, **kwargs): hs = kwargs[ATTR_HS_COLOR] if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS): states[self._config.get(CONF_BRIGHTNESS)] = brightness - states[self._config.get(CONF_COLOR_MODE)] = self._modes.white + color_mode = self._modes.white else: states[self._config.get(CONF_COLOR)] = self.__to_color(hs, brightness) - states[self._config.get(CONF_COLOR_MODE)] = self._modes.color + color_mode = self._modes.color if ATTR_COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in color_modes: if brightness is None: @@ -504,29 +527,21 @@ async def async_turn_on(self, **kwargs): - (self._upper_color_temp / (self.max_mireds - self.min_mireds)) * (mired - self.min_mireds) ) - states[self._config.get(CONF_COLOR_MODE)] = self._modes.white + color_mode = self._modes.white states[self._config.get(CONF_BRIGHTNESS)] = brightness states[self._config.get(CONF_COLOR_TEMP)] = color_temp + 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 dp_value(self, key, default=None) -> Any | None: - if self._write_only: - # Consider any DP value as not trusted - return None - else: - return super().dp_value(key, default) - def status_updated(self): """Device status was updated.""" - if self._write_only: - # Consider any DP value as not trusted - return - self._state = self.dp_value(self._dp_id) supported = self.supported_features self._effect = None @@ -537,20 +552,9 @@ def status_updated(self): 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: - 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: - hue, sat, value = [ - int(value, 16) for value in textwrap.wrap(color, 4) - ] - self._hs = [hue, sat / 10.0] - self._brightness = value + self.__from_color(color) elif self._brightness is None: - self._brightness = 20 + self._brightness = self._upper_brightness if ColorMode.COLOR_TEMP in self.supported_color_modes: self._color_temp = self.dp_value(CONF_COLOR_TEMP) @@ -564,11 +568,10 @@ def status_updated(self): self._effect = self.__find_scene_by_scene_data( self.dp_value(CONF_SCENE) ) - if self._effect == SCENE_CUSTOM: - if SCENE_CUSTOM not in self._effect_list: - self._effect_list.append(SCENE_CUSTOM) - elif SCENE_CUSTOM in self._effect_list: - self._effect_list.remove(SCENE_CUSTOM) + if self._effect is None: + self._effect = self.__find_scene_by_scene_data( + self._modes.scene + ) if self.is_music_mode and supported & LightEntityFeature.EFFECT: self._effect = SCENE_MUSIC From e8cf0256f1c0753865a1c1e8da51786c46b57767 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Wed, 18 Dec 2024 20:48:21 +0300 Subject: [PATCH 08/23] Names for BLE bulbs DPs --- custom_components/localtuya/core/ha_entities/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/localtuya/core/ha_entities/base.py b/custom_components/localtuya/core/ha_entities/base.py index 78d987606..0a2d35a66 100644 --- a/custom_components/localtuya/core/ha_entities/base.py +++ b/custom_components/localtuya/core/ha_entities/base.py @@ -189,6 +189,7 @@ class DPCode(StrEnum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COLOUR_DATA_RAW = "colour_data_raw" # Colored light mode for BLE COMPRESSOR_COMMAND = "compressor_command" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" @@ -505,6 +506,7 @@ class DPCode(StrEnum): SCENE_9 = "scene_9" SCENE_DATA = "scene_data" # Colored light mode SCENE_DATA_V2 = "scene_data_v2" # Colored light mode + SCENE_DATA_RAW = "scene_data_raw" # Colored light mode for BLE SEEK = "seek" SENS = "sens" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) SENSITIVITY = "sensitivity" # Sensitivity From 22f5f6145f907e06cbb22afaeb95ee7f681769f5 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Thu, 19 Dec 2024 22:10:38 +0300 Subject: [PATCH 09/23] Add color and scene DPs for BLE bulbs automatically --- custom_components/localtuya/config_flow.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 87c82a290..3b1e8255e 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -44,6 +44,7 @@ from .coordinator import pytuya, TuyaCloudApi from .core.cloud_api import TUYA_ENDPOINTS from .core.helpers import templates, get_gateway_by_deviceid, gen_localtuya_entities +from .core.ha_entities.base import DPCode from .const import ( ATTR_UPDATED_AT, CONF_ADD_DEVICE, @@ -1094,6 +1095,16 @@ def schema_defaults(schema, dps_list=None, **defaults): return copy +def __is_special_dp(dp, cloud_dp_codes: dict[str, dict]) -> bool: + if not (dp_data := cloud_dp_codes.get(dp)): + return false + if not (code := dp_data.get("code")): + return false + return code in ( + DPCode.COLOUR_DATA_RAW, + DPCode.SCENE_DATA_RAW, + ) + def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) -> list: """Return list of friendly DPS values.""" strs = [] @@ -1102,7 +1113,11 @@ def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) for dp, func in cloud_dp_codes.items(): # Default Manual dp value is -1, we will replace it if it in cloud. add_dp = dp not in dps_data or dps_data.get(dp) == -1 - if add_dp and ((value := func.get("value")) or value is not None): + if add_dp and ( + (value := func.get("value")) + or value is not None + or __is_special_dp(dp, cloud_dp_codes) + ): dps_data[dp] = f"{value}, cloud pull" for dp, value in dps_data.items(): From f8e08683ca429d715ebd7862f3e69dbddb476989 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 10:23:08 +0300 Subject: [PATCH 10/23] Add any DP, regardless of the "value" --- custom_components/localtuya/config_flow.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 3b1e8255e..9f9833a76 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -44,7 +44,6 @@ from .coordinator import pytuya, TuyaCloudApi from .core.cloud_api import TUYA_ENDPOINTS from .core.helpers import templates, get_gateway_by_deviceid, gen_localtuya_entities -from .core.ha_entities.base import DPCode from .const import ( ATTR_UPDATED_AT, CONF_ADD_DEVICE, @@ -1095,16 +1094,6 @@ def schema_defaults(schema, dps_list=None, **defaults): return copy -def __is_special_dp(dp, cloud_dp_codes: dict[str, dict]) -> bool: - if not (dp_data := cloud_dp_codes.get(dp)): - return false - if not (code := dp_data.get("code")): - return false - return code in ( - DPCode.COLOUR_DATA_RAW, - DPCode.SCENE_DATA_RAW, - ) - def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) -> list: """Return list of friendly DPS values.""" strs = [] @@ -1113,11 +1102,8 @@ def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) for dp, func in cloud_dp_codes.items(): # Default Manual dp value is -1, we will replace it if it in cloud. add_dp = dp not in dps_data or dps_data.get(dp) == -1 - if add_dp and ( - (value := func.get("value")) - or value is not None - or __is_special_dp(dp, cloud_dp_codes) - ): + if add_dp: + value = func.get("value", "null") dps_data[dp] = f"{value}, cloud pull" for dp, value in dps_data.items(): From 98b2d2d0c496f5ef848f7dfd8bb662b20041d7e2 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 10:28:15 +0300 Subject: [PATCH 11/23] Automatic support for BLE color and scene data DPs --- custom_components/localtuya/core/ha_entities/lights.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/localtuya/core/ha_entities/lights.py b/custom_components/localtuya/core/ha_entities/lights.py index dfc580b9e..1f3ecb747 100644 --- a/custom_components/localtuya/core/ha_entities/lights.py +++ b/custom_components/localtuya/core/ha_entities/lights.py @@ -84,8 +84,8 @@ def localtuya_light( color_mode=DPCode.WORK_MODE, brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), - color=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA), - scene=(DPCode.SCENE_DATA_V2, DPCode.SCENE_DATA), + color=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA_RAW, DPCode.COLOUR_DATA), + scene=(DPCode.SCENE_DATA_V2, DPCode.SCENE_DATA_RAW, DPCode.SCENE_DATA), custom_configs=localtuya_light(29, 1000, 2700, 6500, False, True), ), # Not documented From 7c7e41b3f1c57fd6c2cd823cd5bffc2226608c9c Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 10:30:24 +0300 Subject: [PATCH 12/23] Deduction of BLE bulb from DP 0 presence --- custom_components/localtuya/const.py | 1 - custom_components/localtuya/light.py | 11 ++++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index ee8e4c985..bf74e5252 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -97,7 +97,6 @@ CONF_MUSIC_MODE = "music_mode" CONF_SCENE_VALUES = "scene_values" CONF_SCENE_VALUES_FRIENDLY = "scene_values_friendly" -CONF_WRITE_ONLY = "write_only" # switch CONF_CURRENT = "current" diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 60bfc14cb..2dfe59ec0 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -34,7 +34,6 @@ CONF_COLOR_TEMP_REVERSE, CONF_MUSIC_MODE, CONF_SCENE_VALUES, - CONF_WRITE_ONLY, ) _LOGGER = logging.getLogger(__name__) @@ -161,7 +160,6 @@ def flow_schema(dps): 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(), - vol.Optional(CONF_WRITE_ONLY, default=False): selector.BooleanSelector(), } @@ -179,7 +177,7 @@ def __init__( 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._config.get(CONF_WRITE_ONLY, False) + self._write_only = self.is_ble if self._write_only: self._device.write_only = self._write_only @@ -232,6 +230,13 @@ def __init__( if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) + @property + def is_ble(self): + """Return if this sub-device is BLE.""" + # BLE bulbs don't have status, we can rely on status to detect that but this workaround works fine. + # we can also add status check this way even if somehow 0 was added by mistake it still works. + return self._device.is_subdevice and "0" in self._device._device_config.manual_dps.split(",") + @property def is_on(self): """Check if Tuya light is on.""" From fba664064f9bbd623df595e15c45fcd0c36f13d4 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 11:09:04 +0300 Subject: [PATCH 13/23] Fix: Music instead of Mode Scene, when DP CONF_COLOR is None --- custom_components/localtuya/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 2dfe59ec0..138b6eb06 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -393,7 +393,7 @@ def __find_scene_by_scene_data(self, data): return next( (item for item in self._effect_list if self._scenes.get(item) == data), None, - ) + ) if data is not None else None def __get_color_mode(self): return ( From 785ee123a5168b56d2fa05a13d86c10b4a66521a Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 11:10:20 +0300 Subject: [PATCH 14/23] Less calls --- custom_components/localtuya/light.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 138b6eb06..f71791947 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -565,18 +565,15 @@ def status_updated(self): self._color_temp = self.dp_value(CONF_COLOR_TEMP) if self.is_scene_mode and supported & LightEntityFeature.EFFECT: - if self.dp_value(CONF_COLOR_MODE) != self._modes.scene: - self._effect = self.__find_scene_by_scene_data( - self.dp_value(CONF_COLOR_MODE) - ) + 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( - self._modes.scene - ) + self._effect = self.__find_scene_by_scene_data(color_mode) if self.is_music_mode and supported & LightEntityFeature.EFFECT: self._effect = SCENE_MUSIC From 212e1a1aff037acba7ae1c196d3aef2e6ab49784 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 12:55:50 +0300 Subject: [PATCH 15/23] String not required anymore --- custom_components/localtuya/translations/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 9afc8b4dc..fed2f0ee7 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -196,7 +196,6 @@ "music_mode": "Music mode available?", "scene": "Scene", "scene_values": "(Optional) Scene values", - "write_only": "Write-only DPs (usually BLE bulbs)", "select_options": "Select options values", "fan_speed_control": "Fan Speed Control DP", "fan_oscillating_control": "Fan Oscillating Control DP", From 4a1db74436fb4c974060c7c8a7c083c81e07726f Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 13:22:53 +0300 Subject: [PATCH 16/23] Alphabetical order --- custom_components/localtuya/core/ha_entities/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/localtuya/core/ha_entities/base.py b/custom_components/localtuya/core/ha_entities/base.py index 0a2d35a66..762a37cca 100644 --- a/custom_components/localtuya/core/ha_entities/base.py +++ b/custom_components/localtuya/core/ha_entities/base.py @@ -188,8 +188,8 @@ class DPCode(StrEnum): COLOR_DATA_V2 = "color_data_v2" COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode - COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode COLOUR_DATA_RAW = "colour_data_raw" # Colored light mode for BLE + COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode COMPRESSOR_COMMAND = "compressor_command" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" @@ -505,8 +505,8 @@ class DPCode(StrEnum): SCENE_8 = "scene_8" SCENE_9 = "scene_9" SCENE_DATA = "scene_data" # Colored light mode - SCENE_DATA_V2 = "scene_data_v2" # Colored light mode SCENE_DATA_RAW = "scene_data_raw" # Colored light mode for BLE + SCENE_DATA_V2 = "scene_data_v2" # Colored light mode SEEK = "seek" SENS = "sens" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) SENSITIVITY = "sensitivity" # Sensitivity From eb5db24101fa6c3a1be2cdbdf82ba341e3851e78 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 20:20:48 +0300 Subject: [PATCH 17/23] Mark write-only DPs as such. --- custom_components/localtuya/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 9f9833a76..9c6adced9 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -1104,7 +1104,10 @@ def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) add_dp = dp not in dps_data or dps_data.get(dp) == -1 if add_dp: value = func.get("value", "null") - dps_data[dp] = f"{value}, cloud pull" + if func.get("accessMode", "null") == "wr": + dps_data[dp] = f"{value}, write-only" + else: + dps_data[dp] = f"{value}, cloud pull" for dp, value in dps_data.items(): if (dp_data := cloud_dp_codes.get(dp)) and (code := dp_data.get("code")): From 043b11f7fbf81c090acc6c30dc8eb7d1c729cd02 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 20:26:02 +0300 Subject: [PATCH 18/23] Using cloud DP data for Ligths --- custom_components/localtuya/entity.py | 14 +++ custom_components/localtuya/light.py | 155 ++++++++++++++++++-------- 2 files changed, 120 insertions(+), 49 deletions(-) diff --git a/custom_components/localtuya/entity.py b/custom_components/localtuya/entity.py index b3bc9b74e..844405652 100644 --- a/custom_components/localtuya/entity.py +++ b/custom_components/localtuya/entity.py @@ -284,6 +284,20 @@ def dp_value(self, key, default=None) -> Any | None: return value + def dp_code(self, key): + """Returns DP code, if available from the cloud""" + dp_id = self._config.get(key) + if dp_id is None: + return None + for dp in self._device_config.dps_strings: + all = dp.split(" ") + if dp_id == all[0]: + if len(all) > 3 and all[2] == "code:": + return all[3] + else: + return None + return None + def status_updated(self) -> None: """Device status was updated. diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index f71791947..52539012e 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -21,6 +21,7 @@ ) from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE +from .core.ha_entities.base import DPCode from .config_flow import col_to_select from .entity import LocalTuyaEntity, async_setup_entry from .const import ( @@ -177,7 +178,7 @@ def __init__( 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.is_ble + self._write_only = self._is_write_only if self._write_only: self._device.write_only = self._write_only @@ -211,14 +212,24 @@ def __init__( values_list = list(self._config.get(CONF_SCENE_VALUES)) values_name = list(self._config.get(CONF_SCENE_VALUES).values()) self._scenes = dict(zip(values_name, values_list)) - elif self._write_only: # BLE bulbs - self._scenes = SCENE_LIST_RGBW_BLE - elif int(self._config.get(CONF_SCENE)) < 20: - self._scenes = SCENE_LIST_RGBW_255 - elif self._config.get(CONF_BRIGHTNESS) is None: - self._scenes = SCENE_LIST_RGB_1000 else: - self._scenes = SCENE_LIST_RGBW_1000 + scene_code = self.dp_code(CONF_SCENE) + if scene_code is None: + # Using fuzzy logic to detect scene data format + if self._write_only: # BLE bulbs + self._scenes = SCENE_LIST_RGBW_BLE + elif int(self._config.get(CONF_SCENE)) < 20: + self._scenes = SCENE_LIST_RGBW_255 + elif self._config.get(CONF_BRIGHTNESS) is None: + self._scenes = SCENE_LIST_RGB_1000 + else: + self._scenes = SCENE_LIST_RGBW_1000 + elif scene_code == DPCode.SCENE_DATA_V2: + self._scenes = SCENE_LIST_RGBW_1000 + elif scene_code == DPCode.SCENE_DATA_RAW: + self._scenes = SCENE_LIST_RGBW_BLE + elif scene_code == DPCode.SCENE_DATA: + self._scenes = SCENE_LIST_RGBW_255 self._scenes = {**self._modes.as_dict(), **self._scenes} @@ -230,12 +241,34 @@ def __init__( if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) + if self.has_config(CONF_COLOR): + color_code = self.dp_code(CONF_COLOR) + if color_code is None: + self.__to_color = self.__to_color_common + self.__from_color = self.__from_color_common + elif color_code in (DPCode.COLOUR_DATA_V2, DPCode.COLOR_DATA_V2): + self.__to_color = self.__to_color_v2 + self.__from_color = self.__from_color_v2 + elif color_code == DPCode.COLOUR_DATA_RAW: + self.__to_color = self.__to_color_raw + self.__from_color = self.__from_color_raw + elif color_code == DPCode.COLOUR_DATA: + self.__to_color = self.__to_color_ + self.__from_color = self.__from_color_ + else: + self.__to_color = self.__to_color_common + self.__from_color = self.__from_color_common + @property - def is_ble(self): - """Return if this sub-device is BLE.""" - # BLE bulbs don't have status, we can rely on status to detect that but this workaround works fine. - # we can also add status check this way even if somehow 0 was added by mistake it still works. - return self._device.is_subdevice and "0" in self._device._device_config.manual_dps.split(",") + def _is_write_only(self): + """Return if this sub-device is write-only (BLE).""" + if not self._device.is_subdevice: + return False + for dp in self._device_config.dps_strings: + all = dp.split(" ") + if all[0] == self._dp_id: + return "write-only" in all or "cloud" in all + return False @property def is_on(self): @@ -402,27 +435,41 @@ def __get_color_mode(self): else self._modes.white ) - def __to_color(self, hs, brightness): - """Converts HSB values to a string.""" - # FIXME: the format should be selected by DP name, not a fuzzy logic - if self._write_only: # BLE bulbs - color = base64.b64encode( + 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") - self._hs = hs - elif self.__is_color_rgb_encoded(): -# It is not + 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) ) - color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( + return "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( round(rgb[0]), round(rgb[1]), round(rgb[2]), @@ -431,35 +478,45 @@ def __to_color(self, hs, brightness): 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 - color = "{:04x}{:04x}{:04x}".format( - round(hs[0]), round(hs[1] * 10.0), brightness - ) - return color + 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(self, color): + def __from_color_common(self, color): """Convert a string to HSL values.""" - if self._write_only: # BLE bulbs - 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 - elif self.__is_color_rgb_encoded(): + 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] self._brightness = value else: - hue, sat, value = [ - int(value, 16) for value in textwrap.wrap(color, 4) - ] - self._hs = [hue, sat / 10.0] - self._brightness = value + self.__from_color_v2(color) async def async_turn_on(self, **kwargs): """Turn on or control the light.""" From 78642b60040ca47220a136bc16cdbd8c79cfa6c0 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 20:57:12 +0300 Subject: [PATCH 19/23] More scenes --- custom_components/localtuya/light.py | 39 ++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 52539012e..741933759 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -71,19 +71,48 @@ # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-11-scene_data_v2 SCENE_LIST_RGBW_1000 = { - "Night": "000e0d0000000000000000c80000", - "Read": "010e0d0000000000000003e801f4", + "Night 1": "000e0d0000000000000000c80000", + "Night 2": "000e0d00002e03e802cc00000000", + "Read 1": "010e0d0000000000000003e801f4", + "Read 2": "010e0d000084000003e800000000", "Meeting": "020e0d0000000000000003e803e8", - "Leasure": "030e0d0000000000000001f401f4", + "Working": "020e0d00001403e803e800000000", + "Leasure 1": "030e0d0000000000000001f401f4", + "Leisure 2": "030e0d0000e80383031c00000000", "Soft": "04464602007803e803e800000000464602007803e8000a00000000", "Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803" + "e800000000", - "Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8" - + "00000000", + "Colorful": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + + "e800000000", "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" + "3e800000000", + "Breathe": "3146460200000000000003e80000464602000000000000000a0000", + "Step Change": "32646401000000000000000a000064640100000000000001f400006464010000000" + + "0000003e8000064640100000000000001f40000", + "Rythm Of Breathing": "33545402000000000000000a0000545402000000000000011c0000545402" + + "00000000000003e80000545402000000000000000a0000", + "Flash": "2c646401000000000000000a000064640100000000000003e80000", + "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", From cc4c20b236f86f2d48d95e34a7d3ce9c3857be86 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 21 Dec 2024 21:32:17 +0300 Subject: [PATCH 20/23] Less scenes --- custom_components/localtuya/light.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 741933759..d47a82c0c 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -88,12 +88,6 @@ "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" + "3e800000000", - "Breathe": "3146460200000000000003e80000464602000000000000000a0000", - "Step Change": "32646401000000000000000a000064640100000000000001f400006464010000000" - + "0000003e8000064640100000000000001f40000", - "Rythm Of Breathing": "33545402000000000000000a0000545402000000000000011c0000545402" - + "00000000000003e80000545402000000000000000a0000", - "Flash": "2c646401000000000000000a000064640100000000000003e80000", "Forest": "19464601007803e803e800000000464602006e0320025800000000464602005a038403e8" + "00000000", "Dream": "1C4646020104032003e800000000464602011802bc03e800000000464602011303e803e80" From 61c161b09c6bd0a5ec8f409377f289b20214ac62 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sun, 22 Dec 2024 16:42:59 +0300 Subject: [PATCH 21/23] Scene data must be lower case --- custom_components/localtuya/light.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index d47a82c0c..c6c678aac 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -90,11 +90,11 @@ + "3e800000000", "Forest": "19464601007803e803e800000000464602006e0320025800000000464602005a038403e8" + "00000000", - "Dream": "1C4646020104032003e800000000464602011802bc03e800000000464602011303e803e80" + "Dream": "1c4646020104032003e800000000464602011802bc03e800000000464602011303e803e80" + "0000000", - "F Style": "1E323201015e01f403e800000000323202003201f403e80000000032320200a001f403e" + "F Style": "1e323201015e01f403e800000000323202003201f403e80000000032320200a001f403e" + "800000000", - "A Style": "1F46460100dc02bc03e800000000464602006e03200258000000004646020014038403e" + "A Style": "1f46460100dc02bc03e800000000464602006e03200258000000004646020014038403e" + "800000000464601012703e802ee0000000046460100000384028a00000000", "Halloween": "28464601011303e803e800000000464601001e03e803e800000000", "Christmas": "225a5a0100f003e803e8000000005a5a01003d03e803e800000000464601000003e80" From be2f9e1ff638625f51fe2d0eceb25b606d954587 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sun, 22 Dec 2024 16:44:46 +0300 Subject: [PATCH 22/23] Obsolete --- custom_components/localtuya/light.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index c6c678aac..c0ae74ad8 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -257,9 +257,6 @@ def __init__( self._scenes = {**self._modes.as_dict(), **self._scenes} self._effect_list = list(self._scenes.keys()) - elif self._write_only and self._config.get(CONF_MUSIC_MODE): - # BLE bulbs with no scene value DP configured - self._scenes = self._modes.as_dict() if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) From 3f514ae7980c4fe189a8819aaffaae08ffe997a9 Mon Sep 17 00:00:00 2001 From: Lurker00 Date: Sat, 28 Dec 2024 10:27:15 +0300 Subject: [PATCH 23/23] BLE bulb cloudless manual setup --- custom_components/localtuya/light.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index c0ae74ad8..c2e386c3a 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -287,8 +287,12 @@ def _is_write_only(self): for dp in self._device_config.dps_strings: all = dp.split(" ") if all[0] == self._dp_id: - return "write-only" in all or "cloud" in all - return False + if "write-only" in all or "cloud" in all: + return True + else: + break + # Setup without cloud? + return "0" in self._device_config.manual_dps.split(",") @property def is_on(self):