diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 41f8f67..b5e06d8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -52,7 +52,7 @@ } }, "features": { - "ghcr.io/devcontainers-contrib/features/poetry:2": {}, + "ghcr.io/devcontainers-extra/features/poetry:2": {}, "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/node:1": {}, "ghcr.io/devcontainers/features/python:1": { diff --git a/.devcontainer/launch.json b/.devcontainer/launch.json new file mode 100644 index 0000000..21c880d --- /dev/null +++ b/.devcontainer/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run example.py", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/examples/example.py", + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/.gitignore b/.gitignore index 12ea934..d3db5ec 100644 --- a/.gitignore +++ b/.gitignore @@ -94,9 +94,6 @@ ENV/ # ruff .ruff_cache -# Visual Studio Code -.vscode - # IntelliJ Idea family of suites .idea *.iml diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ad1df69 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run tadoasync example.py", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/examples/example.py", + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/README.md b/README.md index 306ebf3..e14c286 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,25 @@ based on the following: - `MINOR`: Backwards-compatible new features and enhancements. - `PATCH`: Backwards-compatible bugfixes and package updates. +## Usage + +As of the 15th of March 2025, Tado has updated their OAuth2 authentication flow. It will now use the device flow, instead of a username/password flow. This means that the user will have to authenticate the device using a browser, and then enter the code that is displayed on the browser into the terminal. + +PyTado handles this as following: + +1. The `_login_device_flow()` will be invoked at the initialization of a PyTado object. This will start the device flow and will return a URL and a code that the user will have to enter in the browser. The URL can be obtained via the method `device_verification_url()`. Or, when in debug mode, the URL will be printed. Alternatively, you can use the `device_activation_status()` method to check if the device has been activated. It returns three statuses: `NOT_STARTED`, `PENDING`, and `COMPLETED`. Wait to invoke the `device_activation()` method until the status is `PENDING`. + +2. Once the URL is obtained, the user will have to enter the code that is displayed on the browser into the terminal. By default, the URL has the `user_code` attached, for the ease of going trough the flow. At this point, run the method `device_activation()`. It will poll every five seconds to see if the flow has been completed. If the flow has been completed, the method will return a token that will be used for all further requests. It will timeout after five minutes. + +3. Once the token has been obtained, the user can use the PyTado object to interact with the Tado API. The token will be stored in the `Tado` object, and will be used for all further requests. The token will be refreshed automatically when it expires. +The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`. + +### Screenshots of the device flow + +![Tado device flow: invoking](/screenshots/tado-device-flow-0.png) +![Tado device flow: browser](/screenshots/tado-device-flow-1.png) +![Tado device flow: complete](/screenshots/tado-device-flow-2.png) + ## Contributing This is an active open-source project. We are always open to people who want to diff --git a/examples/example.py b/examples/example.py index f1f5544..69d7a1b 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,14 +1,42 @@ """Asynchronous Python client for the Tado API. This is an example file.""" +from __future__ import annotations + import asyncio +import logging from tadoasync import Tado +logging.basicConfig(level=logging.DEBUG) + async def main() -> None: """Show example on how to use aiohttp.ClientSession.""" - async with Tado("username", "password") as tado: - await tado.get_devices() + refresh_token: str | None = None + async with Tado(debug=True) as tado: + print("Device activation status: ", tado.device_activation_status) # noqa: T201 + print("Device verification URL: ", tado.device_verification_url) # noqa: T201 + + print("Starting device activation") # noqa: T201 + await tado.device_activation() + refresh_token = tado.refresh_token + + print("Device activation status: ", tado.device_activation_status) # noqa: T201 + + devices = await tado.get_devices() + + print("Devices: ", devices) # noqa: T201 + + print("Trying to use the stored refresh token for another run...") # noqa: T201 + await asyncio.sleep(1) + + async with Tado(debug=True, refresh_token=refresh_token) as tado: + print("Refresh token: ", tado.refresh_token) # noqa: T201 + print("Device activation status: ", tado.device_activation_status) # noqa: T201 + + devices = await tado.get_devices() + + print("Devices: ", devices) # noqa: T201 if __name__ == "__main__": diff --git a/poetry.lock b/poetry.lock index 0298591..de6aef4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -126,18 +126,19 @@ speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (> [[package]] name = "aioresponses" -version = "0.7.6" +version = "0.7.8" description = "Mock out requests made by ClientSession from aiohttp package" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "aioresponses-0.7.6-py2.py3-none-any.whl", hash = "sha256:d2c26defbb9b440ea2685ec132e90700907fd10bcca3e85ec2f157219f0d26f7"}, - {file = "aioresponses-0.7.6.tar.gz", hash = "sha256:f795d9dbda2d61774840e7e32f5366f45752d1adc1b74c9362afd017296c7ee1"}, + {file = "aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94"}, + {file = "aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11"}, ] [package.dependencies] aiohttp = ">=3.3.0,<4.0.0" +packaging = ">=22.0" [[package]] name = "aiosignal" @@ -1278,7 +1279,7 @@ version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, @@ -1773,7 +1774,7 @@ description = "C version of reader, parser and emitter for ruamel.yaml derived f optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\"" +markers = "platform_python_implementation == \"CPython\" and python_version == \"3.12\"" files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, @@ -2151,4 +2152,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "6c31074ce3998cda67a718a5563fd929decef11f765b9961d767e15178f4b4fe" +content-hash = "5703f1c01a14f2b2b31738201ec60baf947a3910cbab01a76cddccc80327176b" diff --git a/pyproject.toml b/pyproject.toml index b9630ed..bb46b30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ mashumaro = ">=3.10" orjson = ">=3.9.8" python = "^3.12" yarl = ">=1.6.0" -aioresponses = "^0.7.6" +aioresponses = "^0.7.7" [tool.poetry.urls] "Bug Tracker" = "https://github.com/erwindouna/python-tado/issues" diff --git a/screenshots/tado-device-flow-0.png b/screenshots/tado-device-flow-0.png new file mode 100644 index 0000000..17a59f1 Binary files /dev/null and b/screenshots/tado-device-flow-0.png differ diff --git a/screenshots/tado-device-flow-1.png b/screenshots/tado-device-flow-1.png new file mode 100644 index 0000000..cde471b Binary files /dev/null and b/screenshots/tado-device-flow-1.png differ diff --git a/screenshots/tado-device-flow-2.png b/screenshots/tado-device-flow-2.png new file mode 100644 index 0000000..1a38436 Binary files /dev/null and b/screenshots/tado-device-flow-2.png differ diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index ca60e61..b373514 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -3,11 +3,14 @@ from __future__ import annotations import asyncio +import enum +import logging import time from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from importlib import metadata from typing import Self +from urllib.parse import urlencode import orjson from aiohttp import ClientResponseError @@ -54,10 +57,9 @@ ZoneState, ) -CLIENT_ID = "tado-web-app" -CLIENT_SECRET = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" # noqa: S105 -AUTHORIZATION_BASE_URL = "https://auth.tado.com/oauth/authorize" -TOKEN_URL = "https://auth.tado.com/oauth/token" # noqa: S105 +CLIENT_ID = "1bb50063-6b0c-4d11-bd99-387f4a91cc46" +TOKEN_URL = "https://login.tado.com/oauth2/token" # noqa: S105 +DEVICE_AUTH_URL = "https://login.tado.com/oauth2/device_authorize" API_URL = "my.tado.com/api/v2" TADO_HOST_URL = "my.tado.com" TADO_API_PATH = "/api/v2" @@ -66,6 +68,16 @@ EIQ_API_PATH = "/api" VERSION = metadata.version(__package__) +_LOGGER = logging.getLogger(__name__) + + +class DeviceActivationStatus(enum.StrEnum): + """Device Activation Status Enum.""" + + NOT_STARTED = "NOT_STARTED" + PENDING = "PENDING" + COMPLETED = "COMPLETED" + @dataclass class Tado: # pylint: disable=too-many-instance-attributes @@ -73,22 +85,13 @@ class Tado: # pylint: disable=too-many-instance-attributes def __init__( self, - username: str, - password: str, + refresh_token: str | None = None, debug: bool | None = None, session: ClientSession | None = None, request_timeout: int = 10, ) -> None: - """Initialize the Tado object. - - :param username: Tado account username. - :param password: Tado account password. - :param debug: Enable debug logging. - :param session: HTTP client session. - :param request_timeout: Timeout for HTTP requests. - """ - self._username: str = username - self._password: str = password + """Initialize the Tado object.""" + self._refresh_token = refresh_token self._debug: bool = debug or False self._session = session self._request_timeout = request_timeout @@ -101,21 +104,175 @@ def __init__( self._access_token: str | None = None self._token_expiry: float | None = None - self._refresh_token: str | None = None self._access_headers: dict[str, str] | None = None self._home_id: int | None = None self._me: GetMe | None = None self._auto_geofencing_supported: bool | None = None + self._user_code: str | None = None + self._device_verification_url: str | None = None + self._device_flow_data: dict[str, str] = {} + self._device_activation_status = DeviceActivationStatus.NOT_STARTED + self._expires_at: datetime | None = None + + _LOGGER.setLevel(logging.DEBUG if debug else logging.INFO) + + async def async_init(self) -> None: + """Asynchronous initialization for the Tado object.""" + if self._refresh_token is None: + self._device_activation_status = await self.login_device_flow() + else: + self._device_ready() + get_me = await self.get_me() + self._home_id = get_me.homes[0].id + + @property + def device_activation_status(self) -> DeviceActivationStatus: + """Return the device activation status.""" + return self._device_activation_status + + @property + def device_verification_url(self) -> str | None: + """Return the device verification URL.""" + return self._device_verification_url + + @property + def refresh_token(self) -> str | None: + """Return the refresh token.""" + return self._refresh_token + + async def login_device_flow(self) -> DeviceActivationStatus: + """Login using device flow.""" + if self._device_activation_status != DeviceActivationStatus.NOT_STARTED: + raise TadoError("Device activation already in progress or completed") + + data = { + "client_id": CLIENT_ID, + "scope": "offline_access", + } + + try: + async with asyncio.timeout(self._request_timeout): + session = self._ensure_session() + request = await session.post(url=DEVICE_AUTH_URL, data=data) + request.raise_for_status() + except asyncio.TimeoutError as err: + raise TadoConnectionError( + "Timeout occurred while connecting to Tado." + ) from err + except ClientResponseError as err: + await self.check_request_status(err, login=True) + + content_type = request.headers.get("content-type") + if content_type and "application/json" not in content_type: + text = await request.text() + raise TadoError( + "Unexpected response from Tado. Content-Type: " + f"{request.headers.get('content-type')}, " + f"Response body: {text}" + ) + + if request.status != 200: + raise TadoError(f"Failed to start device activation flow: {request.status}") + + self._device_flow_data = await request.json() + + user_code = urlencode({"user_code": self._device_flow_data["user_code"]}) + visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}" + self._user_code = self._device_flow_data["user_code"] + self._device_verification_url = visit_url + + _LOGGER.info("Please visit the following URL: %s", visit_url) + + expires_in_seconds = float(self._device_flow_data["expires_in"]) + self._expires_at = datetime.now(timezone.utc) + timedelta( + seconds=expires_in_seconds + ) + + _LOGGER.info( + "Waiting for user to authorize the device. Expires at %s", + self._expires_at.strftime("%Y-%m-%d %H:%M:%S"), + ) + + return DeviceActivationStatus.PENDING + + async def _check_device_activation(self) -> bool: + if self._expires_at is not None and datetime.timestamp( + datetime.now(timezone.utc) + ) > datetime.timestamp(self._expires_at): + raise TadoError("User took too long to enter key") + + # Await the desired interval, before polling the API again + await asyncio.sleep(float(self._device_flow_data["interval"])) + + data = { + "client_id": CLIENT_ID, + "device_code": self._device_flow_data["device_code"], + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + try: + async with asyncio.timeout(self._request_timeout): + session = self._ensure_session() + request = await session.post(url=TOKEN_URL, data=data) + if request.status == 400: + response = await request.json() + if response.get("error") == "authorization_pending": + _LOGGER.info("Authorization pending. Continuing polling...") + return False + request.raise_for_status() + except asyncio.TimeoutError as err: + raise TadoConnectionError( + "Timeout occurred while connecting to Tado." + ) from err + + content_type = request.headers.get("content-type") + if content_type and "application/json" not in content_type: + text = await request.text() + raise TadoError( + "Unexpected response from Tado. Content-Type: " + f"{request.headers.get('content-type')}, " + f"Response body: {text}" + ) + + if request.status == 200: + response = await request.json() + self._access_token = response["access_token"] + self._token_expiry = time.time() + float(response["expires_in"]) + self._refresh_token = response["refresh_token"] + + get_me = await self.get_me() + self._home_id = get_me.homes[0].id + + return True + + raise TadoError(f"Login failed. Reason: {request.reason}") + + async def device_activation(self) -> None: + """Start the device activation process and get the refresh token.""" + if self._device_activation_status == DeviceActivationStatus.NOT_STARTED: + raise TadoError( + "Device activation has not yet started or has already completed" + ) + + while True: + if await self._check_device_activation(): + break + + self._device_ready() + + def _device_ready(self) -> None: + """Clear up after device activation.""" + self._user_code = None + self._device_verification_url = None + self._device_activation_status = DeviceActivationStatus.COMPLETED + async def login(self) -> None: """Perform login to Tado.""" data = { "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, "grant_type": "password", "scope": "home.user", - "username": self._username, - "password": self._password, } if self._session is None: @@ -189,19 +346,16 @@ async def _refresh_auth(self) -> None: data = { "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, "grant_type": "refresh_token", - "scope": "home.user", "refresh_token": self._refresh_token, } - if self._session is None: - self._session = ClientSession() - self._close_session = True + _LOGGER.debug("Refreshing Tado token") try: async with asyncio.timeout(self._request_timeout): - request = await self._session.post(url=TOKEN_URL, data=data) + session = self._ensure_session() + request = await session.post(url=TOKEN_URL, data=data) request.raise_for_status() except asyncio.TimeoutError as err: raise TadoConnectionError( @@ -215,6 +369,8 @@ async def _refresh_auth(self) -> None: self._token_expiry = time.time() + float(response["expires_in"]) self._refresh_token = response["refresh_token"] + _LOGGER.debug("Tado token refreshed") + async def get_me(self) -> GetMe: """Get the user information.""" if self._me is None: @@ -428,7 +584,8 @@ async def _request( try: async with asyncio.timeout(self._request_timeout): - request = await self._session.request( # type: ignore[union-attr] + session = self._ensure_session() + request = await session.request( method=method.value, url=str(url), headers=headers, json=data ) request.raise_for_status() @@ -608,9 +765,16 @@ async def close(self) -> None: if self._session and self._close_session: await self._session.close() + def _ensure_session(self) -> ClientSession: + """Return an active aiohttp ClientSession, creating one if needed.""" + if self._session is None or self._session.closed: + self._session = ClientSession() + self._close_session = True + return self._session + async def __aenter__(self) -> Self: """Async enter.""" - await self.login() + await self.async_init() return self async def __aexit__(self, *_exc_info: object) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 4d2ae8f..13fefae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from syrupy import SnapshotAssertion from tests import load_fixture -from .const import TADO_API_URL, TADO_TOKEN_URL +from .const import TADO_API_URL, TADO_DEVICE_AUTH_URL, TADO_TOKEN_URL from .syrupy import TadoSnapshotExtension @@ -24,17 +24,28 @@ def snapshot_assertion(snapshot: SnapshotAssertion) -> SnapshotAssertion: async def client() -> AsyncGenerator[Tado, None]: """Return a Tado client.""" async with aiohttp.ClientSession() as session, Tado( - username="username", - password="password", session=session, request_timeout=10, ) as tado: + await tado.device_activation() yield tado @pytest.fixture(autouse=True) def _tado_oauth(responses: aioresponses) -> None: """Mock the Tado token URL.""" + responses.post( + TADO_DEVICE_AUTH_URL, + status=200, + payload={ + "device_code": "XXX_code_XXX", + "expires_in": 300, + "interval": 0, + "user_code": "7BQ5ZQ", + "verification_uri": "https://login.tado.com/oauth2/device", + "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ", + }, + ) responses.post( TADO_TOKEN_URL, status=200, diff --git a/tests/const.py b/tests/const.py index a1d9d75..dcd74ce 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,6 +1,7 @@ """Constants for tests of Python Tado.""" TADO_API_URL = "https://my.tado.com/api/v2" -TADO_TOKEN_URL = "https://auth.tado.com/oauth/token" +TADO_TOKEN_URL = "https://login.tado.com/oauth2/token" +TADO_DEVICE_AUTH_URL = "https://login.tado.com/oauth2/device_authorize" TADO_EIQ_URL = "https://energy-insights.tado.com/api" diff --git a/tests/test_tado.py b/tests/test_tado.py index abfd27f..11640ad 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -3,7 +3,7 @@ import asyncio import os import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -21,6 +21,7 @@ TadoError, TadoReadingError, ) +from tadoasync.tadoasync import DEVICE_AUTH_URL, DeviceActivationStatus from syrupy import SnapshotAssertion from tests import load_fixture @@ -38,7 +39,7 @@ async def test_create_session( body=load_fixture("me.json"), ) async with aiohttp.ClientSession(): - tado = Tado(username="username", password="password") + tado = Tado() await tado.get_me() assert tado._session is not None assert not tado._session.closed @@ -48,7 +49,7 @@ async def test_create_session( async def test_close_session() -> None: """Test not closing the session when the session does not exist.""" - tado = Tado(username="username", password="password") + tado = Tado() tado._close_session = True await tado.close() @@ -61,8 +62,9 @@ async def test_login_success(responses: aioresponses) -> None: body=load_fixture("me.json"), ) async with aiohttp.ClientSession() as session: - tado = Tado(username="username", password="password", session=session) - await tado.login() + tado = Tado(session=session) + await tado.async_init() + await tado.device_activation() assert tado._access_token == "test_access_token" assert tado._token_expiry is not None assert tado._token_expiry > time.time() @@ -77,40 +79,95 @@ async def test_login_success_no_session(responses: aioresponses) -> None: body=load_fixture("me.json"), ) async with aiohttp.ClientSession(): - tado = Tado(username="username", password="password") - await tado.login() + tado = Tado() + await tado.async_init() + await tado.device_activation() assert tado._access_token == "test_access_token" assert tado._token_expiry is not None assert tado._token_expiry > time.time() assert tado._refresh_token == "test_refresh_token" -async def test_login_timeout(python_tado: Tado, responses: aioresponses) -> None: - """Test login timeout.""" +async def test_activation_timeout(responses: aioresponses) -> None: + """Test activation timeout.""" + responses.post( + DEVICE_AUTH_URL, + status=200, + payload={ + "device_code": "XXX_code_XXX", + "expires_in": 1, + "interval": 0, + "user_code": "7BQ5ZQ", + "verification_uri": "https://login.tado.com/oauth2/device", + "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ", + }, + ) responses.post( TADO_TOKEN_URL, + status=400, + payload={"error": "authorization_pending"}, + ) + + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + await tado.async_init() + tado._expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + with pytest.raises(TadoError, match="User took too long"): + await tado.device_activation() + + +async def test_login_device_flow_timeout(responses: aioresponses) -> None: + """Test timeout during device auth flow.""" + responses._matches.clear() + responses.post( + DEVICE_AUTH_URL, exception=asyncio.TimeoutError(), + repeat=True, ) - with pytest.raises(TadoConnectionError): - await python_tado.login() + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + with pytest.raises(TadoConnectionError): + await tado.async_init() -async def test_login_invalid_content_type( - python_tado: Tado, responses: aioresponses -) -> None: + +async def test_login_invalid_content_type(responses: aioresponses) -> None: """Test login invalid content type.""" + responses._matches.clear() responses.post( - TADO_TOKEN_URL, + DEVICE_AUTH_URL, status=200, headers={"content-type": "text/plain"}, body="Unexpected response", ) - with pytest.raises(TadoError): - await python_tado.login() + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + with pytest.raises(TadoError): + await tado.async_init() + + +async def test_login_invalid_status() -> None: + """Test login with non-200 status triggers.""" + mock_response = MagicMock(spec=ClientResponse) + mock_response.status = 400 + mock_response.headers = {"content-type": "application/json"} + mock_response.raise_for_status = MagicMock(return_value=None) + mock_response.json = AsyncMock(return_value={"error": "bad_request"}) + mock_response.text = AsyncMock(return_value="Unexpected response") + + async def mock_post(*args: Any, **kwargs: Any) -> ClientResponse: # noqa: ARG001 # pylint: disable=unused-argument + return mock_response + + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + with patch("aiohttp.ClientSession.post", new=mock_post), pytest.raises( + TadoError + ): + await tado.async_init() -async def test_login_client_response_error(python_tado: Tado) -> None: +async def test_login_client_response_error() -> None: """Test login client response error.""" mock_request_info = MagicMock(spec=RequestInfo) mock_response = MagicMock(spec=ClientResponse) @@ -123,10 +180,33 @@ async def test_login_client_response_error(python_tado: Tado) -> None: async def mock_post(*args: Any, **kwargs: Any) -> ClientResponse: # noqa: ARG001 # pylint: disable=unused-argument return mock_response - with patch("aiohttp.ClientSession.post", new=mock_post), pytest.raises( - TadoAuthenticationError - ): - await python_tado.login() + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + with patch("aiohttp.ClientSession.post", new=mock_post), pytest.raises( + TadoAuthenticationError + ): + await tado.async_init() + + +async def test_login_client_response_still_pending() -> None: + """Test login client response error.""" + mock_request_info = MagicMock(spec=RequestInfo) + mock_response = MagicMock(spec=ClientResponse) + mock_response.raise_for_status.side_effect = ClientResponseError( + mock_request_info, (mock_response,), status=400 + ) + mock_response.status = 400 + mock_response.text = AsyncMock(return_value="authorization_pending") + + async def mock_post(*args: Any, **kwargs: Any) -> ClientResponse: # noqa: ARG001 # pylint: disable=unused-argument + return mock_response + + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + with patch("aiohttp.ClientSession.post", new=mock_post), pytest.raises( + TadoAuthenticationError + ): + await tado.async_init() async def test_refresh_auth_success(responses: aioresponses) -> None: @@ -142,7 +222,7 @@ async def test_refresh_auth_success(responses: aioresponses) -> None: headers={"content-type": "application/json"}, ) async with aiohttp.ClientSession() as session: - tado = Tado(username="username", password="password", session=session) + tado = Tado(session=session) tado._access_token = "old_test_access_token" tado._token_expiry = time.time() - 10 # make sure the token is expired tado._refresh_token = "old_test_refresh_token" @@ -187,6 +267,29 @@ async def mock_post(*args: Any, **kwargs: Any) -> ClientResponse: # noqa: ARG00 await python_tado._refresh_auth() +async def test_device_flow_sets_verification_url(responses: aioresponses) -> None: + """Test device flow sets verification URL.""" + responses.post( + DEVICE_AUTH_URL, + status=200, + payload={ + "device_code": "XXX", + "expires_in": 600, + "interval": 0, + "user_code": "7BQ5ZQ", + "verification_uri": "https://login.tado.com/oauth2/device", + }, + ) + + async with aiohttp.ClientSession() as session: + tado = Tado(session=session) + await tado.login_device_flow() + assert ( + tado.device_verification_url + == "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ" + ) + + async def test_get_me( python_tado: Tado, responses: aioresponses, snapshot: SnapshotAssertion ) -> None: @@ -602,8 +705,30 @@ async def response_handler(_: str, **_kwargs: Any) -> CallbackResult: # pylint: callback=response_handler, ) - async with aiohttp.ClientSession() as session, Tado( - username="username", password="password", request_timeout=0, session=session - ) as tado: + async with aiohttp.ClientSession() as session: + tado = Tado(session=session, request_timeout=0) + await tado.login() with pytest.raises(TadoConnectionError): - assert await tado.get_devices() + await tado.get_devices() + await tado.close() + + +async def test_async_init_with_refresh_token() -> None: + """Cover where refresh token exists and _device_ready is invoked.""" + tado = Tado() + tado._refresh_token = "existing" + + # Ensure device_activation_status starts NOT_STARTED + assert tado.device_activation_status == DeviceActivationStatus.NOT_STARTED + await tado.async_init() + assert tado.device_activation_status == DeviceActivationStatus.COMPLETED + + +async def test_login_device_flow_already_in_progress() -> None: + """Ensure calling login_device_flow again raises TadoError.""" + tado = Tado() + tado._device_activation_status = DeviceActivationStatus.PENDING + with pytest.raises( + TadoError, match="Device activation already in progress or completed" + ): + await tado.login_device_flow()