core/homeassistant/components/tuya/light.py

368 lines
12 KiB
Python

"""Support for the Tuya lights."""
from __future__ import annotations
import json
import logging
from typing import Any
from tuya_iot import TuyaDevice, TuyaDeviceManager
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
COLOR_MODE_BRIGHTNESS,
COLOR_MODE_COLOR_TEMP,
COLOR_MODE_HS,
COLOR_MODE_ONOFF,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
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
_LOGGER = logging.getLogger(__name__)
MIREDS_MAX = 500
MIREDS_MIN = 153
HSV_HA_HUE_MIN = 0
HSV_HA_HUE_MAX = 360
HSV_HA_SATURATION_MIN = 0
HSV_HA_SATURATION_MAX = 100
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
}
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},
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up tuya light dynamically through tuya discovery."""
hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id]
@callback
def async_discover_device(device_ids: list[str]):
"""Discover and add a discovered tuya light."""
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))
async_add_entities(entities)
async_discover_device([*hass_data.device_manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)
)
class TuyaLightEntity(TuyaEntity, LightEntity):
"""Tuya light device."""
def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> 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)
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self.tuya_device.status.get(DPCode.SWITCH_LED, 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)
if (
DPCode.LIGHT in self.tuya_device.status
and DPCode.SWITCH_LED not in self.tuya_device.status
):
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)}
]
self._send_command(commands)
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
if (
DPCode.LIGHT in self.tuya_device.status
and DPCode.SWITCH_LED not in self.tuya_device.status
):
commands = [{"code": DPCode.LIGHT, "value": False}]
else:
commands = [{"code": DPCode.SWITCH_LED, "value": False}]
self._send_command(commands)
@property
def brightness(self) -> int | None:
"""Return the brightness of the light."""
old_range = self._tuya_brightness_range()
brightness = self.tuya_device.status.get(self.dp_code_bright, 0)
if self._work_mode().startswith(WORK_MODE_COLOUR):
colour_json = self.tuya_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))
return int(self.remap(brightness, old_range[0], old_range[1], 0, 255))
def _tuya_brightness_range(self) -> tuple[int, int]:
if self.dp_code_bright not in self.tuya_device.status:
return 0, 255
bright_item = self.tuya_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)
@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
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hs_color of the light."""
colour_json = self.tuya_device.status.get(self.dp_code_colour)
if not colour_json:
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,
)
@property
def color_temp(self) -> int:
"""Return the color_temp of the light."""
new_range = self._tuya_temp_range()
tuya_color_temp = self.tuya_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.tuya_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.tuya_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.tuya_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.tuya_device.status.get(DPCode.WORK_MODE, "")
def _get_hsv(self) -> dict[str, int]:
if (
self.dp_code_colour not in self.tuya_device.status
or len(self.tuya_device.status[self.dp_code_colour]) == 0
):
return {"h": 0, "s": 0, "v": 0}
return json.loads(self.tuya_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.tuya_device.status:
color_modes.append(COLOR_MODE_BRIGHTNESS)
if self.dp_code_temp in self.tuya_device.status:
color_modes.append(COLOR_MODE_COLOR_TEMP)
if (
self.dp_code_colour in self.tuya_device.status
and len(self.tuya_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