From 3a2d50fe23b65601ff2f85357adf7add9a86f23c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 15 Jun 2021 00:05:40 +0200 Subject: [PATCH] Add Xiaomi Miio EU gateway support (#47955) * Add EU gateway support * add options flow translations * fix options flow * fix missing import * try to fix async_add_executor_job * try to fix async_add_executor_job * fix unload * check for login succes * fix not reloading * use cloud option * fix styling * Return after if Co-authored-by: Nathan Tilley * cleanup * add options flow tests * fix new tests * fix typo in docstring * add missing blank line * Use async_on_unload Co-authored-by: Martin Hjelmare * Use async_on_unload Co-authored-by: Martin Hjelmare * Use async_setup_platforms Co-authored-by: Martin Hjelmare * Use async_unload_platforms Co-authored-by: Martin Hjelmare * Update homeassistant/components/xiaomi_miio/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/xiaomi_miio/const.py Co-authored-by: Martin Hjelmare * default use_cloud False * add options flow checks * fix styling * fix issort * add MiCloud check tests * fix indent * fix styling * fix tests * fix tests * fix black * re-write config flow * add explicit return type * update strings.json * black formatting * fix config flow Tested the config flow and it is now fully working * fix styling * Fix current tests * Add missing tests * fix styling * add re-auth flow * fix styling * fix reauth flow * Add reauth flow test * use ConfigEntryAuthFailed * also trigger reauth @ login error * fix styling * remove unused import * fix spelling Co-authored-by: Martin Hjelmare * Fix spelling Co-authored-by: Martin Hjelmare * fix spelling Co-authored-by: Martin Hjelmare * remove unessesary .keys() Co-authored-by: Martin Hjelmare * combine async_add_executor_job calls * remove async_step_model * fix wrong indent * fix gatway.py * fix tests Co-authored-by: Nathan Tilley Co-authored-by: Martin Hjelmare --- .../components/xiaomi_miio/__init__.py | 75 ++- .../components/xiaomi_miio/config_flow.py | 353 ++++++++--- homeassistant/components/xiaomi_miio/const.py | 12 + .../components/xiaomi_miio/gateway.py | 84 ++- .../components/xiaomi_miio/manifest.json | 2 +- .../components/xiaomi_miio/strings.json | 55 +- .../xiaomi_miio/translations/en.json | 103 ++-- requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../xiaomi_miio/test_config_flow.py | 550 ++++++++++++++++-- 10 files changed, 1046 insertions(+), 194 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index fa2dfcb9944a8..076aed4d30c42 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -51,6 +51,30 @@ async def async_setup_entry( ) +def get_platforms(config_entry): + """Return the platforms belonging to a config_entry.""" + model = config_entry.data[CONF_MODEL] + flow_type = config_entry.data[CONF_FLOW_TYPE] + + if flow_type == CONF_GATEWAY: + return GATEWAY_PLATFORMS + if flow_type == CONF_DEVICE: + if model in MODELS_SWITCH: + return SWITCH_PLATFORMS + if model in MODELS_FAN: + return FAN_PLATFORMS + if model in MODELS_LIGHT: + return LIGHT_PLATFORMS + for vacuum_model in MODELS_VACUUM: + if model.startswith(vacuum_model): + return VACUUM_PLATFORMS + for air_monitor_model in MODELS_AIR_MONITOR: + if model.startswith(air_monitor_model): + return AIR_MONITOR_PLATFORMS + + return [] + + async def async_setup_gateway_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): @@ -64,8 +88,10 @@ async def async_setup_gateway_entry( if entry.unique_id.endswith("-gateway"): hass.config_entries.async_update_entry(entry, unique_id=entry.data["mac"]) + entry.async_on_unload(entry.add_update_listener(update_listener)) + # Connect to gateway - gateway = ConnectXiaomiGateway(hass) + gateway = ConnectXiaomiGateway(hass, entry) if not await gateway.async_connect_gateway(host, token): return False gateway_info = gateway.gateway_info @@ -128,29 +154,36 @@ async def async_setup_device_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): """Set up the Xiaomi Miio device component from a config entry.""" - model = entry.data[CONF_MODEL] - - # Identify platforms to setup - platforms = [] - if model in MODELS_SWITCH: - platforms = SWITCH_PLATFORMS - elif model in MODELS_FAN: - platforms = FAN_PLATFORMS - elif model in MODELS_LIGHT: - platforms = LIGHT_PLATFORMS - for vacuum_model in MODELS_VACUUM: - if model.startswith(vacuum_model): - platforms = VACUUM_PLATFORMS - for air_monitor_model in MODELS_AIR_MONITOR: - if model.startswith(air_monitor_model): - platforms = AIR_MONITOR_PLATFORMS + platforms = get_platforms(entry) if not platforms: return False - for platform in platforms: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + hass.config_entries.async_setup_platforms(entry, platforms) return True + + +async def async_unload_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Unload a config entry.""" + platforms = get_platforms(config_entry) + + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, platforms + ) + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def update_listener( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 59eee0e6e040c..790a82a041116 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -2,34 +2,98 @@ import logging from re import search +from micloud import MiCloud import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac from .const import ( + CONF_CLOUD_COUNTRY, + CONF_CLOUD_PASSWORD, + CONF_CLOUD_SUBDEVICES, + CONF_CLOUD_USERNAME, CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, CONF_MAC, + CONF_MANUAL, CONF_MODEL, + DEFAULT_CLOUD_COUNTRY, DOMAIN, MODELS_ALL, MODELS_ALL_DEVICES, MODELS_GATEWAY, + SERVER_COUNTRY_CODES, ) from .device import ConnectXiaomiDevice _LOGGER = logging.getLogger(__name__) -DEFAULT_GATEWAY_NAME = "Xiaomi Gateway" - DEVICE_SETTINGS = { vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), } DEVICE_CONFIG = vol.Schema({vol.Required(CONF_HOST): str}).extend(DEVICE_SETTINGS) -DEVICE_MODEL_CONFIG = {vol.Optional(CONF_MODEL): vol.In(MODELS_ALL)} +DEVICE_MODEL_CONFIG = vol.Schema({vol.Required(CONF_MODEL): vol.In(MODELS_ALL)}) +DEVICE_CLOUD_CONFIG = vol.Schema( + { + vol.Optional(CONF_CLOUD_USERNAME): str, + vol.Optional(CONF_CLOUD_PASSWORD): str, + vol.Optional(CONF_CLOUD_COUNTRY, default=DEFAULT_CLOUD_COUNTRY): vol.In( + SERVER_COUNTRY_CODES + ), + vol.Optional(CONF_MANUAL, default=False): bool, + } +) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Init object.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + errors = {} + if user_input is not None: + use_cloud = user_input.get(CONF_CLOUD_SUBDEVICES, False) + cloud_username = self.config_entry.data.get(CONF_CLOUD_USERNAME) + cloud_password = self.config_entry.data.get(CONF_CLOUD_PASSWORD) + cloud_country = self.config_entry.data.get(CONF_CLOUD_COUNTRY) + + if use_cloud and ( + not cloud_username or not cloud_password or not cloud_country + ): + errors["base"] = "cloud_credentials_incomplete" + # trigger re-auth flow + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=self.config_entry.data, + ) + ) + + if not errors: + return self.async_create_entry(title="", data=user_input) + + settings_schema = vol.Schema( + { + vol.Optional( + CONF_CLOUD_SUBDEVICES, + default=self.config_entry.options.get(CONF_CLOUD_SUBDEVICES, False), + ): bool + } + ) + + return self.async_show_form( + step_id="init", data_schema=settings_schema, errors=errors + ) class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -41,16 +105,51 @@ def __init__(self): """Initialize.""" self.host = None self.mac = None + self.token = None + self.model = None + self.name = None + self.cloud_username = None + self.cloud_password = None + self.cloud_country = None + self.cloud_devices = {} + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> OptionsFlowHandler: + """Get the options flow.""" + return OptionsFlowHandler(config_entry) + + async def async_step_reauth(self, user_input=None): + """Perform reauth upon an authentication error or missing cloud credentials.""" + self.host = user_input[CONF_HOST] + self.token = user_input[CONF_TOKEN] + self.mac = user_input[CONF_MAC] + self.model = user_input.get(CONF_MODEL) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + if user_input is not None: + return await self.async_step_cloud() + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}) + ) async def async_step_import(self, conf: dict): """Import a configuration from config.yaml.""" - host = conf[CONF_HOST] - self.context.update({"title_placeholders": {"name": f"YAML import {host}"}}) - return await self.async_step_device(user_input=conf) + self.host = conf[CONF_HOST] + self.token = conf[CONF_TOKEN] + self.name = conf.get(CONF_NAME) + self.model = conf.get(CONF_MODEL) + + self.context.update( + {"title_placeholders": {"name": f"YAML import {self.host}"}} + ) + return await self.async_step_connect() async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - return await self.async_step_device() + return await self.async_step_cloud() async def async_step_zeroconf(self, discovery_info): """Handle zeroconf discovery.""" @@ -79,7 +178,7 @@ async def async_step_zeroconf(self, discovery_info): {"title_placeholders": {"name": f"Gateway {self.host}"}} ) - return await self.async_step_device() + return await self.async_step_cloud() for device_model in MODELS_ALL_DEVICES: if name.startswith(device_model.replace(".", "-")): @@ -91,7 +190,7 @@ async def async_step_zeroconf(self, discovery_info): {"title_placeholders": {"name": f"{device_model} {self.host}"}} ) - return await self.async_step_device() + return await self.async_step_cloud() # Discovered device is not yet supported _LOGGER.debug( @@ -101,76 +200,190 @@ async def async_step_zeroconf(self, discovery_info): ) return self.async_abort(reason="not_xiaomi_miio") - async def async_step_device(self, user_input=None): - """Handle a flow initialized by the user to configure a xiaomi miio device.""" + def extract_cloud_info(self, cloud_device_info): + """Extract the cloud info.""" + if self.host is None: + self.host = cloud_device_info["localip"] + if self.mac is None: + self.mac = format_mac(cloud_device_info["mac"]) + if self.model is None: + self.model = cloud_device_info["model"] + if self.name is None: + self.name = cloud_device_info["name"] + self.token = cloud_device_info["token"] + + async def async_step_cloud(self, user_input=None): + """Configure a xiaomi miio device through the Miio Cloud.""" + errors = {} + if user_input is not None: + if user_input[CONF_MANUAL]: + return await self.async_step_manual() + + cloud_username = user_input.get(CONF_CLOUD_USERNAME) + cloud_password = user_input.get(CONF_CLOUD_PASSWORD) + cloud_country = user_input.get(CONF_CLOUD_COUNTRY) + + if not cloud_username or not cloud_password or not cloud_country: + errors["base"] = "cloud_credentials_incomplete" + return self.async_show_form( + step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors + ) + + miio_cloud = MiCloud(cloud_username, cloud_password) + if not await self.hass.async_add_executor_job(miio_cloud.login): + errors["base"] = "cloud_login_error" + return self.async_show_form( + step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors + ) + + devices_raw = await self.hass.async_add_executor_job( + miio_cloud.get_devices, cloud_country + ) + + if not devices_raw: + errors["base"] = "cloud_no_devices" + return self.async_show_form( + step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors + ) + + self.cloud_devices = {} + for device in devices_raw: + parent_id = device.get("parent_id") + if not parent_id: + name = device["name"] + model = device["model"] + list_name = f"{name} - {model}" + self.cloud_devices[list_name] = device + + self.cloud_username = cloud_username + self.cloud_password = cloud_password + self.cloud_country = cloud_country + + if self.host is not None: + for device in self.cloud_devices.values(): + cloud_host = device.get("localip") + if cloud_host == self.host: + self.extract_cloud_info(device) + return await self.async_step_connect() + + if len(self.cloud_devices) == 1: + self.extract_cloud_info(list(self.cloud_devices.values())[0]) + return await self.async_step_connect() + + return await self.async_step_select() + + return self.async_show_form( + step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors + ) + + async def async_step_select(self, user_input=None): + """Handle multiple cloud devices found.""" errors = {} if user_input is not None: - token = user_input[CONF_TOKEN] - model = user_input.get(CONF_MODEL) + cloud_device = self.cloud_devices[user_input["select_device"]] + self.extract_cloud_info(cloud_device) + return await self.async_step_connect() + + select_schema = vol.Schema( + {vol.Required("select_device"): vol.In(list(self.cloud_devices))} + ) + + return self.async_show_form( + step_id="select", data_schema=select_schema, errors=errors + ) + + async def async_step_manual(self, user_input=None): + """Configure a xiaomi miio device Manually.""" + errors = {} + if user_input is not None: + self.token = user_input[CONF_TOKEN] if user_input.get(CONF_HOST): self.host = user_input[CONF_HOST] - # Try to connect to a Xiaomi Device. - connect_device_class = ConnectXiaomiDevice(self.hass) - await connect_device_class.async_connect_device(self.host, token) - device_info = connect_device_class.device_info - - if model is None and device_info is not None: - model = device_info.model - - if model is not None: - if self.mac is None and device_info is not None: - self.mac = format_mac(device_info.mac_address) - - # Setup Gateways - for gateway_model in MODELS_GATEWAY: - if model.startswith(gateway_model): - unique_id = self.mac - await self.async_set_unique_id( - unique_id, raise_on_progress=False - ) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=DEFAULT_GATEWAY_NAME, - data={ - CONF_FLOW_TYPE: CONF_GATEWAY, - CONF_HOST: self.host, - CONF_TOKEN: token, - CONF_MODEL: model, - CONF_MAC: self.mac, - }, - ) - - # Setup all other Miio Devices - name = user_input.get(CONF_NAME, model) - - for device_model in MODELS_ALL_DEVICES: - if model.startswith(device_model): - unique_id = self.mac - await self.async_set_unique_id( - unique_id, raise_on_progress=False - ) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={ - CONF_FLOW_TYPE: CONF_DEVICE, - CONF_HOST: self.host, - CONF_TOKEN: token, - CONF_MODEL: model, - CONF_MAC: self.mac, - }, - ) - errors["base"] = "unknown_device" - else: - errors["base"] = "cannot_connect" + return await self.async_step_connect() if self.host: schema = vol.Schema(DEVICE_SETTINGS) else: schema = DEVICE_CONFIG - if errors: - schema = schema.extend(DEVICE_MODEL_CONFIG) + return self.async_show_form(step_id="manual", data_schema=schema, errors=errors) + + async def async_step_connect(self, user_input=None): + """Connect to a xiaomi miio device.""" + errors = {} + if self.host is None or self.token is None: + return self.async_abort(reason="incomplete_info") + + if user_input is not None: + self.model = user_input[CONF_MODEL] + + # Try to connect to a Xiaomi Device. + connect_device_class = ConnectXiaomiDevice(self.hass) + await connect_device_class.async_connect_device(self.host, self.token) + device_info = connect_device_class.device_info - return self.async_show_form(step_id="device", data_schema=schema, errors=errors) + if self.model is None and device_info is not None: + self.model = device_info.model + + if self.model is None: + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="connect", data_schema=DEVICE_MODEL_CONFIG, errors=errors + ) + + if self.mac is None and device_info is not None: + self.mac = format_mac(device_info.mac_address) + + unique_id = self.mac + existing_entry = await self.async_set_unique_id( + unique_id, raise_on_progress=False + ) + if existing_entry: + data = existing_entry.data.copy() + data[CONF_HOST] = self.host + data[CONF_TOKEN] = self.token + if ( + self.cloud_username is not None + and self.cloud_password is not None + and self.cloud_country is not None + ): + data[CONF_CLOUD_USERNAME] = self.cloud_username + data[CONF_CLOUD_PASSWORD] = self.cloud_password + data[CONF_CLOUD_COUNTRY] = self.cloud_country + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + if self.name is None: + self.name = self.model + + flow_type = None + for gateway_model in MODELS_GATEWAY: + if self.model.startswith(gateway_model): + flow_type = CONF_GATEWAY + + if flow_type is None: + for device_model in MODELS_ALL_DEVICES: + if self.model.startswith(device_model): + flow_type = CONF_DEVICE + + if flow_type is not None: + return self.async_create_entry( + title=self.name, + data={ + CONF_FLOW_TYPE: flow_type, + CONF_HOST: self.host, + CONF_TOKEN: self.token, + CONF_MODEL: self.model, + CONF_MAC: self.mac, + CONF_CLOUD_USERNAME: self.cloud_username, + CONF_CLOUD_PASSWORD: self.cloud_password, + CONF_CLOUD_COUNTRY: self.cloud_country, + }, + ) + + errors["base"] = "unknown_device" + 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 35c4d4a166207..27d0a34bf3960 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -1,16 +1,28 @@ """Constants for the Xiaomi Miio component.""" DOMAIN = "xiaomi_miio" +# Config flow CONF_FLOW_TYPE = "config_flow_device" CONF_GATEWAY = "gateway" CONF_DEVICE = "device" CONF_MODEL = "model" CONF_MAC = "mac" +CONF_CLOUD_USERNAME = "cloud_username" +CONF_CLOUD_PASSWORD = "cloud_password" +CONF_CLOUD_COUNTRY = "cloud_country" +CONF_MANUAL = "manual" + +# Options flow +CONF_CLOUD_SUBDEVICES = "cloud_subdevices" KEY_COORDINATOR = "coordinator" ATTR_AVAILABLE = "available" +# Cloud +SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] +DEFAULT_CLOUD_COUNTRY = "cn" + # Fan Models MODEL_AIRPURIFIER_V1 = "zhimi.airpurifier.v1" MODEL_AIRPURIFIER_V2 = "zhimi.airpurifier.v2" diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index be96f77240a09..7482dae5d7798 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -1,12 +1,22 @@ """Code to handle a Xiaomi Gateway.""" import logging +from micloud import MiCloud 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 -from .const import ATTR_AVAILABLE, DOMAIN +from .const import ( + ATTR_AVAILABLE, + CONF_CLOUD_COUNTRY, + CONF_CLOUD_PASSWORD, + CONF_CLOUD_SUBDEVICES, + CONF_CLOUD_USERNAME, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -14,11 +24,18 @@ class ConnectXiaomiGateway: """Class to async connect to a Xiaomi Gateway.""" - def __init__(self, hass): + def __init__(self, hass, config_entry): """Initialize the entity.""" self._hass = hass + self._config_entry = config_entry self._gateway_device = None self._gateway_info = None + self._use_cloud = None + self._cloud_username = None + self._cloud_password = None + self._cloud_country = None + self._host = None + self._token = None @property def gateway_device(self): @@ -33,21 +50,17 @@ def gateway_info(self): async def async_connect_gateway(self, host, token): """Connect to the Xiaomi Gateway.""" _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) - try: - self._gateway_device = gateway.Gateway(host, token) - # get the gateway info - self._gateway_info = await self._hass.async_add_executor_job( - self._gateway_device.info - ) - # get the connected sub devices - await self._hass.async_add_executor_job( - self._gateway_device.discover_devices - ) - except DeviceException: - _LOGGER.error( - "DeviceException during setup of xiaomi gateway with host %s", host - ) + + self._host = host + self._token = token + self._use_cloud = self._config_entry.options.get(CONF_CLOUD_SUBDEVICES, False) + self._cloud_username = self._config_entry.data.get(CONF_CLOUD_USERNAME) + 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 + _LOGGER.debug( "%s %s %s detected", self._gateway_info.model, @@ -56,6 +69,45 @@ async def async_connect_gateway(self, host, token): ) return True + def connect_gateway(self): + """Connect the gateway in a way that can called by async_add_executor_job.""" + try: + self._gateway_device = gateway.Gateway(self._host, self._token) + # get the gateway info + self._gateway_device.info() + + # get the connected sub devices + if self._use_cloud or self._gateway_info.model == GATEWAY_MODEL_EU: + if ( + self._cloud_username is None + or self._cloud_password is None + or self._cloud_country is None + ): + raise ConfigEntryAuthFailed( + "Missing cloud credentials in Xiaomi Miio configuration" + ) + + # use miio-cloud + miio_cloud = MiCloud(self._cloud_username, self._cloud_password) + if not miio_cloud.login(): + raise ConfigEntryAuthFailed( + "Could not login to Xioami Miio Cloud, check the credentials" + ) + devices_raw = miio_cloud.get_devices(self._cloud_country) + self._gateway_device.get_devices_from_dict(devices_raw) + else: + # use local query (not supported by all gateway types) + self._gateway_device.discover_devices() + + except DeviceException: + _LOGGER.error( + "DeviceException during setup of xiaomi gateway with host %s", + self._host, + ) + return False + + return True + class XiaomiGatewayDevice(CoordinatorEntity, Entity): """Representation of a base Xiaomi Gateway Device.""" diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 939e30edda8b0..1f37d624b95eb 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", "python-miio==0.5.6"], + "requirements": ["construct==2.10.56", "micloud==0.3", "python-miio==0.5.6"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "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 571df98eef187..69a1621c9730c 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -1,23 +1,70 @@ { "config": { "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "incomplete_info": "Incomplete information to setup device, no host or token supplied.", + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "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_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", + "cloud_login_error": "Could not login to Xioami Miio Cloud, check the credentials." }, "flow_title": "{name}", "step": { - "device": { + "reauth_confirm": { + "description": "The Xiaomi Miio integration needs to re-authenticate your account in order to update the tokens or add missing cloud credentials.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "cloud": { + "data": { + "cloud_username": "Cloud username", + "cloud_password": "Cloud password", + "cloud_country": "Cloud server country", + "manual": "Configure manually (not recommended)" + }, + "description": "Log in to the Xiaomi Miio cloud, see https://www.openhab.org/addons/bindings/miio/#country-servers for the cloud server to use.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, + "select": { + "data": { + "select_device": "Miio device" + }, + "description": "Select the Xiaomi Miio device to setup.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, + "manual": { "data": { "host": "[%key:common::config_flow::data::ip%]", - "model": "Device model (Optional)", "token": "[%key:common::config_flow::data::api_token%]" }, "description": "You will need the 32 character [%key:common::config_flow::data::api_token%], see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this [%key:common::config_flow::data::api_token%] is different from the key used by the Xiaomi Aqara integration.", "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, + "connect": { + "data": { + "model": "Device model" + }, + "description": "Manually select the device model from the supported models.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + } + } + }, + "options": { + "error": { + "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country" + }, + "step": { + "init": { + "title": "Xiaomi Miio", + "description": "Specify optional settings", + "data": { + "cloud_subdevices": "Use cloud to get connected subdevices" + } } } } diff --git a/homeassistant/components/xiaomi_miio/translations/en.json b/homeassistant/components/xiaomi_miio/translations/en.json index f5629a86ecac1..9d6a3afbb60b0 100644 --- a/homeassistant/components/xiaomi_miio/translations/en.json +++ b/homeassistant/components/xiaomi_miio/translations/en.json @@ -1,42 +1,71 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress" + "config": { + "abort": { + "reauth_successful": "Re-authentication was successful", + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "incomplete_info": "Incomplete information to setup device, no host or token supplied.", + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio." + }, + "error": { + "cannot_connect": "Failed to connect", + "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", + "cloud_login_error": "Could not login to Xioami Miio Cloud, check the credentials." + }, + "flow_title": "{name}", + "step": { + "reauth_confirm": { + "description": "The Xiaomi Miio integration needs to re-authenticate your acount in order to update the tokens or add missing cloud credentials.", + "title": "Reauthenticate Integration" + }, + "cloud": { + "data": { + "cloud_username": "Cloud username", + "cloud_password": "Cloud password", + "cloud_country": "Cloud server country", + "manual": "Configure manually (not recommended)" }, - "error": { - "cannot_connect": "Failed to connect", - "no_device_selected": "No device selected, please select one device.", - "unknown_device": "The device model is not known, not able to setup the device using config flow." + "description": "Log in to the Xiaomi Miio cloud, see https://www.openhab.org/addons/bindings/miio/#country-servers for the cloud server to use.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, + "select": { + "data": { + "select_device": "Miio device" }, - "flow_title": "{name}", - "step": { - "device": { - "data": { - "host": "IP Address", - "model": "Device model (Optional)", - "name": "Name of the device", - "token": "API Token" - }, - "description": "You will need the 32 character API Token, see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this API Token is different from the key used by the Xiaomi Aqara integration.", - "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" - }, - "gateway": { - "data": { - "host": "IP Address", - "name": "Name of the Gateway", - "token": "API Token" - }, - "description": "You will need the 32 character API Token, see https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token for instructions. Please note, that this API Token is different from the key used by the Xiaomi Aqara integration.", - "title": "Connect to a Xiaomi Gateway" - }, - "user": { - "data": { - "gateway": "Connect to a Xiaomi Gateway" - }, - "description": "Select to which device you want to connect.", - "title": "Xiaomi Miio" - } + "description": "Select the Xiaomi Miio device to setup.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, + "manual": { + "data": { + "host": "IP Address", + "token": "API Token" + }, + "description": "You will need the 32 character API Token, see https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token for instructions. Please note, that this API Token is different from the key used by the Xiaomi Aqara integration.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + }, + "connect": { + "data": { + "model": "Device model" + }, + "description": "Manually select the device model from the supported models.", + "title": "Connect to a Xiaomi Miio Device or Xiaomi Gateway" + } + } + }, + "options": { + "error": { + "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country" + }, + "step": { + "init": { + "title": "Xiaomi Miio", + "description": "Specify optional settings", + "data": { + "cloud_subdevices": "Use cloud to get connected subdevices" } + } } -} \ No newline at end of file + } +} diff --git a/requirements_all.txt b/requirements_all.txt index b735fd3e43127..78b6eb8fe8155 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -956,6 +956,9 @@ meteofrance-api==1.0.2 # homeassistant.components.mfi mficlient==0.3.0 +# homeassistant.components.xiaomi_miio +micloud==0.3 + # homeassistant.components.miflora miflora==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 727b3cf9b4669..f0ba017a98f72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,6 +528,9 @@ meteofrance-api==1.0.2 # homeassistant.components.mfi mficlient==0.3.0 +# homeassistant.components.xiaomi_miio +micloud==0.3 + # homeassistant.components.mill millheater==0.4.1 diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index de1ccbf1a8bce..68091efffa1f4 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -2,27 +2,86 @@ from unittest.mock import Mock, patch from miio import DeviceException +import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.xiaomi_miio import const -from homeassistant.components.xiaomi_miio.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from tests.common import MockConfigEntry + ZEROCONF_NAME = "name" ZEROCONF_PROP = "properties" ZEROCONF_MAC = "mac" TEST_HOST = "1.2.3.4" +TEST_HOST2 = "5.6.7.8" +TEST_CLOUD_USER = "username" +TEST_CLOUD_PASS = "password" +TEST_CLOUD_COUNTRY = "cn" TEST_TOKEN = "12345678901234567890123456789012" TEST_NAME = "Test_Gateway" +TEST_NAME2 = "Test_Gateway_2" TEST_MODEL = const.MODELS_GATEWAY[0] TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_MAC2 = "mn:op:qr:st:uv:wx" TEST_MAC_DEVICE = "abcdefghijkl" +TEST_MAC_DEVICE2 = "mnopqrstuvwx" TEST_GATEWAY_ID = TEST_MAC TEST_HARDWARE_VERSION = "AB123" TEST_FIRMWARE_VERSION = "1.2.3_456" TEST_ZEROCONF_NAME = "lumi-gateway-v3_miio12345678._miio._udp.local." TEST_SUB_DEVICE_LIST = [] +TEST_CLOUD_DEVICES_1 = [ + { + "parent_id": None, + "name": TEST_NAME, + "model": TEST_MODEL, + "localip": TEST_HOST, + "mac": TEST_MAC_DEVICE, + "token": TEST_TOKEN, + } +] +TEST_CLOUD_DEVICES_2 = [ + { + "parent_id": None, + "name": TEST_NAME, + "model": TEST_MODEL, + "localip": TEST_HOST, + "mac": TEST_MAC_DEVICE, + "token": TEST_TOKEN, + }, + { + "parent_id": None, + "name": TEST_NAME2, + "model": TEST_MODEL, + "localip": TEST_HOST2, + "mac": TEST_MAC_DEVICE2, + "token": TEST_TOKEN, + }, +] + + +@pytest.fixture(name="xiaomi_miio_connect", autouse=True) +def xiaomi_miio_connect_fixture(): + """Mock denonavr connection and entry setup.""" + mock_info = get_mock_info() + + with patch( + "homeassistant.components.xiaomi_miio.device.Device.info", + return_value=mock_info, + ), patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.login", + return_value=True, + ), patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.get_devices", + return_value=TEST_CLOUD_DEVICES_1, + ), patch( + "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + ), patch( + "homeassistant.components.xiaomi_miio.async_unload_entry", return_value=True + ): + yield def get_mock_info( @@ -48,7 +107,16 @@ async def test_config_flow_step_gateway_connect_error(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + 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"] == {} with patch( @@ -61,7 +129,7 @@ async def test_config_flow_step_gateway_connect_error(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + assert result["step_id"] == "connect" assert result["errors"] == {"base": "cannot_connect"} @@ -72,33 +140,233 @@ async def test_config_flow_gateway_success(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + assert result["step_id"] == "cloud" assert result["errors"] == {} - mock_info = get_mock_info() + 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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_MODEL + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, + } + + +async def test_config_flow_gateway_cloud_success(hass): + """Test a successful config flow using cloud.""" + 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_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, + } + + +async def test_config_flow_gateway_cloud_multiple_success(hass): + """Test a successful config flow using cloud with multiple devices.""" + 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"] == {} with patch( - "homeassistant.components.xiaomi_miio.device.Device.info", - return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.get_devices", + return_value=TEST_CLOUD_DEVICES_2, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN}, + { + 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"] == "select" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"select_device": f"{TEST_NAME2} - {TEST_MODEL}"}, + ) + assert result["type"] == "create_entry" - assert result["title"] == DEFAULT_GATEWAY_NAME + assert result["title"] == TEST_NAME2 assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_GATEWAY, - CONF_HOST: TEST_HOST, + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + CONF_HOST: TEST_HOST2, CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + const.CONF_MAC: TEST_MAC2, } +async def test_config_flow_gateway_cloud_incomplete(hass): + """Test a failed config flow using incomplete cloud credentials.""" + 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_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "cloud" + assert result["errors"] == {"base": "cloud_credentials_incomplete"} + + +async def test_config_flow_gateway_cloud_login_error(hass): + """Test a failed config flow using cloud login error.""" + 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"] == {} + + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.login", + return_value=False, + ): + 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.""" + 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"] == {} + + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.get_devices", + return_value=[], + ): + 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_no_devices"} + + +async def test_config_flow_gateway_cloud_missing_token(hass): + """Test a failed config flow using cloud with a missing token.""" + 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"] == {} + + cloud_device = [ + { + "parent_id": None, + "name": TEST_NAME, + "model": TEST_MODEL, + "localip": TEST_HOST, + "mac": TEST_MAC_DEVICE, + "token": None, + } + ] + + with patch( + "homeassistant.components.xiaomi_miio.config_flow.MiCloud.get_devices", + return_value=cloud_device, + ): + 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"] == "abort" + assert result["reason"] == "incomplete_info" + + async def test_zeroconf_gateway_success(hass): """Test a successful zeroconf discovery of a gateway.""" result = await hass.config_entries.flow.async_init( @@ -112,26 +380,25 @@ async def test_zeroconf_gateway_success(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + assert result["step_id"] == "cloud" assert result["errors"] == {} - mock_info = get_mock_info() - - with patch( - "homeassistant.components.xiaomi_miio.device.Device.info", - return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_TOKEN: TEST_TOKEN}, - ) + 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"] == "create_entry" - assert result["title"] == DEFAULT_GATEWAY_NAME + assert result["title"] == TEST_NAME assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: TEST_MODEL, @@ -184,7 +451,16 @@ async def test_config_flow_step_device_connect_error(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + 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"] == {} with patch( @@ -197,7 +473,7 @@ async def test_config_flow_step_device_connect_error(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + assert result["step_id"] == "connect" assert result["errors"] == {"base": "cannot_connect"} @@ -208,7 +484,16 @@ async def test_config_flow_step_unknown_device(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + 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"] == {} mock_info = get_mock_info(model="UNKNOWN") @@ -223,7 +508,7 @@ async def test_config_flow_step_unknown_device(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + assert result["step_id"] == "connect" assert result["errors"] == {"base": "unknown_device"} @@ -234,8 +519,6 @@ async def test_import_flow_success(hass): with patch( "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_init( const.DOMAIN, @@ -247,6 +530,9 @@ async def test_import_flow_success(hass): assert result["title"] == TEST_NAME assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: const.MODELS_SWITCH[0], @@ -261,7 +547,16 @@ async def test_config_flow_step_device_manual_model_succes(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + 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"] == {} with patch( @@ -274,7 +569,7 @@ async def test_config_flow_step_device_manual_model_succes(hass): ) assert result["type"] == "form" - assert result["step_id"] == "device" + assert result["step_id"] == "connect" assert result["errors"] == {"base": "cannot_connect"} overwrite_model = const.MODELS_VACUUM[0] @@ -282,18 +577,19 @@ async def test_config_flow_step_device_manual_model_succes(hass): with patch( "homeassistant.components.xiaomi_miio.device.Device.info", side_effect=DeviceException({}), - ), patch( - "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: overwrite_model}, + {const.CONF_MODEL: overwrite_model}, ) assert result["type"] == "create_entry" assert result["title"] == overwrite_model assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: overwrite_model, @@ -308,7 +604,16 @@ async def config_flow_device_success(hass, model_to_test): ) assert result["type"] == "form" - assert result["step_id"] == "device" + 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"] == {} mock_info = get_mock_info(model=model_to_test) @@ -316,8 +621,6 @@ async def config_flow_device_success(hass, model_to_test): with patch( "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -328,6 +631,9 @@ async def config_flow_device_success(hass, model_to_test): assert result["title"] == model_to_test assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: model_to_test, @@ -348,7 +654,16 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): ) assert result["type"] == "form" - assert result["step_id"] == "device" + 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"] == {} mock_info = get_mock_info(model=model_to_test) @@ -356,8 +671,6 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): with patch( "homeassistant.components.xiaomi_miio.device.Device.info", return_value=mock_info, - ), patch( - "homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -368,6 +681,9 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): assert result["title"] == model_to_test assert result["data"] == { const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, const.CONF_MODEL: model_to_test, @@ -399,3 +715,147 @@ async def test_zeroconf_vacuum_success(hass): test_vacuum_model = const.MODELS_VACUUM[0] test_zeroconf_name = const.MODELS_VACUUM[0].replace(".", "-") await zeroconf_device_success(hass, test_zeroconf_name, test_vacuum_model) + + +async def test_options_flow(hass): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, + }, + title=TEST_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + const.CONF_CLOUD_SUBDEVICES: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + const.CONF_CLOUD_SUBDEVICES: True, + } + + +async def test_options_flow_incomplete(hass): + """Test specifying incomplete settings using options flow.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, + }, + title=TEST_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + const.CONF_CLOUD_SUBDEVICES: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "cloud_credentials_incomplete"} + + +async def test_reauth(hass): + """Test a reauth flow.""" + # await setup.async_setup_component(hass, "persistent_notification", {}) + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + const.CONF_CLOUD_USERNAME: None, + const.CONF_CLOUD_PASSWORD: None, + const.CONF_CLOUD_COUNTRY: None, + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, + }, + title=TEST_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data=config_entry.data, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + 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_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "reauth_successful" + + config_data = config_entry.data.copy() + assert config_data == { + const.CONF_FLOW_TYPE: const.CONF_GATEWAY, + const.CONF_CLOUD_USERNAME: TEST_CLOUD_USER, + const.CONF_CLOUD_PASSWORD: TEST_CLOUD_PASS, + const.CONF_CLOUD_COUNTRY: TEST_CLOUD_COUNTRY, + CONF_HOST: TEST_HOST, + CONF_TOKEN: TEST_TOKEN, + const.CONF_MODEL: TEST_MODEL, + const.CONF_MAC: TEST_MAC, + }