Skip to content
Merged
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
29 changes: 22 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,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:
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
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
42 changes: 36 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,40 @@ 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),
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 +248,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 +260,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 +705,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 +716,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.

Loading