python-tado 0.18.7__tar.gz → 0.18.9__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.18.7/python_tado.egg-info → python_tado-0.18.9}/PKG-INFO +54 -2
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/__main__.py +6 -16
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/http.py +157 -13
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/api/hops_tado.py +13 -36
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/api/my_tado.py +2 -1
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/interface.py +57 -13
- {python_tado-0.18.7 → python_tado-0.18.9}/README.md +53 -1
- {python_tado-0.18.7 → python_tado-0.18.9}/pyproject.toml +3 -3
- {python_tado-0.18.7 → python_tado-0.18.9/python_tado.egg-info}/PKG-INFO +54 -2
- {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_http.py +114 -19
- {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_tado_interface.py +5 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/AUTHORS +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/LICENSE +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/__init__.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/const.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/exceptions.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/__init__.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/api/__init__.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/logger.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/zone/__init__.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/zone/hops_zone.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/zone/my_zone.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/SOURCES.txt +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/dependency_links.txt +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/entry_points.txt +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/not-zip-safe +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/requires.txt +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/top_level.txt +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/setup.cfg +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_hops_zone.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_my_tado.py +0 -0
- {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_my_zone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: python-tado
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.9
|
|
4
4
|
Summary: PyTado from chrism0dwk, modfied by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem
|
|
5
5
|
Author-email: Chris Jewell <chrism0dwk@gmail.com>, "w.malgadey" <w.malgadey@gmail.com>, FilBr <filippo.barba@protonmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -723,7 +723,7 @@ heating setups.
|
|
|
723
723
|
|
|
724
724
|
---
|
|
725
725
|
|
|
726
|
-
Original author: Chris Jewell <chrism0dwk@gmail.com>
|
|
726
|
+
Original author: Chris Jewell <chrism0dwk@gmail.com>
|
|
727
727
|
|
|
728
728
|
Licence: GPL v3
|
|
729
729
|
|
|
@@ -744,6 +744,58 @@ cause discomfort and inconvenience to others.
|
|
|
744
744
|
>>> climate = t.get_climate(zone=1)
|
|
745
745
|
|
|
746
746
|
## Usage
|
|
747
|
+
|
|
748
|
+
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.
|
|
749
|
+
|
|
750
|
+
PyTado handles this as following:
|
|
751
|
+
|
|
752
|
+
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`.
|
|
753
|
+
|
|
754
|
+
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.
|
|
755
|
+
|
|
756
|
+
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.
|
|
757
|
+
The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
|
|
758
|
+
|
|
759
|
+
### Screenshots of the device flow
|
|
760
|
+
|
|
761
|
+

|
|
762
|
+

|
|
763
|
+

|
|
764
|
+
|
|
765
|
+
### How to not authenticate the device again
|
|
766
|
+
|
|
767
|
+
It is possible to save the refresh token and reuse to skip the next login.
|
|
768
|
+
|
|
769
|
+
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.
|
|
770
|
+
|
|
771
|
+
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.
|
|
772
|
+
|
|
773
|
+
```python
|
|
774
|
+
import webbrowser # only needed for direct web browser access
|
|
775
|
+
|
|
776
|
+
from PyTado.interface.interface import Tado
|
|
777
|
+
|
|
778
|
+
tado = Tado(token_file_path="/var/tado/refresh_token")
|
|
779
|
+
|
|
780
|
+
status = tado.device_activation_status()
|
|
781
|
+
|
|
782
|
+
if status == "PENDING":
|
|
783
|
+
url = tado.device_verification_url()
|
|
784
|
+
|
|
785
|
+
webbrowser.open_new_tab(url)
|
|
786
|
+
|
|
787
|
+
tado.device_activation()
|
|
788
|
+
|
|
789
|
+
status = tado.device_activation_status()
|
|
790
|
+
|
|
791
|
+
if status == "COMPLETED":
|
|
792
|
+
print("Login successful")
|
|
793
|
+
else:
|
|
794
|
+
print(f"Login status is {status}")
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
## Example code
|
|
798
|
+
|
|
747
799
|
```python
|
|
748
800
|
"""Example client for PyTado"""
|
|
749
801
|
|
|
@@ -9,32 +9,32 @@ import sys
|
|
|
9
9
|
from PyTado.interface import Tado
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def log_in(
|
|
12
|
+
def log_in():
|
|
13
13
|
t = Tado()
|
|
14
14
|
t.device_activation()
|
|
15
15
|
return t
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def get_me(
|
|
19
|
-
t =
|
|
18
|
+
def get_me():
|
|
19
|
+
t = log_in()
|
|
20
20
|
me = t.get_me()
|
|
21
21
|
print(me)
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def get_state(args):
|
|
25
|
-
t =
|
|
25
|
+
t = log_in()
|
|
26
26
|
zone = t.get_state(int(args.zone))
|
|
27
27
|
print(zone)
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def get_states(args):
|
|
31
|
-
t =
|
|
31
|
+
t = log_in()
|
|
32
32
|
zone = t.get_zone_states()
|
|
33
33
|
print(zone)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def get_capabilities(args):
|
|
37
|
-
t =
|
|
37
|
+
t = log_in()
|
|
38
38
|
capabilities = t.get_capabilities(int(args.zone))
|
|
39
39
|
print(capabilities)
|
|
40
40
|
|
|
@@ -46,16 +46,6 @@ def main():
|
|
|
46
46
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
-
required_flags = parser.add_argument_group("required arguments")
|
|
50
|
-
|
|
51
|
-
# Required flags go here.
|
|
52
|
-
required_flags.add_argument(
|
|
53
|
-
"--email",
|
|
54
|
-
required=True,
|
|
55
|
-
help=("Tado username in the form of an email address."),
|
|
56
|
-
)
|
|
57
|
-
required_flags.add_argument("--password", required=True, help="Tado password.")
|
|
58
|
-
|
|
59
49
|
# Flags with default values go here.
|
|
60
50
|
log_levels = {logging.getLevelName(level): level for level in [10, 20, 30, 40, 50]}
|
|
61
51
|
parser.add_argument(
|
|
@@ -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
|
|
|
@@ -58,6 +62,8 @@ class Mode(enum.Enum):
|
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
class DeviceActivationStatus(enum.StrEnum):
|
|
65
|
+
"""Device Activation Status Enum"""
|
|
66
|
+
|
|
61
67
|
NOT_STARTED = "NOT_STARTED"
|
|
62
68
|
PENDING = "PENDING"
|
|
63
69
|
COMPLETED = "COMPLETED"
|
|
@@ -142,16 +148,34 @@ class Http:
|
|
|
142
148
|
|
|
143
149
|
def __init__(
|
|
144
150
|
self,
|
|
151
|
+
token_file_path: str | None = None,
|
|
152
|
+
saved_refresh_token: str | None = None,
|
|
145
153
|
http_session: requests.Session | None = None,
|
|
146
154
|
debug: bool = False,
|
|
147
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 authentication.
|
|
163
|
+
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
|
+
|
|
148
172
|
if debug:
|
|
149
173
|
_LOGGER.setLevel(logging.DEBUG)
|
|
150
174
|
else:
|
|
151
175
|
_LOGGER.setLevel(logging.WARNING)
|
|
152
176
|
|
|
153
177
|
self._refresh_at = datetime.now() + timedelta(minutes=10)
|
|
154
|
-
self._session = http_session or
|
|
178
|
+
self._session = http_session or self._create_session()
|
|
155
179
|
self._session.hooks["response"].append(self._log_response)
|
|
156
180
|
self._headers = {"Referer": "https://app.tado.com/"}
|
|
157
181
|
|
|
@@ -163,24 +187,72 @@ class Http:
|
|
|
163
187
|
self._id: int | None = None
|
|
164
188
|
self._token_refresh: str | None = None
|
|
165
189
|
self._x_api: bool | None = None
|
|
166
|
-
self.
|
|
190
|
+
self._token_file_path = token_file_path
|
|
191
|
+
|
|
192
|
+
if (saved_refresh_token or self._load_token()) and self._refresh_token(
|
|
193
|
+
refresh_token=saved_refresh_token, force_refresh=True
|
|
194
|
+
):
|
|
195
|
+
self._device_ready()
|
|
196
|
+
else:
|
|
197
|
+
self._device_activation_status = self._login_device_flow()
|
|
167
198
|
|
|
168
199
|
@property
|
|
169
200
|
def is_x_line(self) -> bool | None:
|
|
201
|
+
"""
|
|
202
|
+
Check if the current line is an X line.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
bool | None: True if the current line is an X line, False otherwise.
|
|
206
|
+
None if the api is not ready yet.
|
|
207
|
+
"""
|
|
170
208
|
return self._x_api
|
|
171
209
|
|
|
172
210
|
@property
|
|
173
211
|
def user_code(self) -> str | None:
|
|
212
|
+
"""
|
|
213
|
+
Retrieve the user code.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
str | None: The user code if available, otherwise None.
|
|
217
|
+
"""
|
|
174
218
|
return self._user_code
|
|
175
219
|
|
|
176
220
|
@property
|
|
177
221
|
def device_activation_status(self) -> DeviceActivationStatus:
|
|
222
|
+
"""
|
|
223
|
+
Retrieve the activation status of the device.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
DeviceActivationStatus: The current activation status of the device.
|
|
227
|
+
"""
|
|
178
228
|
return self._device_activation_status
|
|
179
229
|
|
|
180
230
|
@property
|
|
181
231
|
def device_verification_url(self) -> str | None:
|
|
232
|
+
"""
|
|
233
|
+
Retrieve the url to activate the device.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
str | None: The current url for device activation or none if
|
|
237
|
+
authentication is not started.
|
|
238
|
+
"""
|
|
182
239
|
return self._device_verification_url
|
|
183
240
|
|
|
241
|
+
@property
|
|
242
|
+
def refresh_token(self) -> str | None:
|
|
243
|
+
"""
|
|
244
|
+
Retrieve the current refresh token for the tado api connection.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
str | None: The current refresh token, or None if not available.
|
|
248
|
+
"""
|
|
249
|
+
return self._token_refresh
|
|
250
|
+
|
|
251
|
+
def _create_session(self) -> requests.Session:
|
|
252
|
+
session = requests.Session()
|
|
253
|
+
session.hooks["response"].append(self._log_response)
|
|
254
|
+
return session
|
|
255
|
+
|
|
184
256
|
def _log_response(self, response: requests.Response, *args, **kwargs) -> None:
|
|
185
257
|
og_request_method = response.request.method
|
|
186
258
|
og_request_url = response.request.url
|
|
@@ -225,8 +297,7 @@ class Http:
|
|
|
225
297
|
if retries > 0:
|
|
226
298
|
_LOGGER.warning("Connection error: %s", e)
|
|
227
299
|
self._session.close()
|
|
228
|
-
self._session =
|
|
229
|
-
self._session.hooks["response"].append(self._log_response)
|
|
300
|
+
self._session = self._create_session()
|
|
230
301
|
retries -= 1
|
|
231
302
|
else:
|
|
232
303
|
_LOGGER.error(
|
|
@@ -283,22 +354,60 @@ class Http:
|
|
|
283
354
|
self._refresh_at = self._refresh_at - timedelta(seconds=30)
|
|
284
355
|
|
|
285
356
|
self._headers["Authorization"] = f"Bearer {access_token}"
|
|
357
|
+
|
|
358
|
+
self._save_token()
|
|
359
|
+
|
|
286
360
|
return refresh_token
|
|
287
361
|
|
|
288
|
-
def
|
|
289
|
-
"""
|
|
290
|
-
|
|
291
|
-
|
|
362
|
+
def _load_token(self) -> bool:
|
|
363
|
+
"""Load the refresh token from a file."""
|
|
364
|
+
|
|
365
|
+
if not self._token_file_path or not os.path.exists(self._token_file_path):
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
with open(self._token_file_path, encoding="utf-8") as f:
|
|
370
|
+
data = json_load(f)
|
|
371
|
+
self._token_refresh = data.get("refresh_token")
|
|
372
|
+
|
|
373
|
+
_LOGGER.debug("Refresh token loaded from %s", self._token_file_path)
|
|
374
|
+
|
|
375
|
+
return True
|
|
376
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
377
|
+
_LOGGER.error("Failed to load refresh token: %s", e)
|
|
378
|
+
raise TadoException(e) from e
|
|
379
|
+
|
|
380
|
+
def _refresh_token(self, refresh_token: str | None = None, force_refresh: bool = False) -> bool:
|
|
381
|
+
"""
|
|
382
|
+
Refresh the OAuth token if it is about to expire or if forced.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
refresh_token (str | None, optional): The refresh token to use for obtaining a new
|
|
386
|
+
access token.
|
|
387
|
+
force_refresh (bool, optional): If True, forces a token refresh regardless of
|
|
388
|
+
expiration. Defaults to False.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
bool: True if the token was successfully refreshed, False if the refresh failed due
|
|
392
|
+
to invalid credentials.
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
TadoException: If a connection error occurs during the token refresh process.
|
|
396
|
+
TadoWrongCredentialsException: If the token refresh fails due to invalid credentials
|
|
397
|
+
and force_refresh is False.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
if self._refresh_at >= datetime.now() and not force_refresh:
|
|
401
|
+
return True
|
|
292
402
|
|
|
293
403
|
url = "https://login.tado.com/oauth2/token"
|
|
294
404
|
data = {
|
|
295
405
|
"client_id": CLIENT_ID_DEVICE,
|
|
296
406
|
"grant_type": "refresh_token",
|
|
297
|
-
"refresh_token": self._token_refresh,
|
|
407
|
+
"refresh_token": refresh_token or self._token_refresh,
|
|
298
408
|
}
|
|
299
409
|
self._session.close()
|
|
300
|
-
self._session =
|
|
301
|
-
self._session.hooks["response"].append(self._log_response)
|
|
410
|
+
self._session = self._create_session()
|
|
302
411
|
|
|
303
412
|
try:
|
|
304
413
|
response = self._session.request(
|
|
@@ -312,17 +421,48 @@ class Http:
|
|
|
312
421
|
"Referer": "https://app.tado.com/",
|
|
313
422
|
},
|
|
314
423
|
)
|
|
424
|
+
|
|
315
425
|
except requests.exceptions.ConnectionError as e:
|
|
316
426
|
_LOGGER.error("Connection error: %s", e)
|
|
317
|
-
raise TadoException(e)
|
|
427
|
+
raise TadoException(e) from e
|
|
318
428
|
|
|
319
429
|
if response.status_code != 200:
|
|
430
|
+
if force_refresh:
|
|
431
|
+
_LOGGER.error(
|
|
432
|
+
"Failed to refresh token, probably wrong credentials. Status code: %s",
|
|
433
|
+
response.status_code,
|
|
434
|
+
)
|
|
435
|
+
return False
|
|
436
|
+
|
|
320
437
|
raise TadoWrongCredentialsException(
|
|
321
|
-
"Failed to refresh token, probably wrong credentials.
|
|
438
|
+
f"Failed to refresh token, probably wrong credentials. Status code: {response.status_code}"
|
|
322
439
|
)
|
|
323
440
|
|
|
324
441
|
self._set_oauth_header(response.json())
|
|
325
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
|
+
|
|
326
466
|
def _login_device_flow(self) -> DeviceActivationStatus:
|
|
327
467
|
"""Start the login to the API using the device flow"""
|
|
328
468
|
|
|
@@ -414,6 +554,10 @@ class Http:
|
|
|
414
554
|
if self._check_device_activation():
|
|
415
555
|
break
|
|
416
556
|
|
|
557
|
+
self._device_ready()
|
|
558
|
+
|
|
559
|
+
def _device_ready(self):
|
|
560
|
+
"""after device refresh code has been obtained"""
|
|
417
561
|
self._id = self._get_id()
|
|
418
562
|
self._x_api = self._check_x_line_generation()
|
|
419
563
|
self._user_code = None
|
|
@@ -3,23 +3,13 @@ PyTado interface implementation for hops.tado.com (Tado X).
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
9
|
-
from .my_tado import Tado, Timetable
|
|
10
|
-
|
|
11
|
-
from ...logger import Logger
|
|
12
8
|
from ...exceptions import TadoNotSupportedException
|
|
13
|
-
from ...http import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Mode,
|
|
18
|
-
TadoRequest,
|
|
19
|
-
TadoXRequest,
|
|
20
|
-
)
|
|
21
|
-
from ...zone import TadoZone, TadoXZone
|
|
22
|
-
|
|
9
|
+
from ...http import Action, Domain, Http, Mode, TadoRequest, TadoXRequest
|
|
10
|
+
from ...logger import Logger
|
|
11
|
+
from ...zone import TadoXZone, TadoZone
|
|
12
|
+
from .my_tado import Tado, Timetable
|
|
23
13
|
|
|
24
14
|
_LOGGER = Logger(__name__)
|
|
25
15
|
|
|
@@ -27,7 +17,8 @@ _LOGGER = Logger(__name__)
|
|
|
27
17
|
class TadoX(Tado):
|
|
28
18
|
"""Interacts with a Tado thermostat via hops.tado.com (Tado X) API.
|
|
29
19
|
|
|
30
|
-
Example usage: http = Http(
|
|
20
|
+
Example usage: http = Http()
|
|
21
|
+
http.device_activation() # Activate the device
|
|
31
22
|
t = TadoX(http)
|
|
32
23
|
t.get_climate(1) # Get climate, room 1.
|
|
33
24
|
"""
|
|
@@ -42,9 +33,7 @@ class TadoX(Tado):
|
|
|
42
33
|
super().__init__(http=http, debug=debug)
|
|
43
34
|
|
|
44
35
|
if not http.is_x_line:
|
|
45
|
-
raise TadoNotSupportedException(
|
|
46
|
-
"TadoX is only usable with LINE_X Generation"
|
|
47
|
-
)
|
|
36
|
+
raise TadoNotSupportedException("TadoX is only usable with LINE_X Generation")
|
|
48
37
|
|
|
49
38
|
if debug:
|
|
50
39
|
_LOGGER.setLevel(logging.DEBUG)
|
|
@@ -124,9 +113,7 @@ class TadoX(Tado):
|
|
|
124
113
|
Gets current capabilities of zone.
|
|
125
114
|
"""
|
|
126
115
|
|
|
127
|
-
raise TadoNotSupportedException(
|
|
128
|
-
"This method is not currently supported by the Tado X API"
|
|
129
|
-
)
|
|
116
|
+
raise TadoNotSupportedException("This method is not currently supported by the Tado X API")
|
|
130
117
|
|
|
131
118
|
def get_climate(self, zone):
|
|
132
119
|
"""
|
|
@@ -147,13 +134,9 @@ class TadoX(Tado):
|
|
|
147
134
|
id = 3 : SEVEN_DAY (MONDAY, TUESDAY, WEDNESDAY ...)
|
|
148
135
|
"""
|
|
149
136
|
|
|
150
|
-
raise TadoNotSupportedException(
|
|
151
|
-
"Tado X API only support seven days timetable"
|
|
152
|
-
)
|
|
137
|
+
raise TadoNotSupportedException("Tado X API only support seven days timetable")
|
|
153
138
|
|
|
154
|
-
def get_schedule(
|
|
155
|
-
self, zone: int, timetable: Timetable, day=None
|
|
156
|
-
) -> dict[str, Any]:
|
|
139
|
+
def get_schedule(self, zone: int, timetable: Timetable, day=None) -> dict[str, Any]:
|
|
157
140
|
"""
|
|
158
141
|
Get the JSON representation of the schedule for a zone.
|
|
159
142
|
Zone has 3 different schedules, one for each timetable (see setTimetable)
|
|
@@ -271,9 +254,7 @@ class TadoX(Tado):
|
|
|
271
254
|
Get current overlay default settings for zone.
|
|
272
255
|
"""
|
|
273
256
|
|
|
274
|
-
raise TadoNotSupportedException(
|
|
275
|
-
"Concept of zones is not available by Tado X API, they use rooms"
|
|
276
|
-
)
|
|
257
|
+
raise TadoNotSupportedException("Concept of zones is not available by Tado X API, they use rooms")
|
|
277
258
|
|
|
278
259
|
def get_open_window_detected(self, zone):
|
|
279
260
|
"""
|
|
@@ -293,18 +274,14 @@ class TadoX(Tado):
|
|
|
293
274
|
Note: This can only be set if an open window was detected in this zone
|
|
294
275
|
"""
|
|
295
276
|
|
|
296
|
-
raise TadoNotSupportedException(
|
|
297
|
-
"This method is not currently supported by the Tado X API"
|
|
298
|
-
)
|
|
277
|
+
raise TadoNotSupportedException("This method is not currently supported by the Tado X API")
|
|
299
278
|
|
|
300
279
|
def reset_open_window(self, zone):
|
|
301
280
|
"""
|
|
302
281
|
Sets the window in zone to closed
|
|
303
282
|
"""
|
|
304
283
|
|
|
305
|
-
raise TadoNotSupportedException(
|
|
306
|
-
"This method is not currently supported by the Tado X API"
|
|
307
|
-
)
|
|
284
|
+
raise TadoNotSupportedException("This method is not currently supported by the Tado X API")
|
|
308
285
|
|
|
309
286
|
def get_device_info(self, device_id, cmd=""):
|
|
310
287
|
"""
|
|
@@ -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
|
"""
|
|
@@ -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,35 @@ 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).
|
|
69
|
+
Defaults to None.
|
|
70
|
+
debug (bool, optional): Flag to enable or disable debug mode. Defaults to False.
|
|
71
|
+
"""
|
|
44
72
|
|
|
45
73
|
self._http = Http(
|
|
74
|
+
token_file_path=token_file_path,
|
|
75
|
+
saved_refresh_token=saved_refresh_token,
|
|
46
76
|
http_session=http_session,
|
|
47
77
|
debug=debug,
|
|
48
78
|
)
|
|
@@ -50,16 +80,12 @@ class Tado:
|
|
|
50
80
|
self._debug = debug
|
|
51
81
|
|
|
52
82
|
def __getattr__(self, name):
|
|
53
|
-
"""
|
|
83
|
+
"""Delegate the called method to api implementation (hops_tado.py or my_tado.py)."""
|
|
54
84
|
|
|
55
|
-
|
|
56
|
-
raise TadoException("API is not initialized. Please complete device authentication first.")
|
|
85
|
+
self._ensure_api_initialized()
|
|
57
86
|
|
|
58
87
|
return getattr(self._api, name)
|
|
59
88
|
|
|
60
|
-
# region Deprecated Methods
|
|
61
|
-
# pylint: disable=invalid-name
|
|
62
|
-
|
|
63
89
|
def device_verification_url(self) -> str | None:
|
|
64
90
|
"""Returns the URL for device verification."""
|
|
65
91
|
return self._http.device_verification_url
|
|
@@ -71,12 +97,30 @@ class Tado:
|
|
|
71
97
|
def device_activation(self) -> None:
|
|
72
98
|
"""Activates the device."""
|
|
73
99
|
self._http.device_activation()
|
|
100
|
+
self._ensure_api_initialized()
|
|
101
|
+
|
|
102
|
+
def get_refresh_token(self) -> str | None:
|
|
103
|
+
"""
|
|
104
|
+
Retrieve the refresh token from the current api connection.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
str | None: The current refresh token, or None if not available.
|
|
108
|
+
"""
|
|
109
|
+
return self._http.refresh_token
|
|
74
110
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
111
|
+
def _ensure_api_initialized(self):
|
|
112
|
+
"""Ensures the API client is initialized."""
|
|
113
|
+
if self._api is None:
|
|
114
|
+
if self._http.device_activation_status == DeviceActivationStatus.COMPLETED:
|
|
115
|
+
if self._http.is_x_line:
|
|
116
|
+
self._api = API.TadoX(http=self._http, debug=self._debug)
|
|
117
|
+
else:
|
|
118
|
+
self._api = API.Tado(http=self._http, debug=self._debug)
|
|
78
119
|
else:
|
|
79
|
-
|
|
120
|
+
raise TadoException("API is not initialized. Please complete device authentication first.")
|
|
121
|
+
|
|
122
|
+
# region Deprecated Methods
|
|
123
|
+
# pylint: disable=invalid-name
|
|
80
124
|
|
|
81
125
|
@deprecated("get_me")
|
|
82
126
|
def getMe(self):
|
|
@@ -13,7 +13,7 @@ heating setups.
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
Original author: Chris Jewell <chrism0dwk@gmail.com>
|
|
16
|
+
Original author: Chris Jewell <chrism0dwk@gmail.com>
|
|
17
17
|
|
|
18
18
|
Licence: GPL v3
|
|
19
19
|
|
|
@@ -34,6 +34,58 @@ cause discomfort and inconvenience to others.
|
|
|
34
34
|
>>> climate = t.get_climate(zone=1)
|
|
35
35
|
|
|
36
36
|
## Usage
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
PyTado handles this as following:
|
|
41
|
+
|
|
42
|
+
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`.
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
|
|
48
|
+
|
|
49
|
+
### Screenshots of the device flow
|
|
50
|
+
|
|
51
|
+

|
|
52
|
+

|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
### How to not authenticate the device again
|
|
56
|
+
|
|
57
|
+
It is possible to save the refresh token and reuse to skip the next login.
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import webbrowser # only needed for direct web browser access
|
|
65
|
+
|
|
66
|
+
from PyTado.interface.interface import Tado
|
|
67
|
+
|
|
68
|
+
tado = Tado(token_file_path="/var/tado/refresh_token")
|
|
69
|
+
|
|
70
|
+
status = tado.device_activation_status()
|
|
71
|
+
|
|
72
|
+
if status == "PENDING":
|
|
73
|
+
url = tado.device_verification_url()
|
|
74
|
+
|
|
75
|
+
webbrowser.open_new_tab(url)
|
|
76
|
+
|
|
77
|
+
tado.device_activation()
|
|
78
|
+
|
|
79
|
+
status = tado.device_activation_status()
|
|
80
|
+
|
|
81
|
+
if status == "COMPLETED":
|
|
82
|
+
print("Login successful")
|
|
83
|
+
else:
|
|
84
|
+
print(f"Login status is {status}")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Example code
|
|
88
|
+
|
|
37
89
|
```python
|
|
38
90
|
"""Example client for PyTado"""
|
|
39
91
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-tado"
|
|
7
|
-
version = "0.18.
|
|
7
|
+
version = "0.18.9"
|
|
8
8
|
description = "PyTado from chrism0dwk, modfied by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Chris Jewell", email = "chrism0dwk@gmail.com" },
|
|
@@ -45,8 +45,8 @@ platforms = ["any"]
|
|
|
45
45
|
zip-safe = false
|
|
46
46
|
|
|
47
47
|
[tool.black]
|
|
48
|
-
line-length =
|
|
49
|
-
target-version = [
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = ["py311"]
|
|
50
50
|
|
|
51
51
|
[tool.pytype]
|
|
52
52
|
python_version = '3.11'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: python-tado
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.9
|
|
4
4
|
Summary: PyTado from chrism0dwk, modfied by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem
|
|
5
5
|
Author-email: Chris Jewell <chrism0dwk@gmail.com>, "w.malgadey" <w.malgadey@gmail.com>, FilBr <filippo.barba@protonmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -723,7 +723,7 @@ heating setups.
|
|
|
723
723
|
|
|
724
724
|
---
|
|
725
725
|
|
|
726
|
-
Original author: Chris Jewell <chrism0dwk@gmail.com>
|
|
726
|
+
Original author: Chris Jewell <chrism0dwk@gmail.com>
|
|
727
727
|
|
|
728
728
|
Licence: GPL v3
|
|
729
729
|
|
|
@@ -744,6 +744,58 @@ cause discomfort and inconvenience to others.
|
|
|
744
744
|
>>> climate = t.get_climate(zone=1)
|
|
745
745
|
|
|
746
746
|
## Usage
|
|
747
|
+
|
|
748
|
+
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.
|
|
749
|
+
|
|
750
|
+
PyTado handles this as following:
|
|
751
|
+
|
|
752
|
+
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`.
|
|
753
|
+
|
|
754
|
+
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.
|
|
755
|
+
|
|
756
|
+
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.
|
|
757
|
+
The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
|
|
758
|
+
|
|
759
|
+
### Screenshots of the device flow
|
|
760
|
+
|
|
761
|
+

|
|
762
|
+

|
|
763
|
+

|
|
764
|
+
|
|
765
|
+
### How to not authenticate the device again
|
|
766
|
+
|
|
767
|
+
It is possible to save the refresh token and reuse to skip the next login.
|
|
768
|
+
|
|
769
|
+
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.
|
|
770
|
+
|
|
771
|
+
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.
|
|
772
|
+
|
|
773
|
+
```python
|
|
774
|
+
import webbrowser # only needed for direct web browser access
|
|
775
|
+
|
|
776
|
+
from PyTado.interface.interface import Tado
|
|
777
|
+
|
|
778
|
+
tado = Tado(token_file_path="/var/tado/refresh_token")
|
|
779
|
+
|
|
780
|
+
status = tado.device_activation_status()
|
|
781
|
+
|
|
782
|
+
if status == "PENDING":
|
|
783
|
+
url = tado.device_verification_url()
|
|
784
|
+
|
|
785
|
+
webbrowser.open_new_tab(url)
|
|
786
|
+
|
|
787
|
+
tado.device_activation()
|
|
788
|
+
|
|
789
|
+
status = tado.device_activation_status()
|
|
790
|
+
|
|
791
|
+
if status == "COMPLETED":
|
|
792
|
+
print("Login successful")
|
|
793
|
+
else:
|
|
794
|
+
print(f"Login status is {status}")
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
## Example code
|
|
798
|
+
|
|
747
799
|
```python
|
|
748
800
|
"""Example client for PyTado"""
|
|
749
801
|
|
|
@@ -1,36 +1,26 @@
|
|
|
1
1
|
"""Test the Http class."""
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timedelta
|
|
4
|
+
import io
|
|
4
5
|
import json
|
|
5
|
-
import responses
|
|
6
6
|
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
import responses
|
|
7
9
|
|
|
8
10
|
from PyTado.const import CLIENT_ID_DEVICE
|
|
9
11
|
from PyTado.exceptions import TadoException
|
|
12
|
+
from PyTado.http import Domain, Endpoint, Http, TadoRequest
|
|
10
13
|
|
|
11
14
|
from . import common
|
|
12
15
|
|
|
13
|
-
from PyTado.http import Http
|
|
14
|
-
|
|
15
16
|
|
|
16
17
|
class TestHttp(unittest.TestCase):
|
|
17
|
-
"""
|
|
18
|
+
"""Test cases for the Http class."""
|
|
18
19
|
|
|
19
20
|
def setUp(self):
|
|
21
|
+
"""Set up mock responses for HTTP requests."""
|
|
20
22
|
super().setUp()
|
|
21
23
|
|
|
22
|
-
# Mock the login response
|
|
23
|
-
responses.add(
|
|
24
|
-
responses.POST,
|
|
25
|
-
"https://auth.tado.com/oauth/token",
|
|
26
|
-
json={
|
|
27
|
-
"access_token": "value",
|
|
28
|
-
"expires_in": 1000,
|
|
29
|
-
"refresh_token": "another_value",
|
|
30
|
-
},
|
|
31
|
-
status=200,
|
|
32
|
-
)
|
|
33
|
-
|
|
34
24
|
responses.add(
|
|
35
25
|
responses.POST,
|
|
36
26
|
"https://login.tado.com/oauth2/device_authorize",
|
|
@@ -76,7 +66,7 @@ class TestHttp(unittest.TestCase):
|
|
|
76
66
|
|
|
77
67
|
@responses.activate
|
|
78
68
|
def test_login_successful(self):
|
|
79
|
-
|
|
69
|
+
"""Test that login is successful and sets the correct properties."""
|
|
80
70
|
instance = Http(debug=True)
|
|
81
71
|
instance.device_activation()
|
|
82
72
|
|
|
@@ -86,7 +76,7 @@ class TestHttp(unittest.TestCase):
|
|
|
86
76
|
|
|
87
77
|
@responses.activate
|
|
88
78
|
def test_login_failed(self):
|
|
89
|
-
|
|
79
|
+
"""Test that login fails with appropriate exceptions."""
|
|
90
80
|
responses.replace(
|
|
91
81
|
responses.POST,
|
|
92
82
|
"https://login.tado.com/oauth2/token",
|
|
@@ -117,7 +107,7 @@ class TestHttp(unittest.TestCase):
|
|
|
117
107
|
|
|
118
108
|
@responses.activate
|
|
119
109
|
def test_line_x(self):
|
|
120
|
-
|
|
110
|
+
"""Test that the we correctly identified new TadoX environments."""
|
|
121
111
|
responses.replace(
|
|
122
112
|
responses.GET,
|
|
123
113
|
"https://my.tado.com/api/v2/homes/1234/",
|
|
@@ -136,6 +126,7 @@ class TestHttp(unittest.TestCase):
|
|
|
136
126
|
|
|
137
127
|
@responses.activate
|
|
138
128
|
def test_refresh_token_success(self):
|
|
129
|
+
"""Test that the refresh token is successfully updated."""
|
|
139
130
|
instance = Http(debug=True)
|
|
140
131
|
instance.device_activation()
|
|
141
132
|
|
|
@@ -170,6 +161,7 @@ class TestHttp(unittest.TestCase):
|
|
|
170
161
|
|
|
171
162
|
@responses.activate
|
|
172
163
|
def test_refresh_token_failure(self):
|
|
164
|
+
"""Test that refresh token failure raises an exception."""
|
|
173
165
|
instance = Http(debug=True)
|
|
174
166
|
instance.device_activation()
|
|
175
167
|
|
|
@@ -188,3 +180,106 @@ class TestHttp(unittest.TestCase):
|
|
|
188
180
|
instance._refresh_token()
|
|
189
181
|
|
|
190
182
|
assert refresh_token.call_count == 1
|
|
183
|
+
|
|
184
|
+
@responses.activate
|
|
185
|
+
def test_configure_url_endpoint_mobile(self):
|
|
186
|
+
"""Test URL configuration for the MOBILE endpoint."""
|
|
187
|
+
http = Http()
|
|
188
|
+
request = TadoRequest(endpoint=Endpoint.MOBILE, command="test")
|
|
189
|
+
url = http._configure_url(request)
|
|
190
|
+
self.assertEqual(url, "https://my.tado.com/mobile/1.9/test")
|
|
191
|
+
|
|
192
|
+
@responses.activate
|
|
193
|
+
def test_configure_url_domain_device(self):
|
|
194
|
+
"""Test URL configuration for the DEVICES domain."""
|
|
195
|
+
http = Http()
|
|
196
|
+
request = TadoRequest(command="test", domain=Domain.DEVICES, device="id1234")
|
|
197
|
+
url = http._configure_url(request)
|
|
198
|
+
self.assertEqual(url, "https://my.tado.com/api/v2/devices/id1234/test")
|
|
199
|
+
|
|
200
|
+
@responses.activate
|
|
201
|
+
def test_configure_url_domain_me(self):
|
|
202
|
+
"""Test URL configuration for the ME domain."""
|
|
203
|
+
http = Http()
|
|
204
|
+
request = TadoRequest(command="test", domain=Domain.ME)
|
|
205
|
+
url = http._configure_url(request)
|
|
206
|
+
self.assertEqual(url, "https://my.tado.com/api/v2/me")
|
|
207
|
+
|
|
208
|
+
@responses.activate
|
|
209
|
+
def test_configure_url_domain_home_with_params(self):
|
|
210
|
+
"""Test URL configuration for the ME domain."""
|
|
211
|
+
http = Http()
|
|
212
|
+
http._id = 123
|
|
213
|
+
request = TadoRequest(command="test", domain=Domain.HOME, params={"test": "value"})
|
|
214
|
+
url = http._configure_url(request)
|
|
215
|
+
self.assertEqual(url, "https://my.tado.com/api/v2/homes/123/test?test=value")
|
|
216
|
+
|
|
217
|
+
@responses.activate
|
|
218
|
+
@mock.patch("time.sleep", return_value=None)
|
|
219
|
+
def test_check_device_activation(self, mock_sleep):
|
|
220
|
+
"""Test the device activation check process."""
|
|
221
|
+
|
|
222
|
+
http = Http()
|
|
223
|
+
http._device_flow_data = {"interval": 5, "device_code": "mock_code"}
|
|
224
|
+
http._expires_at = datetime.now() + timedelta(minutes=5)
|
|
225
|
+
|
|
226
|
+
result = http._check_device_activation()
|
|
227
|
+
self.assertTrue(result)
|
|
228
|
+
mock_sleep.assert_called_once_with(5)
|
|
229
|
+
|
|
230
|
+
@responses.activate
|
|
231
|
+
def test_save_refresh_token(self):
|
|
232
|
+
"""Test if refresh token is saved."""
|
|
233
|
+
|
|
234
|
+
buffer = io.StringIO()
|
|
235
|
+
|
|
236
|
+
# We need to disable the `close` method, since we can't call
|
|
237
|
+
# getvalue() on a closed StringIO object.
|
|
238
|
+
buffer.close = lambda: None
|
|
239
|
+
|
|
240
|
+
mock_open = mock.mock_open()
|
|
241
|
+
mock_open.return_value = buffer
|
|
242
|
+
|
|
243
|
+
with mock.patch("builtins.open", mock_open) as mock_file:
|
|
244
|
+
http = Http(token_file_path="path/to/open")
|
|
245
|
+
http._check_device_activation()
|
|
246
|
+
|
|
247
|
+
mock_file.assert_called_with("path/to/open", 'w', encoding='utf-8')
|
|
248
|
+
assert mock_open.return_value.getvalue() == '{"refresh_token": "another_value"}'
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@responses.activate
|
|
252
|
+
@mock.patch('os.path.exists')
|
|
253
|
+
@mock.patch('PyTado.http.Http._save_token')
|
|
254
|
+
def test_load_refresh_token(self, mock_save, mock_exists):
|
|
255
|
+
"""Test if token is loaded."""
|
|
256
|
+
def side_effect(filename):
|
|
257
|
+
if filename == 'path/to/open':
|
|
258
|
+
return True
|
|
259
|
+
else:
|
|
260
|
+
return False
|
|
261
|
+
mock_exists.side_effect = side_effect
|
|
262
|
+
|
|
263
|
+
with mock.patch("builtins.open", mock.mock_open(read_data='{"refresh_token": "saved_value"}')) as mock_file:
|
|
264
|
+
http = Http(token_file_path="path/to/open")
|
|
265
|
+
|
|
266
|
+
mock_save.assert_called_once()
|
|
267
|
+
mock_file.assert_called_with("path/to/open", encoding='utf-8')
|
|
268
|
+
assert http._device_activation_status == "COMPLETED"
|
|
269
|
+
|
|
270
|
+
@mock.patch("PyTado.http.Http._refresh_token", return_value=True)
|
|
271
|
+
@mock.patch("PyTado.http.Http._device_ready")
|
|
272
|
+
@mock.patch("PyTado.http.Http._load_token")
|
|
273
|
+
@mock.patch("PyTado.http.Http._login_device_flow")
|
|
274
|
+
def test_constructor_with_valid_refresh_token(self, mock_load_token, mock_login_device_flow, mock_device_ready, mock_refresh_token):
|
|
275
|
+
"""
|
|
276
|
+
Test that the Http constructor correctly uses a provided valid refresh token.
|
|
277
|
+
"""
|
|
278
|
+
refresh_token = "mock_refresh_token"
|
|
279
|
+
|
|
280
|
+
Http(saved_refresh_token=refresh_token)
|
|
281
|
+
|
|
282
|
+
mock_refresh_token.assert_called_once_with(refresh_token=refresh_token, force_refresh=True)
|
|
283
|
+
mock_device_ready.assert_called_once()
|
|
284
|
+
mock_load_token.assert_not_called()
|
|
285
|
+
mock_login_device_flow.assert_not_called()
|
|
@@ -79,3 +79,8 @@ class TestTadoInterface(unittest.TestCase):
|
|
|
79
79
|
tado_interface.get_me()
|
|
80
80
|
|
|
81
81
|
self.assertIn("API Error", str(context.exception))
|
|
82
|
+
|
|
83
|
+
def test_get_refresh_token(self):
|
|
84
|
+
tado = Tado()
|
|
85
|
+
with mock.patch.object(tado._http, "_token_refresh", new="mock_refresh_token"):
|
|
86
|
+
self.assertEqual(tado.get_refresh_token(), "mock_refresh_token")
|
|
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
|
|
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
|
|
File without changes
|