diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ee084b77ef1de..ea38c117af784 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,103 +1,75 @@ """Support for Tuya Smart devices.""" from __future__ import annotations -from typing import NamedTuple - -import requests -from tuya_iot import ( - AuthType, - TuyaDevice, - TuyaDeviceListener, - TuyaDeviceManager, - TuyaHomeManager, - TuyaOpenAPI, - TuyaOpenMQ, +import logging +from typing import Any, NamedTuple + +from tuya_sharing import ( + CustomerDevice, + Manager, + SharingDeviceListener, + SharingTokenListener, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, CONF_APP_TYPE, - CONF_AUTH_TYPE, CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, DOMAIN, LOGGER, PLATFORMS, + TUYA_CLIENT_ID, TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, ) +# Suppress logs from the library, it logs unneeded on error +logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL) + class HomeAssistantTuyaData(NamedTuple): """Tuya data stored in the Home Assistant data object.""" - device_listener: TuyaDeviceListener - device_manager: TuyaDeviceManager - home_manager: TuyaHomeManager + manager: Manager + listener: SharingDeviceListener async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" - hass.data.setdefault(DOMAIN, {}) - - auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) - api = TuyaOpenAPI( - endpoint=entry.data[CONF_ENDPOINT], - access_id=entry.data[CONF_ACCESS_ID], - access_secret=entry.data[CONF_ACCESS_SECRET], - auth_type=auth_type, + if CONF_APP_TYPE in entry.data: + raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") + + token_listener = TokenListener(hass, entry) + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + token_listener, ) - api.set_dev_channel("hass") - - try: - if auth_type == AuthType.CUSTOM: - response = await hass.async_add_executor_job( - api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - else: - response = await hass.async_add_executor_job( - api.connect, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTRY_CODE], - entry.data[CONF_APP_TYPE], - ) - except requests.exceptions.RequestException as err: - raise ConfigEntryNotReady(err) from err - - if response.get("success", False) is False: - raise ConfigEntryNotReady(response) - - tuya_mq = TuyaOpenMQ(api) - tuya_mq.start() - - device_ids: set[str] = set() - device_manager = TuyaDeviceManager(api, tuya_mq) - home_manager = TuyaHomeManager(api, tuya_mq, device_manager) - listener = DeviceListener(hass, device_manager, device_ids) - device_manager.add_device_listener(listener) - - hass.data[DOMAIN][entry.entry_id] = HomeAssistantTuyaData( - device_listener=listener, - device_manager=device_manager, - home_manager=home_manager, + listener = DeviceListener(hass, manager) + manager.add_device_listener(listener) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantTuyaData( + manager=manager, listener=listener ) # Get devices & clean up device entities - await hass.async_add_executor_job(home_manager.update_device_cache) - await cleanup_device_registry(hass, device_manager) + await hass.async_add_executor_job(manager.update_device_cache) + await cleanup_device_registry(hass, manager) # Register known device IDs device_registry = dr.async_get(hass) - for device in device_manager.device_map.values(): + for device in manager.device_map.values(): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, @@ -105,15 +77,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name=device.name, model=f"{device.product_name} (unsupported)", ) - device_ids.add(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # If the device does not register any entities, the device does not need to subscribe + # So the subscription is here + await hass.async_add_executor_job(manager.refresh_mq) return True -async def cleanup_device_registry( - hass: HomeAssistant, device_manager: TuyaDeviceManager -) -> None: +async def cleanup_device_registry(hass: HomeAssistant, device_manager: Manager) -> None: """Remove deleted device registry entry if there are no remaining entities.""" device_registry = dr.async_get(hass) for dev_id, device_entry in list(device_registry.devices.items()): @@ -125,59 +97,44 @@ async def cleanup_device_registry( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" - unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload: - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] - hass_data.device_manager.mq.stop() - hass_data.device_manager.remove_device_listener(hass_data.device_listener) - - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + tuya: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + if tuya.manager.mq is not None: + tuya.manager.mq.stop() + tuya.manager.remove_device_listener(tuya.listener) + await hass.async_add_executor_job(tuya.manager.unload) + del hass.data[DOMAIN][entry.entry_id] + return unload_ok - return unload - -class DeviceListener(TuyaDeviceListener): +class DeviceListener(SharingDeviceListener): """Device Update Listener.""" def __init__( self, hass: HomeAssistant, - device_manager: TuyaDeviceManager, - device_ids: set[str], + manager: Manager, ) -> None: """Init DeviceListener.""" self.hass = hass - self.device_manager = device_manager - self.device_ids = device_ids + self.manager = manager - def update_device(self, device: TuyaDevice) -> None: + def update_device(self, device: CustomerDevice) -> None: """Update device status.""" - if device.id in self.device_ids: - LOGGER.debug( - "Received update for device %s: %s", - device.id, - self.device_manager.device_map[device.id].status, - ) - dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") - - def add_device(self, device: TuyaDevice) -> None: + LOGGER.debug( + "Received update for device %s: %s", + device.id, + self.manager.device_map[device.id].status, + ) + dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") + + def add_device(self, device: CustomerDevice) -> None: """Add device added listener.""" # Ensure the device isn't present stale self.hass.add_job(self.async_remove_device, device.id) - self.device_ids.add(device.id) dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) - device_manager = self.device_manager - device_manager.mq.stop() - tuya_mq = TuyaOpenMQ(device_manager.api) - tuya_mq.start() - - device_manager.mq = tuya_mq - tuya_mq.add_message_listener(device_manager.on_message) - def remove_device(self, device_id: str) -> None: """Add device removed listener.""" self.hass.add_job(self.async_remove_device, device_id) @@ -192,4 +149,36 @@ def async_remove_device(self, device_id: str) -> None: ) if device_entry is not None: device_registry.async_remove_device(device_entry.id) - self.device_ids.discard(device_id) + + +class TokenListener(SharingTokenListener): + """Token listener for upstream token updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Init TokenListener.""" + self.hass = hass + self.entry = entry + + def update_token(self, token_info: dict[str, Any]) -> None: + """Update token info in config entry.""" + data = { + **self.entry.data, + CONF_TOKEN_INFO: { + "t": token_info["t"], + "uid": token_info["uid"], + "expire_time": token_info["expire_time"], + "access_token": token_info["access_token"], + "refresh_token": token_info["refresh_token"], + }, + } + + @callback + def async_update_entry() -> None: + """Update config entry.""" + self.hass.config_entries.async_update_entry(self.entry, data=data) + + self.hass.add_job(async_update_entry) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index cd92e62b864f8..681f025f57b65 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -3,7 +3,7 @@ from enum import StrEnum -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -68,18 +68,16 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya siren.""" entities: list[TuyaAlarmEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := ALARM.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaAlarmEntity( - device, hass_data.device_manager, description - ) + TuyaAlarmEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -94,8 +92,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: AlarmControlPanelEntityDescription, ) -> None: """Init Tuya Alarm.""" diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 3aae417aac7d9..7c4e213fe65a1 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -7,7 +7,7 @@ import struct from typing import Any, Literal, Self, overload -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -135,9 +135,11 @@ class TuyaEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: """Init TuyaHaEntity.""" self._attr_unique_id = f"tuya.{device.id}" + # TuyaEntity initialize mq can subscribe + device.set_up = True self.device = device self.device_manager = device_manager diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 8e934ae6593a2..5664801d76e5c 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -3,7 +3,7 @@ from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -354,20 +354,20 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaBinarySensorEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := BINARY_SENSORS.get(device.category): for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: entities.append( TuyaBinarySensorEntity( - device, hass_data.device_manager, description + device, hass_data.manager, description ) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -381,8 +381,8 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaBinarySensorEntityDescription, ) -> None: """Init Tuya binary sensor.""" diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 4c73b70c29aac..5b936b305fb3f 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,7 +1,7 @@ """Support for Tuya buttons.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -74,19 +74,17 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya buttons.""" entities: list[TuyaButtonEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := BUTTONS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaButtonEntity( - device, hass_data.device_manager, description - ) + TuyaButtonEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -98,8 +96,8 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: ButtonEntityDescription, ) -> None: """Init Tuya button.""" diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 72216057affc8..07c4adb8889bc 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -1,7 +1,7 @@ """Support for Tuya cameras.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature @@ -34,13 +34,13 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya camera.""" entities: list[TuyaCameraEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device.category in CAMERAS: - entities.append(TuyaCameraEntity(device, hass_data.device_manager)) + entities.append(TuyaCameraEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -56,8 +56,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, ) -> None: """Init Tuya Camera.""" super().__init__(device, device_manager) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 74399d7099180..9f20df9837090 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.climate import ( SWING_BOTH, @@ -98,18 +98,19 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya climate.""" entities: list[TuyaClimateEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device and device.category in CLIMATE_DESCRIPTIONS: entities.append( TuyaClimateEntity( device, - hass_data.device_manager, + hass_data.manager, CLIMATE_DESCRIPTIONS[device.category], + hass.config.units.temperature_unit, ) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -129,9 +130,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaClimateEntityDescription, + system_temperature_unit: UnitOfTemperature, ) -> None: """Determine which values to use.""" self._attr_target_temperature_step = 1.0 @@ -157,7 +159,7 @@ def __init__( prefered_temperature_unit = UnitOfTemperature.FAHRENHEIT # Default to System Temperature Unit - self._attr_temperature_unit = self.hass.config.units.temperature_unit + self._attr_temperature_unit = system_temperature_unit # Figure out current temperature, use preferred unit or what is available celsius_type = self.find_dpcode( diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index f933ac8451986..3577a6d6b0662 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,143 +1,212 @@ """Config flow for Tuya.""" from __future__ import annotations +from collections.abc import Mapping +from io import BytesIO from typing import Any -from tuya_iot import AuthType, TuyaOpenAPI +import segno +from tuya_sharing import LoginControl import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.data_entry_flow import FlowResult from .const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, - CONF_APP_TYPE, - CONF_AUTH_TYPE, CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, DOMAIN, - LOGGER, - SMARTLIFE_APP, - TUYA_COUNTRIES, + TUYA_CLIENT_ID, TUYA_RESPONSE_CODE, TUYA_RESPONSE_MSG, - TUYA_RESPONSE_PLATFORM_URL, + TUYA_RESPONSE_QR_CODE, TUYA_RESPONSE_RESULT, TUYA_RESPONSE_SUCCESS, - TUYA_SMART_APP, + TUYA_SCHEMA, ) -class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Tuya Config Flow.""" - - @staticmethod - def _try_login(user_input: dict[str, Any]) -> tuple[dict[Any, Any], dict[str, Any]]: - """Try login.""" - response = {} - - country = [ - country - for country in TUYA_COUNTRIES - if country.name == user_input[CONF_COUNTRY_CODE] - ][0] - - data = { - CONF_ENDPOINT: country.endpoint, - CONF_AUTH_TYPE: AuthType.CUSTOM, - CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], - CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_COUNTRY_CODE: country.country_code, - } +class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): + """Tuya config flow.""" + + __user_code: str + __qr_code: str + __qr_image: str + __reauth_entry: ConfigEntry | None = None + + def __init__(self) -> None: + """Initialize the config flow.""" + self.__login_control = LoginControl() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step user.""" + errors = {} + placeholders = {} - for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): - data[CONF_APP_TYPE] = app_type - if data[CONF_APP_TYPE] == "": - data[CONF_AUTH_TYPE] = AuthType.CUSTOM - else: - data[CONF_AUTH_TYPE] = AuthType.SMART_HOME - - api = TuyaOpenAPI( - endpoint=data[CONF_ENDPOINT], - access_id=data[CONF_ACCESS_ID], - access_secret=data[CONF_ACCESS_SECRET], - auth_type=data[CONF_AUTH_TYPE], + if user_input is not None: + success, response = await self.__async_get_qr_code( + user_input[CONF_USER_CODE] ) - api.set_dev_channel("hass") + if success: + return await self.async_step_scan() - response = api.connect( - username=data[CONF_USERNAME], - password=data[CONF_PASSWORD], - country_code=data[CONF_COUNTRY_CODE], - schema=data[CONF_APP_TYPE], + errors["base"] = "login_error" + placeholders = { + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE, "0"), + } + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "") + ): str, + } + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_scan( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step scan.""" + if user_input is None: + return self.async_show_form( + step_id="scan", + description_placeholders={ + TUYA_RESPONSE_QR_CODE: self.__qr_image, + }, ) - LOGGER.debug("Response %s", response) + ret, info = await self.hass.async_add_executor_job( + self.__login_control.login_result, + self.__qr_code, + TUYA_CLIENT_ID, + self.__user_code, + ) + if not ret: + return self.async_show_form( + step_id="scan", + errors={"base": "login_error"}, + description_placeholders={ + TUYA_RESPONSE_QR_CODE: self.__qr_image, + TUYA_RESPONSE_MSG: info.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: info.get(TUYA_RESPONSE_CODE, 0), + }, + ) - if response.get(TUYA_RESPONSE_SUCCESS, False): - break + entry_data = { + CONF_USER_CODE: self.__user_code, + CONF_TOKEN_INFO: { + "t": info["t"], + "uid": info["uid"], + "expire_time": info["expire_time"], + "access_token": info["access_token"], + "refresh_token": info["refresh_token"], + }, + CONF_TERMINAL_ID: info[CONF_TERMINAL_ID], + CONF_ENDPOINT: info[CONF_ENDPOINT], + } - return response, data + if self.__reauth_entry: + return self.async_update_reload_and_abort( + self.__reauth_entry, + data=entry_data, + ) - async def async_step_user(self, user_input=None): - """Step user.""" + return self.async_create_entry( + title=info.get("username"), + data=entry_data, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Tuya.""" + self.__reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + if self.__reauth_entry and CONF_USER_CODE in self.__reauth_entry.data: + success, _ = await self.__async_get_qr_code( + self.__reauth_entry.data[CONF_USER_CODE] + ) + if success: + return await self.async_step_scan() + + return await self.async_step_reauth_user_code() + + async def async_step_reauth_user_code( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with a Tuya.""" errors = {} placeholders = {} if user_input is not None: - response, data = await self.hass.async_add_executor_job( - self._try_login, user_input + success, response = await self.__async_get_qr_code( + user_input[CONF_USER_CODE] ) + if success: + return await self.async_step_scan() - if response.get(TUYA_RESPONSE_SUCCESS, False): - if endpoint := response.get(TUYA_RESPONSE_RESULT, {}).get( - TUYA_RESPONSE_PLATFORM_URL - ): - data[CONF_ENDPOINT] = endpoint - - data[CONF_AUTH_TYPE] = data[CONF_AUTH_TYPE].value - - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=data, - ) errors["base"] = "login_error" placeholders = { - TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE), - TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE, "0"), } - - if user_input is None: + else: user_input = {} return self.async_show_form( - step_id="user", + step_id="reauth_user_code", data_schema=vol.Schema( { vol.Required( - CONF_COUNTRY_CODE, - default=user_input.get(CONF_COUNTRY_CODE, "United States"), - ): vol.In( - # We don't pass a dict {code:name} because country codes can be duplicate. - [country.name for country in TUYA_COUNTRIES] - ), - vol.Required( - CONF_ACCESS_ID, default=user_input.get(CONF_ACCESS_ID, "") - ): str, - vol.Required( - CONF_ACCESS_SECRET, - default=user_input.get(CONF_ACCESS_SECRET, ""), - ): str, - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") - ): str, - vol.Required( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "") ): str, } ), errors=errors, description_placeholders=placeholders, ) + + async def __async_get_qr_code(self, user_code: str) -> tuple[bool, dict[str, Any]]: + """Get the QR code.""" + response = await self.hass.async_add_executor_job( + self.__login_control.qr_code, + TUYA_CLIENT_ID, + TUYA_SCHEMA, + user_code, + ) + if success := response.get(TUYA_RESPONSE_SUCCESS, False): + self.__user_code = user_code + self.__qr_code = response[TUYA_RESPONSE_RESULT][TUYA_RESPONSE_QR_CODE] + self.__qr_image = _generate_qr_code(self.__qr_code) + return success, response + + +def _generate_qr_code(data: str) -> str: + """Create an SVG QR code that can be scanned with the Smart Life app.""" + qr_code = segno.make(f"tuyaSmart--qrLogin?token={data}", error="h") + with BytesIO() as buffer: + qr_code.save( + buffer, + kind="svg", + border=5, + scale=5, + xmldecl=False, + svgns=False, + svgclass=None, + lineclass=None, + svgversion=2, + dark="#1abcf2", + ) + return str(buffer.getvalue().decode("ascii")) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 4cdca8f39048a..8f15114aa8092 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -6,8 +6,6 @@ from enum import StrEnum import logging -from tuya_iot import TuyaCloudOpenAPIEndpoint - from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -31,24 +29,24 @@ DOMAIN = "tuya" LOGGER = logging.getLogger(__package__) -CONF_AUTH_TYPE = "auth_type" -CONF_PROJECT_TYPE = "tuya_project_type" -CONF_ENDPOINT = "endpoint" -CONF_ACCESS_ID = "access_id" -CONF_ACCESS_SECRET = "access_secret" CONF_APP_TYPE = "tuya_app_type" +CONF_ENDPOINT = "endpoint" +CONF_TERMINAL_ID = "terminal_id" +CONF_TOKEN_INFO = "token_info" +CONF_USER_CODE = "user_code" +CONF_USERNAME = "username" + +TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" +TUYA_SCHEMA = "haauthorize" TUYA_DISCOVERY_NEW = "tuya_discovery_new" TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" TUYA_RESPONSE_CODE = "code" -TUYA_RESPONSE_RESULT = "result" TUYA_RESPONSE_MSG = "msg" +TUYA_RESPONSE_QR_CODE = "qrcode" +TUYA_RESPONSE_RESULT = "result" TUYA_RESPONSE_SUCCESS = "success" -TUYA_RESPONSE_PLATFORM_URL = "platform_url" - -TUYA_SMART_APP = "tuyaSmart" -SMARTLIFE_APP = "smartlife" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -570,259 +568,3 @@ class UnitOfMeasurement: DEVICE_CLASS_UNITS.setdefault(device_class, {})[uom.unit] = uom for unit_alias in uom.aliases: DEVICE_CLASS_UNITS[device_class][unit_alias] = uom - - -@dataclass -class Country: - """Describe a supported country.""" - - name: str - country_code: str - endpoint: str = TuyaCloudOpenAPIEndpoint.AMERICA - - -# https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb -TUYA_COUNTRIES = [ - Country("Afghanistan", "93", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Albania", "355", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Algeria", "213", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("American Samoa", "1-684", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Andorra", "376", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Angola", "244", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Anguilla", "1-264", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Antarctica", "672", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Antigua and Barbuda", "1-268", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Argentina", "54", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Armenia", "374", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Aruba", "297", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Australia", "61", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Austria", "43", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Azerbaijan", "994", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bahamas", "1-242", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bahrain", "973", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bangladesh", "880", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Barbados", "1-246", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belarus", "375", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belgium", "32", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belize", "501", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Benin", "229", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bermuda", "1-441", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bhutan", "975", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bolivia", "591", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Bosnia and Herzegovina", "387", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Botswana", "267", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Brazil", "55", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("British Indian Ocean Territory", "246", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("British Virgin Islands", "1-284", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Brunei", "673", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bulgaria", "359", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Burkina Faso", "226", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Burundi", "257", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cambodia", "855", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cameroon", "237", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Canada", "1", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Capo Verde", "238", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cayman Islands", "1-345", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Central African Republic", "236", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Chad", "235", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Chile", "56", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("China", "86", TuyaCloudOpenAPIEndpoint.CHINA), - Country("Christmas Island", "61"), - Country("Cocos Islands", "61"), - Country("Colombia", "57", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Comoros", "269", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cook Islands", "682", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Costa Rica", "506", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Croatia", "385", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cuba", "53"), - Country("Curacao", "599", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Cyprus", "357", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Czech Republic", "420", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Democratic Republic of the Congo", "243", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Denmark", "45", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Djibouti", "253", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Dominica", "1-767", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Dominican Republic", "1-809", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("East Timor", "670", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Ecuador", "593", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Egypt", "20", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("El Salvador", "503", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Equatorial Guinea", "240", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Eritrea", "291", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Estonia", "372", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ethiopia", "251", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Falkland Islands", "500", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Faroe Islands", "298", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Fiji", "679", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Finland", "358", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("France", "33", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("French Polynesia", "689", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gabon", "241", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gambia", "220", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Georgia", "995", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Germany", "49", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ghana", "233", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gibraltar", "350", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Greece", "30", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Greenland", "299", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Grenada", "1-473", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Guam", "1-671", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Guatemala", "502", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Guernsey", "44-1481"), - Country("Guinea", "224"), - Country("Guinea-Bissau", "245", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Guyana", "592", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Haiti", "509", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Honduras", "504", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Hong Kong", "852", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Hungary", "36", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Iceland", "354", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("India", "91", TuyaCloudOpenAPIEndpoint.INDIA), - Country("Indonesia", "62", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Iran", "98"), - Country("Iraq", "964", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ireland", "353", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Isle of Man", "44-1624"), - Country("Israel", "972", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Italy", "39", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ivory Coast", "225", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Jamaica", "1-876", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Japan", "81", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Jersey", "44-1534"), - Country("Jordan", "962", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kazakhstan", "7", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kenya", "254", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kiribati", "686", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Kosovo", "383"), - Country("Kuwait", "965", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kyrgyzstan", "996", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Laos", "856", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Latvia", "371", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lebanon", "961", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lesotho", "266", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Liberia", "231", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Libya", "218", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Liechtenstein", "423", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lithuania", "370", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Luxembourg", "352", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Macao", "853", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Macedonia", "389", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Madagascar", "261", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malawi", "265", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malaysia", "60", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Maldives", "960", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mali", "223", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malta", "356", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Marshall Islands", "692", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mauritania", "222", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mauritius", "230", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mayotte", "262", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mexico", "52", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Micronesia", "691", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Moldova", "373", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Monaco", "377", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mongolia", "976", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Montenegro", "382", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Montserrat", "1-664", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Morocco", "212", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mozambique", "258", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Myanmar", "95", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Namibia", "264", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Nauru", "674", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Nepal", "977", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Netherlands", "31", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Netherlands Antilles", "599"), - Country("New Caledonia", "687", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("New Zealand", "64", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Nicaragua", "505", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Niger", "227", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Nigeria", "234", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Niue", "683", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("North Korea", "850"), - Country("Northern Mariana Islands", "1-670", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Norway", "47", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Oman", "968", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Pakistan", "92", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Palau", "680", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Palestine", "970", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Panama", "507", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Papua New Guinea", "675", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Paraguay", "595", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Peru", "51", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Philippines", "63", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Pitcairn", "64"), - Country("Poland", "48", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Portugal", "351", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Puerto Rico", "1-787, 1-939", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Qatar", "974", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Republic of the Congo", "242", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Reunion", "262", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Romania", "40", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Russia", "7", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Rwanda", "250", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Barthelemy", "590", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Helena", "290"), - Country("Saint Kitts and Nevis", "1-869", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Lucia", "1-758", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Martin", "590", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Pierre and Miquelon", "508", TuyaCloudOpenAPIEndpoint.EUROPE), - Country( - "Saint Vincent and the Grenadines", "1-784", TuyaCloudOpenAPIEndpoint.EUROPE - ), - Country("Samoa", "685", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("San Marino", "378", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sao Tome and Principe", "239", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Saudi Arabia", "966", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Senegal", "221", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Serbia", "381", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Seychelles", "248", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sierra Leone", "232", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Singapore", "65", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sint Maarten", "1-721", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Slovakia", "421", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Slovenia", "386", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Solomon Islands", "677", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Somalia", "252", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("South Africa", "27", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("South Korea", "82", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("South Sudan", "211"), - Country("Spain", "34", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sri Lanka", "94", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sudan", "249"), - Country("Suriname", "597", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Svalbard and Jan Mayen", "4779", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Swaziland", "268", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sweden", "46", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Switzerland", "41", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Syria", "963"), - Country("Taiwan", "886", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Tajikistan", "992", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tanzania", "255", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Thailand", "66", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Togo", "228", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tokelau", "690", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Tonga", "676", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Trinidad and Tobago", "1-868", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tunisia", "216", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turkey", "90", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turkmenistan", "993", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turks and Caicos Islands", "1-649", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tuvalu", "688", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("U.S. Virgin Islands", "1-340", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Uganda", "256", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ukraine", "380", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United Arab Emirates", "971", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United Kingdom", "44", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United States", "1", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Uruguay", "598", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Uzbekistan", "998", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Vanuatu", "678", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Vatican", "379", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Venezuela", "58", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Vietnam", "84", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Wallis and Futuna", "681", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Western Sahara", "212", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Yemen", "967", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Zambia", "260", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Zimbabwe", "263", TuyaCloudOpenAPIEndpoint.EUROPE), -] diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 46bd0721ccb51..912087d2c8c29 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.cover import ( ATTR_POSITION, @@ -152,7 +152,7 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya cover.""" entities: list[TuyaCoverEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := COVERS.get(device.category): for description in descriptions: if ( @@ -160,14 +160,12 @@ def async_discover_device(device_ids: list[str]) -> None: or description.key in device.status_range ): entities.append( - TuyaCoverEntity( - device, hass_data.device_manager, description - ) + TuyaCoverEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -184,8 +182,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaCoverEntityDescription, ) -> None: """Init Tuya Cover.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index adac97174b911..cdd0d5ed51c06 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -5,18 +5,17 @@ import json from typing import Any, cast -from tuya_iot import TuyaDevice +from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.util import dt as dt_util from . import HomeAssistantTuyaData -from .const import CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, DOMAIN, DPCode +from .const import DOMAIN, DPCode async def async_get_config_entry_diagnostics( @@ -43,14 +42,12 @@ def _async_get_diagnostics( hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] mqtt_connected = None - if hass_data.home_manager.mq.client: - mqtt_connected = hass_data.home_manager.mq.client.is_connected() + if hass_data.manager.mq.client: + mqtt_connected = hass_data.manager.mq.client.is_connected() data = { - "endpoint": entry.data[CONF_ENDPOINT], - "auth_type": entry.data[CONF_AUTH_TYPE], - "country_code": entry.data[CONF_COUNTRY_CODE], - "app_type": entry.data[CONF_APP_TYPE], + "endpoint": hass_data.manager.customer_api.endpoint, + "terminal_id": hass_data.manager.terminal_id, "mqtt_connected": mqtt_connected, "disabled_by": entry.disabled_by, "disabled_polling": entry.pref_disable_polling, @@ -59,13 +56,13 @@ def _async_get_diagnostics( if device: tuya_device_id = next(iter(device.identifiers))[1] data |= _async_device_as_dict( - hass, hass_data.device_manager.device_map[tuya_device_id] + hass, hass_data.manager.device_map[tuya_device_id] ) else: data.update( devices=[ _async_device_as_dict(hass, device) - for device in hass_data.device_manager.device_map.values() + for device in hass_data.manager.device_map.values() ] ) @@ -73,13 +70,15 @@ def _async_get_diagnostics( @callback -def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, Any]: +def _async_device_as_dict( + hass: HomeAssistant, device: CustomerDevice +) -> dict[str, Any]: """Represent a Tuya device as a dictionary.""" # Base device information, without sensitive information. data = { + "id": device.id, "name": device.name, - "model": device.model if hasattr(device, "model") else None, "category": device.category, "product_id": device.product_id, "product_name": device.product_name, @@ -93,6 +92,8 @@ def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, "status_range": {}, "status": {}, "home_assistant": {}, + "set_up": device.set_up, + "support_local": device.support_local, } # Gather Tuya states diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 210cc5c75180d..0971462e450f3 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -3,7 +3,7 @@ from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -44,12 +44,12 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya fan.""" entities: list[TuyaFanEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device and device.category in TUYA_SUPPORT_TYPE: - entities.append(TuyaFanEntity(device, hass_data.device_manager)) + entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -69,8 +69,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, ) -> None: """Init Tuya Fan Device.""" super().__init__(device, device_manager) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index a8008ced95317..7cc4fee03fc24 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -3,7 +3,7 @@ from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.humidifier import ( HumidifierDeviceClass, @@ -65,14 +65,14 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya (de)humidifier.""" entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if description := HUMIDIFIERS.get(device.category): entities.append( - TuyaHumidifierEntity(device, hass_data.device_manager, description) + TuyaHumidifierEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -90,8 +90,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaHumidifierEntityDescription, ) -> None: """Init Tuya (de)humidifier.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 50927d35d3229..98d704326ae6a 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -5,7 +5,7 @@ import json from typing import Any, cast -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -413,19 +413,17 @@ def async_discover_device(device_ids: list[str]): """Discover and add a discovered tuya light.""" entities: list[TuyaLightEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := LIGHTS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaLightEntity( - device, hass_data.device_manager, description - ) + TuyaLightEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -447,8 +445,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaLightEntityDescription, ) -> None: """Init TuyaHaLight.""" diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index a6d0a28d36ac1..71e43c8d445c9 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-iot-py-sdk==0.6.6"] + "requirements": ["tuya-device-sharing-sdk==0.1.9", "segno==1.5.3"] } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 5e7bdcc260a11..8fc55d2c23068 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -1,7 +1,7 @@ """Support for Tuya number.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( NumberDeviceClass, @@ -323,19 +323,17 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya number.""" entities: list[TuyaNumberEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := NUMBERS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaNumberEntity( - device, hass_data.device_manager, description - ) + TuyaNumberEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -349,8 +347,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: NumberEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 289e319df1bb5..8db3ef60658de 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -3,7 +3,7 @@ from typing import Any -from tuya_iot import TuyaHomeManager, TuyaScene +from tuya_sharing import Manager, SharingScene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry @@ -20,10 +20,8 @@ async def async_setup_entry( ) -> None: """Set up Tuya scenes.""" hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] - scenes = await hass.async_add_executor_job(hass_data.home_manager.query_scenes) - async_add_entities( - TuyaSceneEntity(hass_data.home_manager, scene) for scene in scenes - ) + scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes) + async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes) class TuyaSceneEntity(Scene): @@ -33,7 +31,7 @@ class TuyaSceneEntity(Scene): _attr_has_entity_name = True _attr_name = None - def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: + def __init__(self, home_manager: Manager, scene: SharingScene) -> None: """Init Tuya Scene.""" super().__init__() self._attr_unique_id = f"tys{scene.scene_id}" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index bc44ddf479cc2..5d712767697b0 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -1,7 +1,7 @@ """Support for Tuya select.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -356,19 +356,17 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya select.""" entities: list[TuyaSelectEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SELECTS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSelectEntity( - device, hass_data.device_manager, description - ) + TuyaSelectEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -380,8 +378,8 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SelectEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 62b59cb8ed936..80c76a0c25395 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -3,8 +3,8 @@ from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager -from tuya_iot.device import TuyaDeviceStatusRange +from tuya_sharing import CustomerDevice, Manager +from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( SensorDeviceClass, @@ -1112,19 +1112,17 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya sensor.""" entities: list[TuyaSensorEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SENSORS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSensorEntity( - device, hass_data.device_manager, description - ) + TuyaSensorEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -1136,15 +1134,15 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): entity_description: TuyaSensorEntityDescription - _status_range: TuyaDeviceStatusRange | None = None + _status_range: DeviceStatusRange | None = None _type: DPType | None = None _type_data: IntegerTypeData | EnumTypeData | None = None _uom: UnitOfMeasurement | None = None def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaSensorEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index c2dc8cea99ba1..baba339318d72 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -3,7 +3,7 @@ from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.siren import ( SirenEntity, @@ -57,19 +57,17 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya siren.""" entities: list[TuyaSirenEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SIRENS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSirenEntity( - device, hass_data.device_manager, description - ) + TuyaSirenEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -84,8 +82,8 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SirenEntityDescription, ) -> None: """Init Tuya Siren.""" diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ad9da548d6c45..6e4848d9cc091 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1,20 +1,26 @@ { "config": { "step": { + "reauth_user_code": { + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "data": { + "user_code": "User code" + } + }, "user": { - "description": "Enter your Tuya credentials", + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", "data": { - "country_code": "Country", - "access_id": "Tuya IoT Access ID", - "access_secret": "Tuya IoT Access Secret", - "username": "Account", - "password": "[%key:common::config_flow::data::password%]" + "user_code": "User code" } + }, + "scan": { + "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login:\n\n {qrcode} \n\nContinue to the next step once you have completed this step in the app." } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "login_error": "Login error ({code}): {msg}" + "login_error": "Login error ({code}): {msg}", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index ba304b4069e56..a89dbbd713227 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -3,7 +3,7 @@ from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.switch import ( SwitchDeviceClass, @@ -730,19 +730,17 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya sensor.""" entities: list[TuyaSwitchEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SWITCHES.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSwitchEntity( - device, hass_data.device_manager, description - ) + TuyaSwitchEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -754,8 +752,8 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SwitchEntityDescription, ) -> None: """Init TuyaHaSwitch.""" diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index d067d3786ea9a..9ebfe8995188b 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -3,7 +3,7 @@ from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( STATE_CLEANING, @@ -61,12 +61,12 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya vacuum.""" entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device.category == "sd": - entities.append(TuyaVacuumEntity(device, hass_data.device_manager)) + entities.append(TuyaVacuumEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -80,7 +80,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): _battery_level: IntegerTypeData | None = None _attr_name = None - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: """Init Tuya vacuum.""" super().__init__(device, device_manager) diff --git a/requirements_all.txt b/requirements_all.txt index 191b529641064..9a4be9d5891b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2487,6 +2487,9 @@ scsgate==0.1.0 # homeassistant.components.backup securetar==2023.3.0 +# homeassistant.components.tuya +segno==1.5.3 + # homeassistant.components.sendgrid sendgrid==6.8.2 @@ -2723,7 +2726,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.6 +tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu twentemilieu==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 894c5a8735e00..0a54a4022115d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1891,6 +1891,9 @@ screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 +# homeassistant.components.tuya +segno==1.5.3 + # homeassistant.components.emulated_kasa # homeassistant.components.sense sense-energy==0.12.2 @@ -2067,7 +2070,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.6 +tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu twentemilieu==2.0.1 diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py new file mode 100644 index 0000000000000..6decb7c5f10e8 --- /dev/null +++ b/tests/components/tuya/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for the Tuya integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock an old config entry that can be migrated.""" + return MockConfigEntry( + title="Old Tuya configuration entry", + domain=DOMAIN, + data={CONF_APP_TYPE: "tuyaSmart"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock an config entry.""" + return MockConfigEntry( + title="12345", + domain=DOMAIN, + data={CONF_USER_CODE: "12345"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_tuya_login_control() -> Generator[MagicMock, None, None]: + """Return a mocked Tuya login control.""" + with patch( + "homeassistant.components.tuya.config_flow.LoginControl", autospec=True + ) as login_control_mock: + login_control = login_control_mock.return_value + login_control.qr_code.return_value = { + "success": True, + "result": { + "qrcode": "mocked_qr_code", + }, + } + login_control.login_result.return_value = ( + True, + { + "t": "mocked_t", + "uid": "mocked_uid", + "username": "mocked_username", + "expire_time": "mocked_expire_time", + "access_token": "mocked_access_token", + "refresh_token": "mocked_refresh_token", + "terminal_id": "mocked_terminal_id", + "endpoint": "mocked_endpoint", + }, + ) + yield login_control diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000000..416a656c238a8 --- /dev/null +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -0,0 +1,112 @@ +# serializer version: 1 +# name: test_reauth_flow + ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }) +# --- +# name: test_reauth_flow_migration + ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Old Tuya configuration entry', + 'unique_id': '12345', + 'version': 1, + }) +# --- +# name: test_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + }), + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tuya', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'mocked_username', + 'unique_id': None, + 'version': 1, + }), + 'title': 'mocked_username', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index f8345683d4ac6..66a5d1d226d09 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -1,127 +1,270 @@ """Tests for the Tuya config flow.""" from __future__ import annotations -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from tuya_iot import TuyaCloudOpenAPIEndpoint - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.tuya.const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, - CONF_APP_TYPE, - CONF_AUTH_TYPE, - CONF_ENDPOINT, - DOMAIN, - SMARTLIFE_APP, - TUYA_COUNTRIES, - TUYA_SMART_APP, -) -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import ANY, MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -MOCK_SMART_HOME_PROJECT_TYPE = 0 -MOCK_INDUSTRY_PROJECT_TYPE = 1 - -MOCK_COUNTRY = "India" -MOCK_ACCESS_ID = "myAccessId" -MOCK_ACCESS_SECRET = "myAccessSecret" -MOCK_USERNAME = "myUsername" -MOCK_PASSWORD = "myPassword" -MOCK_ENDPOINT = TuyaCloudOpenAPIEndpoint.INDIA - -TUYA_INPUT_DATA = { - CONF_COUNTRY_CODE: MOCK_COUNTRY, - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, -} - -RESPONSE_SUCCESS = { - "success": True, - "code": 1024, - "result": {"platform_url": MOCK_ENDPOINT}, -} -RESPONSE_ERROR = {"success": False, "code": 123, "msg": "Error"} - - -@pytest.fixture(name="tuya") -def tuya_fixture() -> MagicMock: - """Patch libraries.""" - with patch("homeassistant.components.tuya.config_flow.TuyaOpenAPI") as tuya: - yield tuya - - -@pytest.fixture(name="tuya_setup", autouse=True) -def tuya_setup_fixture() -> None: - """Mock tuya entry setup.""" - with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): - yield - - -@pytest.mark.parametrize( - ("app_type", "side_effects", "project_type"), - [ - ("", [RESPONSE_SUCCESS], 1), - (TUYA_SMART_APP, [RESPONSE_ERROR, RESPONSE_SUCCESS], 0), - (SMARTLIFE_APP, [RESPONSE_ERROR, RESPONSE_ERROR, RESPONSE_SUCCESS], 0), - ], -) + +@pytest.mark.usefixtures("mock_tuya_login_control") async def test_user_flow( hass: HomeAssistant, - tuya: MagicMock, - app_type: str, - side_effects: list[dict[str, Any]], - project_type: int, -): - """Test user flow.""" + snapshot: SnapshotAssertion, +) -> None: + """Test the full happy path user flow from start to finish.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + assert result2.get("description_placeholders") == {"qrcode": ANY} - tuya().connect = MagicMock(side_effect=side_effects) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_DATA + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, ) - await hass.async_block_till_done() - country = [country for country in TUYA_COUNTRIES if country.name == MOCK_COUNTRY][0] + assert result3.get("type") == FlowResultType.CREATE_ENTRY + assert result3 == snapshot + + +async def test_user_flow_failed_qr_code( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, +) -> None: + """Test an error occurring while retrieving the QR code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + # Something went wrong getting the QR code (like an invalid user code) + mock_tuya_login_control.qr_code.return_value["success"] = False + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.qr_code.return_value["success"] = True + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + assert result3.get("step_id") == "scan" - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_USERNAME - assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID - assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_ENDPOINT] == country.endpoint - assert result["data"][CONF_APP_TYPE] == app_type - assert result["data"][CONF_AUTH_TYPE] == project_type - assert result["data"][CONF_COUNTRY_CODE] == country.country_code - assert not result["result"].unique_id + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result3.get("type") == FlowResultType.CREATE_ENTRY -async def test_error_on_invalid_credentials(hass: HomeAssistant, tuya) -> None: - """Test when we have invalid credentials.""" +async def test_user_flow_failed_scan( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, +) -> None: + """Test an error occurring while verifying login.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + + # Access has been denied, or the code hasn't been scanned yet + good_values = mock_tuya_login_control.login_result.return_value + mock_tuya_login_control.login_result.return_value = ( + False, + {"msg": "oops", "code": 42}, + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.FORM + assert result3.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.login_result.return_value = good_values + + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result4.get("type") == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_tuya_login_control") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "scan" + assert result.get("description_placeholders") == {"qrcode": ANY} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry == snapshot + + +@pytest.mark.usefixtures("mock_tuya_login_control") +async def test_reauth_flow_migration( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the reauthentication configuration flow. + + This flow tests the migration from an old config entry. + """ + mock_old_config_entry.add_to_hass(hass) + + # Ensure old data is there, new data is missing + assert CONF_APP_TYPE in mock_old_config_entry.data + assert CONF_USER_CODE not in mock_old_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_old_config_entry.unique_id, + "entry_id": mock_old_config_entry.entry_id, + }, + data=mock_old_config_entry.data, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_user_code" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + assert result2.get("description_placeholders") == {"qrcode": ANY} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" + + # Ensure the old data is gone, new data is present + assert CONF_APP_TYPE not in mock_old_config_entry.data + assert CONF_USER_CODE in mock_old_config_entry.data + + assert mock_old_config_entry == snapshot + + +async def test_reauth_flow_failed_qr_code( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test an error occurring while retrieving the QR code.""" + mock_old_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_old_config_entry.unique_id, + "entry_id": mock_old_config_entry.entry_id, + }, + data=mock_old_config_entry.data, + ) + + # Something went wrong getting the QR code (like an invalid user code) + mock_tuya_login_control.qr_code.return_value["success"] = False + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.qr_code.return_value["success"] = True + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + assert result3.get("step_id") == "scan" - tuya().connect = MagicMock(return_value=RESPONSE_ERROR) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_DATA + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, ) - await hass.async_block_till_done() - assert result["errors"]["base"] == "login_error" - assert result["description_placeholders"]["code"] == RESPONSE_ERROR["code"] - assert result["description_placeholders"]["msg"] == RESPONSE_ERROR["msg"] + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful"