From 52d2c706ce484c3a7c6cf18a8fb79ce550f24443 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 14 Mar 2025 11:25:59 +0000 Subject: [PATCH 01/28] Bump PyTado 0.19.0 --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b83e2695137ccf..867e845883befe 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.6"] + "requirements": ["python-tado==0.18.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76926fd1001caa..2e9226a1a2bb2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2473,7 +2473,7 @@ python-snoo==0.6.1 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.7 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 819d9756f85956..5ffc68c1487fd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ python-snoo==0.6.1 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.7 # homeassistant.components.technove python-technove==2.0.0 From 67874927c9293b87687205ecab09e0a13253c000 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 15 Mar 2025 14:38:38 +0000 Subject: [PATCH 02/28] Initial setup --- homeassistant/components/tado/__init__.py | 16 +- homeassistant/components/tado/config_flow.py | 171 ++++++++++++------- homeassistant/components/tado/const.py | 1 + homeassistant/components/tado/strings.json | 10 +- 4 files changed, 132 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 4b0203acda3989..417f5f5a3653da 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -8,11 +8,12 @@ from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ConfigEntryAuthFailed from .const import ( CONF_FALLBACK, @@ -61,11 +62,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _LOGGER.debug("Setting up Tado connection") try: - tado = await hass.async_add_executor_job( - Tado, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) + tado = await hass.async_add_executor_job(Tado) + device_status = await hass.async_add_executor_job(tado.device_activation_status) + + if device_status != "COMPLETED": + raise ConfigEntryAuthFailed( + f"Device loginf flow status is {device_status}. Starting re-authentication." + ) + except PyTado.exceptions.TadoWrongCredentialsException as err: raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index f251a292800b68..90d365c667825c 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -2,10 +2,11 @@ from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging from typing import Any -import PyTado from PyTado.interface import Tado import requests.exceptions import voluptuous as vol @@ -16,7 +17,7 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ( @@ -43,46 +44,41 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - + """Validate the user input allows us to connect.""" try: - tado = await hass.async_add_executor_job( - Tado, data[CONF_USERNAME], data[CONF_PASSWORD] - ) + tado = await hass.async_add_executor_job(Tado) tado_me = await hass.async_add_executor_job(tado.get_me) except KeyError as ex: raise InvalidAuth from ex except RuntimeError as ex: raise CannotConnect from ex except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: + if 400 <= ex.response.status_code < 500: raise InvalidAuth from ex raise CannotConnect from ex - if "homes" not in tado_me or len(tado_me["homes"]) == 0: + if "homes" not in tado_me or not tado_me["homes"]: raise NoHomes home = tado_me["homes"][0] - unique_id = str(home["id"]) - name = home["name"] - - return {"title": name, UNIQUE_ID: unique_id} + return {"title": home["name"], UNIQUE_ID: str(home["id"])} class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" VERSION = 1 + login_task: asyncio.Task | None = None + access_token: str | None = None + refresh_token: str | None = None + tado: Tado | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors = {} - if user_input is not None: + if user_input: try: validated = await validate_input(self.hass, user_input) except CannotConnect: @@ -95,7 +91,7 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - if "base" not in errors: + if not errors: await self.async_set_unique_id(validated[UNIQUE_ID]) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -106,57 +102,114 @@ async def async_step_user( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_homekit( - self, discovery_info: ZeroconfServiceInfo + async def async_step_reauth( + self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle HomeKit discovery.""" - self._async_abort_entries_match() - properties = { - key.lower(): value for (key, value) in discovery_info.properties.items() - } - await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) - self._abort_if_unique_id_configured() - return await self.async_step_user() + """Handle reauth on credential failure.""" + return await self.async_step_reauth_prepare() - async def async_step_reconfigure( + async def async_step_reauth_prepare( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a reconfiguration flow initialized by the user.""" - errors: dict[str, str] = {} - reconfigure_entry = self._get_reconfigure_entry() + """Prepare reauth.""" + if user_input is None: + return self.async_show_form(step_id="reauth_prepare") - if user_input is not None: - user_input[CONF_USERNAME] = reconfigure_entry.data[CONF_USERNAME] - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except PyTado.exceptions.TadoWrongCredentialsException: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + return await self.async_step_reauth_confirm() - if not errors: - return self.async_update_reload_and_abort( - reconfigure_entry, data_updates=user_input - ) + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle users reauth credentials.""" - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, + async def _wait_for_login() -> None: + """Wait for the user to login.""" + _LOGGER.debug("Waiting for device activation") + try: + await self.hass.async_add_executor_job(self.tado.device_activation) + except Exception as ex: + _LOGGER.exception("Error while waiting for device activation") + raise CannotConnect from ex + + if ( + await self.hass.async_add_executor_job( + self.tado.device_activation_status + ) + != "COMPLETED" + ): + raise CannotConnect + + _LOGGER.debug("Device activation completed. Obtaining tokens") + + if self.tado is None: + _LOGGER.debug("Initiating device activation") + self.tado = await self.hass.async_add_executor_job(Tado) + tado_device_url = await self.hass.async_add_executor_job( + self.tado.device_verification_url + ) + user_code = tado_device_url.split("user_code=")[-1] + + _LOGGER.debug("Checking login task") + if self.login_task is None: + _LOGGER.debug("Creating task for device activation") + self.login_task = self.hass.async_create_task(_wait_for_login()) + + if self.login_task.done(): + _LOGGER.debug("Login task is done, checking results") + if self.login_task.exception(): + return self.async_show_progress_done( + next_step_id="could_not_authenticate" + ) + self.access_token = await self.hass.async_add_executor_job( + self.tado.get_access_token + ) + self.refresh_token = await self.hass.async_add_executor_job( + self.tado.get_refresh_token + ) + return self.async_show_progress_done(next_step_id="finalize_reauth_login") + + return self.async_show_progress( + step_id="reauth_confirm", + progress_action="wait_for_device", description_placeholders={ - CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] + "url": tado_device_url, + "code": user_code, }, + progress_task=self.login_task, ) + async def async_step_finalize_reauth_login( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the finalization of reauth.""" + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data={ + CONF_ACCESS_TOKEN: self.access_token, + CONF_PASSWORD: self.refresh_token, + }, + ) + + async def async_step_could_not_authenticate( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle issues that need transition await from progress step.""" + return self.async_abort(reason="could_not_authenticate") + + async def async_step_homekit( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle HomeKit discovery.""" + self._async_abort_entries_match() + properties = { + key.lower(): value for key, value in discovery_info.properties.items() + } + await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) + self._abort_if_unique_id_configured() + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( @@ -173,7 +226,7 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" - if user_input is not None: + if user_input: return self.async_create_entry(data=user_input) data_schema = vol.Schema( diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index bdc4bff1943b20..7720ff091103ee 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -37,6 +37,7 @@ # Configuration CONF_FALLBACK = "fallback" CONF_HOME_ID = "home_id" +CONF_REFRESH_TOKEN = "refresh_token" DATA = "data" # Weather diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index ff1afc3c03d021..e94260c7d00c22 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,8 +1,12 @@ { "config": { + "progress": { + "wait_for_device": "To authenticate, open the following URL and login at Tado: \n {url} \n If the code is not automatically copied, and paste the following code to authorize the integration: \n```\n{code}\n```\n\nThe login atempt will time out after five minutes." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "could_not_authenticate": "Could not authenticate with Tado." }, "step": { "user": { @@ -12,6 +16,10 @@ }, "title": "Connect to your Tado account" }, + "reauth_prepare": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "You need re-authenicate again with Tado. Press Submit to start the re-authentication process." + }, "reconfigure": { "title": "Reconfigure your Tado", "description": "Reconfigure the entry for your account: `{username}`.", From 64e973d256dd7db1503e84784d2f2f65ce314b8e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 16 Mar 2025 14:04:18 +0000 Subject: [PATCH 03/28] Current state --- homeassistant/components/tado/__init__.py | 11 ++-- homeassistant/components/tado/config_flow.py | 64 +++++++++++--------- homeassistant/components/tado/coordinator.py | 7 ++- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 417f5f5a3653da..07ae4198f55f5f 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -8,7 +8,7 @@ from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -17,6 +17,7 @@ from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, @@ -62,7 +63,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _LOGGER.debug("Setting up Tado connection") try: - tado = await hass.async_add_executor_job(Tado) + tado = await hass.async_add_executor_job( + Tado, None, entry.data[CONF_ACCESS_TOKEN], entry.data[CONF_REFRESH_TOKEN] + ) device_status = await hass.async_add_executor_job(tado.device_activation_status) if device_status != "COMPLETED": @@ -74,9 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err - _LOGGER.debug( - "Tado connection established for username: %s", entry.data[CONF_USERNAME] - ) + _LOGGER.debug("Tado connection established") coordinator = TadoDataUpdateCoordinator(hass, entry, tado) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 90d365c667825c..d8dd03839c12ba 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_REAUTH, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -27,6 +28,7 @@ from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, @@ -77,43 +79,20 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} - if user_input: - try: - validated = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: - await self.async_set_unique_id(validated[UNIQUE_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=validated["title"], data=user_input - ) - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) + return await self.async_step_auth_prepare() async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth on credential failure.""" - return await self.async_step_reauth_prepare() + return await self.async_step_auth_prepare() - async def async_step_reauth_prepare( + async def async_step_auth_prepare( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare reauth.""" if user_input is None: - return self.async_show_form(step_id="reauth_prepare") + return self.async_show_form(step_id="auth_prepare") return await self.async_step_reauth_confirm() @@ -166,7 +145,10 @@ async def _wait_for_login() -> None: self.refresh_token = await self.hass.async_add_executor_job( self.tado.get_refresh_token ) - return self.async_show_progress_done(next_step_id="finalize_reauth_login") + _LOGGER.debug( + "Tokens obtained, finalizing reauth. Tokens: %s", self.access_token + ) + return self.async_show_progress_done(next_step_id="finalize_auth_login") return self.async_show_progress( step_id="reauth_confirm", @@ -178,16 +160,38 @@ async def _wait_for_login() -> None: progress_task=self.login_task, ) - async def async_step_finalize_reauth_login( + async def async_step_finalize_auth_login( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle the finalization of reauth.""" + _LOGGER.debug("Finalizing reauth") + tado_me = await self.hass.async_add_executor_job(self.tado.get_me) + + if "homes" not in tado_me or len(tado_me["homes"]) == 0: + raise NoHomes + + home = tado_me["homes"][0] + unique_id = str(home["id"]) + name = home["name"] + + if self.source != SOURCE_REAUTH: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=name, + data={ + CONF_ACCESS_TOKEN: self.access_token, + CONF_REFRESH_TOKEN: self.refresh_token, + }, + ) + return self.async_update_reload_and_abort( self._get_reauth_entry(), data={ CONF_ACCESS_TOKEN: self.access_token, - CONF_PASSWORD: self.refresh_token, + CONF_REFRESH_TOKEN: self.refresh_token, }, ) diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 559bc4a16fb6c5..81c3aefa777842 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -10,7 +10,7 @@ from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,6 +20,7 @@ from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, @@ -58,8 +59,8 @@ def __init__( update_interval=SCAN_INTERVAL, ) self._tado = tado - self._username = config_entry.data[CONF_USERNAME] - self._password = config_entry.data[CONF_PASSWORD] + self._access_token = config_entry.data[CONF_ACCESS_TOKEN] + self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN] self._fallback = config_entry.options.get( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT ) From ef35be249114f607e4b0a6b344bb44696d30b81e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 16 Mar 2025 19:36:02 +0000 Subject: [PATCH 04/28] Update to PyTado 0.18.8 --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 867e845883befe..bd9741bd69ccfe 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.7"] + "requirements": ["python-tado==0.18.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e9226a1a2bb2a..5fb8d64454bb4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2473,7 +2473,7 @@ python-snoo==0.6.1 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.7 +python-tado==0.18.8 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ffc68c1487fd2..fef901995aa882 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ python-snoo==0.6.1 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.7 +python-tado==0.18.8 # homeassistant.components.technove python-technove==2.0.0 From 6f415c99261ce0b66327073ec07ab761fa9c6a41 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 16 Mar 2025 21:16:45 +0000 Subject: [PATCH 05/28] First concept for review --- homeassistant/components/tado/__init__.py | 6 +++++- homeassistant/components/tado/config_flow.py | 21 +++----------------- homeassistant/components/tado/coordinator.py | 2 -- homeassistant/components/tado/strings.json | 6 +++--- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 07ae4198f55f5f..24eeb9f96060f0 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -63,8 +63,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _LOGGER.debug("Setting up Tado connection") try: + _LOGGER.debug( + "Creating tado instance with refresh token: %s", + entry.data[CONF_REFRESH_TOKEN], + ) tado = await hass.async_add_executor_job( - Tado, None, entry.data[CONF_ACCESS_TOKEN], entry.data[CONF_REFRESH_TOKEN] + Tado, None, entry.data[CONF_REFRESH_TOKEN] ) device_status = await hass.async_add_executor_job(tado.device_activation_status) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index d8dd03839c12ba..acef01a2cf0c74 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -18,7 +18,7 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ( @@ -71,7 +71,6 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 login_task: asyncio.Task | None = None - access_token: str | None = None refresh_token: str | None = None tado: Tado | None = None @@ -118,8 +117,6 @@ async def _wait_for_login() -> None: ): raise CannotConnect - _LOGGER.debug("Device activation completed. Obtaining tokens") - if self.tado is None: _LOGGER.debug("Initiating device activation") self.tado = await self.hass.async_add_executor_job(Tado) @@ -139,15 +136,9 @@ async def _wait_for_login() -> None: return self.async_show_progress_done( next_step_id="could_not_authenticate" ) - self.access_token = await self.hass.async_add_executor_job( - self.tado.get_access_token - ) self.refresh_token = await self.hass.async_add_executor_job( self.tado.get_refresh_token ) - _LOGGER.debug( - "Tokens obtained, finalizing reauth. Tokens: %s", self.access_token - ) return self.async_show_progress_done(next_step_id="finalize_auth_login") return self.async_show_progress( @@ -181,18 +172,12 @@ async def async_step_finalize_auth_login( return self.async_create_entry( title=name, - data={ - CONF_ACCESS_TOKEN: self.access_token, - CONF_REFRESH_TOKEN: self.refresh_token, - }, + data={CONF_REFRESH_TOKEN: self.refresh_token}, ) return self.async_update_reload_and_abort( self._get_reauth_entry(), - data={ - CONF_ACCESS_TOKEN: self.access_token, - CONF_REFRESH_TOKEN: self.refresh_token, - }, + data={CONF_REFRESH_TOKEN: self.refresh_token}, ) async def async_step_could_not_authenticate( diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 81c3aefa777842..a59a3d7697b002 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -10,7 +10,6 @@ from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -59,7 +58,6 @@ def __init__( update_interval=SCAN_INTERVAL, ) self._tado = tado - self._access_token = config_entry.data[CONF_ACCESS_TOKEN] self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN] self._fallback = config_entry.options.get( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index e94260c7d00c22..8b7b46145e34c0 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -16,9 +16,9 @@ }, "title": "Connect to your Tado account" }, - "reauth_prepare": { - "title": "[%key:common::config_flow::title::reauth%]", - "description": "You need re-authenicate again with Tado. Press Submit to start the re-authentication process." + "auth_prepare": { + "title": "Authenticate with Tado", + "description": "You need (re-)authenicate again with Tado. Press Submit to start the (re-)authentication process." }, "reconfigure": { "title": "Reconfigure your Tado", From 99c79796e280ddbe0c796375fa6177157caf3d0e Mon Sep 17 00:00:00 2001 From: Joostlek Date: Mon, 17 Mar 2025 12:33:16 +0100 Subject: [PATCH 06/28] Fix --- homeassistant/components/tado/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 24eeb9f96060f0..f258c540f08623 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -8,12 +8,15 @@ from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ConfigEntryAuthFailed from .const import ( CONF_FALLBACK, From 01174899e9db7f8fe6d049ce78a3f78f5e4f5d80 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Mon, 17 Mar 2025 12:53:33 +0100 Subject: [PATCH 07/28] Fix --- homeassistant/components/tado/__init__.py | 27 ++++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index f258c540f08623..078647ebe99457 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -65,25 +65,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool _async_import_options_from_data_if_missing(hass, entry) _LOGGER.debug("Setting up Tado connection") - try: - _LOGGER.debug( - "Creating tado instance with refresh token: %s", - entry.data[CONF_REFRESH_TOKEN], - ) - tado = await hass.async_add_executor_job( - Tado, None, entry.data[CONF_REFRESH_TOKEN] - ) - device_status = await hass.async_add_executor_job(tado.device_activation_status) + _LOGGER.debug( + "Creating tado instance with refresh token: %s", + entry.data[CONF_REFRESH_TOKEN], + ) - if device_status != "COMPLETED": - raise ConfigEntryAuthFailed( - f"Device loginf flow status is {device_status}. Starting re-authentication." - ) + def create_tado_instance() -> tuple[Tado, str]: + tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) + return tado, tado.device_activation_status() + try: + tado, device_status = await hass.async_add_executor_job(create_tado_instance) except PyTado.exceptions.TadoWrongCredentialsException as err: raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err + if device_status != "COMPLETED": + raise ConfigEntryAuthFailed( + f"Device login flow status is {device_status}. Starting re-authentication." + ) + _LOGGER.debug("Tado connection established") coordinator = TadoDataUpdateCoordinator(hass, entry, tado) From 870dd69ecac9257c9f721a4f36b90934ca30f4f5 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Mon, 17 Mar 2025 13:07:08 +0100 Subject: [PATCH 08/28] Fix --- homeassistant/components/tado/config_flow.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index acef01a2cf0c74..b58a43913206b1 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -100,8 +100,18 @@ async def async_step_reauth_confirm( ) -> ConfigFlowResult: """Handle users reauth credentials.""" + if self.tado is None: + _LOGGER.debug("Initiating device activation") + self.tado = await self.hass.async_add_executor_job(Tado) + assert self.tado is not None + tado_device_url = await self.hass.async_add_executor_job( + self.tado.device_verification_url + ) + user_code = tado_device_url.split("user_code=")[-1] + async def _wait_for_login() -> None: """Wait for the user to login.""" + assert self.tado is not None _LOGGER.debug("Waiting for device activation") try: await self.hass.async_add_executor_job(self.tado.device_activation) @@ -117,14 +127,6 @@ async def _wait_for_login() -> None: ): raise CannotConnect - if self.tado is None: - _LOGGER.debug("Initiating device activation") - self.tado = await self.hass.async_add_executor_job(Tado) - tado_device_url = await self.hass.async_add_executor_job( - self.tado.device_verification_url - ) - user_code = tado_device_url.split("user_code=")[-1] - _LOGGER.debug("Checking login task") if self.login_task is None: _LOGGER.debug("Creating task for device activation") @@ -157,6 +159,7 @@ async def async_step_finalize_auth_login( ) -> ConfigFlowResult: """Handle the finalization of reauth.""" _LOGGER.debug("Finalizing reauth") + assert self.tado is not None tado_me = await self.hass.async_add_executor_job(self.tado.get_me) if "homes" not in tado_me or len(tado_me["homes"]) == 0: From c8d55cae74cd0960fd994d1738208064428c5626 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 17 Mar 2025 20:28:43 +0000 Subject: [PATCH 09/28] First concept for review --- homeassistant/components/tado/__init__.py | 15 ++++++++++++--- homeassistant/components/tado/coordinator.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 078647ebe99457..cb5f50160b8d17 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -70,12 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool entry.data[CONF_REFRESH_TOKEN], ) - def create_tado_instance() -> tuple[Tado, str]: + def create_tado_instance() -> tuple[Tado, str, str]: + """Create a Tado instance, this time with a previously obtained refresh token.""" tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) - return tado, tado.device_activation_status() + return tado, tado.device_activation_status(), tado.get_refresh_token() try: - tado, device_status = await hass.async_add_executor_job(create_tado_instance) + tado, device_status, new_refresh_token = await hass.async_add_executor_job( + create_tado_instance + ) + _LOGGER.debug("New refresh token: %s", new_refresh_token) + # Mindtwist here: the refresh token from the config_flow has been invalidated + # and a new one has been obtained. We need to update the config entry with the new + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_REFRESH_TOKEN: new_refresh_token} + ) except PyTado.exceptions.TadoWrongCredentialsException as err: raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index a59a3d7697b002..5f3aa1de1e4969 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -107,6 +107,18 @@ async def _async_update_data(self) -> dict[str, dict]: self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] + refresh_token = await self.hass.async_add_executor_job( + self._tado.get_refresh_token + ) + + if refresh_token != self._refresh_token: + _LOGGER.debug("New refresh token obtained from Tado: %s", refresh_token) + self._refresh_token = refresh_token + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + return self.data async def _async_update_devices(self) -> dict[str, dict]: From 615551eabef8a0d9f8f469869359854e6b49d60e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 17 Mar 2025 20:41:03 +0000 Subject: [PATCH 10/28] Bump PyTado to 0.18.9 --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index bd9741bd69ccfe..75ddbacc58502e 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.8"] + "requirements": ["python-tado==0.18.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ff9e39dbe210f..7fc72605520227 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2477,7 +2477,7 @@ python-snoo==0.6.3 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.8 +python-tado==0.18.9 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11d7088f7cd8c3..53f1a4c4873876 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2007,7 +2007,7 @@ python-snoo==0.6.3 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.8 +python-tado==0.18.9 # homeassistant.components.technove python-technove==2.0.0 From 0b453f246bae1c38690de116e62d0e8b38510b0b Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 17 Mar 2025 21:36:51 +0000 Subject: [PATCH 11/28] Remove redundant part --- homeassistant/components/tado/__init__.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index cb5f50160b8d17..c7edd4070d6fc6 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -70,21 +70,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool entry.data[CONF_REFRESH_TOKEN], ) - def create_tado_instance() -> tuple[Tado, str, str]: + def create_tado_instance() -> tuple[Tado, str]: """Create a Tado instance, this time with a previously obtained refresh token.""" tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) - return tado, tado.device_activation_status(), tado.get_refresh_token() + return tado, tado.device_activation_status() try: - tado, device_status, new_refresh_token = await hass.async_add_executor_job( - create_tado_instance - ) - _LOGGER.debug("New refresh token: %s", new_refresh_token) - # Mindtwist here: the refresh token from the config_flow has been invalidated - # and a new one has been obtained. We need to update the config entry with the new - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: new_refresh_token} - ) + tado, device_status = await hass.async_add_executor_job(create_tado_instance) except PyTado.exceptions.TadoWrongCredentialsException as err: raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: From 36d7f98bff813a15cd3e1939d5f53b5d94a18011 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 18 Mar 2025 19:17:12 +0000 Subject: [PATCH 12/28] Initial test setup --- tests/components/tado/__init__.py | 2 +- .../tado/fixtures/device_authorize.json | 8 +++ tests/components/tado/test_config_flow.py | 70 ++++++++++++------- 3 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 tests/components/tado/fixtures/device_authorize.json diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py index 11d199f01a1657..e6b6257e6ea628 100644 --- a/tests/components/tado/__init__.py +++ b/tests/components/tado/__init__.py @@ -1 +1 @@ -"""Tests for the tado integration.""" +"""Tests for the Tado integration.""" diff --git a/tests/components/tado/fixtures/device_authorize.json b/tests/components/tado/fixtures/device_authorize.json new file mode 100644 index 00000000000000..aacd171fafde87 --- /dev/null +++ b/tests/components/tado/fixtures/device_authorize.json @@ -0,0 +1,8 @@ +{ + "device_code": "ABCD", + "expires_in": 300, + "interval": 5, + "user_code": "TEST", + "verification_uri": "https://login.tado.com/oauth2/device", + "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=TEST" +} diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 19acb0aecbd79d..baffed017d2d16 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -4,9 +4,11 @@ from ipaddress import ip_address from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import PyTado import pytest import requests +import requests_mock from homeassistant import config_entries from homeassistant.components.tado.config_flow import NoHomes @@ -23,7 +25,7 @@ ZeroconfServiceInfo, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture def _get_mock_tado_api(get_me=None) -> MagicMock: @@ -125,40 +127,54 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} -async def test_create_entry(hass: HomeAssistant) -> None: +async def test_create_entry( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test we can setup though the user path.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + with requests_mock.mock() as m: + m.post( + "https://login.tado.com/oauth2/device_authorize", + text=load_fixture("tado/device_authorize.json"), + ) - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_prepare" - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, + result["flow_id"], user_input={} + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.SHOW_PROGRESS + + m.post( + "https://login.tado.com/oauth2/token", text=load_fixture("tado/token.json") + ) + m.get("https://my.tado.com/api/v2/me", text=load_fixture("tado/me.json")) + m.get( + "https://my.tado.com/api/v2/homes/1/", text=load_fixture("tado/home.json") ) + + freezer.tick(10) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 + with ( + patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {"refresh_token": "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_invalid_auth(hass: HomeAssistant) -> None: From 68e430dcaf6c8d5d1c2cc94b1acbbc82781a4fd4 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 18 Mar 2025 19:51:55 +0000 Subject: [PATCH 13/28] Authentication exceptions --- tests/components/tado/test_config_flow.py | 157 +++++++++------------- 1 file changed, 60 insertions(+), 97 deletions(-) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index baffed017d2d16..49e9655c7f8609 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -5,13 +5,12 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -import PyTado import pytest import requests import requests_mock from homeassistant import config_entries -from homeassistant.components.tado.config_flow import NoHomes +from homeassistant.components.tado import CONF_REFRESH_TOKEN from homeassistant.components.tado.const import ( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT, @@ -38,15 +37,17 @@ def _get_mock_tado_api(get_me=None) -> MagicMock: @pytest.mark.parametrize( - ("exception", "error"), + ("exception"), [ - (KeyError, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (ValueError, "unknown"), + KeyError, + RuntimeError, + ValueError, ], ) -async def test_form_exceptions( - hass: HomeAssistant, exception: Exception, error: str +async def test_authentication_exceptions( + hass: HomeAssistant, + exception: Exception, + freezer: FrozenDateTimeFactory, ) -> None: """Test we handle Form Exceptions.""" result = await hass.config_entries.flow.async_init( @@ -57,15 +58,11 @@ async def test_form_exceptions( "homeassistant.components.tado.config_flow.Tado", side_effect=exception, ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} + assert result["step_id"] == "auth_prepare" - # Test a retry to recover, upon failure mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) with ( @@ -80,17 +77,56 @@ async def test_form_exceptions( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": "test-username", "password": "test-password"}, + {CONF_REFRESH_TOKEN: "refresh"}, ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.ABORT + + # Test a retry to recover, upon failure + with requests_mock.mock() as m: + m.post( + "https://login.tado.com/oauth2/device_authorize", + text=load_fixture("tado/device_authorize.json"), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_prepare" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.SHOW_PROGRESS + + m.post( + "https://login.tado.com/oauth2/token", text=load_fixture("tado/token.json") + ) + m.get("https://my.tado.com/api/v2/me", text=load_fixture("tado/me.json")) + m.get( + "https://my.tado.com/api/v2/homes/1/", text=load_fixture("tado/home.json") + ) + + freezer.tick(10) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 async def test_options_flow(hass: HomeAssistant) -> None: @@ -132,6 +168,7 @@ async def test_create_entry( ) -> None: """Test we can setup though the user path.""" + # I use a generic with to ensure all calls, including the polling checks are all in the same context with requests_mock.mock() as m: m.post( "https://login.tado.com/oauth2/device_authorize", @@ -173,7 +210,7 @@ async def test_create_entry( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "home name" - assert result["data"] == {"refresh_token": "refresh"} + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} assert len(mock_setup_entry.mock_calls) == 1 @@ -292,77 +329,3 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (PyTado.exceptions.TadoWrongCredentialsException, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (NoHomes, "no_homes"), - (ValueError, "unknown"), - ], -) -async def test_reconfigure_flow( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - """Test re-configuration flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "home_id": 1, - }, - unique_id="unique_id", - ) - entry.add_to_hass(hass) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.FORM - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reconfigure_successful" - entry = hass.config_entries.async_get_entry(entry.entry_id) - assert entry - assert entry.title == "Mock Title" - assert entry.data == { - "username": "test-username", - "password": "test-password", - "home_id": 1, - } From f914dc302834a6deeae11836690520a71671d3b8 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Wed, 19 Mar 2025 15:49:11 +0100 Subject: [PATCH 14/28] Fix --- homeassistant/components/tado/config_flow.py | 91 ++---- homeassistant/components/tado/strings.json | 14 +- tests/components/tado/conftest.py | 49 +++ tests/components/tado/test_config_flow.py | 324 ++++++------------- 4 files changed, 180 insertions(+), 298 deletions(-) create mode 100644 tests/components/tado/conftest.py diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index b58a43913206b1..4dfebbcc6d0b11 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -7,9 +7,11 @@ import logging from typing import Any +from PyTado.exceptions import TadoException +from PyTado.http import DeviceActivationStatus from PyTado.interface import Tado -import requests.exceptions import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( SOURCE_REAUTH, @@ -19,7 +21,7 @@ OptionsFlow, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID, @@ -32,7 +34,6 @@ CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, - UNIQUE_ID, ) _LOGGER = logging.getLogger(__name__) @@ -45,27 +46,6 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect.""" - try: - tado = await hass.async_add_executor_job(Tado) - tado_me = await hass.async_add_executor_job(tado.get_me) - except KeyError as ex: - raise InvalidAuth from ex - except RuntimeError as ex: - raise CannotConnect from ex - except requests.exceptions.HTTPError as ex: - if 400 <= ex.response.status_code < 500: - raise InvalidAuth from ex - raise CannotConnect from ex - - if "homes" not in tado_me or not tado_me["homes"]: - raise NoHomes - - home = tado_me["homes"][0] - return {"title": home["name"], UNIQUE_ID: str(home["id"])} - - class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" @@ -74,40 +54,36 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): refresh_token: str | None = None tado: Tado | None = None - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - return await self.async_step_auth_prepare() - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth on credential failure.""" - return await self.async_step_auth_prepare() + return await self.async_step_reauth_confirm() - async def async_step_auth_prepare( + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare reauth.""" if user_input is None: - return self.async_show_form(step_id="auth_prepare") + return self.async_show_form(step_id="reauth_confirm") - return await self.async_step_reauth_confirm() + return await self.async_step_user() - async def async_step_reauth_confirm( + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle users reauth credentials.""" if self.tado is None: _LOGGER.debug("Initiating device activation") - self.tado = await self.hass.async_add_executor_job(Tado) + try: + self.tado = await self.hass.async_add_executor_job(Tado) + except TadoException: + _LOGGER.exception("Error while initiating Tado") + return self.async_abort(reason="cannot_connect") assert self.tado is not None - tado_device_url = await self.hass.async_add_executor_job( - self.tado.device_verification_url - ) - user_code = tado_device_url.split("user_code=")[-1] + tado_device_url = self.tado.device_verification_url() + user_code = URL(tado_device_url).query["user_code"] async def _wait_for_login() -> None: """Wait for the user to login.""" @@ -120,10 +96,8 @@ async def _wait_for_login() -> None: raise CannotConnect from ex if ( - await self.hass.async_add_executor_job( - self.tado.device_activation_status - ) - != "COMPLETED" + self.tado.device_activation_status() + is not DeviceActivationStatus.COMPLETED ): raise CannotConnect @@ -135,16 +109,14 @@ async def _wait_for_login() -> None: if self.login_task.done(): _LOGGER.debug("Login task is done, checking results") if self.login_task.exception(): - return self.async_show_progress_done( - next_step_id="could_not_authenticate" - ) + return self.async_show_progress_done(next_step_id="timeout") self.refresh_token = await self.hass.async_add_executor_job( self.tado.get_refresh_token ) - return self.async_show_progress_done(next_step_id="finalize_auth_login") + return self.async_show_progress_done(next_step_id="finish_login") return self.async_show_progress( - step_id="reauth_confirm", + step_id="user", progress_action="wait_for_device", description_placeholders={ "url": tado_device_url, @@ -153,7 +125,7 @@ async def _wait_for_login() -> None: progress_task=self.login_task, ) - async def async_step_finalize_auth_login( + async def async_step_finish_login( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: @@ -163,7 +135,7 @@ async def async_step_finalize_auth_login( tado_me = await self.hass.async_add_executor_job(self.tado.get_me) if "homes" not in tado_me or len(tado_me["homes"]) == 0: - raise NoHomes + return self.async_abort(reason="no_homes") home = tado_me["homes"][0] unique_id = str(home["id"]) @@ -183,12 +155,17 @@ async def async_step_finalize_auth_login( data={CONF_REFRESH_TOKEN: self.refresh_token}, ) - async def async_step_could_not_authenticate( + async def async_step_timeout( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle issues that need transition await from progress step.""" - return self.async_abort(reason="could_not_authenticate") + if user_input is None: + return self.async_show_form( + step_id="timeout", + ) + del self.login_task + return await self.async_step_user() async def async_step_homekit( self, discovery_info: ZeroconfServiceInfo @@ -236,11 +213,3 @@ async def async_step_init( class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoHomes(HomeAssistantError): - """Error to indicate the account has no homes.""" diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 8b7b46145e34c0..d8c4e26320aa8e 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -4,9 +4,11 @@ "wait_for_device": "To authenticate, open the following URL and login at Tado: \n {url} \n If the code is not automatically copied, and paste the following code to authorize the integration: \n```\n{code}\n```\n\nThe login atempt will time out after five minutes." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "could_not_authenticate": "Could not authenticate with Tado." + "could_not_authenticate": "Could not authenticate with Tado.", + "no_homes": "There are no homes linked to this Tado account.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "user": { @@ -18,7 +20,7 @@ }, "auth_prepare": { "title": "Authenticate with Tado", - "description": "You need (re-)authenicate again with Tado. Press Submit to start the (re-)authentication process." + "description": "You need (re-)authenticate again with Tado. Press Submit to start the (re-)authentication process." }, "reconfigure": { "title": "Reconfigure your Tado", @@ -30,12 +32,6 @@ "password": "Enter the (new) password for Tado." } } - }, - "error": { - "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this Tado account.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "options": { diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py new file mode 100644 index 00000000000000..44a93a1b74cd2a --- /dev/null +++ b/tests/components/tado/conftest.py @@ -0,0 +1,49 @@ +"""Fixtures for Tado tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from PyTado.http import DeviceActivationStatus +import pytest + +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_tado_api() -> Generator[MagicMock]: + """Mock the Tado API.""" + with ( + patch("homeassistant.components.tado.Tado") as mock_tado, + patch("homeassistant.components.tado.config_flow.Tado", new=mock_tado), + ): + client = mock_tado.return_value + client.device_verification_url.return_value = ( + "https://login.tado.com/oauth2/device?user_code=TEST" + ) + client.device_activation_status.return_value = DeviceActivationStatus.COMPLETED + client.get_me.return_value = load_json_object_fixture("me.json", DOMAIN) + client.get_refresh_token.return_value = "refresh" + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.tado.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REFRESH_TOKEN: "refresh", + }, + unique_id="1", + ) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 49e9655c7f8609..60209196d5d0c9 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,21 +1,19 @@ """Test the Tado config flow.""" -from http import HTTPStatus from ipaddress import ip_address -from unittest.mock import MagicMock, patch +import threading +from unittest.mock import AsyncMock, MagicMock, patch -from freezegun.api import FrozenDateTimeFactory -import pytest -import requests -import requests_mock +from PyTado.exceptions import TadoException +from PyTado.http import DeviceActivationStatus -from homeassistant import config_entries from homeassistant.components.tado import CONF_REFRESH_TOKEN from homeassistant.components.tado.const import ( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, ) +from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -24,273 +22,144 @@ ZeroconfServiceInfo, ) -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry -def _get_mock_tado_api(get_me=None) -> MagicMock: - mock_tado = MagicMock() - if isinstance(get_me, Exception): - type(mock_tado).get_me = MagicMock(side_effect=get_me) - else: - type(mock_tado).get_me = MagicMock(return_value=get_me) - return mock_tado - - -@pytest.mark.parametrize( - ("exception"), - [ - KeyError, - RuntimeError, - ValueError, - ], -) -async def test_authentication_exceptions( +async def test_full_flow( hass: HomeAssistant, - exception: Exception, - freezer: FrozenDateTimeFactory, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, ) -> None: - """Test we handle Form Exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + """Test the full flow of the config flow.""" - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth_prepare" - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_REFRESH_TOKEN: "refresh"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - - # Test a retry to recover, upon failure - with requests_mock.mock() as m: - m.post( - "https://login.tado.com/oauth2/device_authorize", - text=load_fixture("tado/device_authorize.json"), - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth_prepare" + event = threading.Event() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) - assert result["step_id"] == "reauth_confirm" - assert result["type"] is FlowResultType.SHOW_PROGRESS + mock_tado_api.device_activation = mock_tado_api_device_activation - m.post( - "https://login.tado.com/oauth2/token", text=load_fixture("tado/token.json") - ) - m.get("https://my.tado.com/api/v2/me", text=load_fixture("tado/me.json")) - m.get( - "https://my.tado.com/api/v2/homes/1/", text=load_fixture("tado/home.json") - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" - freezer.tick(10) - await hass.async_block_till_done() + event.set() + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "home name" - assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) - entry.add_to_hass(hass) +async def test_auth_timeout( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the auth timeout.""" + mock_tado_api.device_activation_status.return_value = DeviceActivationStatus.PENDING result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "timeout" - with patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - entry.entry_id, context={"source": config_entries.SOURCE_USER} + mock_tado_api.device_activation_status.return_value = ( + DeviceActivationStatus.COMPLETED ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 -async def test_create_entry( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +async def test_no_homes( + hass: HomeAssistant, + mock_tado_api: MagicMock, ) -> None: - """Test we can setup though the user path.""" + """Test the full flow of the config flow.""" + mock_tado_api.get_me.return_value["homes"] = [] - # I use a generic with to ensure all calls, including the polling checks are all in the same context - with requests_mock.mock() as m: - m.post( - "https://login.tado.com/oauth2/device_authorize", - text=load_fixture("tado/device_authorize.json"), - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth_prepare" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - assert result["step_id"] == "reauth_confirm" - assert result["type"] is FlowResultType.SHOW_PROGRESS - - m.post( - "https://login.tado.com/oauth2/token", text=load_fixture("tado/token.json") - ) - m.get("https://my.tado.com/api/v2/me", text=load_fixture("tado/me.json")) - m.get( - "https://my.tado.com/api/v2/homes/1/", text=load_fixture("tado/home.json") - ) - - freezer.tick(10) - await hass.async_block_till_done() - - with ( - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "home name" - assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "finish_login" - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_homes" - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} +def _get_mock_tado_api(get_me=None) -> MagicMock: + mock_tado = MagicMock() + if isinstance(get_me, Exception): + type(mock_tado).get_me = MagicMock(side_effect=get_me) + else: + type(mock_tado).get_me = MagicMock(return_value=get_me) + return mock_tado -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) +async def test_tado_creation( + hass: HomeAssistant, +) -> None: + """Test we handle Form Exceptions.""" with patch( "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, + side_effect=TadoException("Test exception"), ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" -async def test_no_homes(hass: HomeAssistant) -> None: - """Test we handle no homes error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) +async def test_options_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow options.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, + ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_homes"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} -async def test_form_homekit(hass: HomeAssistant) -> None: +async def test_homekit( + hass: HomeAssistant, + mock_tado_api: MagicMock, +) -> None: """Test that we abort from homekit if tado is already setup.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -301,8 +170,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None: type="mock_type", ), ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE flow = next( flow for flow in hass.config_entries.flow.async_progress() @@ -317,7 +185,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], From 9a85ca9f847288ca5ad26895e82823578f89f86b Mon Sep 17 00:00:00 2001 From: Joostlek Date: Wed, 19 Mar 2025 15:50:08 +0100 Subject: [PATCH 15/28] Fix --- tests/components/tado/test_config_flow.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 60209196d5d0c9..fd12b090436678 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -83,10 +83,7 @@ async def test_auth_timeout( assert len(mock_setup_entry.mock_calls) == 1 -async def test_no_homes( - hass: HomeAssistant, - mock_tado_api: MagicMock, -) -> None: +async def test_no_homes(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: """Test the full flow of the config flow.""" mock_tado_api.get_me.return_value["homes"] = [] @@ -102,18 +99,7 @@ async def test_no_homes( assert result["reason"] == "no_homes" -def _get_mock_tado_api(get_me=None) -> MagicMock: - mock_tado = MagicMock() - if isinstance(get_me, Exception): - type(mock_tado).get_me = MagicMock(side_effect=get_me) - else: - type(mock_tado).get_me = MagicMock(return_value=get_me) - return mock_tado - - -async def test_tado_creation( - hass: HomeAssistant, -) -> None: +async def test_tado_creation(hass: HomeAssistant) -> None: """Test we handle Form Exceptions.""" with patch( From 16020b15ef8049d24602790d3e2d82749c40339a Mon Sep 17 00:00:00 2001 From: Joostlek Date: Wed, 19 Mar 2025 15:51:29 +0100 Subject: [PATCH 16/28] Fix --- tests/components/tado/test_config_flow.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index fd12b090436678..ba29886f38f9b8 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -137,10 +137,7 @@ async def test_options_flow( assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} -async def test_homekit( - hass: HomeAssistant, - mock_tado_api: MagicMock, -) -> None: +async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: """Test that we abort from homekit if tado is already setup.""" result = await hass.config_entries.flow.async_init( From 8c4d4f3635dcabc3b4b4b5fe15dc88dc6937f929 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 20:05:03 +0000 Subject: [PATCH 17/28] Update version to 2 --- homeassistant/components/tado/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 4dfebbcc6d0b11..3a23b08e986eaa 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -49,7 +49,7 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" - VERSION = 1 + VERSION = 2 login_task: asyncio.Task | None = None refresh_token: str | None = None tado: Tado | None = None From 7e717a1a368d8494449496f8d354ccb63aba2825 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 20:19:18 +0000 Subject: [PATCH 18/28] All migration code --- homeassistant/components/tado/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index c7edd4070d6fc6..656599c97cf519 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -8,7 +8,7 @@ from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -101,6 +101,19 @@ def create_tado_instance() -> tuple[Tado, str]: return True +async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version < 2: + _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data) + data = dict(entry.data) + data.pop(CONF_USERNAME, None) + data.pop(CONF_PASSWORD, None) + hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + _LOGGER.debug("Migration to version 2 successful") + return True + + @callback def _async_import_options_from_data_if_missing( hass: HomeAssistant, entry: TadoConfigEntry From e8e890282efb2e35476c867c8893e7c696416e03 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 20:24:25 +0000 Subject: [PATCH 19/28] Small tuning --- homeassistant/components/tado/__init__.py | 4 ++-- homeassistant/components/tado/config_flow.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 656599c97cf519..8aefa13447922d 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -107,8 +107,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bo if entry.version < 2: _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data) data = dict(entry.data) - data.pop(CONF_USERNAME, None) - data.pop(CONF_PASSWORD, None) + data.pop(CONF_USERNAME) + data.pop(CONF_PASSWORD) hass.config_entries.async_update_entry(entry=entry, data=data, version=2) _LOGGER.debug("Migration to version 2 successful") return True diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 3a23b08e986eaa..16b9ebbd75d31f 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -20,7 +20,6 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ( @@ -38,13 +37,6 @@ _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" From 21e22bfd9f59588d6bf3e8455579732ab0075207 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 20:27:13 +0000 Subject: [PATCH 20/28] Add reauth unique ID check --- homeassistant/components/tado/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 16b9ebbd75d31f..b5c8cce8a62689 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -142,6 +142,7 @@ async def async_step_finish_login( data={CONF_REFRESH_TOKEN: self.refresh_token}, ) + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data={CONF_REFRESH_TOKEN: self.refresh_token}, From 25b850b178dd5687d7a34111dd54b1fc4c99dbcf Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 20:48:20 +0000 Subject: [PATCH 21/28] Add reauth test --- homeassistant/components/tado/strings.json | 3 +- tests/components/tado/test_config_flow.py | 52 +++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index d8c4e26320aa8e..90d9e92911fa3d 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -8,7 +8,8 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "could_not_authenticate": "Could not authenticate with Tado.", "no_homes": "There are no homes linked to this Tado account.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "step": { "user": { diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index ba29886f38f9b8..c0e7df966f769f 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -7,9 +7,9 @@ from PyTado.exceptions import TadoException from PyTado.http import DeviceActivationStatus -from homeassistant.components.tado import CONF_REFRESH_TOKEN from homeassistant.components.tado.const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, ) @@ -57,6 +57,56 @@ def mock_tado_api_device_activation() -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config when reauthticating.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ABC-123-DEF-456", + data={CONF_REFRESH_TOKEN: "totally_refresh_for_reauth"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # The no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + + async def test_auth_timeout( hass: HomeAssistant, mock_tado_api: MagicMock, From c9731628b3fe6e45f20e1bc637ce8ca83e80f2fa Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 21:14:17 +0000 Subject: [PATCH 22/28] 100% on config flow --- tests/components/tado/test_config_flow.py | 35 +++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index c0e7df966f769f..f7418309d461cf 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -4,9 +4,10 @@ import threading from unittest.mock import AsyncMock, MagicMock, patch -from PyTado.exceptions import TadoException from PyTado.http import DeviceActivationStatus +import pytest +from homeassistant.components.tado.config_flow import TadoException from homeassistant.components.tado.const import ( CONF_FALLBACK, CONF_REFRESH_TOKEN, @@ -125,7 +126,13 @@ async def test_auth_timeout( DeviceActivationStatus.COMPLETED ) - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "timeout" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "home name" @@ -163,6 +170,30 @@ async def test_tado_creation(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "timeout"), + (TadoException, "timeout"), + ], +) +async def test_wait_for_login_exception( + hass: HomeAssistant, + mock_tado_api: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test that an exception in wait for login is handled properly.""" + mock_tado_api.device_activation.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + # @joostlek: I think the timeout step is not rightfully named, but heck, it works + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == error + + async def test_options_flow( hass: HomeAssistant, mock_tado_api: MagicMock, From 85b0b9a96dc40fafe76b3afdebb91a2ee23b6db0 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 19 Mar 2025 21:40:10 +0000 Subject: [PATCH 23/28] Making tests working on new device flow --- homeassistant/components/tado/__init__.py | 4 ++-- tests/components/tado/test_helper.py | 6 +++--- tests/components/tado/util.py | 13 +++++++++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 8aefa13447922d..656599c97cf519 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -107,8 +107,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bo if entry.version < 2: _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data) data = dict(entry.data) - data.pop(CONF_USERNAME) - data.pop(CONF_PASSWORD) + data.pop(CONF_USERNAME, None) + data.pop(CONF_PASSWORD, None) hass.config_entries.async_update_entry(entry=entry, data=data, version=2) _LOGGER.debug("Migration to version 2 successful") return True diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index da959c2124ad7f..7f798e3797cd33 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -5,7 +5,7 @@ from PyTado.interface import Tado import pytest -from homeassistant.components.tado import TadoDataUpdateCoordinator +from homeassistant.components.tado import CONF_REFRESH_TOKEN, TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, @@ -28,13 +28,13 @@ def entry(request: pytest.FixtureRequest) -> MockConfigEntry: request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT ) return MockConfigEntry( - version=1, - minor_version=1, + version=2, domain=DOMAIN, title="Tado", data={ CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_REFRESH_TOKEN: "test-refresh", }, options={ "fallback": fallback, diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 5bf87dbed33c12..6872e4e45c4f08 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -2,7 +2,7 @@ import requests_mock -from homeassistant.components.tado import DOMAIN +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -178,9 +178,18 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=load_fixture(zone_1_state_fixture), ) + m.post( + "https://login.tado.com/oauth2/token", + text=load_fixture(token_fixture), + ) entry = MockConfigEntry( domain=DOMAIN, - data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, + version=2, + data={ + CONF_USERNAME: "mock", + CONF_PASSWORD: "mock", + CONF_REFRESH_TOKEN: "mock-token", + }, options={"fallback": "NEXT_TIME_BLOCK"}, ) entry.add_to_hass(hass) From 31283c8c449192ce542c3236f3b5809e8ef7ce12 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Fri, 21 Mar 2025 11:34:32 +0100 Subject: [PATCH 24/28] Fix --- homeassistant/components/tado/__init__.py | 8 ++------ homeassistant/components/tado/config_flow.py | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 656599c97cf519..d1994075f12cec 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -61,6 +61,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Set up Tado from a config entry.""" + if CONF_REFRESH_TOKEN not in entry.data: + raise ConfigEntryAuthFailed _async_import_options_from_data_if_missing(hass, entry) @@ -96,7 +98,6 @@ def create_tado_instance() -> tuple[Tado, str]: entry.runtime_data = TadoData(coordinator, mobile_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -133,11 +134,6 @@ def _async_import_options_from_data_if_missing( hass.config_entries.async_update_entry(entry, options=options) -async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index b5c8cce8a62689..64763469885c56 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -189,7 +189,9 @@ async def async_step_init( ) -> ConfigFlowResult: """Handle options flow.""" if user_input: - return self.async_create_entry(data=user_input) + result = self.async_create_entry(data=user_input) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + return result data_schema = vol.Schema( { From b43842c5b36b42037cba778700f17d6fd5e87098 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Fri, 21 Mar 2025 11:41:22 +0100 Subject: [PATCH 25/28] Fix --- tests/components/tado/conftest.py | 1 + tests/components/tado/test_init.py | 30 ++++++++++++++++++++++++++++++ tests/components/tado/util.py | 3 --- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 tests/components/tado/test_init.py diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py index 44a93a1b74cd2a..1aa62b218a2c9e 100644 --- a/tests/components/tado/conftest.py +++ b/tests/components/tado/conftest.py @@ -46,4 +46,5 @@ def mock_config_entry() -> MockConfigEntry: CONF_REFRESH_TOKEN: "refresh", }, unique_id="1", + version=2, ) diff --git a/tests/components/tado/test_init.py b/tests/components/tado/test_init.py new file mode 100644 index 00000000000000..2f2ccacf3c030a --- /dev/null +++ b/tests/components/tado/test_init.py @@ -0,0 +1,30 @@ +"""Test the Tado integration.""" + +from homeassistant.components.tado import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_v1_migration(hass: HomeAssistant) -> None: + """Test migration from v1 to v2 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test", + CONF_PASSWORD: "test", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.version == 2 + assert CONF_USERNAME not in entry.data + assert CONF_PASSWORD not in entry.data + + assert entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 6872e4e45c4f08..6fd333dff5157d 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -3,7 +3,6 @@ import requests_mock from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -186,8 +185,6 @@ async def async_init_integration( domain=DOMAIN, version=2, data={ - CONF_USERNAME: "mock", - CONF_PASSWORD: "mock", CONF_REFRESH_TOKEN: "mock-token", }, options={"fallback": "NEXT_TIME_BLOCK"}, From 575bacbeb5c65ce28ea34a4f847403edcafd36b8 Mon Sep 17 00:00:00 2001 From: Joostlek Date: Fri, 21 Mar 2025 11:47:12 +0100 Subject: [PATCH 26/28] Fix --- homeassistant/components/tado/strings.json | 24 +++++----------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 90d9e92911fa3d..d1267ae1572333 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,7 +1,7 @@ { "config": { "progress": { - "wait_for_device": "To authenticate, open the following URL and login at Tado: \n {url} \n If the code is not automatically copied, and paste the following code to authorize the integration: \n```\n{code}\n```\n\nThe login atempt will time out after five minutes." + "wait_for_device": "To authenticate, open the following URL and login at Tado: \n {url} \n If the code is not automatically copied, and paste the following code to authorize the integration: \n```\n{code}\n```\n\nThe login attempt will time out after five minutes." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", @@ -12,26 +12,12 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Connect to your Tado account" - }, - "auth_prepare": { + "reauth_confirm": { "title": "Authenticate with Tado", - "description": "You need (re-)authenticate again with Tado. Press Submit to start the (re-)authentication process." + "description": "You need to reauthenticate with Tado. Press Submit to start the authentication process." }, - "reconfigure": { - "title": "Reconfigure your Tado", - "description": "Reconfigure the entry for your account: `{username}`.", - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "Enter the (new) password for Tado." - } + "timeout": { + "description": "The authentication process timed out. Please try again." } } }, From 6066ceb8eb424285ae8d83aa47d0207549662766 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Mar 2025 16:32:58 +0100 Subject: [PATCH 27/28] Update homeassistant/components/tado/strings.json --- homeassistant/components/tado/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index d1267ae1572333..756d4bfc20d836 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -14,7 +14,7 @@ "step": { "reauth_confirm": { "title": "Authenticate with Tado", - "description": "You need to reauthenticate with Tado. Press Submit to start the authentication process." + "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process." }, "timeout": { "description": "The authentication process timed out. Please try again." From c66a9235841e2bcd09d7643e60619d1f0bdc6647 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 24 Mar 2025 13:18:48 +0100 Subject: [PATCH 28/28] Update homeassistant/components/tado/strings.json --- homeassistant/components/tado/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 756d4bfc20d836..c7aef7eb51cf83 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,7 +1,7 @@ { "config": { "progress": { - "wait_for_device": "To authenticate, open the following URL and login at Tado: \n {url} \n If the code is not automatically copied, and paste the following code to authorize the integration: \n```\n{code}\n```\n\nThe login attempt will time out after five minutes." + "wait_for_device": "To authenticate, open the following URL and login at Tado:\n{url}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{code}```\n\n\nThe login attempt will time out after five minutes." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",