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.const import CONF_PASSWORD, CONF_USERNAME, 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 .const import (
CONF_FALLBACK,
CONF_REFRESH_TOKEN,
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_MODE,
@ -56,24 +61,35 @@ type TadoConfigEntry = ConfigEntry[TadoData]
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)
_LOGGER.debug("Setting up Tado connection")
try:
tado = await hass.async_add_executor_job(
Tado,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
_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:
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
_LOGGER.debug(
"Tado connection established for username: %s", entry.data[CONF_USERNAME]
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)
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)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
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
@ -106,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)

View File

@ -2,22 +2,25 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging
from typing import Any
import PyTado
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,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
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,
@ -26,137 +29,149 @@ from homeassistant.helpers.service_info.zeroconf import (
from .const import (
CONF_FALLBACK,
CONF_REFRESH_TOKEN,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_OPTIONS,
DOMAIN,
UNIQUE_ID,
)
_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):
"""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(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
"""Handle users reauth credentials."""
if self.tado is None:
_LOGGER.debug("Initiating device activation")
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"
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 = self.tado.device_verification_url()
user_code = URL(tado_device_url).query["user_code"]
if "base" not in errors:
await self.async_set_unique_id(validated[UNIQUE_ID])
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)
except Exception as ex:
_LOGGER.exception("Error while waiting for device activation")
raise CannotConnect from ex
if (
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=validated["title"], data=user_input
title=name,
data={CONF_REFRESH_TOKEN: self.refresh_token},
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
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(
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()
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()
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
@callback
def async_get_options_flow(
@ -173,8 +188,10 @@ class OptionsFlowHandler(OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(data=user_input)
if 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(
{
@ -191,11 +208,3 @@ class OptionsFlowHandler(OptionsFlow):
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."""

View File

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

View File

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

View File

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

View File

@ -1,35 +1,26 @@
{
"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": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"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.",
"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": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"reauth_confirm": {
"title": "Authenticate with Tado",
"description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process."
},
"title": "Connect to your Tado account"
},
"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."
}
}
},
"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": {
"step": {
"init": {

2
requirements_all.txt generated
View File

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

View File

@ -2007,7 +2007,7 @@ python-snoo==0.6.4
python-songpal==0.16.2
# homeassistant.components.tado
python-tado==0.18.6
python-tado==0.18.9
# homeassistant.components.technove
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."""
from http import HTTPStatus
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 requests
from homeassistant import config_entries
from homeassistant.components.tado.config_flow import NoHomes
from homeassistant.components.tado.config_flow import TadoException
from homeassistant.components.tado.const import (
CONF_FALLBACK,
CONF_REFRESH_TOKEN,
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
@ -26,92 +26,186 @@ from homeassistant.helpers.service_info.zeroconf import (
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
async def test_full_flow(
hass: HomeAssistant,
mock_tado_api: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the full flow of the config flow."""
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(
("exception", "error"),
[
(KeyError, "invalid_auth"),
(RuntimeError, "cannot_connect"),
(ValueError, "unknown"),
(Exception, "timeout"),
(TadoException, "timeout"),
],
)
async def test_form_exceptions(
hass: HomeAssistant, exception: Exception, error: str
async def test_wait_for_login_exception(
hass: HomeAssistant,
mock_tado_api: MagicMock,
exception: Exception,
error: str,
) -> 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(
DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"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"},
)
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
# @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) -> 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."""
entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"})
entry.add_to_hass(hass)
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 patch(
"homeassistant.components.tado.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(entry.entry_id)
mock_config_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.options.async_init(
entry.entry_id, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@ -119,125 +213,17 @@ async def test_options_flow(hass: HomeAssistant) -> None:
result["flow_id"],
{CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}
async def test_create_entry(hass: HomeAssistant) -> 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:
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")],
@ -248,8 +234,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()
@ -264,7 +249,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")],
@ -276,77 +261,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,
}

View File

@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
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,

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
from homeassistant.components.tado import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN
from homeassistant.core import HomeAssistant
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",
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_REFRESH_TOKEN: "mock-token",
},
options={"fallback": "NEXT_TIME_BLOCK"},
)
entry.add_to_hass(hass)