diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 5382adb8d96..4788c3220bf 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -35,6 +35,7 @@ from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -65,6 +66,8 @@ from .const import ( MODELS_PURIFIER_MIOT, MODELS_SWITCH, MODELS_VACUUM, + AuthException, + SetupException, ) from .gateway import ConnectXiaomiGateway @@ -100,10 +103,9 @@ async def async_setup_entry( ): """Set up the Xiaomi Miio components from a config entry.""" hass.data.setdefault(DOMAIN, {}) - if entry.data[ - CONF_FLOW_TYPE - ] == CONF_GATEWAY and not await async_setup_gateway_entry(hass, entry): - return False + if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: + await async_setup_gateway_entry(hass, entry) + return True return bool( entry.data[CONF_FLOW_TYPE] != CONF_DEVICE @@ -362,8 +364,12 @@ async def async_setup_gateway_entry( # Connect to gateway gateway = ConnectXiaomiGateway(hass, entry) - if not await gateway.async_connect_gateway(host, token): - return False + try: + await gateway.async_connect_gateway(host, token) + except AuthException as error: + raise ConfigEntryAuthFailed() from error + except SetupException as error: + raise ConfigEntryNotReady() from error gateway_info = gateway.gateway_info gateway_model = f"{gateway_info.model}-{gateway_info.hardware_version}" @@ -416,8 +422,6 @@ async def async_setup_gateway_entry( hass.config_entries.async_forward_entry_setup(entry, platform) ) - return True - async def async_setup_device_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 790a82a0411..5256b37ccda 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -3,6 +3,7 @@ import logging from re import search from micloud import MiCloud +from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol from homeassistant import config_entries @@ -28,6 +29,8 @@ from .const import ( MODELS_ALL_DEVICES, MODELS_GATEWAY, SERVER_COUNTRY_CODES, + AuthException, + SetupException, ) from .device import ConnectXiaomiDevice @@ -230,8 +233,13 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) miio_cloud = MiCloud(cloud_username, cloud_password) - if not await self.hass.async_add_executor_job(miio_cloud.login): + try: + if not await self.hass.async_add_executor_job(miio_cloud.login): + errors["base"] = "cloud_login_error" + except MiCloudAccessDenied: errors["base"] = "cloud_login_error" + + if errors: return self.async_show_form( step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors ) @@ -320,14 +328,24 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Try to connect to a Xiaomi Device. connect_device_class = ConnectXiaomiDevice(self.hass) - await connect_device_class.async_connect_device(self.host, self.token) + try: + await connect_device_class.async_connect_device(self.host, self.token) + except AuthException: + if self.model is None: + errors["base"] = "wrong_token" + except SetupException: + if self.model is None: + errors["base"] = "cannot_connect" + device_info = connect_device_class.device_info if self.model is None and device_info is not None: self.model = device_info.model - if self.model is None: + if self.model is None and not errors: errors["base"] = "cannot_connect" + + if errors: return self.async_show_form( step_id="connect", data_schema=DEVICE_MODEL_CONFIG, errors=errors ) diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 4adc2d287dd..e27fb4d2110 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -37,6 +37,16 @@ SUCCESS = ["ok"] SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] DEFAULT_CLOUD_COUNTRY = "cn" + +# Exceptions +class AuthException(Exception): + """Exception indicating an authentication error.""" + + +class SetupException(Exception): + """Exception indicating a failure during setup.""" + + # Fan Models MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index aa81d8c23b6..be9c1151aa5 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -7,12 +7,11 @@ import logging from construct.core import ChecksumError from miio import Device, DeviceException -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_MAC, CONF_MODEL, DOMAIN +from .const import CONF_MAC, CONF_MODEL, DOMAIN, AuthException, SetupException _LOGGER = logging.getLogger(__name__) @@ -48,14 +47,11 @@ class ConnectXiaomiDevice: ) except DeviceException as error: if isinstance(error.__cause__, ChecksumError): - raise ConfigEntryAuthFailed(error) from error + raise AuthException(error) from error - _LOGGER.error( - "DeviceException during setup of xiaomi device with host %s: %s", - host, - error, - ) - return False + raise SetupException( + f"DeviceException during setup of xiaomi device with host {host}" + ) from error _LOGGER.debug( "%s %s %s detected", @@ -63,7 +59,6 @@ class ConnectXiaomiDevice: self._device_info.firmware_version, self._device_info.hardware_version, ) - return True class XiaomiMiioEntity(Entity): diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index 8b7a5c77a17..c873a56fb44 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -3,10 +3,10 @@ import logging from construct.core import ChecksumError from micloud import MiCloud +from micloud.micloudexception import MiCloudAccessDenied from miio import DeviceException, gateway from miio.gateway.gateway import GATEWAY_MODEL_EU -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -17,6 +17,8 @@ from .const import ( CONF_CLOUD_SUBDEVICES, CONF_CLOUD_USERNAME, DOMAIN, + AuthException, + SetupException, ) _LOGGER = logging.getLogger(__name__) @@ -59,8 +61,7 @@ class ConnectXiaomiGateway: self._cloud_password = self._config_entry.data.get(CONF_CLOUD_PASSWORD) self._cloud_country = self._config_entry.data.get(CONF_CLOUD_COUNTRY) - if not await self._hass.async_add_executor_job(self.connect_gateway): - return False + await self._hass.async_add_executor_job(self.connect_gateway) _LOGGER.debug( "%s %s %s detected", @@ -68,7 +69,6 @@ class ConnectXiaomiGateway: self._gateway_info.firmware_version, self._gateway_info.hardware_version, ) - return True def connect_gateway(self): """Connect the gateway in a way that can called by async_add_executor_job.""" @@ -78,14 +78,11 @@ class ConnectXiaomiGateway: self._gateway_info = self._gateway_device.info() except DeviceException as error: if isinstance(error.__cause__, ChecksumError): - raise ConfigEntryAuthFailed(error) from error + raise AuthException(error) from error - _LOGGER.error( - "DeviceException during setup of xiaomi gateway with host %s: %s", - self._host, - error, - ) - return False + raise SetupException( + "DeviceException during setup of xiaomi gateway with host {self._host}" + ) from error # get the connected sub devices use_cloud = self._use_cloud or self._gateway_info.model == GATEWAY_MODEL_EU @@ -109,27 +106,27 @@ class ConnectXiaomiGateway: or self._cloud_password is None or self._cloud_country is None ): - raise ConfigEntryAuthFailed( + raise AuthException( "Missing cloud credentials in Xiaomi Miio configuration" ) try: miio_cloud = MiCloud(self._cloud_username, self._cloud_password) if not miio_cloud.login(): - raise ConfigEntryAuthFailed( - "Could not login to Xiaomi Miio Cloud, check the credentials" + raise SetupException( + "Failed to login to Xiaomi Miio Cloud during setup of Xiaomi" + " gateway with host {self._host}", ) devices_raw = miio_cloud.get_devices(self._cloud_country) self._gateway_device.get_devices_from_dict(devices_raw) + except MiCloudAccessDenied as error: + raise AuthException( + "Could not login to Xiaomi Miio Cloud, check the credentials" + ) from error except DeviceException as error: - _LOGGER.error( - "DeviceException during setup of xiaomi gateway with host %s: %s", - self._host, - error, - ) - return False - - return True + raise SetupException( + f"DeviceException during setup of xiaomi gateway with host {self._host}" + ) from error class XiaomiGatewayDevice(CoordinatorEntity, Entity): diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 28f3c2da0c5..37c6b8f8a09 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.8"], + "requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.8"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 129f6f1ecbf..1331d22e933 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -9,6 +9,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "wrong_token": "Checksum error, wrong token", "unknown_device": "The device model is not known, not able to setup the device using config flow.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index 84593a3edc1..9ad0063df58 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -9,6 +9,7 @@ }, "error": { "cannot_connect": "Failed to connect", + "wrong_token": "Checksum error, wrong token", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", diff --git a/requirements_all.txt b/requirements_all.txt index e5aed307008..23ac2a8dd71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -993,7 +993,7 @@ meteofrance-api==1.0.2 mficlient==0.3.0 # homeassistant.components.xiaomi_miio -micloud==0.3 +micloud==0.4 # homeassistant.components.miflora miflora==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b40b0ca3f43..05d686cc856 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -588,7 +588,7 @@ meteofrance-api==1.0.2 mficlient==0.3.0 # homeassistant.components.xiaomi_miio -micloud==0.3 +micloud==0.4 # homeassistant.components.mill millheater==0.6.2 diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 5e7c0351c14..24aa5ac04e4 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Xiaomi Miio config flow.""" from unittest.mock import Mock, patch +from construct.core import ChecksumError +from micloud.micloudexception import MiCloudAccessDenied from miio import DeviceException import pytest @@ -300,6 +302,23 @@ async def test_config_flow_gateway_cloud_login_error(hass): assert result["step_id"] == "cloud" assert result["errors"] == {"base": "cloud_login_error"} + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.login", + side_effect=MiCloudAccessDenied({}), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "cloud" + assert result["errors"] == {"base": "cloud_login_error"} + async def test_config_flow_gateway_cloud_no_devices(hass): """Test a failed config flow using cloud with no devices.""" @@ -540,8 +559,8 @@ async def test_import_flow_success(hass): } -async def test_config_flow_step_device_manual_model_succes(hass): - """Test config flow, device connection error, manual model.""" +async def test_config_flow_step_device_manual_model_error(hass): + """Test config flow, device connection error, model None.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -561,7 +580,7 @@ async def test_config_flow_step_device_manual_model_succes(hass): with patch( "homeassistant.components.xiaomi_miio.device.Device.info", - side_effect=DeviceException({}), + return_value=get_mock_info(model=None), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -572,6 +591,41 @@ async def test_config_flow_step_device_manual_model_succes(hass): assert result["step_id"] == "connect" assert result["errors"] == {"base": "cannot_connect"} + +async def test_config_flow_step_device_manual_model_succes(hass): + """Test config flow, device connection error, manual model.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "cloud" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {const.CONF_MANUAL: True}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "manual" + assert result["errors"] == {} + + error = DeviceException({}) + error.__cause__ = ChecksumError({}) + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "connect" + assert result["errors"] == {"base": "wrong_token"} + overwrite_model = const.MODELS_VACUUM[0] with patch(