diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 9d8bb873836..64a2d203695 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -4,12 +4,17 @@ from datetime import timedelta import logging from tuyaha import TuyaApi -from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException, TuyaServerException +from tuyaha.tuyaapi import ( + TuyaAPIException, + TuyaFrequentlyInvokeException, + TuyaNetException, + TuyaServerException, +) import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -21,24 +26,30 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CONF_COUNTRYCODE, + CONF_DISCOVERY_INTERVAL, + CONF_QUERY_DEVICE, + CONF_QUERY_INTERVAL, + DEFAULT_DISCOVERY_INTERVAL, + DEFAULT_QUERY_INTERVAL, DOMAIN, + SIGNAL_CONFIG_ENTITY, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, TUYA_DATA, + TUYA_DEVICES_CONF, TUYA_DISCOVERY_NEW, TUYA_PLATFORMS, + TUYA_TYPE_NOT_QUERY, ) _LOGGER = logging.getLogger(__name__) +ATTR_TUYA_DEV_ID = "tuya_device_id" ENTRY_IS_SETUP = "tuya_entry_is_setup" -PARALLEL_UPDATES = 0 - SERVICE_FORCE_UPDATE = "force_update" SERVICE_PULL_DEVICES = "pull_devices" -SIGNAL_DELETE_ENTITY = "tuya_delete" -SIGNAL_UPDATE_ENTITY = "tuya_update" - TUYA_TYPE_TO_HA = { "climate": "climate", "cover": "cover", @@ -56,9 +67,9 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { - vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_COUNTRYCODE): cv.string, + vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_PLATFORM, default="tuya"): cv.string, } ) @@ -68,6 +79,30 @@ CONFIG_SCHEMA = vol.Schema( ) +def _update_discovery_interval(hass, interval): + tuya = hass.data[DOMAIN].get(TUYA_DATA) + if not tuya: + return + + try: + tuya.discovery_interval = interval + _LOGGER.info("Tuya discovery device poll interval set to %s seconds", interval) + except ValueError as ex: + _LOGGER.warning(ex) + + +def _update_query_interval(hass, interval): + tuya = hass.data[DOMAIN].get(TUYA_DATA) + if not tuya: + return + + try: + tuya.query_interval = interval + _LOGGER.info("Tuya query device poll interval set to %s seconds", interval) + except ValueError as ex: + _LOGGER.warning(ex) + + async def async_setup(hass, config): """Set up the Tuya integration.""" @@ -82,7 +117,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Tuya platform.""" tuya = TuyaApi() @@ -95,7 +130,11 @@ async def async_setup_entry(hass, entry): await hass.async_add_executor_job( tuya.init, username, password, country_code, platform ) - except (TuyaNetException, TuyaServerException) as exc: + except ( + TuyaNetException, + TuyaServerException, + TuyaFrequentlyInvokeException, + ) as exc: raise ConfigEntryNotReady() from exc except TuyaAPIException as exc: @@ -107,12 +146,22 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN] = { TUYA_DATA: tuya, + TUYA_DEVICES_CONF: entry.options.copy(), TUYA_TRACKER: None, ENTRY_IS_SETUP: set(), "entities": {}, "pending": {}, + "listener": entry.add_update_listener(update_listener), } + _update_discovery_interval( + hass, entry.options.get(CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL) + ) + + _update_query_interval( + hass, entry.options.get(CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL) + ) + async def async_load_devices(device_list): """Load new devices by device_list.""" device_type_list = {} @@ -139,11 +188,13 @@ async def async_setup_entry(hass, entry): else: async_dispatcher_send(hass, TUYA_DISCOVERY_NEW.format(ha_type), dev_ids) - device_list = await hass.async_add_executor_job(tuya.get_all_devices) - await async_load_devices(device_list) + await async_load_devices(tuya.get_all_devices()) def _get_updated_devices(): - tuya.poll_devices_update() + try: + tuya.poll_devices_update() + except TuyaFrequentlyInvokeException as exc: + _LOGGER.error(exc) return tuya.get_all_devices() async def async_poll_devices_update(event_time): @@ -162,7 +213,7 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN]["entities"].pop(dev_id) hass.data[DOMAIN][TUYA_TRACKER] = async_track_time_interval( - hass, async_poll_devices_update, timedelta(minutes=5) + hass, async_poll_devices_update, timedelta(minutes=2) ) hass.services.async_register( @@ -178,7 +229,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unloading the Tuya platforms.""" unload_ok = all( await asyncio.gather( @@ -191,10 +242,8 @@ async def async_unload_entry(hass, entry): ) ) if unload_ok: - hass.data[DOMAIN][ENTRY_IS_SETUP] = set() + hass.data[DOMAIN]["listener"]() hass.data[DOMAIN][TUYA_TRACKER]() - hass.data[DOMAIN][TUYA_TRACKER] = None - hass.data[DOMAIN][TUYA_DATA] = None hass.services.async_remove(DOMAIN, SERVICE_FORCE_UPDATE) hass.services.async_remove(DOMAIN, SERVICE_PULL_DEVICES) hass.data.pop(DOMAIN) @@ -202,20 +251,86 @@ async def async_unload_entry(hass, entry): return unload_ok +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Update when config_entry options update.""" + hass.data[DOMAIN][TUYA_DEVICES_CONF] = entry.options.copy() + _update_discovery_interval( + hass, entry.options.get(CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL) + ) + _update_query_interval( + hass, entry.options.get(CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL) + ) + async_dispatcher_send(hass, SIGNAL_CONFIG_ENTITY) + + +async def cleanup_device_registry(hass: HomeAssistant, device_id): + """Remove device registry entry if there are no remaining entities.""" + + device_registry = await hass.helpers.device_registry.async_get_registry() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + if device_id and not hass.helpers.entity_registry.async_entries_for_device( + entity_registry, device_id + ): + device_registry.async_remove_device(device_id) + + class TuyaDevice(Entity): """Tuya base device.""" + _dev_can_query_count = 0 + def __init__(self, tuya, platform): """Init Tuya devices.""" self._tuya = tuya self._tuya_platform = platform + def _device_can_query(self): + """Check if device can also use query method.""" + dev_type = self._tuya.device_type() + return dev_type not in TUYA_TYPE_NOT_QUERY + + def _inc_device_count(self): + """Increment static variable device count.""" + if not self._device_can_query(): + return + TuyaDevice._dev_can_query_count += 1 + + def _dec_device_count(self): + """Decrement static variable device count.""" + if not self._device_can_query(): + return + TuyaDevice._dev_can_query_count -= 1 + + def _get_device_config(self): + """Get updated device options.""" + devices_config = self.hass.data[DOMAIN].get(TUYA_DEVICES_CONF) + if not devices_config: + return {} + dev_conf = devices_config.get(self.object_id, {}) + if dev_conf: + _LOGGER.debug( + "Configuration for deviceID %s: %s", self.object_id, str(dev_conf) + ) + return dev_conf + async def async_added_to_hass(self): """Call when entity is added to hass.""" - dev_id = self._tuya.object_id() - self.hass.data[DOMAIN]["entities"][dev_id] = self.entity_id - async_dispatcher_connect(self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback) - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + self.hass.data[DOMAIN]["entities"][self.object_id] = self.entity_id + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback + ) + ) + self._inc_device_count() + + async def async_will_remove_from_hass(self): + """Call when entity is removed from hass.""" + self._dec_device_count() @property def object_id(self): @@ -252,7 +367,14 @@ class TuyaDevice(Entity): def update(self): """Refresh Tuya device data.""" - self._tuya.update() + query_dev = self.hass.data[DOMAIN][TUYA_DEVICES_CONF].get(CONF_QUERY_DEVICE, "") + use_discovery = ( + TuyaDevice._dev_can_query_count > 1 and self.object_id != query_dev + ) + try: + self._tuya.update(use_discovery=use_discovery) + except TuyaFrequentlyInvokeException as exc: + _LOGGER.error(exc) async def _delete_callback(self, dev_id): """Remove this entity.""" @@ -261,7 +383,9 @@ class TuyaDevice(Entity): await self.hass.helpers.entity_registry.async_get_registry() ) if entity_registry.async_is_registered(self.entity_id): + entity_entry = entity_registry.async_get(self.entity_id) entity_registry.async_remove(self.entity_id) + await cleanup_device_registry(self.hass, entity_entry.device_id) else: await self.async_remove() diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 99939c4b9c0..b5725e6faf6 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -1,4 +1,7 @@ """Support for the Tuya climate devices.""" +from datetime import timedelta +import logging + from homeassistant.components.climate import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, @@ -19,18 +22,32 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ( ATTR_TEMPERATURE, CONF_PLATFORM, + CONF_UNIT_OF_MEASUREMENT, + ENTITY_MATCH_NONE, + PRECISION_TENTHS, PRECISION_WHOLE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.core import callback, valid_entity_id from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from .const import ( + CONF_CURR_TEMP_DIVIDER, + CONF_EXT_TEMP_SENSOR, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_TEMP_DIVIDER, + DOMAIN, + SIGNAL_CONFIG_ENTITY, + TUYA_DATA, + TUYA_DISCOVERY_NEW, +) DEVICE_TYPE = "climate" -PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) HA_STATE_TO_TUYA = { HVAC_MODE_AUTO: "auto", @@ -43,6 +60,8 @@ TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} FAN_MODES = {FAN_LOW, FAN_MEDIUM, FAN_HIGH} +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up tuya sensors dynamically through tuya discovery.""" @@ -89,21 +108,62 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) self.operations = [HVAC_MODE_OFF] + self._has_operation = False + self._def_hvac_mode = HVAC_MODE_AUTO + self._min_temp = None + self._max_temp = None + self._temp_entity = None + self._temp_entity_error = False + + @callback + def _process_config(self): + """Set device config parameter.""" + config = self._get_device_config() + if not config: + return + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + if unit: + self._tuya.set_unit("FAHRENHEIT" if unit == TEMP_FAHRENHEIT else "CELSIUS") + self._tuya.temp_divider = config.get(CONF_TEMP_DIVIDER, 0) + self._tuya.curr_temp_divider = config.get(CONF_CURR_TEMP_DIVIDER, 0) + min_temp = config.get(CONF_MIN_TEMP, 0) + max_temp = config.get(CONF_MAX_TEMP, 0) + if min_temp >= max_temp: + self._min_temp = self._max_temp = None + else: + self._min_temp = min_temp + self._max_temp = max_temp + self._temp_entity = config.get(CONF_EXT_TEMP_SENSOR) async def async_added_to_hass(self): """Create operation list when add to hass.""" await super().async_added_to_hass() + self._process_config() + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_CONFIG_ENTITY, self._process_config + ) + ) + modes = self._tuya.operation_list() if modes is None: + if self._def_hvac_mode not in self.operations: + self.operations.append(self._def_hvac_mode) return for mode in modes: - if mode in TUYA_STATE_TO_HA: - self.operations.append(TUYA_STATE_TO_HA[mode]) + if mode not in TUYA_STATE_TO_HA: + continue + ha_mode = TUYA_STATE_TO_HA[mode] + if ha_mode not in self.operations: + self.operations.append(ha_mode) + self._has_operation = True @property def precision(self): """Return the precision of the system.""" + if self._tuya.has_decimal(): + return PRECISION_TENTHS return PRECISION_WHOLE @property @@ -120,6 +180,9 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): if not self._tuya.state(): return HVAC_MODE_OFF + if not self._has_operation: + return self._def_hvac_mode + mode = self._tuya.current_operation() if mode is None: return None @@ -133,7 +196,10 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): @property def current_temperature(self): """Return the current temperature.""" - return self._tuya.current_temperature() + curr_temp = self._tuya.current_temperature() + if curr_temp is None: + return self._get_ext_temperature() + return curr_temp @property def target_temperature(self): @@ -168,11 +234,13 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): """Set new target operation mode.""" if hvac_mode == HVAC_MODE_OFF: self._tuya.turn_off() + return if not self._tuya.state(): self._tuya.turn_on() - self._tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode)) + if self._has_operation: + self._tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode)) @property def supported_features(self): @@ -187,9 +255,55 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): @property def min_temp(self): """Return the minimum temperature.""" - return self._tuya.min_temp() + min_temp = ( + self._min_temp if self._min_temp is not None else self._tuya.min_temp() + ) + if min_temp is not None: + return min_temp + return super().min_temp @property def max_temp(self): """Return the maximum temperature.""" - return self._tuya.max_temp() + max_temp = ( + self._max_temp if self._max_temp is not None else self._tuya.max_temp() + ) + if max_temp is not None: + return max_temp + return super().max_temp + + def _set_and_log_temp_error(self, error_msg): + if not self._temp_entity_error: + _LOGGER.warning( + "Error on Tuya external temperature sensor %s: %s", + self._temp_entity, + error_msg, + ) + self._temp_entity_error = True + + def _get_ext_temperature(self): + """Get external temperature entity current state.""" + if not self._temp_entity or self._temp_entity == ENTITY_MATCH_NONE: + return None + + entity_name = self._temp_entity + if not valid_entity_id(entity_name): + self._set_and_log_temp_error("entity name is invalid") + return None + + state_obj = self.hass.states.get(entity_name) + if state_obj: + temperature = state_obj.state + try: + float(temperature) + except (TypeError, ValueError): + self._set_and_log_temp_error( + "entity state is not available or is not a number" + ) + return None + + self._temp_entity_error = False + return temperature + + self._set_and_log_temp_error("entity not found") + return None diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index f00396d4405..b457749ddfc 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -6,13 +6,47 @@ from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException, TuyaServerExcepti import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PLATFORM, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + ENTITY_MATCH_NONE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv # pylint:disable=unused-import -from .const import CONF_COUNTRYCODE, DOMAIN, TUYA_PLATFORMS +from .const import ( + CONF_BRIGHTNESS_RANGE_MODE, + CONF_COUNTRYCODE, + CONF_CURR_TEMP_DIVIDER, + CONF_DISCOVERY_INTERVAL, + CONF_EXT_TEMP_SENSOR, + CONF_MAX_KELVIN, + CONF_MAX_TEMP, + CONF_MIN_KELVIN, + CONF_MIN_TEMP, + CONF_QUERY_DEVICE, + CONF_QUERY_INTERVAL, + CONF_SUPPORT_COLOR, + CONF_TEMP_DIVIDER, + CONF_TUYA_MAX_COLTEMP, + DEFAULT_DISCOVERY_INTERVAL, + DEFAULT_QUERY_INTERVAL, + DEFAULT_TUYA_MAX_COLTEMP, + DOMAIN, + TUYA_DATA, + TUYA_PLATFORMS, + TUYA_TYPE_NOT_QUERY, +) _LOGGER = logging.getLogger(__name__) +CONF_LIST_DEVICES = "list_devices" + DATA_SCHEMA_USER = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -22,6 +56,10 @@ DATA_SCHEMA_USER = vol.Schema( } ) +ERROR_DEV_MULTI_TYPE = "dev_multi_type" +ERROR_DEV_NOT_CONFIG = "dev_not_config" +ERROR_DEV_NOT_FOUND = "dev_not_found" + RESULT_AUTH_FAILED = "invalid_auth" RESULT_CONN_ERROR = "cannot_connect" RESULT_SUCCESS = "success" @@ -31,6 +69,8 @@ RESULT_LOG_MESSAGE = { RESULT_CONN_ERROR: "Connection error", } +TUYA_TYPE_CONFIG = ["climate", "light"] + class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a tuya config flow.""" @@ -46,7 +86,7 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._username = None self._is_import = False - def _get_entry(self): + def _save_entry(self): return self.async_create_entry( title=self._username, data={ @@ -93,7 +133,7 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): result = await self.hass.async_add_executor_job(self._try_connect) if result == RESULT_SUCCESS: - return self._get_entry() + return self._save_entry() if result != RESULT_AUTH_FAILED or self._is_import: if self._is_import: _LOGGER.error( @@ -106,3 +146,263 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Tuya.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + self._conf_devs_id = None + self._conf_devs_option = {} + self._form_error = None + + def _get_form_error(self): + """Set the error to be shown in the options form.""" + errors = {} + if self._form_error: + errors["base"] = self._form_error + self._form_error = None + return errors + + def _get_tuya_devices_filtered(self, types, exclude_mode=False, type_prefix=True): + """Get the list of Tuya device to filtered by types.""" + config_list = {} + types_filter = set(types) + tuya = self.hass.data[DOMAIN][TUYA_DATA] + devices_list = tuya.get_all_devices() + for device in devices_list: + dev_type = device.device_type() + exclude = ( + dev_type in types_filter + if exclude_mode + else dev_type not in types_filter + ) + if exclude: + continue + dev_id = device.object_id() + if type_prefix: + dev_id = f"{dev_type}-{dev_id}" + config_list[dev_id] = f"{device.name()} ({dev_type})" + + return config_list + + def _get_device(self, dev_id): + """Get specific device from tuya library.""" + tuya = self.hass.data[DOMAIN][TUYA_DATA] + return tuya.get_device_by_id(dev_id) + + def _save_config(self, data): + """Save the updated options.""" + curr_conf = self.config_entry.options.copy() + curr_conf.update(data) + curr_conf.update(self._conf_devs_option) + + return self.async_create_entry(title="", data=curr_conf) + + async def _async_device_form(self, devs_id): + """Return configuration form for devices.""" + conf_devs_id = [] + for count, dev_id in enumerate(devs_id): + device_info = dev_id.split("-") + if count == 0: + device_type = device_info[0] + device_id = device_info[1] + elif device_type != device_info[0]: + self._form_error = ERROR_DEV_MULTI_TYPE + return await self.async_step_init() + conf_devs_id.append(device_info[1]) + + device = self._get_device(device_id) + if not device: + self._form_error = ERROR_DEV_NOT_FOUND + return await self.async_step_init() + + curr_conf = self._conf_devs_option.get( + device_id, self.config_entry.options.get(device_id, {}) + ) + + config_schema = await self._get_device_schema(device_type, curr_conf, device) + if not config_schema: + self._form_error = ERROR_DEV_NOT_CONFIG + return await self.async_step_init() + + self._conf_devs_id = conf_devs_id + device_name = ( + "(multiple devices selected)" if len(conf_devs_id) > 1 else device.name() + ) + + return self.async_show_form( + step_id="device", + data_schema=config_schema, + description_placeholders={ + "device_type": device_type, + "device_name": device_name, + }, + ) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + dev_ids = user_input.get(CONF_LIST_DEVICES) + if dev_ids: + return await self.async_step_device(None, dev_ids) + + user_input.pop(CONF_LIST_DEVICES, []) + return self._save_config(data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_DISCOVERY_INTERVAL, + default=self.config_entry.options.get( + CONF_DISCOVERY_INTERVAL, DEFAULT_DISCOVERY_INTERVAL + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=30, max=900)), + } + ) + + query_devices = self._get_tuya_devices_filtered( + TUYA_TYPE_NOT_QUERY, True, False + ) + if query_devices: + devices = {ENTITY_MATCH_NONE: "Default"} + devices.update(query_devices) + def_val = self.config_entry.options.get(CONF_QUERY_DEVICE) + if not def_val or not query_devices.get(def_val): + def_val = ENTITY_MATCH_NONE + data_schema = data_schema.extend( + { + vol.Optional( + CONF_QUERY_INTERVAL, + default=self.config_entry.options.get( + CONF_QUERY_INTERVAL, DEFAULT_QUERY_INTERVAL + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=30, max=240)), + vol.Optional(CONF_QUERY_DEVICE, default=def_val): vol.In(devices), + } + ) + + config_devices = self._get_tuya_devices_filtered(TUYA_TYPE_CONFIG, False, True) + if config_devices: + data_schema = data_schema.extend( + {vol.Optional(CONF_LIST_DEVICES): cv.multi_select(config_devices)} + ) + + return self.async_show_form( + step_id="init", + data_schema=data_schema, + errors=self._get_form_error(), + ) + + async def async_step_device(self, user_input=None, dev_ids=None): + """Handle options flow for device.""" + if dev_ids is not None: + return await self._async_device_form(dev_ids) + if user_input is not None: + for device_id in self._conf_devs_id: + self._conf_devs_option[device_id] = user_input + + return await self.async_step_init() + + async def _get_device_schema(self, device_type, curr_conf, device): + """Return option schema for device.""" + if device_type == "light": + return self._get_light_schema(curr_conf, device) + if device_type == "climate": + entities_list = await _get_entities_matching_domains(self.hass, ["sensor"]) + return self._get_climate_schema(curr_conf, device, entities_list) + return None + + @staticmethod + def _get_light_schema(curr_conf, device): + """Create option schema for light device.""" + min_kelvin = device.max_color_temp() + max_kelvin = device.min_color_temp() + + config_schema = vol.Schema( + { + vol.Optional( + CONF_SUPPORT_COLOR, + default=curr_conf.get(CONF_SUPPORT_COLOR, False), + ): bool, + vol.Optional( + CONF_BRIGHTNESS_RANGE_MODE, + default=curr_conf.get(CONF_BRIGHTNESS_RANGE_MODE, 0), + ): vol.In({0: "Range 1-255", 1: "Range 10-1000"}), + vol.Optional( + CONF_MIN_KELVIN, + default=curr_conf.get(CONF_MIN_KELVIN, min_kelvin), + ): vol.All(vol.Coerce(int), vol.Clamp(min=min_kelvin, max=max_kelvin)), + vol.Optional( + CONF_MAX_KELVIN, + default=curr_conf.get(CONF_MAX_KELVIN, max_kelvin), + ): vol.All(vol.Coerce(int), vol.Clamp(min=min_kelvin, max=max_kelvin)), + vol.Optional( + CONF_TUYA_MAX_COLTEMP, + default=curr_conf.get( + CONF_TUYA_MAX_COLTEMP, DEFAULT_TUYA_MAX_COLTEMP + ), + ): vol.All( + vol.Coerce(int), + vol.Clamp( + min=DEFAULT_TUYA_MAX_COLTEMP, max=DEFAULT_TUYA_MAX_COLTEMP * 10 + ), + ), + } + ) + + return config_schema + + @staticmethod + def _get_climate_schema(curr_conf, device, entities_list): + """Create option schema for climate device.""" + unit = device.temperature_unit() + def_unit = TEMP_FAHRENHEIT if unit == "FAHRENHEIT" else TEMP_CELSIUS + entities_list.insert(0, ENTITY_MATCH_NONE) + + config_schema = vol.Schema( + { + vol.Optional( + CONF_UNIT_OF_MEASUREMENT, + default=curr_conf.get(CONF_UNIT_OF_MEASUREMENT, def_unit), + ): vol.In({TEMP_CELSIUS: "Celsius", TEMP_FAHRENHEIT: "Fahrenheit"}), + vol.Optional( + CONF_TEMP_DIVIDER, + default=curr_conf.get(CONF_TEMP_DIVIDER, 0), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), + vol.Optional( + CONF_CURR_TEMP_DIVIDER, + default=curr_conf.get(CONF_CURR_TEMP_DIVIDER, 0), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), + vol.Optional( + CONF_MIN_TEMP, + default=curr_conf.get(CONF_MIN_TEMP, 0), + ): int, + vol.Optional( + CONF_MAX_TEMP, + default=curr_conf.get(CONF_MAX_TEMP, 0), + ): int, + vol.Optional( + CONF_EXT_TEMP_SENSOR, + default=curr_conf.get(CONF_EXT_TEMP_SENSOR, ENTITY_MATCH_NONE), + ): vol.In(entities_list), + } + ) + + return config_schema + + +async def _get_entities_matching_domains(hass, domains): + """List entities in the given domains.""" + included_domains = set(domains) + entity_ids = hass.states.async_entity_ids(included_domains) + entity_ids.sort() + return entity_ids diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 4e395750b23..f931fd1a410 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -1,10 +1,32 @@ """Constants for the Tuya integration.""" +CONF_BRIGHTNESS_RANGE_MODE = "brightness_range_mode" CONF_COUNTRYCODE = "country_code" +CONF_CURR_TEMP_DIVIDER = "curr_temp_divider" +CONF_DISCOVERY_INTERVAL = "discovery_interval" +CONF_EXT_TEMP_SENSOR = "ext_temp_sensor" +CONF_MAX_KELVIN = "max_kelvin" +CONF_MAX_TEMP = "max_temp" +CONF_MIN_KELVIN = "min_kelvin" +CONF_MIN_TEMP = "min_temp" +CONF_QUERY_DEVICE = "query_device" +CONF_QUERY_INTERVAL = "query_interval" +CONF_SUPPORT_COLOR = "support_color" +CONF_TEMP_DIVIDER = "temp_divider" +CONF_TUYA_MAX_COLTEMP = "tuya_max_coltemp" + +DEFAULT_DISCOVERY_INTERVAL = 605 +DEFAULT_QUERY_INTERVAL = 120 +DEFAULT_TUYA_MAX_COLTEMP = 10000 DOMAIN = "tuya" +SIGNAL_CONFIG_ENTITY = "tuya_config" +SIGNAL_DELETE_ENTITY = "tuya_delete" +SIGNAL_UPDATE_ENTITY = "tuya_update" + TUYA_DATA = "tuya_data" +TUYA_DEVICES_CONF = "devices_config" TUYA_DISCOVERY_NEW = "tuya_discovery_new_{}" TUYA_PLATFORMS = { @@ -12,3 +34,5 @@ TUYA_PLATFORMS = { "smart_life": "Smart Life", "jinvoo_smart": "Jinvoo Smart", } + +TUYA_TYPE_NOT_QUERY = ["scene", "switch"] diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 3c94ed6a53d..2d5d5e036a1 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -1,4 +1,6 @@ """Support for Tuya covers.""" +from datetime import timedelta + from homeassistant.components.cover import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, @@ -13,7 +15,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import TuyaDevice from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW -PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -60,6 +62,8 @@ class TuyaCover(TuyaDevice, CoverEntity): """Init tuya cover device.""" super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + self._was_closing = False + self._was_opening = False @property def supported_features(self): @@ -69,14 +73,34 @@ class TuyaCover(TuyaDevice, CoverEntity): supported_features |= SUPPORT_STOP return supported_features + @property + def is_opening(self): + """Return if the cover is opening or not.""" + state = self._tuya.state() + if state == 1: + self._was_opening = True + self._was_closing = False + return True + return False + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + state = self._tuya.state() + if state == 2: + self._was_opening = False + self._was_closing = True + return True + return False + @property def is_closed(self): """Return if the cover is closed or not.""" state = self._tuya.state() - if state == 1: - return False - if state == 2: + if state != 2 and self._was_closing: return True + if state != 1 and self._was_opening: + return False return None def open_cover(self, **kwargs): @@ -89,4 +113,7 @@ class TuyaCover(TuyaDevice, CoverEntity): def stop_cover(self, **kwargs): """Stop the cover.""" + if self.is_closed is None: + self._was_opening = False + self._was_closing = False self._tuya.stop_cover() diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index cc8272fabba..39510e1cb3a 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,4 +1,6 @@ """Support for Tuya fans.""" +from datetime import timedelta + from homeassistant.components.fan import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, @@ -12,7 +14,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import TuyaDevice from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW -PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry(hass, config_entry, async_add_entities): diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index ee9c92221df..4602e65a4d5 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,4 +1,6 @@ """Support for the Tuya lights.""" +from datetime import timedelta + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -11,13 +13,33 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import color as colorutil from . import TuyaDevice -from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW +from .const import ( + CONF_BRIGHTNESS_RANGE_MODE, + CONF_MAX_KELVIN, + CONF_MIN_KELVIN, + CONF_SUPPORT_COLOR, + CONF_TUYA_MAX_COLTEMP, + DEFAULT_TUYA_MAX_COLTEMP, + DOMAIN, + SIGNAL_CONFIG_ENTITY, + TUYA_DATA, + TUYA_DISCOVERY_NEW, +) -PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) + +TUYA_BRIGHTNESS_RANGE0 = (1, 255) +TUYA_BRIGHTNESS_RANGE1 = (10, 1000) + +BRIGHTNESS_MODES = { + 0: TUYA_BRIGHTNESS_RANGE0, + 1: TUYA_BRIGHTNESS_RANGE1, +} async def async_setup_entry(hass, config_entry, async_add_entities): @@ -64,6 +86,49 @@ class TuyaLight(TuyaDevice, LightEntity): """Init Tuya light device.""" super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + self._min_kelvin = tuya.max_color_temp() + self._max_kelvin = tuya.min_color_temp() + + @callback + def _process_config(self): + """Set device config parameter.""" + config = self._get_device_config() + if not config: + return + + # support color config + supp_color = config.get(CONF_SUPPORT_COLOR, False) + if supp_color: + self._tuya.force_support_color() + # brightness range config + self._tuya.brightness_white_range = BRIGHTNESS_MODES.get( + config.get(CONF_BRIGHTNESS_RANGE_MODE, 0), + TUYA_BRIGHTNESS_RANGE0, + ) + # color set temp range + min_tuya = self._tuya.max_color_temp() + min_kelvin = config.get(CONF_MIN_KELVIN, min_tuya) + max_tuya = self._tuya.min_color_temp() + max_kelvin = config.get(CONF_MAX_KELVIN, max_tuya) + self._min_kelvin = min(max(min_kelvin, min_tuya), max_tuya) + self._max_kelvin = min(max(max_kelvin, self._min_kelvin), max_tuya) + # color shown temp range + max_color_temp = max( + config.get(CONF_TUYA_MAX_COLTEMP, DEFAULT_TUYA_MAX_COLTEMP), + DEFAULT_TUYA_MAX_COLTEMP, + ) + self._tuya.color_temp_range = (1000, max_color_temp) + + async def async_added_to_hass(self): + """Set config parameter when add to hass.""" + await super().async_added_to_hass() + self._process_config() + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_CONFIG_ENTITY, self._process_config + ) + ) + return @property def brightness(self): @@ -93,12 +158,12 @@ class TuyaLight(TuyaDevice, LightEntity): @property def min_mireds(self): """Return color temperature min mireds.""" - return colorutil.color_temperature_kelvin_to_mired(self._tuya.min_color_temp()) + return colorutil.color_temperature_kelvin_to_mired(self._max_kelvin) @property def max_mireds(self): """Return color temperature max mireds.""" - return colorutil.color_temperature_kelvin_to_mired(self._tuya.max_color_temp()) + return colorutil.color_temperature_kelvin_to_mired(self._min_kelvin) def turn_on(self, **kwargs): """Turn on or control the light.""" diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 40f9fca11ee..430b2bc7e27 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -10,8 +10,6 @@ from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" -PARALLEL_UPDATES = 0 - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up tuya sensors dynamically through tuya discovery.""" diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 08123db3a36..5939dfb05f2 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -21,5 +21,41 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "options": { + "step": { + "init": { + "title": "Configure Tuya Options", + "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", + "data": { + "discovery_interval": "Discovery device polling interval in seconds", + "query_device": "Select device that will use query method for faster status update", + "query_interval": "Query device polling interval in seconds", + "list_devices": "Select the devices to configure or leave empty to save configuration" + } + }, + "device": { + "title": "Configure Tuya Device", + "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", + "data": { + "support_color": "Force color support", + "brightness_range_mode": "Brightness range used by device", + "min_kelvin": "Min color temperature supported in kelvin", + "max_kelvin": "Max color temperature supported in kelvin", + "tuya_max_coltemp": "Max color temperature reported by device", + "unit_of_measurement": "Temperature unit used by device", + "temp_divider": "Temperature values divider (0 = use default)", + "curr_temp_divider": "Current Temperature value divider (0 = use default)", + "min_temp": "Min target temperature (use min and max = 0 for default)", + "max_temp": "Max target temperature (use min and max = 0 for default)", + "ext_temp_sensor": "Sensor for current temperature" + } + } + }, + "error": { + "dev_multi_type": "Multiple selected devices to configure must be of the same type", + "dev_not_config": "Device type not configurable", + "dev_not_found": "Device not found" + } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index e4d0778cab0..3f5ff6db163 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,4 +1,6 @@ """Support for Tuya switches.""" +from datetime import timedelta + from homeassistant.components.switch import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, @@ -10,7 +12,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import TuyaDevice from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW -PARALLEL_UPDATES = 0 +SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry(hass, config_entry, async_add_entities): diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json index 8b013c6c062..9bf1744edd8 100644 --- a/homeassistant/components/tuya/translations/en.json +++ b/homeassistant/components/tuya/translations/en.json @@ -1,14 +1,11 @@ { "config": { "abort": { - "auth_failed": "Invalid authentication", "cannot_connect": "Failed to connect", - "conn_error": "Failed to connect", "invalid_auth": "Invalid authentication", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { - "auth_failed": "Invalid authentication", "invalid_auth": "Invalid authentication" }, "flow_title": "Tuya configuration", @@ -24,5 +21,41 @@ "title": "Tuya" } } + }, + "options": { + "error": { + "dev_multi_type": "Multiple selected devices to configure must be of the same type", + "dev_not_config": "Device type not configurable", + "dev_not_found": "Device not found" + }, + "step": { + "init": { + "title": "Configure Tuya Options", + "description": "Do not set pollings interval values too low or the calls will fail generating error message in the log", + "data": { + "discovery_interval": "Discovery device polling interval in seconds", + "query_device": "Select device that will use query method for faster status update", + "query_interval": "Query device polling interval in seconds", + "list_devices": "Select the devices to configure or leave empty to save configuration" + } + }, + "device": { + "title": "Configure Tuya Device", + "description": "Configure options to adjust displayed information for {device_type} device `{device_name}`", + "data": { + "support_color": "Force color support", + "brightness_range_mode": "Brightness range used by device", + "min_kelvin": "Min color temperature supported in kelvin", + "max_kelvin": "Max color temperature supported in kelvin", + "tuya_max_coltemp": "Max color temperature reported by device", + "unit_of_measurement": "Temperature unit used by device", + "temp_divider": "Temperature values divider (0 = use default)", + "curr_temp_divider": "Current Temperature value divider (0 = use default)", + "min_temp": "Min target temperature (use min and max = 0 for default)", + "max_temp": "Max target temperature (use min and max = 0 for default)", + "ext_temp_sensor": "Sensor for current temperature" + } + } + } } } \ No newline at end of file