Tado migrate to OAuth Device Flow (#140761)

* Bump PyTado 0.19.0

* Initial setup

* Current state

* Update to PyTado 0.18.8

* First concept for review

* Fix

* Fix

* Fix

* First concept for review

* Bump PyTado to 0.18.9

* Remove redundant part

* Initial test setup

* Authentication exceptions

* Fix

* Fix

* Fix

* Update version to 2

* All migration code

* Small tuning

* Add reauth unique ID check

* Add reauth test

* 100% on config flow

* Making tests working on new device flow

* Fix

* Fix

* Fix

* Update homeassistant/components/tado/strings.json

* Update homeassistant/components/tado/strings.json

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
This commit is contained in:
Erwin Douna 2025-03-24 14:28:12 +01:00 committed by GitHub
parent 83a0ed4250
commit 358f78c7cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 470 additions and 430 deletions

View File

@ -10,12 +10,17 @@ from PyTado.interface import Tado
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback 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 import config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_FALLBACK, CONF_FALLBACK,
CONF_REFRESH_TOKEN,
CONST_OVERLAY_MANUAL, CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_MODE,
@ -56,23 +61,34 @@ type TadoConfigEntry = ConfigEntry[TadoData]
async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
"""Set up Tado from a config entry.""" """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) _async_import_options_from_data_if_missing(hass, entry)
_LOGGER.debug("Setting up Tado connection") _LOGGER.debug("Setting up Tado connection")
_LOGGER.debug(
"Creating tado instance with refresh token: %s",
entry.data[CONF_REFRESH_TOKEN],
)
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()
try: try:
tado = await hass.async_add_executor_job( tado, device_status = await hass.async_add_executor_job(create_tado_instance)
Tado,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
except PyTado.exceptions.TadoWrongCredentialsException as err: except PyTado.exceptions.TadoWrongCredentialsException as err:
raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err
except PyTado.exceptions.TadoException as err: except PyTado.exceptions.TadoException as err:
raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err
_LOGGER.debug( if device_status != "COMPLETED":
"Tado connection established for username: %s", entry.data[CONF_USERNAME] raise ConfigEntryAuthFailed(
) f"Device login flow status is {device_status}. Starting re-authentication."
)
_LOGGER.debug("Tado connection established")
coordinator = TadoDataUpdateCoordinator(hass, entry, tado) coordinator = TadoDataUpdateCoordinator(hass, entry, tado)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
@ -82,11 +98,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool
entry.runtime_data = TadoData(coordinator, mobile_coordinator) entry.runtime_data = TadoData(coordinator, mobile_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True 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 @callback
def _async_import_options_from_data_if_missing( def _async_import_options_from_data_if_missing(
hass: HomeAssistant, entry: TadoConfigEntry hass: HomeAssistant, entry: TadoConfigEntry
@ -106,11 +134,6 @@ def _async_import_options_from_data_if_missing(
hass.config_entries.async_update_entry(entry, options=options) 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: async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -2,22 +2,25 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
import PyTado from PyTado.exceptions import TadoException
from PyTado.http import DeviceActivationStatus
from PyTado.interface import Tado from PyTado.interface import Tado
import requests.exceptions
import voluptuous as vol import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
) )
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.zeroconf import ( from homeassistant.helpers.service_info.zeroconf import (
ATTR_PROPERTIES_ID, ATTR_PROPERTIES_ID,
@ -26,137 +29,149 @@ from homeassistant.helpers.service_info.zeroconf import (
from .const import ( from .const import (
CONF_FALLBACK, CONF_FALLBACK,
CONF_REFRESH_TOKEN,
CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_OPTIONS, CONST_OVERLAY_TADO_OPTIONS,
DOMAIN, DOMAIN,
UNIQUE_ID,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
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.
"""
try:
tado = await hass.async_add_executor_job(
Tado, data[CONF_USERNAME], data[CONF_PASSWORD]
)
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:
raise InvalidAuth from ex
raise CannotConnect from ex
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"]
return {"title": name, UNIQUE_ID: unique_id}
class TadoConfigFlow(ConfigFlow, domain=DOMAIN): class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Tado.""" """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
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth on credential failure."""
return await self.async_step_reauth_confirm()
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="reauth_confirm")
return await self.async_step_user()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle users reauth credentials."""
errors = {}
if user_input is not None: if self.tado is None:
_LOGGER.debug("Initiating device activation")
try: try:
validated = await validate_input(self.hass, user_input) self.tado = await self.hass.async_add_executor_job(Tado)
except CannotConnect: except TadoException:
errors["base"] = "cannot_connect" _LOGGER.exception("Error while initiating Tado")
except InvalidAuth: return self.async_abort(reason="cannot_connect")
errors["base"] = "invalid_auth" assert self.tado is not None
except NoHomes: tado_device_url = self.tado.device_verification_url()
errors["base"] = "no_homes" user_code = URL(tado_device_url).query["user_code"]
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if "base" not in errors: async def _wait_for_login() -> None:
await self.async_set_unique_id(validated[UNIQUE_ID]) """Wait for the user to login."""
self._abort_if_unique_id_configured() assert self.tado is not None
return self.async_create_entry( _LOGGER.debug("Waiting for device activation")
title=validated["title"], data=user_input 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
return self.async_show_form( if (
step_id="user", data_schema=DATA_SCHEMA, errors=errors self.tado.device_activation_status()
is not DeviceActivationStatus.COMPLETED
):
raise CannotConnect
_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="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="finish_login")
return self.async_show_progress(
step_id="user",
progress_action="wait_for_device",
description_placeholders={
"url": tado_device_url,
"code": user_code,
},
progress_task=self.login_task,
) )
async def async_step_finish_login(
self,
user_input: dict[str, Any] | None = None,
) -> 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:
return self.async_abort(reason="no_homes")
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_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},
)
async def async_step_timeout(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle issues that need transition await from progress step."""
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( async def async_step_homekit(
self, discovery_info: ZeroconfServiceInfo self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle HomeKit discovery.""" """Handle HomeKit discovery."""
self._async_abort_entries_match() self._async_abort_entries_match()
properties = { properties = {
key.lower(): value for (key, value) in discovery_info.properties.items() key.lower(): value for key, value in discovery_info.properties.items()
} }
await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return await self.async_step_user() return await self.async_step_user()
async def async_step_reconfigure(
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()
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"
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=user_input
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
description_placeholders={
CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]
},
)
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -173,8 +188,10 @@ class OptionsFlowHandler(OptionsFlow):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle options flow.""" """Handle options flow."""
if user_input is not None: 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( data_schema = vol.Schema(
{ {
@ -191,11 +208,3 @@ class OptionsFlowHandler(OptionsFlow):
class CannotConnect(HomeAssistantError): class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect.""" """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."""

