Skip to content

Commit a4adaf1

Browse files
committed
Initial commit for device auth
1 parent aaf1c31 commit a4adaf1

File tree

9 files changed

+213
-34
lines changed

9 files changed

+213
-34
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ based on the following:
6464
- `MINOR`: Backwards-compatible new features and enhancements.
6565
- `PATCH`: Backwards-compatible bugfixes and package updates.
6666

67+
## Usage
68+
69+
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.
70+
71+
PyTado handles this as following:
72+
73+
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`.
74+
75+
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.
76+
77+
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.
78+
The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
79+
80+
### Screenshots of the device flow
81+
82+
![Tado device flow: invoking](/screenshots/tado-device-flow-0.png)
83+
![Tado device flow: browser](/screenshots/tado-device-flow-1.png)
84+
![Tado device flow: complete](/screenshots/tado-device-flow-2.png)
85+
6786
## Contributing
6887

6988
This is an active open-source project. We are always open to people who want to

examples/example.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
"""Asynchronous Python client for the Tado API. This is an example file."""
22

33
import asyncio
4+
import logging
45

56
from tadoasync import Tado
67

8+
logging.basicConfig(level=logging.DEBUG)
9+
710

811
async def main() -> None:
912
"""Show example on how to use aiohttp.ClientSession."""
10-
async with Tado("username", "password") as tado:
11-
await tado.get_devices()
13+
async with Tado(debug=True) as tado:
14+
print("Device activation status: ", tado.device_activation_status) # noqa: T201
15+
print("Device verification URL: ", tado.device_verification_url) # noqa: T201
16+
17+
print("Starting device activation") # noqa: T201
18+
await tado.device_activation()
19+
20+
print("Device activation status: ", tado.device_activation_status) # noqa: T201
21+
22+
devices = await tado.get_devices()
23+
24+
print("Devices: ", devices) # noqa: T201
1225

1326

1427
if __name__ == "__main__":

screenshots/tado-device-flow-0.png

12.3 KB
Loading

screenshots/tado-device-flow-1.png

23.4 KB
Loading

screenshots/tado-device-flow-2.png

7.54 KB
Loading

src/tadoasync/tadoasync.py

Lines changed: 158 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import enum
7+
import logging
68
import time
79
from dataclasses import dataclass
8-
from datetime import datetime, timezone
10+
from datetime import datetime, timedelta, timezone
911
from importlib import metadata
1012
from typing import Self
13+
from urllib.parse import urlencode
1114

1215
import orjson
1316
from aiohttp import ClientResponseError
@@ -54,10 +57,9 @@
5457
ZoneState,
5558
)
5659

57-
CLIENT_ID = "tado-web-app"
58-
CLIENT_SECRET = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" # noqa: S105
59-
AUTHORIZATION_BASE_URL = "https://auth.tado.com/oauth/authorize"
60-
TOKEN_URL = "https://auth.tado.com/oauth/token" # noqa: S105
60+
CLIENT_ID = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"
61+
TOKEN_URL = "https://login.tado.com/oauth2/token" # noqa: S105
62+
DEVICE_AUTH_URL = "https://login.tado.com/oauth2/device_authorize"
6163
API_URL = "my.tado.com/api/v2"
6264
TADO_HOST_URL = "my.tado.com"
6365
TADO_API_PATH = "/api/v2"
@@ -66,29 +68,28 @@
6668
EIQ_API_PATH = "/api"
6769
VERSION = metadata.version(__package__)
6870

71+
_LOGGER = logging.getLogger(__name__)
72+
73+
74+
class DeviceActivationStatus(enum.StrEnum):
75+
"""Device Activation Status Enum."""
76+
77+
NOT_STARTED = "NOT_STARTED"
78+
PENDING = "PENDING"
79+
COMPLETED = "COMPLETED"
80+
6981

7082
@dataclass
7183
class Tado: # pylint: disable=too-many-instance-attributes
7284
"""Base class for Tado."""
7385

7486
def __init__(
7587
self,
76-
username: str,
77-
password: str,
7888
debug: bool | None = None,
7989
session: ClientSession | None = None,
8090
request_timeout: int = 10,
8191
) -> None:
82-
"""Initialize the Tado object.
83-
84-
:param username: Tado account username.
85-
:param password: Tado account password.
86-
:param debug: Enable debug logging.
87-
:param session: HTTP client session.
88-
:param request_timeout: Timeout for HTTP requests.
89-
"""
90-
self._username: str = username
91-
self._password: str = password
92+
"""Initialize the Tado object."""
9293
self._debug: bool = debug or False
9394
self._session = session
9495
self._request_timeout = request_timeout
@@ -107,15 +108,151 @@ def __init__(
107108
self._me: GetMe | None = None
108109
self._auto_geofencing_supported: bool | None = None
109110

111+
self._user_code: str | None = None
112+
self._device_verification_url: str | None = None
113+
self._device_flow_data: dict[str, str] = {}
114+
self._device_activation_status = DeviceActivationStatus.NOT_STARTED
115+
self._expires_at: datetime | None = None
116+
117+
_LOGGER.setLevel(logging.DEBUG if debug else logging.INFO)
118+
119+
async def async_init(self) -> None:
120+
"""Asynchronous initialization for the Tado object."""
121+
if self._refresh_token is None:
122+
self._device_activation_status = await self.login_device_flow()
123+
else:
124+
self._device_ready()
125+
126+
@property
127+
def device_activation_status(self) -> DeviceActivationStatus:
128+
"""Return the device activation status."""
129+
return self._device_activation_status
130+
131+
@property
132+
def device_verification_url(self) -> str | None:
133+
"""Return the device verification URL."""
134+
return self._device_verification_url
135+
136+
async def login_device_flow(self) -> DeviceActivationStatus:
137+
"""Login using device flow."""
138+
if self._device_activation_status != DeviceActivationStatus.NOT_STARTED:
139+
raise TadoError("Device activation already in progress or completed")
140+
141+
data = {
142+
"client_id": CLIENT_ID,
143+
"scope": "offline_access",
144+
}
145+
146+
if self._session is None:
147+
self._session = ClientSession()
148+
self._close_session = True
149+
150+
try:
151+
async with asyncio.timeout(self._request_timeout):
152+
request = await self._session.post(url=DEVICE_AUTH_URL, data=data)
153+
request.raise_for_status()
154+
except asyncio.TimeoutError as err:
155+
raise TadoConnectionError(
156+
"Timeout occurred while connecting to Tado."
157+
) from err
158+
except ClientResponseError as err:
159+
await self.check_request_status(err, login=True)
160+
161+
if request.status != 200:
162+
raise TadoError(f"Failed to start device activation flow: {request.status}")
163+
164+
self._device_flow_data = await request.json()
165+
166+
user_code = urlencode({"user_code": self._device_flow_data["user_code"]})
167+
visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}"
168+
self._user_code = self._device_flow_data["user_code"]
169+
self._device_verification_url = visit_url
170+
171+
_LOGGER.info("Please visit the following URL: %s", visit_url)
172+
173+
expires_in_seconds = float(self._device_flow_data["expires_in"])
174+
self._expires_at = datetime.now(timezone.utc) + timedelta(
175+
seconds=expires_in_seconds
176+
)
177+
178+
_LOGGER.info(
179+
"Waiting for user to authorize the device. Expires at %s",
180+
self._expires_at.strftime("%Y-%m-%d %H:%M:%S"),
181+
)
182+
183+
return DeviceActivationStatus.PENDING
184+
185+
async def _check_device_activation(self) -> bool:
186+
if self._expires_at is not None and datetime.timestamp(
187+
datetime.now(timezone.utc)
188+
) > datetime.timestamp(self._expires_at):
189+
raise TadoError("User took too long to enter key")
190+
191+
# Await the desired interval, before polling the API again
192+
await asyncio.sleep(float(self._device_flow_data["interval"]))
193+
194+
data = {
195+
"client_id": CLIENT_ID,
196+
"device_code": self._device_flow_data["device_code"],
197+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
198+
}
199+
200+
if self._session is None:
201+
self._session = ClientSession()
202+
self._close_session = True
203+
204+
try:
205+
async with asyncio.timeout(self._request_timeout):
206+
request = await self._session.post(url=TOKEN_URL, data=data)
207+
if request.status == 400:
208+
response = await request.json()
209+
if response.get("error") == "authorization_pending":
210+
_LOGGER.info("Authorization pending. Continuing polling...")
211+
return False
212+
request.raise_for_status()
213+
except asyncio.TimeoutError as err:
214+
raise TadoConnectionError(
215+
"Timeout occurred while connecting to Tado."
216+
) from err
217+
218+
if request.status == 200:
219+
response = await request.json()
220+
self._access_token = response["access_token"]
221+
self._token_expiry = time.time() + float(response["expires_in"])
222+
self._refresh_token = response["refresh_token"]
223+
224+
get_me = await self.get_me()
225+
self._home_id = get_me.homes[0].id
226+
227+
return True
228+
229+
raise TadoError(f"Login failed. Reason: {request.reason}")
230+
231+
async def device_activation(self) -> None:
232+
"""Start the device activation process and get the refresh token."""
233+
if self._device_activation_status == DeviceActivationStatus.NOT_STARTED:
234+
raise TadoError(
235+
"Device activation has not yet started or has already completed"
236+
)
237+
238+
while True:
239+
if await self._check_device_activation():
240+
break
241+
242+
self._device_ready()
243+
244+
def _device_ready(self) -> None:
245+
"""Clear up after device activation."""
246+
self._user_code = None
247+
self._device_verification_url = None
248+
self._device_activation_status = DeviceActivationStatus.COMPLETED
249+
110250
async def login(self) -> None:
111251
"""Perform login to Tado."""
112252
data = {
113253
"client_id": CLIENT_ID,
114-
"client_secret": CLIENT_SECRET,
115254
"grant_type": "password",
116255
"scope": "home.user",
117-
"username": self._username,
118-
"password": self._password,
119256
}
120257

121258
if self._session is None:
@@ -189,7 +326,6 @@ async def _refresh_auth(self) -> None:
189326

190327
data = {
191328
"client_id": CLIENT_ID,
192-
"client_secret": CLIENT_SECRET,
193329
"grant_type": "refresh_token",
194330
"scope": "home.user",
195331
"refresh_token": self._refresh_token,
@@ -610,7 +746,7 @@ async def close(self) -> None:
610746

611747
async def __aenter__(self) -> Self:
612748
"""Async enter."""
613-
await self.login()
749+
await self.async_init()
614750
return self
615751

616752
async def __aexit__(self, *_exc_info: object) -> None:

tests/conftest.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from syrupy import SnapshotAssertion
1111
from tests import load_fixture
1212

13-
from .const import TADO_API_URL, TADO_TOKEN_URL
13+
from .const import TADO_API_URL, TADO_DEVICE_AUTH_URL, TADO_TOKEN_URL
1414
from .syrupy import TadoSnapshotExtension
1515

1616

@@ -24,8 +24,6 @@ def snapshot_assertion(snapshot: SnapshotAssertion) -> SnapshotAssertion:
2424
async def client() -> AsyncGenerator[Tado, None]:
2525
"""Return a Tado client."""
2626
async with aiohttp.ClientSession() as session, Tado(
27-
username="username",
28-
password="password",
2927
session=session,
3028
request_timeout=10,
3129
) as tado:
@@ -35,6 +33,18 @@ async def client() -> AsyncGenerator[Tado, None]:
3533
@pytest.fixture(autouse=True)
3634
def _tado_oauth(responses: aioresponses) -> None:
3735
"""Mock the Tado token URL."""
36+
responses.post(
37+
TADO_DEVICE_AUTH_URL,
38+
status=200,
39+
payload={
40+
"device_code": "XXX_code_XXX",
41+
"expires_in": 300,
42+
"interval": 5,
43+
"user_code": "7BQ5ZQ",
44+
"verification_uri": "https://login.tado.com/oauth2/device",
45+
"verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ",
46+
},
47+
)
3848
responses.post(
3949
TADO_TOKEN_URL,
4050
status=200,

tests/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Constants for tests of Python Tado."""
22

33
TADO_API_URL = "https://my.tado.com/api/v2"
4-
TADO_TOKEN_URL = "https://auth.tado.com/oauth/token"
4+
TADO_TOKEN_URL = "https://login.tado.com/oauth2/token"
5+
TADO_DEVICE_AUTH_URL = "https://login.tado.com/oauth2/device_authorize"
56

67
TADO_EIQ_URL = "https://energy-insights.tado.com/api"

tests/test_tado.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async def test_create_session(
3838
body=load_fixture("me.json"),
3939
)
4040
async with aiohttp.ClientSession():
41-
tado = Tado(username="username", password="password")
41+
tado = Tado()
4242
await tado.get_me()
4343
assert tado._session is not None
4444
assert not tado._session.closed
@@ -48,7 +48,7 @@ async def test_create_session(
4848

4949
async def test_close_session() -> None:
5050
"""Test not closing the session when the session does not exist."""
51-
tado = Tado(username="username", password="password")
51+
tado = Tado()
5252
tado._close_session = True
5353
await tado.close()
5454

@@ -61,7 +61,7 @@ async def test_login_success(responses: aioresponses) -> None:
6161
body=load_fixture("me.json"),
6262
)
6363
async with aiohttp.ClientSession() as session:
64-
tado = Tado(username="username", password="password", session=session)
64+
tado = Tado(session=session)
6565
await tado.login()
6666
assert tado._access_token == "test_access_token"
6767
assert tado._token_expiry is not None
@@ -77,7 +77,7 @@ async def test_login_success_no_session(responses: aioresponses) -> None:
7777
body=load_fixture("me.json"),
7878
)
7979
async with aiohttp.ClientSession():
80-
tado = Tado(username="username", password="password")
80+
tado = Tado()
8181
await tado.login()
8282
assert tado._access_token == "test_access_token"
8383
assert tado._token_expiry is not None
@@ -142,7 +142,7 @@ async def test_refresh_auth_success(responses: aioresponses) -> None:
142142
headers={"content-type": "application/json"},
143143
)
144144
async with aiohttp.ClientSession() as session:
145-
tado = Tado(username="username", password="password", session=session)
145+
tado = Tado(session=session)
146146
tado._access_token = "old_test_access_token"
147147
tado._token_expiry = time.time() - 10 # make sure the token is expired
148148
tado._refresh_token = "old_test_refresh_token"
@@ -603,7 +603,7 @@ async def response_handler(_: str, **_kwargs: Any) -> CallbackResult: # pylint:
603603
)
604604

605605
async with aiohttp.ClientSession() as session, Tado(
606-
username="username", password="password", request_timeout=0, session=session
606+
request_timeout=0, session=session
607607
) as tado:
608608
with pytest.raises(TadoConnectionError):
609609
assert await tado.get_devices()

0 commit comments

Comments
 (0)