diff --git a/README.md b/README.md index e54de7d..8f5211e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The Cloud API configuration page will appear, requesting to input your Tuya IoT To setup a Tuya IoT Platform account and setup a project in it, refer to the instructions for the official Tuya integration: https://www.home-assistant.io/integrations/tuya/ -The place to find the Client ID and Secret is described in this link (in the ["Get Authorization Key"](https://www.home-assistant.io/integrations/tuya/#get-authorization-key) paragraph), while the User ID can be found in the "Link Tuya App Account" subtab within the Cloud project: +The Client ID and Secret can be found at `Cloud > Development > Overview` and the User ID can be found in the "Link Tuya App Account" subtab within the Cloud project: ![user_id.png](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/8-user_id.png) diff --git a/custom_components/localtuya/__init__.py b/custom_components/localtuya/__init__.py index b126c2f..a54656d 100644 --- a/custom_components/localtuya/__init__.py +++ b/custom_components/localtuya/__init__.py @@ -26,6 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import async_register_admin_service from .cloud_api import TuyaCloudApi from .common import TuyaDevice, async_config_entry_by_device_id @@ -139,7 +140,7 @@ async def async_setup(hass: HomeAssistant, config: dict): ) new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) hass.config_entries.async_update_entry(entry, data=new_data) - + elif device_id in hass.data[DOMAIN][TUYA_DEVICES]: _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) @@ -162,7 +163,8 @@ async def async_setup(hass: HomeAssistant, config: dict): async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL) - hass.helpers.service.async_register_admin_service( + async_register_admin_service( + hass, DOMAIN, SERVICE_RELOAD, _handle_reload, @@ -261,22 +263,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): res = await tuya_api.async_get_devices_list() hass.data[DOMAIN][DATA_CLOUD] = tuya_api - async def setup_entities(device_ids): - platforms = set() - for dev_id in device_ids: - entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES] - platforms = platforms.union( - set(entity[CONF_PLATFORM] for entity in entities) - ) - hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id) - - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_setup(entry, platform) - for platform in platforms - ] + platforms = set() + for dev_id in entry.data[CONF_DEVICES].keys(): + entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES] + platforms = platforms.union( + set(entity[CONF_PLATFORM] for entity in entities) ) + hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id) + # Setup all platforms at once, letting HA handling each platform and avoiding + # potential integration restarts while elements are still initialising. + await hass.config_entries.async_forward_entry_setups(entry, platforms) + + async def setup_entities(device_ids): for dev_id in device_ids: hass.data[DOMAIN][TUYA_DEVICES][dev_id].async_connect() diff --git a/custom_components/localtuya/climate.py b/custom_components/localtuya/climate.py index 0730eaf..616381b 100644 --- a/custom_components/localtuya/climate.py +++ b/custom_components/localtuya/climate.py @@ -73,6 +73,10 @@ HVAC_MODE_SETS = { HVACMode.HEAT: "Manual", HVACMode.AUTO: "Auto", }, + "MANUAL/AUTO": { + HVACMode.HEAT: "MANUAL", + HVACMode.AUTO: "AUTO", + }, "Manual/Program": { HVACMode.HEAT: "Manual", HVACMode.AUTO: "Program", @@ -91,6 +95,11 @@ HVAC_MODE_SETS = { HVACMode.COOL: "cold", HVACMode.AUTO: "auto", }, + "Cold/Dehumidify/Hot": { + HVACMode.HEAT: "hot", + HVACMode.DRY: "dehumidify", + HVACMode.COOL: "cold", + }, "1/0": { HVACMode.HEAT: "1", HVACMode.AUTO: "0", @@ -113,6 +122,10 @@ HVAC_ACTION_SETS = { HVACAction.HEATING: "Heat", HVACAction.IDLE: "Warming", }, + "heating/warming": { + HVACAction.HEATING: "heating", + HVACAction.IDLE: "warming", + }, } HVAC_FAN_MODE_SETS = { "Auto/Low/Middle/High/Strong": { @@ -135,6 +148,11 @@ PRESET_SETS = { PRESET_HOME: "Program", PRESET_NONE: "Manual", }, + "smart/holiday/hold": { + PRESET_AWAY: "holiday", + PRESET_HOME: "smart", + PRESET_NONE: "hold", + }, } TEMPERATURE_CELSIUS = "celsius" diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 4ad46f6..fdb3503 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -127,7 +127,7 @@ def async_config_entry_by_device_id(hass, device_id): if device_id in entry.data.get(CONF_DEVICES, []): return entry else: - _LOGGER.warning(f"Missing device configuration for device_id {device_id}") + _LOGGER.debug(f"Missing device configuration for device_id {device_id}") return None diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 5bce692..75c843e 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -53,6 +53,7 @@ CONF_BRIGHTNESS_LOWER = "brightness_lower" CONF_BRIGHTNESS_UPPER = "brightness_upper" CONF_COLOR = "color" CONF_COLOR_MODE = "color_mode" +CONF_COLOR_MODE_SET = "color_mode_set" CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin" CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin" CONF_COLOR_TEMP_REVERSE = "color_temp_reverse" diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index 5e1a3c9..59c33ac 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -9,7 +9,8 @@ from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, - FanEntity, FanEntityFeature, + FanEntityFeature, + FanEntity, ) from homeassistant.util.percentage import ( int_states_in_range, @@ -186,9 +187,9 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): self.schedule_update_ha_state() @property - def supported_features(self) -> int: + def supported_features(self) -> FanEntityFeature: """Flag supported features.""" - features = 0 + features = FanEntityFeature(0) if self.has_config(CONF_FAN_OSCILLATING_CONTROL): features |= FanEntityFeature.OSCILLATE @@ -199,6 +200,9 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): if self.has_config(CONF_FAN_DIRECTION): features |= FanEntityFeature.DIRECTION + features |= FanEntityFeature.TURN_OFF + features |= FanEntityFeature.TURN_ON + return features @property diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 7c74e49..66773c2 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -1,21 +1,19 @@ """Platform to locally control Tuya-based light devices.""" import logging import textwrap +from dataclasses import dataclass from functools import partial import homeassistant.util.color as color_util import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, DOMAIN, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - SUPPORT_EFFECT, LightEntity, + LightEntityFeature, + ColorMode, ) from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE @@ -28,7 +26,7 @@ from .const import ( CONF_COLOR_TEMP_MAX_KELVIN, CONF_COLOR_TEMP_MIN_KELVIN, CONF_COLOR_TEMP_REVERSE, - CONF_MUSIC_MODE, + CONF_MUSIC_MODE, CONF_COLOR_MODE_SET, ) _LOGGER = logging.getLogger(__name__) @@ -41,6 +39,7 @@ DEFAULT_COLOR_TEMP_REVERSE = False DEFAULT_LOWER_BRIGHTNESS = 29 DEFAULT_UPPER_BRIGHTNESS = 1000 +MODE_MANUAL = "manual" MODE_COLOR = "colour" MODE_MUSIC = "music" MODE_SCENE = "scene" @@ -49,6 +48,8 @@ MODE_WHITE = "white" SCENE_CUSTOM = "Custom" SCENE_MUSIC = "Music" +MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1} + SCENE_LIST_RGBW_1000 = { "Night": "000e0d0000000000000000c80000", "Read": "010e0d0000000000000003e801f4", @@ -91,6 +92,22 @@ SCENE_LIST_RGB_1000 = { + "0000000", } +@dataclass(frozen=True) +class Mode: + color: str = MODE_COLOR + music: str = MODE_MUSIC + scene: str = MODE_SCENE + white: str = MODE_WHITE + + def as_list(self) -> list: + return [self.color, self.music, self.scene, self.white] + + def as_dict(self) -> dict[str, str]: + default = {"Default": self.white} + return {**default, "Mode Color": self.color, "Mode Scene": self.scene} + +MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)} + def map_range(value, from_lower, from_upper, to_lower, to_upper): """Map a value in one range to another.""" @@ -162,10 +179,12 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): self._color_temp_reverse = self._config.get( CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE ) + self._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))] self._hs = None self._effect = None self._effect_list = [] - self._scenes = None + self._scenes = {} + if self.has_config(CONF_SCENE): if self._config.get(CONF_SCENE) < 20: self._scenes = SCENE_LIST_RGBW_255 @@ -174,6 +193,7 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): else: self._scenes = SCENE_LIST_RGBW_1000 self._effect_list = list(self._scenes.keys()) + if self._config.get(CONF_MUSIC_MODE): self._effect_list.append(SCENE_MUSIC) @@ -197,8 +217,8 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): if self.is_color_mode: return self._hs if ( - self.supported_features & SUPPORT_COLOR - and not self.supported_features & SUPPORT_COLOR_TEMP + ColorMode.HS in self.supported_color_modes + and not ColorMode.COLOR_TEMP in self.supported_color_modes ): return [0, 0] return None @@ -241,45 +261,76 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): @property def effect_list(self): """Return the list of supported effects for this light.""" - return self._effect_list + if self.is_scene_mode or self.is_music_mode: + return self._effect + elif (color_mode := self.__get_color_mode()) in self._scenes.values(): + return self.__find_scene_by_scene_data(color_mode) + return None @property - def supported_features(self): - """Flag supported features.""" - supports = 0 - if self.has_config(CONF_BRIGHTNESS): - supports |= SUPPORT_BRIGHTNESS + def supported_color_modes(self) -> set[ColorMode] | set[str] | None: + """Flag supported color modes.""" + color_modes: set[ColorMode] = set() + if self.has_config(CONF_COLOR_TEMP): - supports |= SUPPORT_COLOR_TEMP + color_modes.add(ColorMode.COLOR_TEMP) if self.has_config(CONF_COLOR): - supports |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS + color_modes.add(ColorMode.HS) + + if not color_modes and self.has_config(CONF_BRIGHTNESS): + return {ColorMode.BRIGHTNESS} + + if not color_modes: + return {ColorMode.ONOFF} + + return color_modes + + @property + def supported_features(self) -> LightEntityFeature: + """Flag supported features.""" + supports = LightEntityFeature(0) if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE): - supports |= SUPPORT_EFFECT + supports |= LightEntityFeature.EFFECT return supports + @property + def color_mode(self) -> ColorMode: + """Return the color_mode of the light.""" + if len(self.supported_color_modes) == 1: + return next(iter(self.supported_color_modes)) + + if self.is_color_mode: + return ColorMode.HS + if self.is_white_mode: + return ColorMode.COLOR_TEMP + if self._brightness: + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + @property def is_white_mode(self): """Return true if the light is in white mode.""" color_mode = self.__get_color_mode() - return color_mode is None or color_mode == MODE_WHITE + return color_mode is None or color_mode == self._modes.white @property def is_color_mode(self): """Return true if the light is in color mode.""" color_mode = self.__get_color_mode() - return color_mode is not None and color_mode == MODE_COLOR + return color_mode is not None and color_mode == self._modes.color @property def is_scene_mode(self): """Return true if the light is in scene mode.""" color_mode = self.__get_color_mode() - return color_mode is not None and color_mode.startswith(MODE_SCENE) + return color_mode is not None and color_mode.startswith(self._modes.scene) @property def is_music_mode(self): """Return true if the light is in music mode.""" color_mode = self.__get_color_mode() - return color_mode is not None and color_mode == MODE_MUSIC + return color_mode is not None and color_mode == self._modes.music def __is_color_rgb_encoded(self): return len(self.dps_conf(CONF_COLOR)) > 12 @@ -294,7 +345,7 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): return ( self.dps_conf(CONF_COLOR_MODE) if self.has_config(CONF_COLOR_MODE) - else MODE_WHITE + else self._modes.white ) async def async_turn_on(self, **kwargs): @@ -304,7 +355,7 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): states[self._dp_id] = True features = self.supported_features brightness = None - if ATTR_EFFECT in kwargs and (features & SUPPORT_EFFECT): + if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT): scene = self._scenes.get(kwargs[ATTR_EFFECT]) if scene is not None: if scene.startswith(MODE_SCENE): @@ -315,7 +366,11 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): elif kwargs[ATTR_EFFECT] == SCENE_MUSIC: states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC - if ATTR_BRIGHTNESS in kwargs and (features & SUPPORT_BRIGHTNESS): + if ATTR_BRIGHTNESS in kwargs and ( + ColorMode.BRIGHTNESS in self.supported_color_modes + or self.has_config(CONF_BRIGHTNESS) + or self.has_config(CONF_COLOR) + ): brightness = map_range( int(kwargs[ATTR_BRIGHTNESS]), 0, @@ -347,7 +402,7 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): states[self._config.get(CONF_COLOR)] = color states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR - if ATTR_HS_COLOR in kwargs and (features & SUPPORT_COLOR): + if ATTR_HS_COLOR in kwargs and ColorMode.HS in self.supported_color_modes: if brightness is None: brightness = self._brightness hs = kwargs[ATTR_HS_COLOR] @@ -374,10 +429,10 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): states[self._config.get(CONF_COLOR)] = color states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR - if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP): + if ColorMode.COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in self.supported_color_modes: if brightness is None: brightness = self._brightness - mired = int(kwargs[ATTR_COLOR_TEMP]) + mired = int(kwargs[ColorMode.COLOR_TEMP]) if self._color_temp_reverse: mired = self._max_mired - (mired - self._min_mired) if mired < self._min_mired: @@ -403,10 +458,14 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): self._state = self.dps(self._dp_id) supported = self.supported_features self._effect = None - if supported & SUPPORT_BRIGHTNESS and self.has_config(CONF_BRIGHTNESS): + + if (ColorMode.BRIGHTNESS in self.supported_color_modes + or self.has_config(CONF_BRIGHTNESS) + or self.has_config(CONF_COLOR) + ): self._brightness = self.dps_conf(CONF_BRIGHTNESS) - if supported & SUPPORT_COLOR: + if ColorMode.HS in self.supported_color_modes: color = self.dps_conf(CONF_COLOR) if color is not None and not self.is_white_mode: if self.__is_color_rgb_encoded(): @@ -422,10 +481,10 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): self._hs = [hue, sat / 10.0] self._brightness = value - if supported & SUPPORT_COLOR_TEMP: + if ColorMode.COLOR_TEMP in self.supported_color_modes: self._color_temp = self.dps_conf(CONF_COLOR_TEMP) - if self.is_scene_mode and supported & SUPPORT_EFFECT: + if self.is_scene_mode and supported & LightEntityFeature.EFFECT: if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE: self._effect = self.__find_scene_by_scene_data( self.dps_conf(CONF_COLOR_MODE) @@ -440,7 +499,7 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): elif SCENE_CUSTOM in self._effect_list: self._effect_list.remove(SCENE_CUSTOM) - if self.is_music_mode and supported & SUPPORT_EFFECT: + if self.is_music_mode and supported & LightEntityFeature.EFFECT: self._effect = SCENE_MUSIC diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json index 28e36fa..95f34fb 100644 --- a/custom_components/localtuya/manifest.json +++ b/custom_components/localtuya/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/rospogrigio/localtuya/issues", "requirements": [], - "version": "5.2.1" + "version": "5.2.3" } diff --git a/custom_components/localtuya/vacuum.py b/custom_components/localtuya/vacuum.py index 11e7a61..0ac0b1e 100644 --- a/custom_components/localtuya/vacuum.py +++ b/custom_components/localtuya/vacuum.py @@ -5,13 +5,7 @@ from functools import partial import voluptuous as vol from homeassistant.components.vacuum import ( DOMAIN, - STATE_CLEANING, - STATE_DOCKED, - STATE_ERROR, - STATE_IDLE, - STATE_PAUSED, - STATE_RETURNING, - StateVacuumEntity, VacuumEntityFeature, + StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) from .common import LocalTuyaEntity, async_setup_entry @@ -207,15 +201,15 @@ class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): state_value = str(self.dps(self._dp_id)) if state_value in self._idle_status_list: - self._state = STATE_IDLE + self._state = VacuumActivity.IDLE elif state_value in self._docked_status_list: - self._state = STATE_DOCKED + self._state = VacuumActivity.DOCKED elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]: - self._state = STATE_RETURNING + self._state = VacuumActivity.RETURNING elif state_value == self._config[CONF_PAUSED_STATE]: - self._state = STATE_PAUSED + self._state = VacuumActivity.PAUSED else: - self._state = STATE_CLEANING + self._state = VacuumActivity.CLEANING if self.has_config(CONF_BATTERY_DP): self._battery_level = self.dps_conf(CONF_BATTERY_DP) @@ -241,7 +235,7 @@ class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): if self.has_config(CONF_FAULT_DP): self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP) if self._attrs[FAULT] != 0: - self._state = STATE_ERROR + self._state = VacuumActivity.ERROR async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema)