Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
30 changes: 23 additions & 7 deletions src/tadoasync/tadoasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -123,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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we do this step here now? :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because otherwise async_init would be dependent on my.tado.com. I figured the call to get_me here was used to get an access token from the supplied refresh token, so I directly moved that here. Or did I understand that wrong?

In any case, we need an acces token to extract the home ID, so if _home_id should be accessible right after init, this is necessary. Otherwise, if we say it is not relevant at this point, we can of course wait for the first call to _request, which will automatically call _refresh_auth and set the home id with this (L384).


@property
def device_activation_status(self) -> DeviceActivationStatus:
Expand Down Expand Up @@ -239,9 +240,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}")
Expand Down Expand Up @@ -302,8 +301,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:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't jwt.DecodeError a subclass of ValueError? If so, then we already have the parent exception we're catching.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, DecodeError is a subclass of InvalidTokenError, which is a subclass of PyJWTError, which is a subclass of Exception

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
Expand Down Expand Up @@ -367,6 +381,8 @@ 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:
Expand Down
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
},
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/auth_token.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImd0eSI6WyJ1cm46aWV0ZjpwYXJhbXM6b2F1dGg6Z3JhbnQtdHlwZTpkZXZpY2VfY29kZSIsInJlZnJlc2hfdG9rZW4iXSwia2lkIjoiNmNmM2E5NDg4YzA3YmJlMjQ1YzVlNjVkNGZkNTQ3OTAifQ.eyJhdWQiOlsicGFydG5lciJdLCJleHAiOjE3NzE3NzI0MTIsImlhdCI6MTc3MTc3MTgxMiwiaXNzIjoidGFkbyIsIm5iZiI6MTc3MTc3MTgxMiwic3ViIjoiYTRlNzU1MzAtMjU4Zi00NWVkLWE3NWMtMTRlMDdmZjU4MmQzIiwianRpIjoiMjljNDYwM2EtOWZiOS00NjE1LTgxM2ItNWM1NzBhN2M0ZTZjIiwiZW1haWwiOiJ1c2VyQGRvbWFpbi50bGQiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInJvbGVzIjpbXSwiYXV0aF90aW1lIjoxNzcxNzcxODEwLCJhcHBsaWNhdGlvbklkIjoiMWJiNTAwNjMtNmIwYy00ZDExLWJkOTktMzg3ZjRhOTFjYzQ2IiwidGlkIjoiZjRjMzU0MWYtNzhjNC00ZWJiLWIwZDYtYmJkNzc4NzRjMTJiIiwic2lkIjoiN2JlZGEyMTQtNjU3Ni00NGUyLTllNWYtNjg1MjVmYjk5NzJjIiwidGFkb19ob21lcyI6W3siaWQiOjF9XSwibG9jYWxlIjoiZW5fVVMiLCJ0YWRvX3Njb3BlIjpbImhvbWUudXNlciIsImlkZW50aXR5OnJlYWQiXSwidGFkb191c2VybmFtZSI6InVzZXJAZG9tYWluLnRsZCIsIm5hbWUiOiJ0ZXN0X3VzZXIiLCJ0YWRvX2NsaWVudF9pZCI6InRhZG8tZGV2aWNlLWxpbmtpbmcifQ.H6wFUeoCJoKzqRKa-Ootqiex4ERZwEKkrIJEKg1PnBBQ9Iq3gHsV0iPfY2SQpme35VZwcWC7jbs1FVSwjrRk1L0VxaN7d2D0QXtoWj48_k9AG81LJcdkiuYdDRpL5X39leFcMdMb9EARvZSVUNQvfCOGFlwG_fVrKs5ZyM5dzlR7Weq-XdYzYyZv2awcRjfWJlQbpV-lOZa3Utk24ME6ztIn4xeQvgm_2JdIXqsFJQE5jVh-zO6LdhFW9rHVPyHuvXWA7Fww2kz1MJx6yt-rljxQXZdP09WKOkoWD8GhWB-nwZe3mvgirp_XU97CquL79b19MseDtGzb1RqFK4tyF5SCpN4U0D9gH6oibWQtA9-xiOCbnJTQ2Q2U-NLNGxveiJgV2H5F67uulpotKPxz1poAIAL-fMmfrvxcV4T9RntCktu8hBWfuVb6M6l3oIaylRyt21M3G-tMi_I-9NUpKq0LH6tnBx_UaLkN6i5rPfHlZTaaw8erNAnjYcssP-ya
40 changes: 34 additions & 6 deletions tests/test_tado.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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",
},
Expand All @@ -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"

Expand Down Expand Up @@ -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",
Expand All @@ -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",
},
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.