python-tado 0.19.0__tar.gz → 0.19.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_tado-0.19.0 → python_tado-0.19.1}/PKG-INFO +37 -2
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/__main__.py +52 -12
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/const.py +1 -1
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/http.py +169 -19
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/interface/api/hops_tado.py +2 -1
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/interface/api/my_tado.py +3 -2
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/interface/interface.py +59 -14
- {python_tado-0.19.0 → python_tado-0.19.1}/README.md +35 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/pyproject.toml +3 -3
- {python_tado-0.19.0 → python_tado-0.19.1}/AUTHORS +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/LICENSE +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/__init__.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/exceptions.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/interface/__init__.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/interface/api/__init__.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/logger.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/zone/__init__.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/zone/hops_zone.py +0 -0
- {python_tado-0.19.0 → python_tado-0.19.1}/PyTado/zone/my_zone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-tado
|
|
3
|
-
Version: 0.19.
|
|
3
|
+
Version: 0.19.1
|
|
4
4
|
Summary: PyTado from chrism0dwk, modified by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem
|
|
5
5
|
License: GPL-3.0-or-later
|
|
6
6
|
Keywords: tado
|
|
@@ -21,7 +21,7 @@ Provides-Extra: all
|
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Provides-Extra: lint
|
|
23
23
|
Provides-Extra: test
|
|
24
|
-
Requires-Dist: black (>=24.
|
|
24
|
+
Requires-Dist: black (>=24.10) ; extra == "lint" or extra == "all"
|
|
25
25
|
Requires-Dist: pre-commit ; extra == "dev" or extra == "all"
|
|
26
26
|
Requires-Dist: pylint ; extra == "lint" or extra == "all"
|
|
27
27
|
Requires-Dist: pytest ; extra == "test" or extra == "all"
|
|
@@ -69,6 +69,7 @@ the GNU Public Licence version 3, and should therefore not be used where it may
|
|
|
69
69
|
cause discomfort and inconvenience to others.
|
|
70
70
|
|
|
71
71
|
## Usage
|
|
72
|
+
|
|
72
73
|
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.
|
|
73
74
|
|
|
74
75
|
PyTado handles this as following:
|
|
@@ -81,11 +82,45 @@ PyTado handles this as following:
|
|
|
81
82
|
The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
|
|
82
83
|
|
|
83
84
|
### Screenshots of the device flow
|
|
85
|
+
|
|
84
86
|

|
|
85
87
|

|
|
86
88
|

|
|
87
89
|
|
|
90
|
+
### How to not authenticate the device again
|
|
91
|
+
|
|
92
|
+
It is possible to save the refresh token and reuse to skip the next login.
|
|
93
|
+
|
|
94
|
+
The following code will use the `refresh_token` file to save the refresh-token after login, and load the refresh-token if you create the Tado interface class again.
|
|
95
|
+
|
|
96
|
+
If the file doesn't exists, the webbrowser is started and the device authentication url is automatically opened. You can activate the device in the browser. When you restart the program, the refresh-token is reused and no webbrowser will be opened.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import webbrowser # only needed for direct web browser access
|
|
100
|
+
|
|
101
|
+
from PyTado.interface.interface import Tado
|
|
102
|
+
|
|
103
|
+
tado = Tado(token_file_path="/var/tado/refresh_token")
|
|
104
|
+
|
|
105
|
+
status = tado.device_activation_status()
|
|
106
|
+
|
|
107
|
+
if status == "PENDING":
|
|
108
|
+
url = tado.device_verification_url()
|
|
109
|
+
|
|
110
|
+
webbrowser.open_new_tab(url)
|
|
111
|
+
|
|
112
|
+
tado.device_activation()
|
|
113
|
+
|
|
114
|
+
status = tado.device_activation_status()
|
|
115
|
+
|
|
116
|
+
if status == "COMPLETED":
|
|
117
|
+
print("Login successful")
|
|
118
|
+
else:
|
|
119
|
+
print(f"Login status is {status}")
|
|
120
|
+
```
|
|
121
|
+
|
|
88
122
|
## Example code
|
|
123
|
+
|
|
89
124
|
```python
|
|
90
125
|
"""Example client for PyTado"""
|
|
91
126
|
|
|
@@ -9,37 +9,78 @@ import sys
|
|
|
9
9
|
from PyTado.interface import Tado
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def log_in(
|
|
13
|
-
|
|
12
|
+
def log_in(args):
|
|
13
|
+
"""
|
|
14
|
+
Log in to the Tado API by activating the current device.
|
|
15
|
+
|
|
16
|
+
Add --token_file_path to the command line arguments to store the
|
|
17
|
+
refresh token in a file.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
args (argparse.Namespace): The parsed command-line arguments.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Tado: An instance of the Tado interface.
|
|
24
|
+
"""
|
|
25
|
+
t = Tado(token_file_path=args.token_file_path)
|
|
26
|
+
t.device_activation()
|
|
14
27
|
return t
|
|
15
28
|
|
|
16
29
|
|
|
17
30
|
def get_me(args):
|
|
18
|
-
|
|
31
|
+
"""
|
|
32
|
+
Retrieve and print home information from the Tado API.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
args (argparse.Namespace): The parsed command-line arguments.
|
|
36
|
+
"""
|
|
37
|
+
t = log_in(args)
|
|
19
38
|
me = t.get_me()
|
|
20
39
|
print(me)
|
|
21
40
|
|
|
22
41
|
|
|
23
42
|
def get_state(args):
|
|
24
|
-
|
|
43
|
+
"""
|
|
44
|
+
Retrieve and print the state of a specific zone from the Tado API.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
args (argparse.Namespace): The parsed command-line arguments.
|
|
48
|
+
"""
|
|
49
|
+
t = log_in(args)
|
|
25
50
|
zone = t.get_state(int(args.zone))
|
|
26
51
|
print(zone)
|
|
27
52
|
|
|
28
53
|
|
|
29
54
|
def get_states(args):
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
"""
|
|
56
|
+
Retrieve and print the states of all zones from the Tado API.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
args (argparse.Namespace): The parsed command-line arguments.
|
|
60
|
+
"""
|
|
61
|
+
t = log_in(args)
|
|
62
|
+
zones = t.get_zone_states()
|
|
63
|
+
print(zones)
|
|
33
64
|
|
|
34
65
|
|
|
35
66
|
def get_capabilities(args):
|
|
36
|
-
|
|
67
|
+
"""
|
|
68
|
+
Retrieve and print the capabilities of a specific zone from the Tado API.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
args (argparse.Namespace): The parsed command-line arguments.
|
|
72
|
+
"""
|
|
73
|
+
t = log_in(args)
|
|
37
74
|
capabilities = t.get_capabilities(int(args.zone))
|
|
38
75
|
print(capabilities)
|
|
39
76
|
|
|
40
77
|
|
|
41
78
|
def main():
|
|
42
|
-
"""
|
|
79
|
+
"""
|
|
80
|
+
Main method for the script.
|
|
81
|
+
|
|
82
|
+
Sets up the argument parser, handles subcommands, and executes the appropriate function.
|
|
83
|
+
"""
|
|
43
84
|
parser = argparse.ArgumentParser(
|
|
44
85
|
description="Pytado - Tado thermostat device control",
|
|
45
86
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
@@ -49,11 +90,10 @@ def main():
|
|
|
49
90
|
|
|
50
91
|
# Required flags go here.
|
|
51
92
|
required_flags.add_argument(
|
|
52
|
-
"--
|
|
93
|
+
"--token_file_path",
|
|
53
94
|
required=True,
|
|
54
|
-
help=
|
|
95
|
+
help="Path to the file where the refresh token should be stored.",
|
|
55
96
|
)
|
|
56
|
-
required_flags.add_argument("--password", required=True, help="Tado password.")
|
|
57
97
|
|
|
58
98
|
# Flags with default values go here.
|
|
59
99
|
log_levels = {logging.getLevelName(level): level for level in [10, 20, 30, 40, 50]}
|
|
@@ -5,9 +5,13 @@ Do all the API HTTP heavy lifting in this file
|
|
|
5
5
|
import enum
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
+
import os
|
|
8
9
|
import pprint
|
|
9
10
|
import time
|
|
10
11
|
from datetime import datetime, timedelta
|
|
12
|
+
from json import dump as json_dump
|
|
13
|
+
from json import load as json_load
|
|
14
|
+
from pathlib import Path
|
|
11
15
|
from typing import Any
|
|
12
16
|
from urllib.parse import urlencode
|
|
13
17
|
|
|
@@ -57,8 +61,9 @@ class Mode(enum.Enum):
|
|
|
57
61
|
PLAIN = 2
|
|
58
62
|
|
|
59
63
|
|
|
60
|
-
class DeviceActivationStatus(enum.
|
|
61
|
-
"""Device Activation Status Enum"""
|
|
64
|
+
class DeviceActivationStatus(enum.StrEnum):
|
|
65
|
+
"""Device Activation Status Enum"""
|
|
66
|
+
|
|
62
67
|
NOT_STARTED = "NOT_STARTED"
|
|
63
68
|
PENDING = "PENDING"
|
|
64
69
|
COMPLETED = "COMPLETED"
|
|
@@ -143,16 +148,34 @@ class Http:
|
|
|
143
148
|
|
|
144
149
|
def __init__(
|
|
145
150
|
self,
|
|
151
|
+
token_file_path: str | None = None,
|
|
152
|
+
saved_refresh_token: str | None = None,
|
|
146
153
|
http_session: requests.Session | None = None,
|
|
147
154
|
debug: bool = False,
|
|
148
155
|
) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Initialize the HTTP client for interacting with the Tado API.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
token_file_path (str | None): Path to the file where the token is stored.
|
|
161
|
+
If None, the token will not be saved to a file.
|
|
162
|
+
saved_refresh_token (str | None): A previously saved refresh token to use for
|
|
163
|
+
authentication. If None, a new token will be requested.
|
|
164
|
+
http_session (requests.Session | None): An optional pre-configured HTTP session.
|
|
165
|
+
If None, a new session will be created.
|
|
166
|
+
debug (bool): If True, enables debug logging. Defaults to False.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
None
|
|
170
|
+
"""
|
|
171
|
+
|
|
149
172
|
if debug:
|
|
150
173
|
_LOGGER.setLevel(logging.DEBUG)
|
|
151
174
|
else:
|
|
152
175
|
_LOGGER.setLevel(logging.WARNING)
|
|
153
176
|
|
|
154
177
|
self._refresh_at = datetime.now() + timedelta(minutes=10)
|
|
155
|
-
self._session = http_session or
|
|
178
|
+
self._session = http_session or self._create_session()
|
|
156
179
|
self._session.hooks["response"].append(self._log_response)
|
|
157
180
|
self._headers = {"Referer": "https://app.tado.com/"}
|
|
158
181
|
|
|
@@ -164,24 +187,71 @@ class Http:
|
|
|
164
187
|
self._id: int | None = None
|
|
165
188
|
self._token_refresh: str | None = None
|
|
166
189
|
self._x_api: bool | None = None
|
|
167
|
-
self.
|
|
190
|
+
self._token_file_path = token_file_path
|
|
191
|
+
|
|
192
|
+
if saved_refresh_token or self._load_token():
|
|
193
|
+
if self._refresh_token(refresh_token=saved_refresh_token, force_refresh=True):
|
|
194
|
+
self._device_ready()
|
|
195
|
+
else:
|
|
196
|
+
self._device_activation_status = self._login_device_flow()
|
|
168
197
|
|
|
169
198
|
@property
|
|
170
199
|
def is_x_line(self) -> bool | None:
|
|
200
|
+
"""
|
|
201
|
+
Check if the current line is an X line.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
bool | None: True if the current line is an X line, False otherwise.
|
|
205
|
+
None if the api is not ready yet.
|
|
206
|
+
"""
|
|
171
207
|
return self._x_api
|
|
172
208
|
|
|
173
209
|
@property
|
|
174
210
|
def user_code(self) -> str | None:
|
|
211
|
+
"""
|
|
212
|
+
Retrieve the user code.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
str | None: The user code if available, otherwise None.
|
|
216
|
+
"""
|
|
175
217
|
return self._user_code
|
|
176
218
|
|
|
177
219
|
@property
|
|
178
220
|
def device_activation_status(self) -> DeviceActivationStatus:
|
|
221
|
+
"""
|
|
222
|
+
Retrieve the activation status of the device.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
DeviceActivationStatus: The current activation status of the device.
|
|
226
|
+
"""
|
|
179
227
|
return self._device_activation_status
|
|
180
228
|
|
|
181
229
|
@property
|
|
182
230
|
def device_verification_url(self) -> str | None:
|
|
231
|
+
"""
|
|
232
|
+
Retrieve the url to activate the device.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
str | None: The current url for device activation or none if
|
|
236
|
+
authentication is not started.
|
|
237
|
+
"""
|
|
183
238
|
return self._device_verification_url
|
|
184
239
|
|
|
240
|
+
@property
|
|
241
|
+
def refresh_token(self) -> str | None:
|
|
242
|
+
"""
|
|
243
|
+
Retrieve the current refresh token for the tado api connection.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
str | None: The current refresh token, or None if not available.
|
|
247
|
+
"""
|
|
248
|
+
return self._token_refresh
|
|
249
|
+
|
|
250
|
+
def _create_session(self) -> requests.Session:
|
|
251
|
+
session = requests.Session()
|
|
252
|
+
session.hooks["response"].append(self._log_response)
|
|
253
|
+
return session
|
|
254
|
+
|
|
185
255
|
def _log_response(self, response: requests.Response, *args, **kwargs) -> None:
|
|
186
256
|
og_request_method = response.request.method
|
|
187
257
|
og_request_url = response.request.url
|
|
@@ -226,8 +296,7 @@ class Http:
|
|
|
226
296
|
if retries > 0:
|
|
227
297
|
_LOGGER.warning("Connection error: %s", e)
|
|
228
298
|
self._session.close()
|
|
229
|
-
self._session =
|
|
230
|
-
self._session.hooks["response"].append(self._log_response)
|
|
299
|
+
self._session = self._create_session()
|
|
231
300
|
retries -= 1
|
|
232
301
|
else:
|
|
233
302
|
_LOGGER.error(
|
|
@@ -284,22 +353,60 @@ class Http:
|
|
|
284
353
|
self._refresh_at = self._refresh_at - timedelta(seconds=30)
|
|
285
354
|
|
|
286
355
|
self._headers["Authorization"] = f"Bearer {access_token}"
|
|
356
|
+
|
|
357
|
+
self._save_token()
|
|
358
|
+
|
|
287
359
|
return refresh_token
|
|
288
360
|
|
|
289
|
-
def
|
|
290
|
-
"""
|
|
291
|
-
|
|
292
|
-
|
|
361
|
+
def _load_token(self) -> bool:
|
|
362
|
+
"""Load the refresh token from a file."""
|
|
363
|
+
|
|
364
|
+
if not self._token_file_path or not os.path.exists(self._token_file_path):
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
with open(self._token_file_path, encoding="utf-8") as f:
|
|
369
|
+
data = json_load(f)
|
|
370
|
+
self._token_refresh = data.get("refresh_token")
|
|
371
|
+
|
|
372
|
+
_LOGGER.debug("Refresh token loaded from %s", self._token_file_path)
|
|
373
|
+
|
|
374
|
+
return True
|
|
375
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
376
|
+
_LOGGER.error("Failed to load refresh token: %s", e)
|
|
377
|
+
raise TadoException(e) from e
|
|
378
|
+
|
|
379
|
+
def _refresh_token(self, refresh_token: str | None = None, force_refresh: bool = False) -> bool:
|
|
380
|
+
"""
|
|
381
|
+
Refresh the OAuth token if it is about to expire or if forced.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
refresh_token (str | None, optional): The refresh token to use for obtaining a new
|
|
385
|
+
access token.
|
|
386
|
+
force_refresh (bool, optional): If True, forces a token refresh regardless of
|
|
387
|
+
expiration. Defaults to False.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
bool: True if the token was successfully refreshed, False if the refresh failed due
|
|
391
|
+
to invalid credentials.
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
TadoException: If a connection error occurs during the token refresh process.
|
|
395
|
+
TadoWrongCredentialsException: If the token refresh fails due to invalid credentials
|
|
396
|
+
and force_refresh is False.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
if self._refresh_at >= datetime.now() and not force_refresh:
|
|
400
|
+
return True
|
|
293
401
|
|
|
294
402
|
url = "https://login.tado.com/oauth2/token"
|
|
295
403
|
data = {
|
|
296
404
|
"client_id": CLIENT_ID_DEVICE,
|
|
297
405
|
"grant_type": "refresh_token",
|
|
298
|
-
"refresh_token": self._token_refresh,
|
|
406
|
+
"refresh_token": refresh_token or self._token_refresh,
|
|
299
407
|
}
|
|
300
408
|
self._session.close()
|
|
301
|
-
self._session =
|
|
302
|
-
self._session.hooks["response"].append(self._log_response)
|
|
409
|
+
self._session = self._create_session()
|
|
303
410
|
|
|
304
411
|
try:
|
|
305
412
|
response = self._session.request(
|
|
@@ -313,17 +420,49 @@ class Http:
|
|
|
313
420
|
"Referer": "https://app.tado.com/",
|
|
314
421
|
},
|
|
315
422
|
)
|
|
423
|
+
|
|
316
424
|
except requests.exceptions.ConnectionError as e:
|
|
317
425
|
_LOGGER.error("Connection error: %s", e)
|
|
318
|
-
raise TadoException(e)
|
|
426
|
+
raise TadoException(e) from e
|
|
319
427
|
|
|
320
428
|
if response.status_code != 200:
|
|
429
|
+
if force_refresh:
|
|
430
|
+
_LOGGER.error(
|
|
431
|
+
"Failed to refresh token, probably wrong credentials. Status code: %s",
|
|
432
|
+
response.status_code,
|
|
433
|
+
)
|
|
434
|
+
return False
|
|
435
|
+
|
|
321
436
|
raise TadoWrongCredentialsException(
|
|
322
|
-
"Failed to refresh token, probably wrong credentials. "
|
|
437
|
+
"Failed to refresh token, probably wrong credentials. "
|
|
438
|
+
f"Status code: {response.status_code}"
|
|
323
439
|
)
|
|
324
440
|
|
|
325
441
|
self._set_oauth_header(response.json())
|
|
326
442
|
|
|
443
|
+
return True
|
|
444
|
+
|
|
445
|
+
def _save_token(self):
|
|
446
|
+
"""Save the refresh token to a file."""
|
|
447
|
+
if not self._token_file_path or not self._token_refresh:
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
token_dir = os.path.dirname(self._token_file_path)
|
|
452
|
+
if token_dir and not os.path.exists(token_dir):
|
|
453
|
+
Path(token_dir).mkdir(parents=True, exist_ok=True)
|
|
454
|
+
|
|
455
|
+
with open(self._token_file_path, "w", encoding="utf-8") as f:
|
|
456
|
+
json_dump(
|
|
457
|
+
{"refresh_token": self._token_refresh},
|
|
458
|
+
f,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
_LOGGER.debug("Refresh token saved to %s", self._token_file_path)
|
|
462
|
+
except Exception as e:
|
|
463
|
+
_LOGGER.error("Failed to save refresh token: %s", e)
|
|
464
|
+
raise TadoException(e) from e
|
|
465
|
+
|
|
327
466
|
def _login_device_flow(self) -> DeviceActivationStatus:
|
|
328
467
|
"""Start the login to the API using the device flow"""
|
|
329
468
|
|
|
@@ -352,7 +491,9 @@ class Http:
|
|
|
352
491
|
raise TadoException(e) from e
|
|
353
492
|
|
|
354
493
|
if response.status_code != 200:
|
|
355
|
-
raise TadoException(
|
|
494
|
+
raise TadoException(
|
|
495
|
+
f"Login failed. Status code: {response.status_code} and reason: {response.reason}"
|
|
496
|
+
)
|
|
356
497
|
|
|
357
498
|
self._device_flow_data = response.json()
|
|
358
499
|
_LOGGER.debug("Device flow response: %s", self._device_flow_data)
|
|
@@ -375,7 +516,9 @@ class Http:
|
|
|
375
516
|
return DeviceActivationStatus.PENDING
|
|
376
517
|
|
|
377
518
|
def _check_device_activation(self) -> bool:
|
|
378
|
-
if self._expires_at is not None and datetime.timestamp(datetime.now()) > datetime.timestamp(
|
|
519
|
+
if self._expires_at is not None and datetime.timestamp(datetime.now()) > datetime.timestamp(
|
|
520
|
+
self._expires_at
|
|
521
|
+
):
|
|
379
522
|
raise TadoException("User took too long to enter key")
|
|
380
523
|
|
|
381
524
|
# Await the desired interval, before polling the API again
|
|
@@ -399,7 +542,10 @@ class Http:
|
|
|
399
542
|
return True
|
|
400
543
|
|
|
401
544
|
# The user has not yet authorized the device, let's continue
|
|
402
|
-
if
|
|
545
|
+
if (
|
|
546
|
+
token_response.status_code == 400
|
|
547
|
+
and token_response.json()["error"] == "authorization_pending"
|
|
548
|
+
):
|
|
403
549
|
_LOGGER.info("Authorization pending, waiting for user to authorize. Continue polling.")
|
|
404
550
|
return False
|
|
405
551
|
|
|
@@ -415,6 +561,10 @@ class Http:
|
|
|
415
561
|
if self._check_device_activation():
|
|
416
562
|
break
|
|
417
563
|
|
|
564
|
+
self._device_ready()
|
|
565
|
+
|
|
566
|
+
def _device_ready(self):
|
|
567
|
+
"""after device refresh code has been obtained"""
|
|
418
568
|
self._id = self._get_id()
|
|
419
569
|
self._x_api = self._check_x_line_generation()
|
|
420
570
|
self._user_code = None
|
|
@@ -439,4 +589,4 @@ class Http:
|
|
|
439
589
|
|
|
440
590
|
home_ = self.request(request)
|
|
441
591
|
|
|
442
|
-
return "generation" in home_ and home_["generation"] == "LINE_X"
|
|
592
|
+
return "generation" in home_ and home_["generation"] == "LINE_X"
|
|
@@ -30,7 +30,8 @@ _LOGGER = Logger(__name__)
|
|
|
30
30
|
class TadoX(Tado):
|
|
31
31
|
"""Interacts with a Tado thermostat via hops.tado.com (Tado X) API.
|
|
32
32
|
|
|
33
|
-
Example usage: http = Http(
|
|
33
|
+
Example usage: http = Http()
|
|
34
|
+
http.device_activation() # Activate the device
|
|
34
35
|
t = TadoX(http)
|
|
35
36
|
t.get_climate(1) # Get climate, room 1.
|
|
36
37
|
"""
|
|
@@ -34,7 +34,8 @@ _LOGGER = Logger(__name__)
|
|
|
34
34
|
class Tado:
|
|
35
35
|
"""Interacts with a Tado thermostat via public my.tado.com API.
|
|
36
36
|
|
|
37
|
-
Example usage: http = Http(
|
|
37
|
+
Example usage: http = Http()
|
|
38
|
+
http.device_activation() # Activate the device
|
|
38
39
|
t = Tado(http)
|
|
39
40
|
t.get_climate(1) # Get climate, zone 1.
|
|
40
41
|
"""
|
|
@@ -619,4 +620,4 @@ class Tado:
|
|
|
619
620
|
request.endpoint = Endpoint.MINDER
|
|
620
621
|
request.params = {"from": date}
|
|
621
622
|
|
|
622
|
-
return self._http.request(request)
|
|
623
|
+
return self._http.request(request)
|
|
@@ -6,12 +6,25 @@ import datetime
|
|
|
6
6
|
import functools
|
|
7
7
|
import warnings
|
|
8
8
|
|
|
9
|
+
import requests
|
|
10
|
+
|
|
9
11
|
import PyTado.interface.api as API
|
|
10
12
|
from PyTado.exceptions import TadoException
|
|
11
13
|
from PyTado.http import DeviceActivationStatus, Http
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
def deprecated(new_func_name):
|
|
17
|
+
"""
|
|
18
|
+
A decorator to mark functions as deprecated. It will result in a warning being emitted
|
|
19
|
+
when the function is used, advising the user to use the new function instead.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
new_func_name (str): The name of the new function that should be used instead.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
function: A decorator that wraps the deprecated function and emits a warning.
|
|
26
|
+
"""
|
|
27
|
+
|
|
15
28
|
def decorator(func):
|
|
16
29
|
@functools.wraps(func)
|
|
17
30
|
def wrapper(*args, **kwargs):
|
|
@@ -31,18 +44,34 @@ def deprecated(new_func_name):
|
|
|
31
44
|
class Tado:
|
|
32
45
|
"""Interacts with a Tado thermostat via public API.
|
|
33
46
|
|
|
34
|
-
Example usage: t = Tado(
|
|
47
|
+
Example usage: t = Tado()
|
|
48
|
+
t.device_activation() # Activate device
|
|
35
49
|
t.get_climate(1) # Get climate, zone 1.
|
|
36
50
|
"""
|
|
37
51
|
|
|
38
52
|
def __init__(
|
|
39
53
|
self,
|
|
40
|
-
|
|
54
|
+
token_file_path: str | None = None,
|
|
55
|
+
saved_refresh_token: str | None = None,
|
|
56
|
+
http_session: requests.Session | None = None,
|
|
41
57
|
debug: bool = False,
|
|
42
58
|
):
|
|
43
|
-
"""
|
|
59
|
+
"""
|
|
60
|
+
Initializes the interface class.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
token_file_path (str | None, optional): Path to a file which will be used to persist
|
|
64
|
+
the refresh_token token. Defaults to None.
|
|
65
|
+
saved_refresh_token (str | None, optional): A previously saved refresh token.
|
|
66
|
+
Defaults to None.
|
|
67
|
+
http_session (requests.Session | None, optional): An optional HTTP session to use for
|
|
68
|
+
requests (can be used in unit tests). Defaults to None.
|
|
69
|
+
debug (bool, optional): Flag to enable or disable debug mode. Defaults to False.
|
|
70
|
+
"""
|
|
44
71
|
|
|
45
72
|
self._http = Http(
|
|
73
|
+
token_file_path=token_file_path,
|
|
74
|
+
saved_refresh_token=saved_refresh_token,
|
|
46
75
|
http_session=http_session,
|
|
47
76
|
debug=debug,
|
|
48
77
|
)
|
|
@@ -50,16 +79,12 @@ class Tado:
|
|
|
50
79
|
self._debug = debug
|
|
51
80
|
|
|
52
81
|
def __getattr__(self, name):
|
|
53
|
-
"""
|
|
82
|
+
"""Delegate the called method to api implementation (hops_tado.py or my_tado.py)."""
|
|
54
83
|
|
|
55
|
-
|
|
56
|
-
raise TadoException("API is not initialized. Please complete device authentication first.")
|
|
84
|
+
self._ensure_api_initialized()
|
|
57
85
|
|
|
58
86
|
return getattr(self._api, name)
|
|
59
87
|
|
|
60
|
-
# region Deprecated Methods
|
|
61
|
-
# pylint: disable=invalid-name
|
|
62
|
-
|
|
63
88
|
def device_verification_url(self) -> str | None:
|
|
64
89
|
"""Returns the URL for device verification."""
|
|
65
90
|
return self._http.device_verification_url
|
|
@@ -71,12 +96,32 @@ class Tado:
|
|
|
71
96
|
def device_activation(self) -> None:
|
|
72
97
|
"""Activates the device."""
|
|
73
98
|
self._http.device_activation()
|
|
99
|
+
self._ensure_api_initialized()
|
|
100
|
+
|
|
101
|
+
def get_refresh_token(self) -> str | None:
|
|
102
|
+
"""
|
|
103
|
+
Retrieve the refresh token from the current api connection.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
str | None: The current refresh token, or None if not available.
|
|
107
|
+
"""
|
|
108
|
+
return self._http.refresh_token
|
|
74
109
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
def _ensure_api_initialized(self):
|
|
111
|
+
"""Ensures the API client is initialized."""
|
|
112
|
+
if self._api is None:
|
|
113
|
+
if self._http.device_activation_status == DeviceActivationStatus.COMPLETED:
|
|
114
|
+
if self._http.is_x_line:
|
|
115
|
+
self._api = API.TadoX(http=self._http, debug=self._debug)
|
|
116
|
+
else:
|
|
117
|
+
self._api = API.Tado(http=self._http, debug=self._debug)
|
|
78
118
|
else:
|
|
79
|
-
|
|
119
|
+
raise TadoException(
|
|
120
|
+
"API is not initialized. Please complete device authentication first."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# region Deprecated Methods
|
|
124
|
+
# pylint: disable=invalid-name
|
|
80
125
|
|
|
81
126
|
@deprecated("get_me")
|
|
82
127
|
def getMe(self):
|
|
@@ -317,4 +362,4 @@ class Tado:
|
|
|
317
362
|
)
|
|
318
363
|
|
|
319
364
|
# pylint: enable=invalid-name
|
|
320
|
-
# endregion
|
|
365
|
+
# endregion
|
|
@@ -30,6 +30,7 @@ the GNU Public Licence version 3, and should therefore not be used where it may
|
|
|
30
30
|
cause discomfort and inconvenience to others.
|
|
31
31
|
|
|
32
32
|
## Usage
|
|
33
|
+
|
|
33
34
|
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.
|
|
34
35
|
|
|
35
36
|
PyTado handles this as following:
|
|
@@ -42,11 +43,45 @@ PyTado handles this as following:
|
|
|
42
43
|
The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
|
|
43
44
|
|
|
44
45
|
### Screenshots of the device flow
|
|
46
|
+
|
|
45
47
|

|
|
46
48
|

|
|
47
49
|

|
|
48
50
|
|
|
51
|
+
### How to not authenticate the device again
|
|
52
|
+
|
|
53
|
+
It is possible to save the refresh token and reuse to skip the next login.
|
|
54
|
+
|
|
55
|
+
The following code will use the `refresh_token` file to save the refresh-token after login, and load the refresh-token if you create the Tado interface class again.
|
|
56
|
+
|
|
57
|
+
If the file doesn't exists, the webbrowser is started and the device authentication url is automatically opened. You can activate the device in the browser. When you restart the program, the refresh-token is reused and no webbrowser will be opened.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import webbrowser # only needed for direct web browser access
|
|
61
|
+
|
|
62
|
+
from PyTado.interface.interface import Tado
|
|
63
|
+
|
|
64
|
+
tado = Tado(token_file_path="/var/tado/refresh_token")
|
|
65
|
+
|
|
66
|
+
status = tado.device_activation_status()
|
|
67
|
+
|
|
68
|
+
if status == "PENDING":
|
|
69
|
+
url = tado.device_verification_url()
|
|
70
|
+
|
|
71
|
+
webbrowser.open_new_tab(url)
|
|
72
|
+
|
|
73
|
+
tado.device_activation()
|
|
74
|
+
|
|
75
|
+
status = tado.device_activation_status()
|
|
76
|
+
|
|
77
|
+
if status == "COMPLETED":
|
|
78
|
+
print("Login successful")
|
|
79
|
+
else:
|
|
80
|
+
print(f"Login status is {status}")
|
|
81
|
+
```
|
|
82
|
+
|
|
49
83
|
## Example code
|
|
84
|
+
|
|
50
85
|
```python
|
|
51
86
|
"""Example client for PyTado"""
|
|
52
87
|
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "python-tado"
|
|
7
|
-
version = "0.19.
|
|
7
|
+
version = "0.19.1"
|
|
8
8
|
description = "PyTado from chrism0dwk, modified by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem"
|
|
9
9
|
authors = [
|
|
10
10
|
"Chris Jewell <chrism0dwk@gmail.com>",
|
|
@@ -32,7 +32,7 @@ repository = "https://github.com/wmalgadey/PyTado"
|
|
|
32
32
|
[tool.poetry.dependencies]
|
|
33
33
|
python = ">=3.11"
|
|
34
34
|
requests = "*"
|
|
35
|
-
black = ">=24.
|
|
35
|
+
black = ">=24.10"
|
|
36
36
|
pylint = "*"
|
|
37
37
|
pre-commit = "*"
|
|
38
38
|
pytype = "*"
|
|
@@ -66,7 +66,7 @@ source = ["PyTado"]
|
|
|
66
66
|
[tool.black]
|
|
67
67
|
line-length = 100
|
|
68
68
|
target-version = ["py311"]
|
|
69
|
-
required-version = "24.
|
|
69
|
+
required-version = "24.10.0"
|
|
70
70
|
|
|
71
71
|
[tool.bandit]
|
|
72
72
|
exclude_dirs = ["tests"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|