From f20e78995ebc9dcd8249bf1194f6ab076bb1a070 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Mon, 23 Feb 2026 21:47:37 +0100 Subject: [PATCH 1/6] extract home ID from jwt instead of /me during setup --- pyproject.toml | 1 + src/tadoasync/tadoasync.py | 36 ++++++++++++++++++++++++++++++----- tests/conftest.py | 4 +++- tests/fixtures/auth_token.txt | 1 + tests/test_tado.py | 14 ++++++++------ uv.lock | 11 +++++++++++ 6 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/auth_token.txt diff --git a/pyproject.toml b/pyproject.toml index 6a59c58..ae4dca3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "orjson>=3.9.8", "yarl>=1.6.0", "aioresponses>=0.7.7,<0.8", + "pyjwt>=2.11.0", ] [project.urls] diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index c1f1ad2..52c046b 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -9,9 +9,10 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from importlib import metadata -from typing import Self +from typing import Any, Self from urllib.parse import urlencode +import jwt import orjson from aiohttp import ClientResponseError from aiohttp.client import ClientSession @@ -239,9 +240,14 @@ async def _check_device_activation(self) -> bool: 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 + decoded = await self._decode_access_token() + try: + self._home_id = int(decoded["tado_homes"][0]["id"]) + except (KeyError, ValueError) as err: + raise TadoError( + "Failed to decode access token and extract home ID" + ) from err return True raise TadoError(f"Login failed. Reason: {request.reason}") @@ -302,8 +308,28 @@ async def login(self) -> None: 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 + decoded = await self._decode_access_token() + + try: + self._home_id = int(decoded["tado_homes"][0]["id"]) + except (KeyError, ValueError) as err: + raise TadoError( + "Failed to decode access token and extract home ID" + ) from err + + async def _decode_access_token(self) -> dict[str, Any]: + """Decode the access token to extract the home ID.""" + if self._access_token is None: + raise TadoError("Access token is not available for decoding") + + try: + return jwt.decode( + self._access_token, + options={"verify_signature": False, "verify_exp": False}, + ) + + except jwt.DecodeError as err: + raise TadoError("Failed to decode access token") from err async def check_request_status( self, response_error: ClientResponseError, *, login: bool = False diff --git a/tests/conftest.py b/tests/conftest.py index 5ce5a65..ddc1d57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,8 @@ async def client() -> AsyncGenerator[Tado, None]: @pytest.fixture(autouse=True) def _tado_oauth(responses: aioresponses) -> None: """Mock the Tado token URL.""" + auth_token = load_fixture("auth_token.txt") + responses.post( TADO_DEVICE_AUTH_URL, status=200, @@ -53,7 +55,7 @@ def _tado_oauth(responses: aioresponses) -> None: TADO_TOKEN_URL, status=200, payload={ - "access_token": "test_access_token", + "access_token": auth_token, "expires_in": 3600, "refresh_token": "test_refresh_token", }, diff --git a/tests/fixtures/auth_token.txt b/tests/fixtures/auth_token.txt new file mode 100644 index 0000000..1f75bde --- /dev/null +++ b/tests/fixtures/auth_token.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImd0eSI6WyJ1cm46aWV0ZjpwYXJhbXM6b2F1dGg6Z3JhbnQtdHlwZTpkZXZpY2VfY29kZSIsInJlZnJlc2hfdG9rZW4iXSwia2lkIjoiNmNmM2E5NDg4YzA3YmJlMjQ1YzVlNjVkNGZkNTQ3OTAifQ.eyJhdWQiOlsicGFydG5lciJdLCJleHAiOjE3NzE3NzI0MTIsImlhdCI6MTc3MTc3MTgxMiwiaXNzIjoidGFkbyIsIm5iZiI6MTc3MTc3MTgxMiwic3ViIjoiYTRlNzU1MzAtMjU4Zi00NWVkLWE3NWMtMTRlMDdmZjU4MmQzIiwianRpIjoiMjljNDYwM2EtOWZiOS00NjE1LTgxM2ItNWM1NzBhN2M0ZTZjIiwiZW1haWwiOiJ1c2VyQGRvbWFpbi50bGQiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInJvbGVzIjpbXSwiYXV0aF90aW1lIjoxNzcxNzcxODEwLCJhcHBsaWNhdGlvbklkIjoiMWJiNTAwNjMtNmIwYy00ZDExLWJkOTktMzg3ZjRhOTFjYzQ2IiwidGlkIjoiZjRjMzU0MWYtNzhjNC00ZWJiLWIwZDYtYmJkNzc4NzRjMTJiIiwic2lkIjoiN2JlZGEyMTQtNjU3Ni00NGUyLTllNWYtNjg1MjVmYjk5NzJjIiwidGFkb19ob21lcyI6W3siaWQiOjF9XSwibG9jYWxlIjoiZW5fVVMiLCJ0YWRvX3Njb3BlIjpbImhvbWUudXNlciIsImlkZW50aXR5OnJlYWQiXSwidGFkb191c2VybmFtZSI6InVzZXJAZG9tYWluLnRsZCIsIm5hbWUiOiJ0ZXN0X3VzZXIiLCJ0YWRvX2NsaWVudF9pZCI6InRhZG8tZGV2aWNlLWxpbmtpbmcifQ.H6wFUeoCJoKzqRKa-Ootqiex4ERZwEKkrIJEKg1PnBBQ9Iq3gHsV0iPfY2SQpme35VZwcWC7jbs1FVSwjrRk1L0VxaN7d2D0QXtoWj48_k9AG81LJcdkiuYdDRpL5X39leFcMdMb9EARvZSVUNQvfCOGFlwG_fVrKs5ZyM5dzlR7Weq-XdYzYyZv2awcRjfWJlQbpV-lOZa3Utk24ME6ztIn4xeQvgm_2JdIXqsFJQE5jVh-zO6LdhFW9rHVPyHuvXWA7Fww2kz1MJx6yt-rljxQXZdP09WKOkoWD8GhWB-nwZe3mvgirp_XU97CquL79b19MseDtGzb1RqFK4tyF5SCpN4U0D9gH6oibWQtA9-xiOCbnJTQ2Q2U-NLNGxveiJgV2H5F67uulpotKPxz1poAIAL-fMmfrvxcV4T9RntCktu8hBWfuVb6M6l3oIaylRyt21M3G-tMi_I-9NUpKq0LH6tnBx_UaLkN6i5rPfHlZTaaw8erNAnjYcssP-ya diff --git a/tests/test_tado.py b/tests/test_tado.py index 5046664..427731e 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -28,6 +28,8 @@ from .const import TADO_API_URL, TADO_EIQ_URL, TADO_TOKEN_URL +AUTH_TOKEN = load_fixture("auth_token.txt") + async def test_create_session( responses: aioresponses, @@ -65,7 +67,7 @@ async def test_login_success(responses: aioresponses) -> None: tado = Tado(session=session) await tado.async_init() await tado.device_activation() - assert tado._access_token == "test_access_token" + assert tado._access_token == AUTH_TOKEN assert tado._token_expiry is not None assert tado._token_expiry > time.time() assert tado._refresh_token == "test_refresh_token" @@ -82,7 +84,7 @@ async def test_login_success_no_session(responses: aioresponses) -> None: tado = Tado() await tado.async_init() await tado.device_activation() - assert tado._access_token == "test_access_token" + assert tado._access_token == AUTH_TOKEN assert tado._token_expiry is not None assert tado._token_expiry > time.time() assert tado._refresh_token == "test_refresh_token" @@ -218,7 +220,7 @@ async def test_refresh_auth_success(responses: aioresponses) -> None: TADO_TOKEN_URL, status=200, payload={ - "access_token": "new_test_access_token", + "access_token": AUTH_TOKEN, "expires_in": "3600", "refresh_token": "new_test_refresh_token", }, @@ -230,7 +232,7 @@ async def test_refresh_auth_success(responses: aioresponses) -> None: tado._token_expiry = time.time() - 10 # make sure the token is expired tado._refresh_token = "old_test_refresh_token" await tado._refresh_auth() - assert tado._access_token == "test_access_token" + assert tado._access_token == AUTH_TOKEN assert tado._token_expiry > time.time() assert tado._refresh_token == "test_refresh_token" @@ -675,7 +677,7 @@ async def test_get_me_timeout(responses: aioresponses) -> None: responses.post( "https://auth.tado.com/oauth/token", payload={ - "access_token": "test_access_token", + "access_token": AUTH_TOKEN, "expires_in": 3600, "refresh_token": "test_refresh_token", "token_type": "bearer", @@ -686,7 +688,7 @@ async def test_get_me_timeout(responses: aioresponses) -> None: TADO_TOKEN_URL, status=200, payload={ - "access_token": "test_access_token", + "access_token": AUTH_TOKEN, "expires_in": 3600, "refresh_token": "test_refresh_token", }, diff --git a/uv.lock b/uv.lock index 7404d97..0ffaeb1 100644 --- a/uv.lock +++ b/uv.lock @@ -848,6 +848,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + [[package]] name = "pylint" version = "3.0.3" @@ -1103,6 +1112,7 @@ dependencies = [ { name = "aioresponses" }, { name = "mashumaro" }, { name = "orjson" }, + { name = "pyjwt" }, { name = "yarl" }, ] @@ -1131,6 +1141,7 @@ requires-dist = [ { name = "aioresponses", specifier = ">=0.7.7,<0.8" }, { name = "mashumaro", specifier = ">=3.10" }, { name = "orjson", specifier = ">=3.9.8" }, + { name = "pyjwt", specifier = ">=2.11.0" }, { name = "yarl", specifier = ">=1.6.0" }, ] From 4ad60a65e29e22d21c6bcee0c00a7d8c978019ad Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Mon, 23 Feb 2026 21:54:29 +0100 Subject: [PATCH 2/6] update strings/exceptions --- src/tadoasync/tadoasync.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 52c046b..8b1f423 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -246,7 +246,7 @@ async def _check_device_activation(self) -> bool: self._home_id = int(decoded["tado_homes"][0]["id"]) except (KeyError, ValueError) as err: raise TadoError( - "Failed to decode access token and extract home ID" + "Failed to extract home ID from access token" ) from err return True @@ -314,11 +314,11 @@ async def login(self) -> None: self._home_id = int(decoded["tado_homes"][0]["id"]) except (KeyError, ValueError) as err: raise TadoError( - "Failed to decode access token and extract home ID" + "Failed to extract home ID from access token" ) from err async def _decode_access_token(self) -> dict[str, Any]: - """Decode the access token to extract the home ID.""" + """Decode the access token.""" if self._access_token is None: raise TadoError("Access token is not available for decoding") From 68cf473ee38b3d90b4953688d354ef93dfc0e109 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Mon, 23 Feb 2026 22:19:02 +0100 Subject: [PATCH 3/6] don't access /me when initialized with refresh token --- src/tadoasync/tadoasync.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 8b1f423..62ce483 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -124,8 +124,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 + + await self._refresh_auth() @property def device_activation_status(self) -> DeviceActivationStatus: @@ -393,6 +393,15 @@ async def _refresh_auth(self) -> None: self._token_expiry = time.time() + float(response["expires_in"]) self._refresh_token = response["refresh_token"] + decoded = await self._decode_access_token() + + try: + self._home_id = int(decoded["tado_homes"][0]["id"]) + except (KeyError, ValueError) as err: + raise TadoError( + "Failed to extract home ID from access token" + ) from err + _LOGGER.debug("Tado token refreshed") async def get_me(self) -> GetMe: From 3a961ccfc49addd8b4386ab6a3c566b96ba25126 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Mon, 23 Feb 2026 22:23:54 +0100 Subject: [PATCH 4/6] move all parts of home id acquisition into a single helper --- src/tadoasync/tadoasync.py | 43 +++++++++++--------------------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 62ce483..11ee19a 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from importlib import metadata -from typing import Any, Self +from typing import Self from urllib.parse import urlencode import jwt @@ -240,14 +240,7 @@ async def _check_device_activation(self) -> bool: self._token_expiry = time.time() + float(response["expires_in"]) self._refresh_token = response["refresh_token"] - decoded = await self._decode_access_token() - - try: - self._home_id = int(decoded["tado_homes"][0]["id"]) - except (KeyError, ValueError) as err: - raise TadoError( - "Failed to extract home ID from access token" - ) from err + self._set_home_id_from_access_token() return True raise TadoError(f"Login failed. Reason: {request.reason}") @@ -308,28 +301,23 @@ async def login(self) -> None: self._token_expiry = time.time() + float(response["expires_in"]) self._refresh_token = response["refresh_token"] - decoded = await self._decode_access_token() - - try: - self._home_id = int(decoded["tado_homes"][0]["id"]) - except (KeyError, ValueError) as err: - raise TadoError( - "Failed to extract home ID from access token" - ) from err + self._set_home_id_from_access_token() - async def _decode_access_token(self) -> dict[str, Any]: - """Decode the access token.""" + def _set_home_id_from_access_token(self) -> None: + """Decode the access token and set the home ID.""" if self._access_token is None: raise TadoError("Access token is not available for decoding") try: - return jwt.decode( + jwt_data = jwt.decode( self._access_token, options={"verify_signature": False, "verify_exp": False}, ) - - except jwt.DecodeError as err: - raise TadoError("Failed to decode access token") from err + self._home_id = int(jwt_data["tado_homes"][0]["id"]) + except (KeyError, TypeError, ValueError, jwt.DecodeError) as err: + raise TadoError( + "Failed to decode access token and extract home ID" + ) from err async def check_request_status( self, response_error: ClientResponseError, *, login: bool = False @@ -393,14 +381,7 @@ async def _refresh_auth(self) -> None: self._token_expiry = time.time() + float(response["expires_in"]) self._refresh_token = response["refresh_token"] - decoded = await self._decode_access_token() - - try: - self._home_id = int(decoded["tado_homes"][0]["id"]) - except (KeyError, ValueError) as err: - raise TadoError( - "Failed to extract home ID from access token" - ) from err + self._set_home_id_from_access_token() _LOGGER.debug("Tado token refreshed") From fe26a6f615b40e01acb419ade0b21c5f5aa978da Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Mon, 23 Feb 2026 22:28:17 +0100 Subject: [PATCH 5/6] add tests for _set_home_id_from_access_token() --- tests/test_tado.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_tado.py b/tests/test_tado.py index 427731e..e644851 100644 --- a/tests/test_tado.py +++ b/tests/test_tado.py @@ -90,6 +90,32 @@ async def test_login_success_no_session(responses: aioresponses) -> None: assert tado._refresh_token == "test_refresh_token" +def test_set_home_id_from_access_token_success() -> None: + """Test successful home ID extraction from access token.""" + tado = Tado() + tado._access_token = AUTH_TOKEN + tado._set_home_id_from_access_token() + assert tado._home_id == 1 + + +@pytest.mark.parametrize( + ("access_token", "decoded_token", "expected_error"), + [ + (None, {}, "Access token is not available for decoding"), + (AUTH_TOKEN, {}, "Failed to decode access token and extract home ID"), + ], +) +def test_set_home_id_from_access_token_errors( + access_token: str | None, decoded_token: dict[str, object], expected_error: str +) -> None: + """Test home ID extraction error paths.""" + tado = Tado() + tado._access_token = access_token + with patch("tadoasync.tadoasync.jwt.decode", return_value=decoded_token): + with pytest.raises(TadoError, match=expected_error): + tado._set_home_id_from_access_token() + + async def test_activation_timeout(responses: aioresponses) -> None: """Test activation timeout.""" responses.post( From 33694b53456c5ee462e746d858a83ee86380b546 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 24 Feb 2026 15:57:03 +0100 Subject: [PATCH 6/6] don't update home id on every auth refresh --- src/tadoasync/tadoasync.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tadoasync/tadoasync.py b/src/tadoasync/tadoasync.py index 11ee19a..121be0c 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -126,6 +126,7 @@ async def async_init(self) -> None: self._device_ready() await self._refresh_auth() + self._set_home_id_from_access_token() @property def device_activation_status(self) -> DeviceActivationStatus: @@ -381,8 +382,6 @@ async def _refresh_auth(self) -> None: self._token_expiry = time.time() + float(response["expires_in"]) self._refresh_token = response["refresh_token"] - self._set_home_id_from_access_token() - _LOGGER.debug("Tado token refreshed") async def get_me(self) -> GetMe: