From a4adaf1696a94b886f0a44416bcf910319c3b6de Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sun, 3 Aug 2025 13:51:09 +0000 Subject: [PATCH 1/8] Initial commit for device auth --- README.md | 19 +++ examples/example.py | 17 ++- screenshots/tado-device-flow-0.png | Bin 0 -> 12615 bytes screenshots/tado-device-flow-1.png | Bin 0 -> 23955 bytes screenshots/tado-device-flow-2.png | Bin 0 -> 7724 bytes src/tadoasync/tadoasync.py | 180 +++++++++++++++++++++++++---- tests/conftest.py | 16 ++- tests/const.py | 3 +- tests/test_tado.py | 12 +- 9 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 screenshots/tado-device-flow-0.png create mode 100644 screenshots/tado-device-flow-1.png create mode 100644 screenshots/tado-device-flow-2.png 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..b0a6b11 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,14 +1,27 @@ """Asynchronous Python client for the Tado API. This is an example file.""" 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() + 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() + + 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/screenshots/tado-device-flow-0.png b/screenshots/tado-device-flow-0.png new file mode 100644 index 0000000000000000000000000000000000000000..17a59f129a9f72c5208682036ff1103dc8bb18eb GIT binary patch literal 12615 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 literal 0 HcmV?d00001 diff --git a/screenshots/tado-device-flow-1.png b/screenshots/tado-device-flow-1.png new file mode 100644 index 0000000000000000000000000000000000000000..cde471bf69dfbbfdb6c315f28d85a55bfc549cd3 GIT binary patch literal 23955 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�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 H`s)7x=_pBp literal 0 HcmV?d00001 diff --git a/screenshots/tado-device-flow-2.png b/screenshots/tado-device-flow-2.png new file mode 100644 index 0000000000000000000000000000000000000000..1a38436f856cb2be1f7e95464cdca9456b14112b GIT binary patch literal 7724 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 literal 0 HcmV?d00001 diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index ca60e61..6d56ca4 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,11 @@ class Tado: # pylint: disable=too-many-instance-attributes def __init__( self, - username: str, - password: str, 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._debug: bool = debug or False self._session = session self._request_timeout = request_timeout @@ -107,15 +108,151 @@ def __init__( 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() + + @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 + + 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", + } + + if self._session is None: + self._session = ClientSession() + self._close_session = True + + try: + async with asyncio.timeout(self._request_timeout): + request = await self._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) + + 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", + } + + if self._session is None: + self._session = ClientSession() + self._close_session = True + + try: + async with asyncio.timeout(self._request_timeout): + request = await self._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 + + 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,7 +326,6 @@ 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, @@ -610,7 +746,7 @@ async def close(self) -> None: 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..4269fc4 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,8 +24,6 @@ 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: @@ -35,6 +33,18 @@ async def client() -> AsyncGenerator[Tado, None]: @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": 5, + "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..ee5d711 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -38,7 +38,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 +48,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,7 +61,7 @@ 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) + tado = Tado(session=session) await tado.login() assert tado._access_token == "test_access_token" assert tado._token_expiry is not None @@ -77,7 +77,7 @@ 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") + tado = Tado() await tado.login() assert tado._access_token == "test_access_token" assert tado._token_expiry is not None @@ -142,7 +142,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" @@ -603,7 +603,7 @@ async def response_handler(_: str, **_kwargs: Any) -> CallbackResult: # pylint: ) async with aiohttp.ClientSession() as session, Tado( - username="username", password="password", request_timeout=0, session=session + request_timeout=0, session=session ) as tado: with pytest.raises(TadoConnectionError): assert await tado.get_devices() From f603dcae316162dfedc817a546dc99a66b341250 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 13:25:59 +0000 Subject: [PATCH 2/8] Fixing test (incomplete code coverage, yet) --- tests/conftest.py | 1 + tests/test_tado.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4269fc4..f6e08f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ async def client() -> AsyncGenerator[Tado, None]: session=session, request_timeout=10, ) as tado: + await tado.login() yield tado diff --git a/tests/test_tado.py b/tests/test_tado.py index ee5d711..a243217 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -602,8 +602,9 @@ async def response_handler(_: str, **_kwargs: Any) -> CallbackResult: # pylint: callback=response_handler, ) - async with aiohttp.ClientSession() as session, Tado( - 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() From f2bcfe84fd8fbf599327154e8509c81d3f50b062 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 14:05:16 +0000 Subject: [PATCH 3/8] Add refresh token and expand tests --- src/tadoasync/tadoasync.py | 3 ++- tests/test_tado.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 6d56ca4..c46d90e 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -85,11 +85,13 @@ class Tado: # pylint: disable=too-many-instance-attributes def __init__( self, + refresh_token: str | None = None, debug: bool | None = None, session: ClientSession | None = None, request_timeout: int = 10, ) -> None: """Initialize the Tado object.""" + self._refresh_token = refresh_token self._debug: bool = debug or False self._session = session self._request_timeout = request_timeout @@ -102,7 +104,6 @@ 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 diff --git a/tests/test_tado.py b/tests/test_tado.py index a243217..58b5be7 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -21,6 +21,7 @@ TadoError, TadoReadingError, ) +from tadoasync.tadoasync import DeviceActivationStatus from syrupy import SnapshotAssertion from tests import load_fixture @@ -209,6 +210,10 @@ async def test_get_devices( body=load_fixture("devices.json"), ) assert await python_tado.get_devices() == snapshot + assert ( + python_tado.device_verification_url + == "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ" + ) async def test_get_mobile_devices( @@ -608,3 +613,14 @@ async def response_handler(_: str, **_kwargs: Any) -> CallbackResult: # pylint: with pytest.raises(TadoConnectionError): 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 From e5fdc18fcc478edd3710241b9b5c301388dc7c79 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 14:25:47 +0000 Subject: [PATCH 4/8] Add test for already in progress --- tests/test_tado.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_tado.py b/tests/test_tado.py index 58b5be7..7eaee0e 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -624,3 +624,13 @@ async def test_async_init_with_refresh_token() -> None: 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() From baa9440cd18249ba086a3d988bca971f08090c09 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 16:47:24 +0000 Subject: [PATCH 5/8] Update to extra --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From fc798f6e9017492b4481c2dcb24310dec75ada71 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 16:53:57 +0000 Subject: [PATCH 6/8] Add launch.json --- .devcontainer/launch.json | 17 +++++++++++++++++ .gitignore | 3 --- .vscode/launch.json | 17 +++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 .devcontainer/launch.json create mode 100644 .vscode/launch.json 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" + } + ] +} From 50c9de3e205b0601c6c8d1037d02f51a4e374f08 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 20:36:38 +0000 Subject: [PATCH 7/8] Enhancing tests --- poetry.lock | 15 ++--- pyproject.toml | 2 +- src/tadoasync/tadoasync.py | 42 +++++++++---- tests/conftest.py | 4 +- tests/test_tado.py | 123 ++++++++++++++++++++++++++++++------- 5 files changed, 142 insertions(+), 44 deletions(-) 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/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index c46d90e..a20089f 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -144,13 +144,10 @@ async def login_device_flow(self) -> DeviceActivationStatus: "scope": "offline_access", } - if self._session is None: - self._session = ClientSession() - self._close_session = True - try: async with asyncio.timeout(self._request_timeout): - request = await self._session.post(url=DEVICE_AUTH_URL, data=data) + 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( @@ -159,6 +156,15 @@ async def login_device_flow(self) -> DeviceActivationStatus: 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}") @@ -198,13 +204,10 @@ async def _check_device_activation(self) -> bool: "grant_type": "urn:ietf:params:oauth:grant-type:device_code", } - if self._session is None: - self._session = ClientSession() - self._close_session = True - 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) if request.status == 400: response = await request.json() if response.get("error") == "authorization_pending": @@ -216,6 +219,15 @@ async def _check_device_activation(self) -> bool: "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"] @@ -565,7 +577,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() @@ -745,6 +758,13 @@ 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.async_init() diff --git a/tests/conftest.py b/tests/conftest.py index f6e08f6..13fefae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,7 +27,7 @@ async def client() -> AsyncGenerator[Tado, None]: session=session, request_timeout=10, ) as tado: - await tado.login() + await tado.device_activation() yield tado @@ -40,7 +40,7 @@ def _tado_oauth(responses: aioresponses) -> None: payload={ "device_code": "XXX_code_XXX", "expires_in": 300, - "interval": 5, + "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", diff --git a/tests/test_tado.py b/tests/test_tado.py index 7eaee0e..d77d3e3 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,7 +21,7 @@ TadoError, TadoReadingError, ) -from tadoasync.tadoasync import DeviceActivationStatus +from tadoasync.tadoasync import DEVICE_AUTH_URL, DeviceActivationStatus from syrupy import SnapshotAssertion from tests import load_fixture @@ -63,7 +63,8 @@ async def test_login_success(responses: aioresponses) -> None: ) async with aiohttp.ClientSession() as session: tado = Tado(session=session) - await tado.login() + 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() @@ -79,39 +80,94 @@ async def test_login_success_no_session(responses: aioresponses) -> None: ) async with aiohttp.ClientSession(): tado = Tado() - await tado.login() + 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) @@ -124,10 +180,12 @@ 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_refresh_auth_success(responses: aioresponses) -> None: @@ -188,6 +246,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: @@ -210,10 +291,6 @@ async def test_get_devices( body=load_fixture("devices.json"), ) assert await python_tado.get_devices() == snapshot - assert ( - python_tado.device_verification_url - == "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ" - ) async def test_get_mobile_devices( From d936b106924d394b298fba0156372a3f8d2eacfa Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 25 Aug 2025 21:20:39 +0000 Subject: [PATCH 8/8] Expand example to demonstrate refresh token works --- examples/example.py | 15 +++++++++++++++ src/tadoasync/tadoasync.py | 17 ++++++++++++----- tests/test_tado.py | 21 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/examples/example.py b/examples/example.py index b0a6b11..69d7a1b 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,5 +1,7 @@ """Asynchronous Python client for the Tado API. This is an example file.""" +from __future__ import annotations + import asyncio import logging @@ -10,13 +12,26 @@ async def main() -> None: """Show example on how to use aiohttp.ClientSession.""" + 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() diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index a20089f..b373514 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -123,6 +123,8 @@ async def async_init(self) -> 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: @@ -134,6 +136,11 @@ 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: @@ -340,17 +347,15 @@ async def _refresh_auth(self) -> None: data = { "client_id": CLIENT_ID, "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( @@ -364,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: diff --git a/tests/test_tado.py b/tests/test_tado.py index d77d3e3..11640ad 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -188,6 +188,27 @@ async def mock_post(*args: Any, **kwargs: Any) -> ClientResponse: # noqa: ARG00 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: """Test successful refresh of auth token.""" responses.post(