Xiaomi Miio appropriatly raise ConfigEntryAuthFailed/ConfigEntryNotReady (#54696)

* Add reties to cloud login

* push to version 0.4 of micloud

* distinguish between authentication error and socket errors

* raise from error

* Update homeassistant/components/xiaomi_miio/gateway.py

Co-authored-by: Franck Nijhof <git@frenck.dev>

* move ConfigEntryNotReady to connect function

* remove unused import

* also add ConfigEntryNotReady for device

* catch exceptions in config flow

* fixes

* bring tests back to 100%

* add missing catch exception

* add test

* fix black

* Update homeassistant/components/xiaomi_miio/device.py

Co-authored-by: Teemu R. <tpr@iki.fi>

* Update homeassistant/components/xiaomi_miio/gateway.py

Co-authored-by: Teemu R. <tpr@iki.fi>

* Update tests/components/xiaomi_miio/test_config_flow.py

Co-authored-by: Teemu R. <tpr@iki.fi>

* fix tests

* define specific exceptions

* fix styling

* fix tests

* use proper DeviceException

* Revert "use proper DeviceException"

This reverts commit 0bd16135387cd6d9e563cd62ac147d0a25c577f3.

* use appropriate side-effect

* remove unused returns

* Update homeassistant/components/xiaomi_miio/const.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* remove unused returns

Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Teemu R. <tpr@iki.fi>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/57738/head
starkillerOG 2021-10-15 01:25:44 +02:00 committed by GitHub
parent c243dca58e
commit e34aed743c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 129 additions and 49 deletions

View File

@ -35,6 +35,7 @@ from miio.gateway.gateway import GatewayException
from homeassistant import config_entries, core from homeassistant import config_entries, core
from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.core import callback 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 import device_registry as dr, entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -65,6 +66,8 @@ from .const import (
MODELS_PURIFIER_MIOT, MODELS_PURIFIER_MIOT,
MODELS_SWITCH, MODELS_SWITCH,
MODELS_VACUUM, MODELS_VACUUM,
AuthException,
SetupException,
) )
from .gateway import ConnectXiaomiGateway from .gateway import ConnectXiaomiGateway
@ -100,10 +103,9 @@ async def async_setup_entry(
): ):
"""Set up the Xiaomi Miio components from a config entry.""" """Set up the Xiaomi Miio components from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
if entry.data[ if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY:
CONF_FLOW_TYPE await async_setup_gateway_entry(hass, entry)
] == CONF_GATEWAY and not await async_setup_gateway_entry(hass, entry): return True
return False
return bool( return bool(
entry.data[CONF_FLOW_TYPE] != CONF_DEVICE entry.data[CONF_FLOW_TYPE] != CONF_DEVICE
@ -362,8 +364,12 @@ async def async_setup_gateway_entry(
# Connect to gateway # Connect to gateway
gateway = ConnectXiaomiGateway(hass, entry) gateway = ConnectXiaomiGateway(hass, entry)
if not await gateway.async_connect_gateway(host, token): try:
return False 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_info = gateway.gateway_info
gateway_model = f"{gateway_info.model}-{gateway_info.hardware_version}" 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) hass.config_entries.async_forward_entry_setup(entry, platform)
) )
return True
async def async_setup_device_entry( async def async_setup_device_entry(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry hass: core.HomeAssistant, entry: config_entries.ConfigEntry

View File

@ -3,6 +3,7 @@ import logging
from re import search from re import search
from micloud import MiCloud from micloud import MiCloud
from micloud.micloudexception import MiCloudAccessDenied
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -28,6 +29,8 @@ from .const import (
MODELS_ALL_DEVICES, MODELS_ALL_DEVICES,
MODELS_GATEWAY, MODELS_GATEWAY,
SERVER_COUNTRY_CODES, SERVER_COUNTRY_CODES,
AuthException,
SetupException,
) )
from .device import ConnectXiaomiDevice from .device import ConnectXiaomiDevice
@ -230,8 +233,13 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
) )
miio_cloud = MiCloud(cloud_username, cloud_password) miio_cloud = MiCloud(cloud_username, cloud_password)
try:
if not await self.hass.async_add_executor_job(miio_cloud.login): if not await self.hass.async_add_executor_job(miio_cloud.login):
errors["base"] = "cloud_login_error" errors["base"] = "cloud_login_error"
except MiCloudAccessDenied:
errors["base"] = "cloud_login_error"
if errors:
return self.async_show_form( return self.async_show_form(
step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors 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. # Try to connect to a Xiaomi Device.
connect_device_class = ConnectXiaomiDevice(self.hass) connect_device_class = ConnectXiaomiDevice(self.hass)
try:
await connect_device_class.async_connect_device(self.host, self.token) 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 device_info = connect_device_class.device_info
if self.model is None and device_info is not None: if self.model is None and device_info is not None:
self.model = device_info.model self.model = device_info.model
if self.model is None: if self.model is None and not errors:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
if errors:
return self.async_show_form( return self.async_show_form(
step_id="connect", data_schema=DEVICE_MODEL_CONFIG, errors=errors step_id="connect", data_schema=DEVICE_MODEL_CONFIG, errors=errors
) )

View File

@ -37,6 +37,16 @@ SUCCESS = ["ok"]
SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"]
DEFAULT_CLOUD_COUNTRY = "cn" DEFAULT_CLOUD_COUNTRY = "cn"
# Exceptions
class AuthException(Exception):
"""Exception indicating an authentication error."""
class SetupException(Exception):
"""Exception indicating a failure during setup."""
# Fan Models # Fan Models
MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2" MODEL_AIRPURIFIER_2H = "zhimi.airpurifier.mc2"
MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1" MODEL_AIRPURIFIER_2S = "zhimi.airpurifier.mc1"

View File

@ -7,12 +7,11 @@ import logging
from construct.core import ChecksumError from construct.core import ChecksumError
from miio import Device, DeviceException from miio import Device, DeviceException
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity 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__) _LOGGER = logging.getLogger(__name__)
@ -48,14 +47,11 @@ class ConnectXiaomiDevice:
) )
except DeviceException as error: except DeviceException as error:
if isinstance(error.__cause__, ChecksumError): if isinstance(error.__cause__, ChecksumError):
raise ConfigEntryAuthFailed(error) from error raise AuthException(error) from error
_LOGGER.error( raise SetupException(
"DeviceException during setup of xiaomi device with host %s: %s", f"DeviceException during setup of xiaomi device with host {host}"
host, ) from error
error,
)
return False
_LOGGER.debug( _LOGGER.debug(
"%s %s %s detected", "%s %s %s detected",
@ -63,7 +59,6 @@ class ConnectXiaomiDevice:
self._device_info.firmware_version, self._device_info.firmware_version,
self._device_info.hardware_version, self._device_info.hardware_version,
) )
return True
class XiaomiMiioEntity(Entity): class XiaomiMiioEntity(Entity):

View File

@ -3,10 +3,10 @@ import logging
from construct.core import ChecksumError from construct.core import ChecksumError
from micloud import MiCloud from micloud import MiCloud
from micloud.micloudexception import MiCloudAccessDenied
from miio import DeviceException, gateway from miio import DeviceException, gateway
from miio.gateway.gateway import GATEWAY_MODEL_EU from miio.gateway.gateway import GATEWAY_MODEL_EU
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -17,6 +17,8 @@ from .const import (
CONF_CLOUD_SUBDEVICES, CONF_CLOUD_SUBDEVICES,
CONF_CLOUD_USERNAME, CONF_CLOUD_USERNAME,
DOMAIN, DOMAIN,
AuthException,
SetupException,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -59,8 +61,7 @@ class ConnectXiaomiGateway:
self._cloud_password = self._config_entry.data.get(CONF_CLOUD_PASSWORD) self._cloud_password = self._config_entry.data.get(CONF_CLOUD_PASSWORD)
self._cloud_country = self._config_entry.data.get(CONF_CLOUD_COUNTRY) self._cloud_country = self._config_entry.data.get(CONF_CLOUD_COUNTRY)
if not await self._hass.async_add_executor_job(self.connect_gateway): await self._hass.async_add_executor_job(self.connect_gateway)
return False
_LOGGER.debug( _LOGGER.debug(
"%s %s %s detected", "%s %s %s detected",
@ -68,7 +69,6 @@ class ConnectXiaomiGateway:
self._gateway_info.firmware_version, self._gateway_info.firmware_version,
self._gateway_info.hardware_version, self._gateway_info.hardware_version,
) )
return True
def connect_gateway(self): def connect_gateway(self):
"""Connect the gateway in a way that can called by async_add_executor_job.""" """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() self._gateway_info = self._gateway_device.info()
except DeviceException as error: except DeviceException as error:
if isinstance(error.__cause__, ChecksumError): if isinstance(error.__cause__, ChecksumError):
raise ConfigEntryAuthFailed(error) from error raise AuthException(error) from error
_LOGGER.error( raise SetupException(
"DeviceException during setup of xiaomi gateway with host %s: %s", "DeviceException during setup of xiaomi gateway with host {self._host}"
self._host, ) from error
error,
)
return False
# get the connected sub devices # get the connected sub devices
use_cloud = self._use_cloud or self._gateway_info.model == GATEWAY_MODEL_EU 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_password is None
or self._cloud_country is None or self._cloud_country is None
): ):
raise ConfigEntryAuthFailed( raise AuthException(
"Missing cloud credentials in Xiaomi Miio configuration" "Missing cloud credentials in Xiaomi Miio configuration"
) )
try: try:
miio_cloud = MiCloud(self._cloud_username, self._cloud_password) miio_cloud = MiCloud(self._cloud_username, self._cloud_password)
if not miio_cloud.login(): if not miio_cloud.login():
raise ConfigEntryAuthFailed( raise SetupException(
"Could not login to Xiaomi Miio Cloud, check the credentials" "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) devices_raw = miio_cloud.get_devices(self._cloud_country)
self._gateway_device.get_devices_from_dict(devices_raw) 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: except DeviceException as error:
_LOGGER.error( raise SetupException(
"DeviceException during setup of xiaomi gateway with host %s: %s", f"DeviceException during setup of xiaomi gateway with host {self._host}"
self._host, ) from error
error,
)
return False
return True
class XiaomiGatewayDevice(CoordinatorEntity, Entity): class XiaomiGatewayDevice(CoordinatorEntity, Entity):

View File

@ -3,7 +3,7 @@
"name": "Xiaomi Miio", "name": "Xiaomi Miio",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "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"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"],
"zeroconf": ["_miio._udp.local."], "zeroconf": ["_miio._udp.local."],
"iot_class": "local_polling" "iot_class": "local_polling"

View File

@ -9,6 +9,7 @@
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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.", "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_no_devices": "No devices found in this Xiaomi Miio cloud account.",
"cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country",

View File

@ -9,6 +9,7 @@
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "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_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_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials.",
"cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.",

View File

@ -993,7 +993,7 @@ meteofrance-api==1.0.2
mficlient==0.3.0 mficlient==0.3.0
# homeassistant.components.xiaomi_miio # homeassistant.components.xiaomi_miio
micloud==0.3 micloud==0.4
# homeassistant.components.miflora # homeassistant.components.miflora
miflora==0.7.0 miflora==0.7.0

View File

@ -588,7 +588,7 @@ meteofrance-api==1.0.2
mficlient==0.3.0 mficlient==0.3.0
# homeassistant.components.xiaomi_miio # homeassistant.components.xiaomi_miio
micloud==0.3 micloud==0.4
# homeassistant.components.mill # homeassistant.components.mill
millheater==0.6.2 millheater==0.6.2

View File

@ -1,6 +1,8 @@
"""Test the Xiaomi Miio config flow.""" """Test the Xiaomi Miio config flow."""
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from construct.core import ChecksumError
from micloud.micloudexception import MiCloudAccessDenied
from miio import DeviceException from miio import DeviceException
import pytest import pytest
@ -300,6 +302,23 @@ async def test_config_flow_gateway_cloud_login_error(hass):
assert result["step_id"] == "cloud" assert result["step_id"] == "cloud"
assert result["errors"] == {"base": "cloud_login_error"} 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): async def test_config_flow_gateway_cloud_no_devices(hass):
"""Test a failed config flow using cloud with no devices.""" """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): async def test_config_flow_step_device_manual_model_error(hass):
"""Test config flow, device connection error, manual model.""" """Test config flow, device connection error, model None."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
const.DOMAIN, context={"source": config_entries.SOURCE_USER} 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( with patch(
"homeassistant.components.xiaomi_miio.device.Device.info", "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 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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["step_id"] == "connect"
assert result["errors"] == {"base": "cannot_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] overwrite_model = const.MODELS_VACUUM[0]
with patch( with patch(