View File

@ -37,6 +37,7 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = {
# Configuration # Configuration
CONF_FALLBACK = "fallback" CONF_FALLBACK = "fallback"
CONF_HOME_ID = "home_id" CONF_HOME_ID = "home_id"
CONF_REFRESH_TOKEN = "refresh_token"
DATA = "data" DATA = "data"
# Weather # Weather

View File

@ -10,7 +10,6 @@ from PyTado.interface import Tado
from requests import RequestException from requests import RequestException
from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -20,6 +19,7 @@ if TYPE_CHECKING:
from .const import ( from .const import (
CONF_FALLBACK, CONF_FALLBACK,
CONF_REFRESH_TOKEN,
CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_DEFAULT,
DOMAIN, DOMAIN,
INSIDE_TEMPERATURE_MEASUREMENT, INSIDE_TEMPERATURE_MEASUREMENT,
@ -58,8 +58,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )
self._tado = tado self._tado = tado
self._username = config_entry.data[CONF_USERNAME] self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN]
self._password = config_entry.data[CONF_PASSWORD]
self._fallback = config_entry.options.get( self._fallback = config_entry.options.get(
CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT
) )
@ -108,6 +107,18 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
self.data["weather"] = home["weather"] self.data["weather"] = home["weather"]
self.data["geofence"] = home["geofence"] 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 return self.data
async def _async_update_devices(self) -> dict[str, dict]: async def _async_update_devices(self) -> dict[str, dict]:

View File

@ -14,5 +14,5 @@
}, },
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["PyTado"], "loggers": ["PyTado"],
"requirements": ["python-tado==0.18.6"] "requirements": ["python-tado==0.18.9"]
} }

