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..121be0c 100644 --- a/src/tadoasync/tadoasync.py +++ b/src/tadoasync/tadoasync.py @@ -12,6 +12,7 @@ from typing import Self from urllib.parse import urlencode +import jwt import orjson from aiohttp import ClientResponseError from aiohttp.client import ClientSession @@ -123,8 +124,9 @@ 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() + self._set_home_id_from_access_token() @property def device_activation_status(self) -> DeviceActivationStatus: @@ -239,9 +241,7 @@ 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 - + self._set_home_id_from_access_token() return True raise TadoError(f"Login failed. Reason: {request.reason}") @@ -302,8 +302,23 @@ 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 + self._set_home_id_from_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: + jwt_data = jwt.decode( + self._access_token, + options={"verify_signature": False, "verify_exp": False}, + ) + 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 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..e644851 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,12 +84,38 @@ 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" +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( @@ -218,7 +246,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 +258,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 +703,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 +714,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" }, ]