"""Tasmota light.""" from __future__ import annotations import colorsys from dataclasses import dataclass import logging from typing import Any, cast from .const import ( COMMAND_CHANNEL, COMMAND_COLOR, COMMAND_CT, COMMAND_DIMMER, COMMAND_FADE, COMMAND_POWER, COMMAND_SCHEME, COMMAND_SPEED, COMMAND_WHITE, CONF_DEEP_SLEEP, CONF_LIGHT_SUBTYPE, CONF_LINK_RGB_CT, CONF_MAC, CONF_OPTIONS, CONF_RELAY, CONF_TUYA, LST_COLDWARM, LST_NONE, LST_RGB, LST_RGBCW, LST_RGBW, LST_SINGLE, OPTION_FADE_FIXED_DURATION, OPTION_NOT_POWER_LINKED, OPTION_PWM_MULTI_CHANNELS, OPTION_REDUCED_CT_RANGE, RL_LIGHT, ) from .entity import ( TasmotaAvailability, TasmotaAvailabilityConfig, TasmotaEntity, TasmotaEntityConfig, ) from .mqtt import ReceiveMessage, send_commands from .utils import ( config_get_friendlyname, config_get_state_offline, config_get_state_online, config_get_state_power_off, config_get_state_power_on, get_state_power, get_topic_command, get_topic_command_state, get_topic_stat_result, get_topic_tele_state, get_topic_tele_will, get_value_by_path, ) LIGHT_TYPE_NONE = 0 LIGHT_TYPE_DIMMER = 1 LIGHT_TYPE_COLDWARM = 2 LIGHT_TYPE_RGB = 3 LIGHT_TYPE_RGBW = 4 LIGHT_TYPE_RGBCW = 5 LIGHT_TYPE_MAP = { LIGHT_TYPE_NONE: LST_NONE, LIGHT_TYPE_DIMMER: LST_SINGLE, LIGHT_TYPE_COLDWARM: LST_COLDWARM, LIGHT_TYPE_RGB: LST_RGB, LIGHT_TYPE_RGBW: LST_RGBW, LIGHT_TYPE_RGBCW: LST_RGBCW, } DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 REDUCED_MIN_MIREDS = 200 REDUCED_MAX_MIREDS = 380 _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class TasmotaLightConfig(TasmotaAvailabilityConfig, TasmotaEntityConfig): """Tasmota light configuation.""" idx: int dimmer_cmd: str dimmer_state: str color_suffix: str command_topic: str control_by_channel: bool fade_fixed_duration: bool light_type: int max_mireds: int min_mireds: int not_power_linked: bool poll_topic: str result_topic: str state_power_off: str state_power_on: str state_topic: str tuya: bool @classmethod def from_discovery_message( cls, config: dict, idx: int, platform: str ) -> TasmotaLightConfig: """Instantiate from discovery message.""" color_suffix = "" dimmer_cmd = COMMAND_DIMMER dimmer_state = COMMAND_DIMMER control_by_channel = False # Use Channel command to control the light if config[CONF_TUYA]: dimmer_idx = 3 # Brightness controlled by DIMMER3 dimmer_cmd = f"{COMMAND_DIMMER}{dimmer_idx}" tasmota_light_sub_type = config[CONF_LIGHT_SUBTYPE] light_type = LIGHT_TYPE_MAP[tasmota_light_sub_type] if config[CONF_OPTIONS][OPTION_PWM_MULTI_CHANNELS]: # Multi-channel PWM instead of a single light, each light controlled by CHANNEL dimmer_state = f"{COMMAND_CHANNEL}{idx + 1}" control_by_channel = True light_type = LIGHT_TYPE_DIMMER elif not config[CONF_LINK_RGB_CT] and tasmota_light_sub_type >= LST_RGBW: # Split light in RGB (idx==0) + White/CT (idx==1) first_light = config[CONF_RELAY].index(RL_LIGHT) if idx - first_light == 0: dimmer_idx = 1 # Brightness controlled by DIMMER1 light_type = LIGHT_TYPE_RGB color_suffix = "=" if idx - first_light == 1: dimmer_idx = 2 # Brightness controlled by DIMMER2 if tasmota_light_sub_type == LST_RGBW: light_type = LIGHT_TYPE_DIMMER else: light_type = LIGHT_TYPE_COLDWARM dimmer_cmd = f"{COMMAND_DIMMER}{dimmer_idx}" dimmer_state = f"{COMMAND_DIMMER}{dimmer_idx}" min_mireds = DEFAULT_MIN_MIREDS max_mireds = DEFAULT_MAX_MIREDS if config[CONF_OPTIONS][OPTION_REDUCED_CT_RANGE]: min_mireds = REDUCED_MIN_MIREDS max_mireds = REDUCED_MAX_MIREDS return cls( endpoint="light", idx=idx, friendly_name=config_get_friendlyname(config, platform, idx), mac=config[CONF_MAC], platform=platform, poll_payload="", poll_topic=get_topic_command_state(config), availability_topic=get_topic_tele_will(config), availability_offline=config_get_state_offline(config), availability_online=config_get_state_online(config), deep_sleep_enabled=config[CONF_DEEP_SLEEP], dimmer_cmd=dimmer_cmd, dimmer_state=dimmer_state, color_suffix=color_suffix, command_topic=get_topic_command(config), control_by_channel=control_by_channel, fade_fixed_duration=config[CONF_OPTIONS][OPTION_FADE_FIXED_DURATION], light_type=light_type, max_mireds=max_mireds, min_mireds=min_mireds, not_power_linked=config[CONF_OPTIONS][OPTION_NOT_POWER_LINKED], result_topic=get_topic_stat_result(config), state_power_off=config_get_state_power_off(config), state_power_on=config_get_state_power_on(config), state_topic=get_topic_tele_state(config), tuya=config[CONF_TUYA], ) class TasmotaLight(TasmotaAvailability, TasmotaEntity): """Representation of a Tasmota light.""" _cfg: TasmotaLightConfig def __init__(self, **kwds: Any): """Initialize.""" self._brightness = None self._color: list[float] | None = None self._color_temp: int | None = None self._state: bool | None = None self._sub_state: dict | None = None super().__init__(**kwds) async def subscribe_topics(self) -> None: """Subscribe to topics.""" def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" if not self._on_state_callback: return attributes = {} idx = self._cfg.idx if self._cfg.endpoint == "light": if self._cfg.light_type != LIGHT_TYPE_NONE: brightness = get_value_by_path( msg.payload, [self._cfg.dimmer_state] ) if brightness is not None: self._brightness = brightness attributes["brightness"] = brightness if ( color := get_value_by_path(msg.payload, [COMMAND_COLOR]) ) is not None: if color.find(",") != -1: color = color.split(",", 3) else: color = [ int(color[i : i + 2], 16) for i in range(0, len(color), 2) ] if len(color) >= 3: color = [float(color[0]), float(color[1]), float(color[2])] self._color = color attributes["color"] = color if ( color_hsb := get_value_by_path(msg.payload, ["HSBColor"]) ) is not None: color_hsb = color_hsb.split(",", 3) if len(color_hsb) == 3: color_hs = [float(color_hsb[0]), float(color_hsb[1])] attributes["color_hs"] = color_hs if ( color_temp := get_value_by_path(msg.payload, [COMMAND_CT]) ) is not None: self._color_temp = color_temp attributes["color_temp"] = color_temp scheme = get_value_by_path(msg.payload, [COMMAND_SCHEME]) if scheme is not None and self.effect_list: try: attributes["effect"] = self.effect_list[scheme] except IndexError: attributes["effect"] = f"Scheme {scheme}" _LOGGER.debug("Unknown scheme %s", scheme) if ( white_value := get_value_by_path(msg.payload, [COMMAND_WHITE]) ) is not None: attributes["white_value"] = white_value state = get_state_power(cast(str, msg.payload), idx) if state == self._cfg.state_power_on: self._state = True self._on_state_callback(True, attributes=attributes) elif state == self._cfg.state_power_off: self._state = False self._on_state_callback(False, attributes=attributes) availability_topics = self.get_availability_topics() topics = { "result_topic": { "event_loop_safe": True, "topic": self._cfg.result_topic, "msg_callback": state_message_received, }, "state_topic": { "event_loop_safe": True, "topic": self._cfg.state_topic, "msg_callback": state_message_received, }, } topics = {**topics, **availability_topics} self._sub_state = await self._mqtt_client.subscribe( self._sub_state, topics, ) async def unsubscribe_topics(self) -> None: """Unsubscribe to all MQTT topics.""" self._sub_state = await self._mqtt_client.unsubscribe(self._sub_state) @property def effect_list(self) -> list[str] | None: """Return effect list.""" if self._cfg.endpoint == "light": return ["Solid", "Wake up", "Cycle up", "Cycle down", "Random"] return None @property def light_type(self) -> int: """Return light type.""" if self._cfg.endpoint == "light": return self._cfg.light_type return LIGHT_TYPE_NONE @property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._cfg.min_mireds @property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._cfg.max_mireds async def set_state(self, state: bool, attributes: dict[str, Any]) -> None: """Turn the light on or off.""" if self._cfg.endpoint == "relay": await self._set_state_relay(state) else: await self._set_state_light(state, attributes) @property def supports_transition(self) -> bool: """Return if the light supports transitions.""" return self.light_type != LIGHT_TYPE_NONE and not self._cfg.tuya async def _set_state_relay(self, state: bool) -> None: """Turn the relay on or off.""" payload = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{self._cfg.idx + 1}" await self._mqtt_client.publish( self._cfg.command_topic + command, payload, ) async def _set_state_light(self, state: bool, attributes: dict[str, Any]) -> None: idx = self._cfg.idx commands: list[tuple[str, str | float]] = [] transition = attributes.get("transition", 0) do_transition = transition > 0 argument: str | float # Set fade if self.supports_transition and "transition" in attributes: command = COMMAND_FADE argument = 1 if do_transition else 0 commands.append((command, argument)) if do_transition: speed = self._calculate_speed(state, attributes) # Clamp speed to the range 1..40 speed = min(max(speed, 1), 40) command = COMMAND_SPEED commands.append((command, speed)) argument = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{idx + 1}" if "brightness" in attributes: argument = attributes["brightness"] if self._cfg.control_by_channel: command = f"{COMMAND_CHANNEL}{idx + 1}" else: command = self._cfg.dimmer_cmd commands.append((command, argument)) if "color" in attributes: color = attributes["color"] argument = f"{color[0]},{color[1]},{color[2]}{self._cfg.color_suffix}" command = f"{COMMAND_COLOR}2" commands.append((command, argument)) if "color_hs" in attributes: argument = round(attributes["color_hs"][0]) command = "HsbColor1" # Hue commands.append((command, argument)) argument = round(attributes["color_hs"][1]) command = "HsbColor2" # Saturation commands.append((command, argument)) if "color_temp" in attributes: argument = attributes["color_temp"] command = COMMAND_CT commands.append((command, argument)) if "effect" in attributes and self.effect_list: try: effect = attributes["effect"] argument = self.effect_list.index(effect) command = COMMAND_SCHEME commands.append((command, argument)) except ValueError: _LOGGER.debug("Unknown effect %s", effect) if "white_value" in attributes: argument = attributes["white_value"] command = COMMAND_WHITE commands.append((command, argument)) if self._cfg.not_power_linked and "brightness" in attributes: # Always send power argument = self._cfg.state_power_on if state else self._cfg.state_power_off command = f"{COMMAND_POWER}{idx + 1}" commands.append((command, argument)) await send_commands(self._mqtt_client, self._cfg.command_topic, commands) def _calculate_speed(self, state: bool, attributes: dict[str, Any]) -> float: # Calculate speed: # Home Assistant's transition is the transition time in seconds. # Tasmota's speed command is the number of half-seconds, scaled for a 100% fade # if fade_fixed_duration (SetOption117) is not set transition: int = attributes.get("transition", 0) if self._cfg.fade_fixed_duration: # Fading at fixed duration return round(transition * 2) old_brightness = self._brightness if self._brightness is not None else 100 now_brightness = old_brightness if self._state else 0 new_brightness = attributes.get("brightness", old_brightness if state else 0) now_channels = [] new_channels = [] # Calculate normalized brightness for all channels if self.light_type >= LIGHT_TYPE_COLDWARM: if self.light_type >= LIGHT_TYPE_RGB and self._color: if "color" in attributes: new_color = attributes["color"] elif "color_hs" in attributes: # Convert hs_color to color color_hs = attributes["color_hs"] rgb = colorsys.hsv_to_rgb(color_hs[0] / 360, color_hs[1] / 100, 1) rgb = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)) new_color = rgb else: new_color = self._color now_color = [x / 255 for x in self._color] new_color = [x / 255 for x in new_color] now_channels.extend(now_color) new_channels.extend(new_color) if self.light_type >= LIGHT_TYPE_COLDWARM and self._color_temp: now_color_temp = self._color_temp new_color_temp = attributes.get("color_temp", self._color_temp) mired_range = self.max_mireds - self.min_mireds now_ct_ratio = (now_color_temp - self.min_mireds) / mired_range new_ct_ratio = (new_color_temp - self.min_mireds) / mired_range now_channels.append(now_ct_ratio) new_channels.append(new_ct_ratio) now_channels = [x * now_brightness / 100 for x in now_channels] new_channels = [x * new_brightness / 100 for x in new_channels] # 1-channel dimmer, or color / color_temp unknown if not new_channels: new_channels = [new_brightness / 100] now_channels = [now_brightness / 100] # Scale transition to the channel with the largest brightness change abs_changes = map( abs, [x1 - x2 for (x1, x2) in zip(now_channels, new_channels)] ) # Mypy is confused about the map, override the inferred typing if (delta_ratio := max(abs_changes)) == 0: speed = 0 else: speed = round(transition * 2 / delta_ratio) return speed