mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 02:07:09 +00:00
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:
parent
83a0ed4250
commit
358f78c7cd
@ -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,24 +61,35 @@ 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")
|
||||||
try:
|
_LOGGER.debug(
|
||||||
tado = await hass.async_add_executor_job(
|
"Creating tado instance with refresh token: %s",
|
||||||
Tado,
|
entry.data[CONF_REFRESH_TOKEN],
|
||||||
entry.data[CONF_USERNAME],
|
|
||||||
entry.data[CONF_PASSWORD],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
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)
|
||||||
|
@ -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."""
|
||||||
|
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()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return self.async_create_entry(
|
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(
|
self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
|
||||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
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."""
|
|
||||||
|
@ -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
|
||||||
|
@ -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]:
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -1,35 +1,26 @@
|
|||||||
{
|
{
|
||||||
"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"
|
"timeout": {
|
||||||
},
|
"description": "The authentication process timed out. Please try again."
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
|
2
requirements_all.txt
generated
2
requirements_all.txt
generated
@ -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
|
||||||
|
2
requirements_test_all.txt
generated
2
requirements_test_all.txt
generated
@ -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
|
||||||
|
@ -1 +1 @@
|
|||||||
"""Tests for the tado integration."""
|
"""Tests for the Tado integration."""
|
||||||
|
50
tests/components/tado/conftest.py
Normal file
50
tests/components/tado/conftest.py
Normal 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,
|
||||||
|
)
|
8
tests/components/tado/fixtures/device_authorize.json
Normal file
8
tests/components/tado/fixtures/device_authorize.json
Normal 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"
|
||||||
|
}
|
@ -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)
|
||||||
|
|
||||||
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)
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
result = await hass.config_entries.options.async_init(
|
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
@ -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,
|
||||||
|
30
tests/components/tado/test_init.py
Normal file
30
tests/components/tado/test_init.py
Normal 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
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user