From be73b21f810b5787f2fec90cce81ffaf3ca9d2e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 18 Oct 2021 20:35:01 +0200 Subject: [PATCH] Refactor Tuya light platform (#57980) --- homeassistant/components/tuya/base.py | 28 +- homeassistant/components/tuya/const.py | 27 +- homeassistant/components/tuya/light.py | 696 +++++++++++++++---------- 3 files changed, 473 insertions(+), 278 deletions(-) diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 66b497c2f4e..f0bcb8e537f 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -22,9 +22,9 @@ class IntegerTypeData: min: int max: int - unit: str scale: float step: float + unit: str | None = None @property def max_scaled(self) -> float: @@ -45,6 +45,32 @@ class IntegerTypeData: """Scale a value.""" return value * 1.0 / (10 ** self.scale) + def remap_value_to( + self, + value: float, + to_min: float | int = 0, + to_max: float | int = 255, + reverse: bool = False, + ) -> float: + """Remap a value from this range to a new range.""" + if reverse: + value = self.max - value + self.min + return ((value - self.min) / (self.max - self.min)) * (to_max - to_min) + to_min + + def remap_value_from( + self, + value: float, + from_min: float | int = 0, + from_max: float | int = 255, + reverse: bool = False, + ) -> float: + """Remap a value from its current range to this range.""" + if reverse: + value = from_max - value + from_min + return ((value - from_min) / (from_max - from_min)) * ( + self.max - self.min + ) + self.min + @classmethod def from_json(cls, data: str) -> IntegerTypeData: """Load JSON string and return a IntegerTypeData object.""" diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 0bc5dcfa575..259fbc73b9e 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -93,8 +93,10 @@ TUYA_SUPPORTED_PRODUCT_CATEGORIES = ( "dj", # Light "dlq", # Breaker "fs", # Fan - "fs", # Fan + "fsd", # Ceiling Fan Light + "fwd", # Ambient Light "fwl", # Ambient light + "gyd", # Motion Sensor Light "jsq", # Humidifier's light "kfj", # Coffee maker "kg", # Switch @@ -108,6 +110,8 @@ TUYA_SUPPORTED_PRODUCT_CATEGORIES = ( "sgbj", # Siren Alarm "sos", # SOS Button "sp", # Smart Camera + "tgq", # Dimmer + "tyndj", # Solar Light "wk", # Thermostat "xdd", # Ceiling Light "xxj", # Diffuser @@ -132,6 +136,15 @@ PLATFORMS = [ ] +class WorkMode(str, Enum): + """Work modes.""" + + COLOUR = "colour" + MUSIC = "music" + SCENE = "scene" + WHITE = "white" + + class DPCode(str, Enum): """Device Property Codes used by Tuya. @@ -144,11 +157,16 @@ class DPCode(str, Enum): ANION = "anion" # Ionizer unit BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage BATTERY_STATE = "battery_state" # Battery state + BRIGHT_CONTROLLER = "bright_controller" BRIGHT_STATE = "bright_state" # Brightness status BRIGHT_VALUE = "bright_value" # Brightness + BRIGHT_VALUE_1 = "bright_value_1" + BRIGHT_VALUE_2 = "bright_value_2" + BRIGHT_VALUE_V2 = "bright_value_v2" C_F = "c_f" # Temperature unit switching CHILD_LOCK = "child_lock" # Child lock CO2_VALUE = "co2_value" # CO2 concentration + COLOR_DATA_V2 = "color_data_v2" COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode CONCENTRATION_SET = "concentration_set" # Concentration setting @@ -176,9 +194,9 @@ class DPCode(str, Enum): RECORD_SWITCH = "record_switch" # Recording switch SENSITIVITY = "sensitivity" # Sensitivity SHAKE = "shake" # Oscillating + SHOCK_STATE = "shock_state" # Vibration status SOS = "sos" # Emergency State SOS_STATE = "sos_state" # Emergency mode - SHOCK_STATE = "shock_state" # Vibration status SPEED = "speed" # Speed level START = "start" # Start SWING = "swing" # Swing mode @@ -190,8 +208,11 @@ class DPCode(str, Enum): SWITCH_5 = "switch_5" # Switch 5 SWITCH_6 = "switch_6" # Switch 6 SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch + SWITCH_CONTROLLER = "switch_controller" SWITCH_HORIZONTAL = "switch_horizontal" # Horizontal swing flap switch SWITCH_LED = "switch_led" # Switch + SWITCH_LED_1 = "switch_led_1" + SWITCH_LED_2 = "switch_led_2" SWITCH_SPRAY = "switch_spray" # Spraying switch SWITCH_USB1 = "switch_usb1" # USB 1 SWITCH_USB2 = "switch_usb2" # USB 2 @@ -201,12 +222,14 @@ class DPCode(str, Enum): SWITCH_USB6 = "switch_usb6" # USB 6 SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch SWITCH_VOICE = "switch_voice" # Voice switch + TEMP_CONTROLLER = "temp_controller" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching TEMP_VALUE = "temp_value" # Color temperature + TEMP_VALUE_V2 = "temp_value_v2" TEMPER_ALARM = "temper_alarm" # Tamper alarm UV = "uv" # UV sterilization WARM = "warm" # Heat preservation diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 9758fdefc80..ba0710c390c 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,8 +1,8 @@ """Support for the Tuya lights.""" from __future__ import annotations +from dataclasses import dataclass import json -import logging from typing import Any from tuya_iot import TuyaDevice, TuyaDeviceManager @@ -16,6 +16,7 @@ from homeassistant.components.light import ( COLOR_MODE_HS, COLOR_MODE_ONOFF, LightEntity, + LightEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -23,45 +24,179 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData -from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .base import IntegerTypeData, TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, WorkMode -_LOGGER = logging.getLogger(__name__) -MIREDS_MAX = 500 -MIREDS_MIN = 153 +@dataclass +class TuyaLightEntityDescription(LightEntityDescription): + """Describe an Tuya light entity.""" -HSV_HA_HUE_MIN = 0 -HSV_HA_HUE_MAX = 360 -HSV_HA_SATURATION_MIN = 0 -HSV_HA_SATURATION_MAX = 100 + color_mode: DPCode | None = None + brightness: DPCode | tuple[DPCode, ...] | None = None + color_temp: DPCode | tuple[DPCode, ...] | None = None + color_data: DPCode | tuple[DPCode, ...] | None = None -WORK_MODE_WHITE = "white" -WORK_MODE_COLOUR = "colour" -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -TUYA_SUPPORT_TYPE = { - "dj", # Light - "dd", # Light strip - "fwl", # Ambient light - "dc", # Light string - "jsq", # Humidifier's light - "xdd", # Ceiling Light - "xxj", # Diffuser's light - "fs", # Fan +LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { + # String Lights + # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu + "dc": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Strip Lights + # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l + "dd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + "dj": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), + color_data=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA), + ), + ), + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Ambient Light + # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g + "fwd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Motion Sensor Light + # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy + "gyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_1, + name="Light", + brightness=DPCode.BRIGHT_VALUE_1, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED_2, + name="Light 2", + brightness=DPCode.BRIGHT_VALUE_2, + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), + # Remote Control + # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov + "ykq": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_CONTROLLER, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_CONTROLLER, + color_temp=DPCode.TEMP_CONTROLLER, + ), + ), } -DEFAULT_HSV = { - "h": {"min": 1, "scale": 0, "unit": "", "max": 360, "step": 1}, - "s": {"min": 1, "scale": 0, "unit": "", "max": 255, "step": 1}, - "v": {"min": 1, "scale": 0, "unit": "", "max": 255, "step": 1}, -} -DEFAULT_HSV_V2 = { - "h": {"min": 1, "scale": 0, "unit": "", "max": 360, "step": 1}, - "s": {"min": 1, "scale": 0, "unit": "", "max": 1000, "step": 1}, - "v": {"min": 1, "scale": 0, "unit": "", "max": 1000, "step": 1}, -} +@dataclass +class ColorTypeData: + """Color Type Data.""" + + h_type: IntegerTypeData + s_type: IntegerTypeData + v_type: IntegerTypeData + + +DEFAULT_COLOR_TYPE_DATA = ColorTypeData( + h_type=IntegerTypeData(min=1, scale=0, max=360, step=1), + s_type=IntegerTypeData(min=1, scale=0, max=255, step=1), + v_type=IntegerTypeData(min=1, scale=0, max=255, step=1), +) + +DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( + h_type=IntegerTypeData(min=1, scale=0, max=360, step=1), + s_type=IntegerTypeData(min=1, scale=0, max=1000, step=1), + v_type=IntegerTypeData(min=1, scale=0, max=1000, step=1), +) + + +@dataclass +class ColorData: + """Color Data.""" + + type_data: ColorTypeData + h_value: int + s_value: int + v_value: int + + @property + def hs_color(self) -> tuple[float, float]: + """Get the HS value from this color data.""" + return ( + self.type_data.h_type.remap_value_to(self.h_value, 0, 360), + self.type_data.s_type.remap_value_to(self.s_value, 0, 100), + ) + + @property + def brightness(self) -> int: + """Get the brightness value from this color data.""" + return round(self.type_data.v_type.remap_value_to(self.v_value, 0, 255)) async def async_setup_entry( @@ -76,8 +211,18 @@ async def async_setup_entry( entities: list[TuyaLightEntity] = [] for device_id in device_ids: device = hass_data.device_manager.device_map[device_id] - if device and device.category in TUYA_SUPPORT_TYPE: - entities.append(TuyaLightEntity(device, hass_data.device_manager)) + if descriptions := LIGHTS.get(device.category): + for description in descriptions: + if ( + description.key in device.function + or description.key in device.status + ): + entities.append( + TuyaLightEntity( + device, hass_data.device_manager, description + ) + ) + async_add_entities(entities) async_discover_device([*hass_data.device_manager.device_map]) @@ -90,278 +235,279 @@ async def async_setup_entry( class TuyaLightEntity(TuyaEntity, LightEntity): """Tuya light device.""" - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + entity_description: TuyaLightEntityDescription + _brightness_dpcode: DPCode | None = None + _brightness_type: IntegerTypeData | None = None + _color_data_dpcode: DPCode | None = None + _color_data_type: ColorTypeData | None = None + _color_temp_dpcode: DPCode | None = None + _color_temp_type: IntegerTypeData | None = None + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + description: TuyaLightEntityDescription, + ) -> None: """Init TuyaHaLight.""" - self.dp_code_bright = DPCode.BRIGHT_VALUE - self.dp_code_temp = DPCode.TEMP_VALUE - self.dp_code_colour = DPCode.COLOUR_DATA - - for key in device.function: - if key.startswith(DPCode.BRIGHT_VALUE): - self.dp_code_bright = key - elif key.startswith(DPCode.TEMP_VALUE): - self.dp_code_temp = key - elif key.startswith(DPCode.COLOUR_DATA): - self.dp_code_colour = key - super().__init__(device, device_manager) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + self._attr_supported_color_modes = {COLOR_MODE_ONOFF} + + # Determine brightness DPCodes + if ( + isinstance(description.brightness, DPCode) + and description.brightness in device.function + ): + self._brightness_dpcode = description.brightness + elif isinstance(description.brightness, tuple): + self._brightness_dpcode = next( + ( + dpcode + for dpcode in description.brightness + if dpcode in device.function + ), + None, + ) + + # Determine DPCodes for color temperature + if ( + isinstance(description.color_temp, DPCode) + and description.color_temp in device.function + ): + self._color_temp_dpcode = description.color_temp + elif isinstance(description.color_temp, tuple): + self._color_temp_dpcode = next( + ( + dpcode + for dpcode in description.color_temp + if dpcode in device.function + ), + None, + ) + + # Determine DPCodes for color data + if ( + isinstance(description.color_data, DPCode) + and description.color_data in device.function + ): + self._color_data_dpcode = description.color_data + elif isinstance(description.color_data, tuple): + self._color_data_dpcode = next( + ( + dpcode + for dpcode in description.color_data + if dpcode in device.function + ), + None, + ) + + # Update internals based on found brightness dpcode + if self._brightness_dpcode: + self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS) + self._brightness_type = IntegerTypeData.from_json( + device.status_range[self._brightness_dpcode].values + ) + + # Update internals based on found color temperature dpcode + if self._color_temp_dpcode: + self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) + self._color_temp_type = IntegerTypeData.from_json( + device.status_range[self._color_temp_dpcode].values + ) + + # Update internals based on found color data dpcode + if self._color_data_dpcode: + self._attr_supported_color_modes.add(COLOR_MODE_HS) + # Fetch color data type information + if function_data := json.loads( + self.device.function[self._color_data_dpcode].values + ): + self._color_data_type = ColorTypeData( + h_type=IntegerTypeData(**function_data["h"]), + s_type=IntegerTypeData(**function_data["s"]), + v_type=IntegerTypeData(**function_data["v"]), + ) + else: + # If no type is found, use a default one + self._color_data_type = DEFAULT_COLOR_TYPE_DATA + if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or ( + self._brightness_type and self._brightness_type.max > 255 + ): + self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 @property def is_on(self) -> bool: """Return true if light is on.""" - return self.device.status.get(DPCode.SWITCH_LED, False) + return self.device.status.get(self.entity_description.key, False) def turn_on(self, **kwargs: Any) -> None: """Turn on or control the light.""" - commands = [] - work_mode = self._work_mode() - _LOGGER.debug("light kwargs-> %s; work_mode %s", kwargs, work_mode) + commands = [{"code": self.entity_description.key, "value": True}] + + if self._color_data_type and ( + ATTR_HS_COLOR in kwargs + or (ATTR_BRIGHTNESS in kwargs and self.color_mode == COLOR_MODE_HS) + ): + if color_mode_dpcode := self.entity_description.color_mode: + commands += [ + { + "code": color_mode_dpcode, + "value": WorkMode.COLOUR, + }, + ] + + if not (brightness := kwargs.get(ATTR_BRIGHTNESS)): + brightness = self.brightness or 0 + + if not (color := kwargs.get(ATTR_HS_COLOR)): + color = self.hs_color or (0, 0) + + commands += [ + { + "code": self._color_data_dpcode, + "value": json.dumps( + { + "h": round( + self._color_data_type.h_type.remap_value_from( + color[0], 0, 360 + ) + ), + "s": round( + self._color_data_type.s_type.remap_value_from( + color[1], 0, 100 + ) + ), + "v": round( + self._color_data_type.v_type.remap_value_from( + brightness + ) + ), + } + ), + }, + ] + + elif ATTR_COLOR_TEMP in kwargs and self._color_temp_type: + if color_mode_dpcode := self.entity_description.color_mode: + commands += [ + { + "code": color_mode_dpcode, + "value": WorkMode.WHITE, + }, + ] + + commands += [ + { + "code": self._color_temp_dpcode, + "value": round( + self._color_temp_type.remap_value_from( + kwargs[ATTR_COLOR_TEMP], + self.min_mireds, + self.max_mireds, + reverse=True, + ) + ), + }, + ] if ( - DPCode.LIGHT in self.device.status - and DPCode.SWITCH_LED not in self.device.status + ATTR_BRIGHTNESS in kwargs + and self.color_mode != COLOR_MODE_HS + and self._brightness_type ): - commands += [{"code": DPCode.LIGHT, "value": True}] - else: - commands += [{"code": DPCode.SWITCH_LED, "value": True}] - - colour_data = self._get_hsv() - v_range = self._tuya_hsv_v_range() - send_colour_data = False - - if ATTR_HS_COLOR in kwargs: - # hsv h - colour_data["h"] = int(kwargs[ATTR_HS_COLOR][0]) - # hsv s - ha_s = kwargs[ATTR_HS_COLOR][1] - s_range = self._tuya_hsv_s_range() - colour_data["s"] = int( - self.remap( - ha_s, - HSV_HA_SATURATION_MIN, - HSV_HA_SATURATION_MAX, - s_range[0], - s_range[1], - ) - ) - # hsv v - ha_v = self.brightness - colour_data["v"] = int(self.remap(ha_v, 0, 255, v_range[0], v_range[1])) - commands += [ - {"code": self.dp_code_colour, "value": json.dumps(colour_data)} - ] - if work_mode != WORK_MODE_COLOUR: - work_mode = WORK_MODE_COLOUR - commands += [{"code": DPCode.WORK_MODE, "value": work_mode}] - - elif ATTR_COLOR_TEMP in kwargs: - # temp color - new_range = self._tuya_temp_range() - color_temp = self.remap( - self.max_mireds - kwargs[ATTR_COLOR_TEMP] + self.min_mireds, - self.min_mireds, - self.max_mireds, - new_range[0], - new_range[1], - ) - commands += [{"code": self.dp_code_temp, "value": int(color_temp)}] - - # brightness - ha_brightness = self.brightness - new_range = self._tuya_brightness_range() - tuya_brightness = self.remap( - ha_brightness, 0, 255, new_range[0], new_range[1] - ) - commands += [{"code": self.dp_code_bright, "value": int(tuya_brightness)}] - - if work_mode != WORK_MODE_WHITE: - work_mode = WORK_MODE_WHITE - commands += [{"code": DPCode.WORK_MODE, "value": WORK_MODE_WHITE}] - - if ATTR_BRIGHTNESS in kwargs: - if work_mode == WORK_MODE_COLOUR: - colour_data["v"] = int( - self.remap(kwargs[ATTR_BRIGHTNESS], 0, 255, v_range[0], v_range[1]) - ) - send_colour_data = True - elif work_mode == WORK_MODE_WHITE: - new_range = self._tuya_brightness_range() - tuya_brightness = int( - self.remap( - kwargs[ATTR_BRIGHTNESS], 0, 255, new_range[0], new_range[1] - ) - ) - commands += [{"code": self.dp_code_bright, "value": tuya_brightness}] - - if send_colour_data: - commands += [ - {"code": self.dp_code_colour, "value": json.dumps(colour_data)} + { + "code": self._brightness_dpcode, + "value": round( + self._brightness_type.remap_value_from(kwargs[ATTR_BRIGHTNESS]) + ), + }, ] self._send_command(commands) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - if ( - DPCode.LIGHT in self.device.status - and DPCode.SWITCH_LED not in self.device.status - ): - commands = [{"code": DPCode.LIGHT, "value": False}] - else: - commands = [{"code": DPCode.SWITCH_LED, "value": False}] - self._send_command(commands) + self._send_command([{"code": self.entity_description.key, "value": False}]) @property def brightness(self) -> int | None: """Return the brightness of the light.""" - old_range = self._tuya_brightness_range() - brightness = self.device.status.get(self.dp_code_bright, 0) + # If the light is currently in color mode, extract the brightness from the color data + if self.color_mode == COLOR_MODE_HS and (color_data := self._get_color_data()): + return color_data.brightness - if self._work_mode().startswith(WORK_MODE_COLOUR): - colour_json = self.device.status.get(self.dp_code_colour) - if not colour_json: - return None - colour_data = json.loads(colour_json) - v_range = self._tuya_hsv_v_range() - hsv_v = colour_data.get("v", 0) - return int(self.remap(hsv_v, v_range[0], v_range[1], 0, 255)) + if not self._brightness_dpcode or not self._brightness_type: + return None - return int(self.remap(brightness, old_range[0], old_range[1], 0, 255)) + brightness = self.device.status.get(self._brightness_dpcode) + if brightness is None: + return None - def _tuya_brightness_range(self) -> tuple[int, int]: - if self.dp_code_bright not in self.device.status: - return 0, 255 - bright_item = self.device.function.get(self.dp_code_bright) - if not bright_item: - return 0, 255 - bright_value = json.loads(bright_item.values) - return bright_value.get("min", 0), bright_value.get("max", 255) + return round(self._brightness_type.remap_value_to(brightness)) @property - def color_mode(self) -> str: - """Return the color_mode of the light.""" - work_mode = self._work_mode() - if work_mode == WORK_MODE_WHITE: - return COLOR_MODE_COLOR_TEMP - return COLOR_MODE_HS + def color_temp(self) -> int | None: + """Return the color_temp of the light.""" + if not self._color_temp_dpcode or not self._color_temp_type: + return None + + temperature = self.device.status.get(self._color_temp_dpcode) + if temperature is None: + return None + + return round( + self._color_temp_type.remap_value_to( + temperature, self.min_mireds, self.max_mireds, reverse=True + ) + ) @property def hs_color(self) -> tuple[float, float] | None: """Return the hs_color of the light.""" - colour_json = self.device.status.get(self.dp_code_colour) - if not colour_json: + if self._color_data_dpcode is None or not ( + color_data := self._get_color_data() + ): return None - colour_data = json.loads(colour_json) - s_range = self._tuya_hsv_s_range() - return colour_data.get("h", 0), self.remap( - colour_data.get("s", 0), - s_range[0], - s_range[1], - HSV_HA_SATURATION_MIN, - HSV_HA_SATURATION_MAX, + return color_data.hs_color + + @property + def color_mode(self) -> str: + """Return the color_mode of the light.""" + # We consider it to be in HS color mode, when work mode is anything + # else than "white". + if ( + self.entity_description.color_mode + and self.device.status.get(self.entity_description.color_mode) + != WorkMode.WHITE + ): + return COLOR_MODE_HS + if self._color_temp_dpcode: + return COLOR_MODE_COLOR_TEMP + if self._brightness_dpcode: + return COLOR_MODE_BRIGHTNESS + return COLOR_MODE_ONOFF + + def _get_color_data(self) -> ColorData | None: + """Get current color data from device.""" + if ( + self._color_data_type is None + or self._color_data_dpcode is None + or self._color_data_dpcode not in self.device.status + ): + return None + + if not (status_data := self.device.status[self._color_data_dpcode]): + return None + + if not (status := json.loads(status_data)): + return None + + return ColorData( + type_data=self._color_data_type, + h_value=status["h"], + s_value=status["s"], + v_value=status["v"], ) - - @property - def color_temp(self) -> int: - """Return the color_temp of the light.""" - new_range = self._tuya_temp_range() - tuya_color_temp = self.device.status.get(self.dp_code_temp, 0) - return ( - self.max_mireds - - self.remap( - tuya_color_temp, - new_range[0], - new_range[1], - self.min_mireds, - self.max_mireds, - ) - + self.min_mireds - ) - - @property - def min_mireds(self) -> int: - """Return color temperature min mireds.""" - return MIREDS_MIN - - @property - def max_mireds(self) -> int: - """Return color temperature max mireds.""" - return MIREDS_MAX - - def _tuya_temp_range(self) -> tuple[int, int]: - temp_item = self.device.function.get(self.dp_code_temp) - if not temp_item: - return 0, 255 - temp_value = json.loads(temp_item.values) - return temp_value.get("min", 0), temp_value.get("max", 255) - - def _tuya_hsv_s_range(self) -> tuple[int, int]: - hsv_data_range = self._tuya_hsv_function() - if hsv_data_range is not None: - hsv_s = hsv_data_range.get("s", {"min": 0, "max": 255}) - return hsv_s.get("min", 0), hsv_s.get("max", 255) - return 0, 255 - - def _tuya_hsv_v_range(self) -> tuple[int, int]: - hsv_data_range = self._tuya_hsv_function() - if hsv_data_range is not None: - hsv_v = hsv_data_range.get("v", {"min": 0, "max": 255}) - return hsv_v.get("min", 0), hsv_v.get("max", 255) - - return 0, 255 - - def _tuya_hsv_function(self) -> dict[str, dict] | None: - hsv_item = self.device.function.get(self.dp_code_colour) - if not hsv_item: - return None - hsv_data = json.loads(hsv_item.values) - if hsv_data: - return hsv_data - colour_json = self.device.status.get(self.dp_code_colour) - if not colour_json: - return None - colour_data = json.loads(colour_json) - if ( - self.dp_code_colour == DPCode.COLOUR_DATA_V2 - or colour_data.get("v", 0) > 255 - or colour_data.get("s", 0) > 255 - ): - return DEFAULT_HSV_V2 - return DEFAULT_HSV - - def _work_mode(self) -> str: - return self.device.status.get(DPCode.WORK_MODE, "") - - def _get_hsv(self) -> dict[str, int]: - if ( - self.dp_code_colour not in self.device.status - or len(self.device.status[self.dp_code_colour]) == 0 - ): - return {"h": 0, "s": 0, "v": 0} - - return json.loads(self.device.status[self.dp_code_colour]) - - @property - def supported_color_modes(self) -> set[str] | None: - """Flag supported color modes.""" - color_modes = [COLOR_MODE_ONOFF] - if self.dp_code_bright in self.device.status: - color_modes.append(COLOR_MODE_BRIGHTNESS) - - if self.dp_code_temp in self.device.status: - color_modes.append(COLOR_MODE_COLOR_TEMP) - - if ( - self.dp_code_colour in self.device.status - and len(self.device.status[self.dp_code_colour]) > 0 - ): - color_modes.append(COLOR_MODE_HS) - return set(color_modes) - - @staticmethod - def remap(old_value, old_min, old_max, new_min, new_max): - """Remap old_value to new_value.""" - return ((old_value - old_min) / (old_max - old_min)) * ( - new_max - new_min - ) + new_min