diff --git a/CODEOWNERS b/CODEOWNERS index 614a725c792..3355571408a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,7 @@ homeassistant/components/tradfri/* @ggravlingen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins homeassistant/components/tts/* @pvizeli +homeassistant/components/tuya/* @ollo69 homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c50e6787d89..2f8b838e7d9 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,4 +1,5 @@ """Support for Tuya Smart devices.""" +import asyncio from datetime import timedelta import logging @@ -6,32 +7,38 @@ from tuyaha import TuyaApi from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException, TuyaServerException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers import discovery +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import call_later, track_time_interval +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CONF_COUNTRYCODE, + DOMAIN, + TUYA_DATA, + TUYA_DISCOVERY_NEW, + TUYA_PLATFORMS, +) _LOGGER = logging.getLogger(__name__) -CONF_COUNTRYCODE = "country_code" +ENTRY_IS_SETUP = "tuya_entry_is_setup" PARALLEL_UPDATES = 0 -DOMAIN = "tuya" -DATA_TUYA = "data_tuya" - -FIRST_RETRY_TIME = 60 -MAX_RETRY_TIME = 900 +SERVICE_FORCE_UPDATE = "force_update" +SERVICE_PULL_DEVICES = "pull_devices" SIGNAL_DELETE_ENTITY = "tuya_delete" SIGNAL_UPDATE_ENTITY = "tuya_update" -SERVICE_FORCE_UPDATE = "force_update" -SERVICE_PULL_DEVICES = "pull_devices" - TUYA_TYPE_TO_HA = { "climate": "climate", "cover": "cover", @@ -41,48 +48,55 @@ TUYA_TYPE_TO_HA = { "switch": "switch", } +TUYA_TRACKER = "tuya_tracker" + 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.Optional(CONF_PLATFORM, default="tuya"): cv.string, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_COUNTRYCODE): cv.string, + vol.Optional(CONF_PLATFORM, default="tuya"): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) -def setup(hass, config, retry_delay=FIRST_RETRY_TIME): - """Set up Tuya Component.""" +async def async_setup(hass, config): + """Set up the Tuya integration.""" - _LOGGER.debug("Setting up integration") - - tuya = TuyaApi() - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - country_code = config[DOMAIN][CONF_COUNTRYCODE] - platform = config[DOMAIN][CONF_PLATFORM] - - try: - tuya.init(username, password, country_code, platform) - except (TuyaNetException, TuyaServerException): - - _LOGGER.warning( - "Connection error during integration setup. Will retry in %s seconds", - retry_delay, + conf = config.get(DOMAIN) + if conf is not None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) ) - def retry_setup(now): - """Retry setup if a error happens on tuya API.""" - setup(hass, config, retry_delay=min(2 * retry_delay, MAX_RETRY_TIME)) + return True - call_later(hass, retry_delay, retry_setup) - return True +async def async_setup_entry(hass, entry): + """Set up Tuya platform.""" + + tuya = TuyaApi() + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + country_code = entry.data[CONF_COUNTRYCODE] + platform = entry.data[CONF_PLATFORM] + + try: + await hass.async_add_executor_job( + tuya.init, username, password, country_code, platform + ) + except (TuyaNetException, TuyaServerException): + raise ConfigEntryNotReady() except TuyaAPIException as exc: _LOGGER.error( @@ -90,10 +104,15 @@ def setup(hass, config, retry_delay=FIRST_RETRY_TIME): ) return False - hass.data[DATA_TUYA] = tuya - hass.data[DOMAIN] = {"entities": {}} + hass.data[DOMAIN] = { + TUYA_DATA: tuya, + TUYA_TRACKER: None, + ENTRY_IS_SETUP: set(), + "entities": {}, + "pending": {}, + } - def load_devices(device_list): + async def async_load_devices(device_list): """Load new devices by device_list.""" device_type_list = {} for device in device_list: @@ -107,51 +126,92 @@ def setup(hass, config, retry_delay=FIRST_RETRY_TIME): device_type_list[ha_type] = [] device_type_list[ha_type].append(device.object_id()) hass.data[DOMAIN]["entities"][device.object_id()] = None + for ha_type, dev_ids in device_type_list.items(): - discovery.load_platform(hass, ha_type, DOMAIN, {"dev_ids": dev_ids}, config) + config_entries_key = f"{ha_type}.tuya" + if config_entries_key not in hass.data[DOMAIN][ENTRY_IS_SETUP]: + hass.data[DOMAIN]["pending"][ha_type] = dev_ids + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, ha_type) + ) + hass.data[DOMAIN][ENTRY_IS_SETUP].add(config_entries_key) + else: + async_dispatcher_send(hass, TUYA_DISCOVERY_NEW.format(ha_type), dev_ids) - device_list = tuya.get_all_devices() - load_devices(device_list) + device_list = await hass.async_add_executor_job(tuya.get_all_devices) + await async_load_devices(device_list) - def poll_devices_update(event_time): + def _get_updated_devices(): + tuya.poll_devices_update() + return tuya.get_all_devices() + + async def async_poll_devices_update(event_time): """Check if accesstoken is expired and pull device list from server.""" _LOGGER.debug("Pull devices from Tuya.") - tuya.poll_devices_update() # Add new discover device. - device_list = tuya.get_all_devices() - load_devices(device_list) + device_list = await hass.async_add_executor_job(_get_updated_devices) + await async_load_devices(device_list) # Delete not exist device. newlist_ids = [] for device in device_list: newlist_ids.append(device.object_id()) for dev_id in list(hass.data[DOMAIN]["entities"]): if dev_id not in newlist_ids: - dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) + async_dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) hass.data[DOMAIN]["entities"].pop(dev_id) - track_time_interval(hass, poll_devices_update, timedelta(minutes=5)) + hass.data[DOMAIN][TUYA_TRACKER] = async_track_time_interval( + hass, async_poll_devices_update, timedelta(minutes=5) + ) - hass.services.register(DOMAIN, SERVICE_PULL_DEVICES, poll_devices_update) + hass.services.async_register( + DOMAIN, SERVICE_PULL_DEVICES, async_poll_devices_update + ) - def force_update(call): + async def async_force_update(call): """Force all devices to pull data.""" - dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) + async_dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) - hass.services.register(DOMAIN, SERVICE_FORCE_UPDATE, force_update) + hass.services.async_register(DOMAIN, SERVICE_FORCE_UPDATE, async_force_update) return True +async def async_unload_entry(hass, entry): + """Unloading the Tuya platforms.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload( + entry, component.split(".", 1)[0] + ) + for component in hass.data[DOMAIN][ENTRY_IS_SETUP] + ] + ) + ) + if unload_ok: + hass.data[DOMAIN][ENTRY_IS_SETUP] = set() + 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) + + return unload_ok + + class TuyaDevice(Entity): """Tuya base device.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init Tuya devices.""" - self.tuya = tuya + self._tuya = tuya + self._platform = platform async def async_added_to_hass(self): """Call when entity is added to hass.""" - dev_id = self.tuya.object_id() + 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) @@ -159,32 +219,49 @@ class TuyaDevice(Entity): @property def object_id(self): """Return Tuya device id.""" - return self.tuya.object_id() + return self._tuya.object_id() @property def unique_id(self): """Return a unique ID.""" - return f"tuya.{self.tuya.object_id()}" + return f"tuya.{self._tuya.object_id()}" @property def name(self): """Return Tuya device name.""" - return self.tuya.name() + return self._tuya.name() @property def available(self): """Return if the device is available.""" - return self.tuya.available() + return self._tuya.available() + + @property + def device_info(self): + """Return a device description for device registry.""" + _device_info = { + "identifiers": {(DOMAIN, f"{self.unique_id}")}, + "manufacturer": TUYA_PLATFORMS.get(self._platform, self._platform), + "name": self.name, + "model": self._tuya.object_type(), + } + return _device_info def update(self): """Refresh Tuya device data.""" - self.tuya.update() + self._tuya.update() @callback - def _delete_callback(self, dev_id): + async def _delete_callback(self, dev_id): """Remove this entity.""" if dev_id == self.object_id: - self.hass.async_create_task(self.async_remove()) + entity_registry = ( + await self.hass.helpers.entity_registry.async_get_registry() + ) + if entity_registry.async_is_registered(self.entity_id): + entity_registry.async_remove(self.entity_id) + else: + await self.async_remove() @callback def _update_callback(self): diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index d2f7dba4c33..4f645418ccb 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -1,5 +1,9 @@ """Support for the Tuya climate devices.""" -from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateEntity +from homeassistant.components.climate import ( + DOMAIN as SENSOR_DOMAIN, + ENTITY_ID_FORMAT, + ClimateEntity, +) from homeassistant.components.climate.const import ( FAN_HIGH, FAN_LOW, @@ -14,12 +18,15 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ( ATTR_TEMPERATURE, + CONF_PLATFORM, PRECISION_WHOLE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_TUYA, TuyaDevice +from . import TuyaDevice +from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW DEVICE_TYPE = "climate" @@ -37,34 +44,53 @@ TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} FAN_MODES = {FAN_LOW, FAN_MEDIUM, FAN_HIGH} -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya Climate devices.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get("dev_ids") - devices = [] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tuya sensors dynamically through tuya discovery.""" + + platform = config_entry.data[CONF_PLATFORM] + + async def async_discover_sensor(dev_ids): + """Discover and add a discovered tuya sensor.""" + if not dev_ids: + return + entities = await hass.async_add_executor_job( + _setup_entities, hass, dev_ids, platform, + ) + async_add_entities(entities) + + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + ) + + devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) + await async_discover_sensor(devices_ids) + + +def _setup_entities(hass, dev_ids, platform): + """Set up Tuya Climate device.""" + tuya = hass.data[DOMAIN][TUYA_DATA] + entities = [] for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: + entity = tuya.get_device_by_id(dev_id) + if entity is None: continue - devices.append(TuyaClimateEntity(device)) - add_entities(devices) + entities.append(TuyaClimateEntity(entity, platform)) + return entities class TuyaClimateEntity(TuyaDevice, ClimateEntity): """Tuya climate devices,include air conditioner,heater.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init climate device.""" - super().__init__(tuya) + super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) self.operations = [HVAC_MODE_OFF] async def async_added_to_hass(self): """Create operation list when add to hass.""" await super().async_added_to_hass() - modes = self.tuya.operation_list() + modes = self._tuya.operation_list() if modes is None: return @@ -80,7 +106,7 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): @property def temperature_unit(self): """Return the unit of measurement used by the platform.""" - unit = self.tuya.temperature_unit() + unit = self._tuya.temperature_unit() if unit == "FAHRENHEIT": return TEMP_FAHRENHEIT return TEMP_CELSIUS @@ -88,10 +114,10 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): @property def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" - if not self.tuya.state(): + if not self._tuya.state(): return HVAC_MODE_OFF - mode = self.tuya.current_operation() + mode = self._tuya.current_operation() if mode is None: return None return TUYA_STATE_TO_HA.get(mode) @@ -104,63 +130,63 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity): @property def current_temperature(self): """Return the current temperature.""" - return self.tuya.current_temperature() + return self._tuya.current_temperature() @property def target_temperature(self): """Return the temperature we try to reach.""" - return self.tuya.target_temperature() + return self._tuya.target_temperature() @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self.tuya.target_temperature_step() + return self._tuya.target_temperature_step() @property def fan_mode(self): """Return the fan setting.""" - return self.tuya.current_fan_mode() + return self._tuya.current_fan_mode() @property def fan_modes(self): """Return the list of available fan modes.""" - return self.tuya.fan_list() + return self._tuya.fan_list() def set_temperature(self, **kwargs): """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs: - self.tuya.set_temperature(kwargs[ATTR_TEMPERATURE]) + self._tuya.set_temperature(kwargs[ATTR_TEMPERATURE]) def set_fan_mode(self, fan_mode): """Set new target fan mode.""" - self.tuya.set_fan_mode(fan_mode) + self._tuya.set_fan_mode(fan_mode) def set_hvac_mode(self, hvac_mode): """Set new target operation mode.""" if hvac_mode == HVAC_MODE_OFF: - self.tuya.turn_off() + self._tuya.turn_off() - if not self.tuya.state(): - self.tuya.turn_on() + if not self._tuya.state(): + self._tuya.turn_on() - self.tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode)) + self._tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode)) @property def supported_features(self): """Return the list of supported features.""" supports = 0 - if self.tuya.support_target_temperature(): + if self._tuya.support_target_temperature(): supports = supports | SUPPORT_TARGET_TEMPERATURE - if self.tuya.support_wind_speed(): + if self._tuya.support_wind_speed(): supports = supports | SUPPORT_FAN_MODE return supports @property def min_temp(self): """Return the minimum temperature.""" - return self.tuya.min_temp() + return self._tuya.min_temp() @property def max_temp(self): """Return the maximum temperature.""" - return self.tuya.max_temp() + return self._tuya.max_temp() diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py new file mode 100644 index 00000000000..c905334aac7 --- /dev/null +++ b/homeassistant/components/tuya/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for Tuya.""" +import logging + +from tuyaha import TuyaApi +from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException, TuyaServerException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME + +# pylint:disable=unused-import +from .const import CONF_COUNTRYCODE, DOMAIN, TUYA_PLATFORMS + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_COUNTRYCODE): vol.Coerce(int), + vol.Required(CONF_PLATFORM): vol.In(TUYA_PLATFORMS), + } +) + +RESULT_AUTH_FAILED = "auth_failed" +RESULT_CONN_ERROR = "conn_error" +RESULT_SUCCESS = "success" + +RESULT_LOG_MESSAGE = { + RESULT_AUTH_FAILED: "Invalid credential", + RESULT_CONN_ERROR: "Connection error", +} + + +class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a tuya config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self._country_code = None + self._password = None + self._platform = None + self._username = None + self._is_import = False + + def _get_entry(self): + return self.async_create_entry( + title=self._username, + data={ + CONF_COUNTRYCODE: self._country_code, + CONF_PASSWORD: self._password, + CONF_PLATFORM: self._platform, + CONF_USERNAME: self._username, + }, + ) + + def _try_connect(self): + """Try to connect and check auth.""" + tuya = TuyaApi() + try: + tuya.init( + self._username, self._password, self._country_code, self._platform + ) + except (TuyaNetException, TuyaServerException): + return RESULT_CONN_ERROR + except TuyaAPIException: + return RESULT_AUTH_FAILED + + return RESULT_SUCCESS + + async def async_step_import(self, user_input=None): + """Handle configuration by yaml file.""" + self._is_import = True + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + + self._country_code = str(user_input[CONF_COUNTRYCODE]) + self._password = user_input[CONF_PASSWORD] + self._platform = user_input[CONF_PLATFORM] + self._username = user_input[CONF_USERNAME] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + return self._get_entry() + if result != RESULT_AUTH_FAILED or self._is_import: + if self._is_import: + _LOGGER.error( + "Error importing from configuration.yaml: %s", + RESULT_LOG_MESSAGE.get(result, "Generic Error"), + ) + return self.async_abort(reason=result) + errors["base"] = result + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors + ) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py new file mode 100644 index 00000000000..4e395750b23 --- /dev/null +++ b/homeassistant/components/tuya/const.py @@ -0,0 +1,14 @@ +"""Constants for the Tuya integration.""" + +CONF_COUNTRYCODE = "country_code" + +DOMAIN = "tuya" + +TUYA_DATA = "tuya_data" +TUYA_DISCOVERY_NEW = "tuya_discovery_new_{}" + +TUYA_PLATFORMS = { + "tuya": "Tuya", + "smart_life": "Smart Life", + "jinvoo_smart": "Jinvoo Smart", +} diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index d7528cf6092..b5336aa8bc4 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -1,52 +1,75 @@ """Support for Tuya covers.""" from homeassistant.components.cover import ( + DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, CoverEntity, ) +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_TUYA, TuyaDevice +from . import TuyaDevice +from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW PARALLEL_UPDATES = 0 -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya cover devices.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get("dev_ids") - devices = [] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tuya sensors dynamically through tuya discovery.""" + + platform = config_entry.data[CONF_PLATFORM] + + async def async_discover_sensor(dev_ids): + """Discover and add a discovered tuya sensor.""" + if not dev_ids: + return + entities = await hass.async_add_executor_job( + _setup_entities, hass, dev_ids, platform, + ) + async_add_entities(entities) + + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + ) + + devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) + await async_discover_sensor(devices_ids) + + +def _setup_entities(hass, dev_ids, platform): + """Set up Tuya Cover device.""" + tuya = hass.data[DOMAIN][TUYA_DATA] + entities = [] for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: + entity = tuya.get_device_by_id(dev_id) + if entity is None: continue - devices.append(TuyaCover(device)) - add_entities(devices) + entities.append(TuyaCover(entity, platform)) + return entities class TuyaCover(TuyaDevice, CoverEntity): """Tuya cover devices.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init tuya cover device.""" - super().__init__(tuya) + super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) @property def supported_features(self): """Flag supported features.""" supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - if self.tuya.support_stop(): + if self._tuya.support_stop(): supported_features |= SUPPORT_STOP return supported_features @property def is_closed(self): """Return if the cover is closed or not.""" - state = self.tuya.state() + state = self._tuya.state() if state == 1: return False if state == 2: @@ -55,12 +78,12 @@ class TuyaCover(TuyaDevice, CoverEntity): def open_cover(self, **kwargs): """Open the cover.""" - self.tuya.open_cover() + self._tuya.open_cover() def close_cover(self, **kwargs): """Close cover.""" - self.tuya.close_cover() + self._tuya.close_cover() def stop_cover(self, **kwargs): """Stop the cover.""" - self.tuya.stop_cover() + self._tuya.stop_cover() diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 90cf452db5b..be40b5f3e3b 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -1,67 +1,89 @@ """Support for Tuya fans.""" from homeassistant.components.fan import ( + DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import STATE_OFF +from homeassistant.const import CONF_PLATFORM, STATE_OFF +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_TUYA, TuyaDevice +from . import TuyaDevice +from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW PARALLEL_UPDATES = 0 -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya fan platform.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get("dev_ids") - devices = [] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tuya sensors dynamically through tuya discovery.""" + + platform = config_entry.data[CONF_PLATFORM] + + async def async_discover_sensor(dev_ids): + """Discover and add a discovered tuya sensor.""" + if not dev_ids: + return + entities = await hass.async_add_executor_job( + _setup_entities, hass, dev_ids, platform, + ) + async_add_entities(entities) + + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + ) + + devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) + await async_discover_sensor(devices_ids) + + +def _setup_entities(hass, dev_ids, platform): + """Set up Tuya Fan device.""" + tuya = hass.data[DOMAIN][TUYA_DATA] + entities = [] for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: + entity = tuya.get_device_by_id(dev_id) + if entity is None: continue - devices.append(TuyaFanDevice(device)) - add_entities(devices) + entities.append(TuyaFanDevice(entity, platform)) + return entities class TuyaFanDevice(TuyaDevice, FanEntity): """Tuya fan devices.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init Tuya fan device.""" - super().__init__(tuya) + super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) self.speeds = [STATE_OFF] async def async_added_to_hass(self): """Create fan list when add to hass.""" await super().async_added_to_hass() - self.speeds.extend(self.tuya.speed_list()) + self.speeds.extend(self._tuya.speed_list()) def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" if speed == STATE_OFF: self.turn_off() else: - self.tuya.set_speed(speed) + self._tuya.set_speed(speed) def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" if speed is not None: self.set_speed(speed) else: - self.tuya.turn_on() + self._tuya.turn_on() def turn_off(self, **kwargs) -> None: """Turn the entity off.""" - self.tuya.turn_off() + self._tuya.turn_off() def oscillate(self, oscillating) -> None: """Oscillate the fan.""" - self.tuya.oscillate(oscillating) + self._tuya.oscillate(oscillating) @property def oscillating(self): @@ -70,18 +92,18 @@ class TuyaFanDevice(TuyaDevice, FanEntity): return None if self.speed == STATE_OFF: return False - return self.tuya.oscillating() + return self._tuya.oscillating() @property def is_on(self): """Return true if the entity is on.""" - return self.tuya.state() + return self._tuya.state() @property def speed(self) -> str: """Return the current speed.""" if self.is_on: - return self.tuya.speed() + return self._tuya.speed() return STATE_OFF @property @@ -93,6 +115,6 @@ class TuyaFanDevice(TuyaDevice, FanEntity): def supported_features(self) -> int: """Flag supported features.""" supports = SUPPORT_SET_SPEED - if self.tuya.support_oscillate(): + if self._tuya.support_oscillate(): supports = supports | SUPPORT_OSCILLATE return supports diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 2959b9239bd..894833386ea 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -3,58 +3,81 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import color as colorutil -from . import DATA_TUYA, TuyaDevice +from . import TuyaDevice +from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW PARALLEL_UPDATES = 0 -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya light platform.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get("dev_ids") - devices = [] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tuya sensors dynamically through tuya discovery.""" + + platform = config_entry.data[CONF_PLATFORM] + + async def async_discover_sensor(dev_ids): + """Discover and add a discovered tuya sensor.""" + if not dev_ids: + return + entities = await hass.async_add_executor_job( + _setup_entities, hass, dev_ids, platform, + ) + async_add_entities(entities) + + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + ) + + devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) + await async_discover_sensor(devices_ids) + + +def _setup_entities(hass, dev_ids, platform): + """Set up Tuya Light device.""" + tuya = hass.data[DOMAIN][TUYA_DATA] + entities = [] for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: + entity = tuya.get_device_by_id(dev_id) + if entity is None: continue - devices.append(TuyaLight(device)) - add_entities(devices) + entities.append(TuyaLight(entity, platform)) + return entities class TuyaLight(TuyaDevice, LightEntity): """Tuya light device.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init Tuya light device.""" - super().__init__(tuya) + super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) @property def brightness(self): """Return the brightness of the light.""" - if self.tuya.brightness() is None: + if self._tuya.brightness() is None: return None - return int(self.tuya.brightness()) + return int(self._tuya.brightness()) @property def hs_color(self): """Return the hs_color of the light.""" - return tuple(map(int, self.tuya.hs_color())) + return tuple(map(int, self._tuya.hs_color())) @property def color_temp(self): """Return the color_temp of the light.""" - color_temp = int(self.tuya.color_temp()) + color_temp = int(self._tuya.color_temp()) if color_temp is None: return None return colorutil.color_temperature_kelvin_to_mired(color_temp) @@ -62,17 +85,17 @@ class TuyaLight(TuyaDevice, LightEntity): @property def is_on(self): """Return true if light is on.""" - return self.tuya.state() + return self._tuya.state() @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._tuya.min_color_temp()) @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._tuya.max_color_temp()) def turn_on(self, **kwargs): """Turn on or control the light.""" @@ -81,27 +104,27 @@ class TuyaLight(TuyaDevice, LightEntity): and ATTR_HS_COLOR not in kwargs and ATTR_COLOR_TEMP not in kwargs ): - self.tuya.turn_on() + self._tuya.turn_on() if ATTR_BRIGHTNESS in kwargs: - self.tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) + self._tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) if ATTR_HS_COLOR in kwargs: - self.tuya.set_color(kwargs[ATTR_HS_COLOR]) + self._tuya.set_color(kwargs[ATTR_HS_COLOR]) if ATTR_COLOR_TEMP in kwargs: color_temp = colorutil.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP] ) - self.tuya.set_color_temp(color_temp) + self._tuya.set_color_temp(color_temp) def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self.tuya.turn_off() + self._tuya.turn_off() @property def supported_features(self): """Flag supported features.""" supports = SUPPORT_BRIGHTNESS - if self.tuya.support_color(): + if self._tuya.support_color(): supports = supports | SUPPORT_COLOR - if self.tuya.support_color_temp(): + if self._tuya.support_color_temp(): supports = supports | SUPPORT_COLOR_TEMP return supports diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 1279f0a2f66..8053dc8f697 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -3,5 +3,6 @@ "name": "Tuya", "documentation": "https://www.home-assistant.io/integrations/tuya", "requirements": ["tuyaha==0.0.6"], - "codeowners": [] + "codeowners": ["@ollo69"], + "config_flow": true } diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 71d83417ca8..60119ad7138 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -1,38 +1,60 @@ """Support for the Tuya scenes.""" from typing import Any -from homeassistant.components.scene import DOMAIN, Scene +from homeassistant.components.scene import DOMAIN as SENSOR_DOMAIN, Scene +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_TUYA, TuyaDevice +from . import TuyaDevice +from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" PARALLEL_UPDATES = 0 -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tuya scenes.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get("dev_ids") - devices = [] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tuya sensors dynamically through tuya discovery.""" + + platform = config_entry.data[CONF_PLATFORM] + + async def async_discover_sensor(dev_ids): + """Discover and add a discovered tuya sensor.""" + if not dev_ids: + return + entities = await hass.async_add_executor_job( + _setup_entities, hass, dev_ids, platform, + ) + async_add_entities(entities) + + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + ) + + devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) + await async_discover_sensor(devices_ids) + + +def _setup_entities(hass, dev_ids, platform): + """Set up Tuya Scene.""" + tuya = hass.data[DOMAIN][TUYA_DATA] + entities = [] for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: + entity = tuya.get_device_by_id(dev_id) + if entity is None: continue - devices.append(TuyaScene(device)) - add_entities(devices) + entities.append(TuyaScene(entity, platform)) + return entities class TuyaScene(TuyaDevice, Scene): """Tuya Scene.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init Tuya scene.""" - super().__init__(tuya) + super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) def activate(self, **kwargs: Any) -> None: """Activate the scene.""" - self.tuya.activate() + self._tuya.activate() diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json new file mode 100644 index 00000000000..10008242cb6 --- /dev/null +++ b/homeassistant/components/tuya/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "flow_title": "Tuya configuration", + "step": { + "user": { + "title": "Tuya", + "description": "Enter your Tuya credential.", + "data": { + "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", + "password": "[%key:common::config_flow::data::password%]", + "platform": "The app where your account register", + "username": "[%key:common::config_flow::data::username%]" + } + } + }, + "abort": { + "already_in_progress": "Tuya configuration is already in progress.", + "auth_failed": "[%key:common::config_flow::error::invalid_auth%]", + "conn_error": "[%key:common::config_flow::error::cannot_connect%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "auth_failed": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 30684c16da5..4a5d2026b01 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -1,43 +1,69 @@ """Support for Tuya switches.""" -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity +from homeassistant.components.switch import ( + DOMAIN as SENSOR_DOMAIN, + ENTITY_ID_FORMAT, + SwitchEntity, +) +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_TUYA, TuyaDevice +from . import TuyaDevice +from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW PARALLEL_UPDATES = 0 -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tuya sensors dynamically through tuya discovery.""" + + platform = config_entry.data[CONF_PLATFORM] + + async def async_discover_sensor(dev_ids): + """Discover and add a discovered tuya sensor.""" + if not dev_ids: + return + entities = await hass.async_add_executor_job( + _setup_entities, hass, dev_ids, platform, + ) + async_add_entities(entities) + + async_dispatcher_connect( + hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor + ) + + devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN) + await async_discover_sensor(devices_ids) + + +def _setup_entities(hass, dev_ids, platform): """Set up Tuya Switch device.""" - if discovery_info is None: - return - tuya = hass.data[DATA_TUYA] - dev_ids = discovery_info.get("dev_ids") - devices = [] + tuya = hass.data[DOMAIN][TUYA_DATA] + entities = [] for dev_id in dev_ids: - device = tuya.get_device_by_id(dev_id) - if device is None: + entity = tuya.get_device_by_id(dev_id) + if entity is None: continue - devices.append(TuyaSwitch(device)) - add_entities(devices) + entities.append(TuyaSwitch(entity, platform)) + return entities class TuyaSwitch(TuyaDevice, SwitchEntity): """Tuya Switch Device.""" - def __init__(self, tuya): + def __init__(self, tuya, platform): """Init Tuya switch device.""" - super().__init__(tuya) + super().__init__(tuya, platform) self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) @property def is_on(self): """Return true if switch is on.""" - return self.tuya.state() + return self._tuya.state() def turn_on(self, **kwargs): """Turn the switch on.""" - self.tuya.turn_on() + self._tuya.turn_on() def turn_off(self, **kwargs): """Turn the device off.""" - self.tuya.turn_off() + self._tuya.turn_off() diff --git a/homeassistant/components/tuya/translations/en.json b/homeassistant/components/tuya/translations/en.json new file mode 100644 index 00000000000..e70c9c10815 --- /dev/null +++ b/homeassistant/components/tuya/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_in_progress": "Tuya configuration is already in progress.", + "auth_failed": "Configured Tuya credential are incorrect.", + "conn_error": "Connection to Tuya failed.", + "single_instance_allowed": "Only a single configuration of Tuya is allowed." + }, + "error": { + "auth_failed": "Provided credential are incorrect." + }, + "flow_title": "Tuya configuration", + "step": { + "user": { + "data": { + "country_code": "Your account country code (e.g., 1 for USA or 86 for China)", + "password": "Your password to log in to Tuya", + "platform": "The app where your account register", + "username": "Your username to log in to Tuya" + }, + "description": "Enter your Tuya credential.", + "title": "Tuya" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 97c4945a420..d288af9c91f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -141,6 +141,7 @@ FLOWS = [ "traccar", "tradfri", "transmission", + "tuya", "twentemilieu", "twilio", "unifi", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1aa5ee4c2fa..c0192c7e400 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -828,6 +828,9 @@ total_connect_client==0.54.1 # homeassistant.components.transmission transmissionrpc==0.11 +# homeassistant.components.tuya +tuyaha==0.0.6 + # homeassistant.components.twentemilieu twentemilieu==0.3.0 diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py new file mode 100644 index 00000000000..56bfc0867c6 --- /dev/null +++ b/tests/components/tuya/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tuya component.""" diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py new file mode 100644 index 00000000000..eeda68cd2d3 --- /dev/null +++ b/tests/components/tuya/test_config_flow.py @@ -0,0 +1,147 @@ +"""Tests for the Tuya config flow.""" +import pytest +from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.tuya.const import CONF_COUNTRYCODE, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry + +USERNAME = "myUsername" +PASSWORD = "myPassword" +COUNTRY_CODE = "1" +TUYA_PLATFORM = "tuya" + +TUYA_USER_DATA = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_COUNTRYCODE: COUNTRY_CODE, + CONF_PLATFORM: TUYA_PLATFORM, +} + + +@pytest.fixture(name="tuya") +def tuya_fixture() -> Mock: + """Patch libraries.""" + with patch("homeassistant.components.tuya.config_flow.TuyaApi") as tuya: + yield tuya + + +async def test_user(hass, tuya): + """Test user config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.tuya.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.tuya.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_COUNTRYCODE] == COUNTRY_CODE + assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass, tuya): + """Test import step.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.tuya.async_setup", return_value=True, + ) as mock_setup, patch( + "homeassistant.components.tuya.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TUYA_USER_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_COUNTRYCODE] == COUNTRY_CODE + assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_if_already_setup(hass, tuya): + """Test we abort if Tuya is already setup.""" + MockConfigEntry(domain=DOMAIN, data=TUYA_USER_DATA).add_to_hass(hass) + + # Should fail, config exist (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + # Should fail, config exist (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_abort_on_invalid_credentials(hass, tuya): + """Test when we have invalid credentials.""" + tuya().init.side_effect = TuyaAPIException("Boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth_failed"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "auth_failed" + + +async def test_abort_on_connection_error(hass, tuya): + """Test when we have a network error.""" + tuya().init.side_effect = TuyaNetException("Boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "conn_error" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "conn_error"