View File

@ -1,33 +1,24 @@
{ {
"config": { "config": {
"progress": {
"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": { "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%]" "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%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}, },
"step": { "step": {
"user": { "reauth_confirm": {
"data": { "title": "Authenticate with Tado",
"password": "[%key:common::config_flow::data::password%]", "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process."
"username": "[%key:common::config_flow::data::username%]"
},
"title": "Connect to your Tado account"
}, },
"reconfigure": { "timeout": {
"title": "Reconfigure your Tado", "description": "The authentication process timed out. Please try again."
"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."
}
} }
},
"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": { "options": {

2
requirements_all.txt generated
View File

@ -2477,7 +2477,7 @@ python-snoo==0.6.4
python-songpal==0.16.2 python-songpal==0.16.2
# homeassistant.components.tado # homeassistant.components.tado
python-tado==0.18.6 python-tado==0.18.9
# homeassistant.components.technove # homeassistant.components.technove
python-technove==2.0.0 python-technove==2.0.0

View File

@ -2007,7 +2007,7 @@ python-snoo==0.6.4
python-songpal==0.16.2 python-songpal==0.16.2
# homeassistant.components.tado # homeassistant.components.tado
python-tado==0.18.6 python-tado==0.18.9
# homeassistant.components.technove # homeassistant.components.technove
python-technove==2.0.0 python-technove==2.0.0

View File

@ -1 +1 @@
"""Tests for the tado integration.""" """Tests for the Tado integration."""

View File

@ -0,0 +1,50 @@
"""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",
version=2,
)

View File

@ -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"
}

View File

@ -1,20 +1,20 @@
"""Test the Tado config flow.""" """Test the Tado config flow."""
from http import HTTPStatus
from ipaddress import ip_address from ipaddress import ip_address
from unittest.mock import MagicMock, patch import threading
from unittest.mock import AsyncMock, MagicMock, patch
import PyTado from PyTado.http import DeviceActivationStatus
import pytest import pytest
import requests
from homeassistant import config_entries from homeassistant.components.tado.config_flow import TadoException
from homeassistant.components.tado.config_flow import NoHomes
from homeassistant.components.tado.const import ( from homeassistant.components.tado.const import (
CONF_FALLBACK, CONF_FALLBACK,
CONF_REFRESH_TOKEN,
CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_DEFAULT,
DOMAIN, DOMAIN,
) )
from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -26,92 +26,186 @@ from homeassistant.helpers.service_info.zeroconf import (
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
def _get_mock_tado_api(get_me=None) -> MagicMock: async def test_full_flow(
mock_tado = MagicMock() hass: HomeAssistant,
if isinstance(get_me, Exception): mock_tado_api: MagicMock,
type(mock_tado).get_me = MagicMock(side_effect=get_me) mock_setup_entry: AsyncMock,
else: ) -> None:
type(mock_tado).get_me = MagicMock(return_value=get_me) """Test the full flow of the config flow."""
return mock_tado
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"}
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,
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": SOURCE_USER}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE
assert result["step_id"] == "timeout"
mock_tado_api.device_activation_status.return_value = (
DeviceActivationStatus.COMPLETED
)
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"
assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"}
assert len(mock_setup_entry.mock_calls) == 1
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"] = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE
assert result["step_id"] == "finish_login"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_homes"
async def test_tado_creation(hass: HomeAssistant) -> None:
"""Test we handle Form Exceptions."""
with patch(
"homeassistant.components.tado.config_flow.Tado",
side_effect=TadoException("Test exception"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("exception", "error"), ("exception", "error"),
[ [
(KeyError, "invalid_auth"), (Exception, "timeout"),
(RuntimeError, "cannot_connect"), (TadoException, "timeout"),
(ValueError, "unknown"),
], ],
) )
async def test_form_exceptions( async def test_wait_for_login_exception(
hass: HomeAssistant, exception: Exception, error: str hass: HomeAssistant,
mock_tado_api: MagicMock,
exception: Exception,
error: str,
) -> None: ) -> None:
"""Test we handle Form Exceptions.""" """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( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
# @joostlek: I think the timeout step is not rightfully named, but heck, it works
with patch( assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE
"homeassistant.components.tado.config_flow.Tado", assert result["step_id"] == error
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-username", "password": "test-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
# Test a retry to recover, upon failure
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"],
{"username": "test-username", "password": "test-password"},
)
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
async def test_options_flow(hass: HomeAssistant) -> None: async def test_options_flow(
hass: HomeAssistant,
mock_tado_api: MagicMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config flow options.""" """Test config flow options."""
entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) mock_config_entry.add_to_hass(hass)
entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
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}
)
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init" assert result["step_id"] == "init"
@ -119,125 +213,17 @@ async def test_options_flow(hass: HomeAssistant) -> None:
result["flow_id"], result["flow_id"],
{CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT},
) )
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}
async def test_create_entry(hass: HomeAssistant) -> None: async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> 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"] == {}
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"],
{"username": "test-username", "password": "test-password"},
)
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
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}
)
response_mock = MagicMock()
type(response_mock).status_code = HTTPStatus.UNAUTHORIZED
mock_tado_api = _get_mock_tado_api(
get_me=requests.HTTPError(response=response_mock)
)
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.FORM
assert result["errors"] == {"base": "invalid_auth"}
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)
)
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.FORM
assert result["errors"] == {"base": "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}
)
mock_tado_api = _get_mock_tado_api(get_me={"homes": []})
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.FORM
assert result["errors"] == {"base": "no_homes"}
async def test_form_homekit(hass: HomeAssistant) -> None:
"""Test that we abort from homekit if tado is already setup.""" """Test that we abort from homekit if tado is already setup."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_HOMEKIT}, context={"source": SOURCE_HOMEKIT},
data=ZeroconfServiceInfo( data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"), ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")], ip_addresses=[ip_address("127.0.0.1")],
@ -248,8 +234,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None:
type="mock_type", type="mock_type",
), ),
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE
assert result["errors"] == {}
flow = next( flow = next(
flow flow
for flow in hass.config_entries.flow.async_progress() for flow in hass.config_entries.flow.async_progress()
@ -264,7 +249,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_HOMEKIT}, context={"source": SOURCE_HOMEKIT},
data=ZeroconfServiceInfo( data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"), ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")], ip_addresses=[ip_address("127.0.0.1")],
@ -276,77 +261,3 @@ async def test_form_homekit(hass: HomeAssistant) -> None:
), ),
) )
assert result["type"] is FlowResultType.ABORT 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,
}

View File

@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
from PyTado.interface import Tado from PyTado.interface import Tado
import pytest import pytest
from homeassistant.components.tado import TadoDataUpdateCoordinator from homeassistant.components.tado import CONF_REFRESH_TOKEN, TadoDataUpdateCoordinator
from homeassistant.components.tado.const import ( from homeassistant.components.tado.const import (
CONST_OVERLAY_MANUAL, CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT, 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 request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT
) )
return MockConfigEntry( return MockConfigEntry(
version=1, version=2,
minor_version=1,
domain=DOMAIN, domain=DOMAIN,
title="Tado", title="Tado",
data={ data={
CONF_USERNAME: "test-username", CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_REFRESH_TOKEN: "test-refresh",
}, },
options={ options={
"fallback": fallback, "fallback": fallback,

View File

@ -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

View File

@ -2,8 +2,7 @@
import requests_mock 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 from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture from tests.common import MockConfigEntry, load_fixture
@ -178,9 +177,16 @@ async def async_init_integration(
"https://my.tado.com/api/v2/homes/1/zones/1/state", "https://my.tado.com/api/v2/homes/1/zones/1/state",
text=load_fixture(zone_1_state_fixture), text=load_fixture(zone_1_state_fixture),
) )
m.post(
"https://login.tado.com/oauth2/token",
text=load_fixture(token_fixture),
)
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, version=2,
data={
CONF_REFRESH_TOKEN: "mock-token",
},
options={"fallback": "NEXT_TIME_BLOCK"}, options={"fallback": "NEXT_TIME_BLOCK"},
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)