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.
Files changed (32) hide show
  1. {python_tado-0.18.7/python_tado.egg-info → python_tado-0.18.9}/PKG-INFO +54 -2
  2. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/__main__.py +6 -16
  3. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/http.py +157 -13
  4. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/api/hops_tado.py +13 -36
  5. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/api/my_tado.py +2 -1
  6. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/interface.py +57 -13
  7. {python_tado-0.18.7 → python_tado-0.18.9}/README.md +53 -1
  8. {python_tado-0.18.7 → python_tado-0.18.9}/pyproject.toml +3 -3
  9. {python_tado-0.18.7 → python_tado-0.18.9/python_tado.egg-info}/PKG-INFO +54 -2
  10. {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_http.py +114 -19
  11. {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_tado_interface.py +5 -0
  12. {python_tado-0.18.7 → python_tado-0.18.9}/AUTHORS +0 -0
  13. {python_tado-0.18.7 → python_tado-0.18.9}/LICENSE +0 -0
  14. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/__init__.py +0 -0
  15. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/const.py +0 -0
  16. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/exceptions.py +0 -0
  17. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/__init__.py +0 -0
  18. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/interface/api/__init__.py +0 -0
  19. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/logger.py +0 -0
  20. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/zone/__init__.py +0 -0
  21. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/zone/hops_zone.py +0 -0
  22. {python_tado-0.18.7 → python_tado-0.18.9}/PyTado/zone/my_zone.py +0 -0
  23. {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/SOURCES.txt +0 -0
  24. {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/dependency_links.txt +0 -0
  25. {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/entry_points.txt +0 -0
  26. {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/not-zip-safe +0 -0
  27. {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/requires.txt +0 -0
  28. {python_tado-0.18.7 → python_tado-0.18.9}/python_tado.egg-info/top_level.txt +0 -0
  29. {python_tado-0.18.7 → python_tado-0.18.9}/setup.cfg +0 -0
  30. {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_hops_zone.py +0 -0
  31. {python_tado-0.18.7 → python_tado-0.18.9}/tests/test_my_tado.py +0 -0
  32. {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.7
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
+ ![Tado device flow: invoking](/screenshots/tado-device-flow-0.png)
762
+ ![Tado device flow: browser](/screenshots/tado-device-flow-1.png)
763
+ ![Tado device flow: complete](/screenshots/tado-device-flow-2.png)
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(username, password):
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(args):
19
- t = Tado(args.email, args.password)
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 = Tado(args.email, args.password)
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 = Tado(args.email, args.password)
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 = Tado(args.email, args.password)
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 requests.Session()
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._device_activation_status = self._login_device_flow()
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 = requests.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 _refresh_token(self) -> None:
289
- """Refresh the token if it is about to expire"""
290
- if self._refresh_at >= datetime.now():
291
- return
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 = requests.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. " f"Status code: {response.status_code}"
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
- Action,
15
- Domain,
16
- Http,
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('me@somewhere.com', 'mypasswd')
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('me@somewhere.com', 'mypasswd')
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('me@somewhere.com', 'mypasswd')
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
- http_session=None,
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
- """Class Constructor"""
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
- """Delegiert den Aufruf von Methoden an die richtige API-Client-Implementierung."""
83
+ """Delegate the called method to api implementation (hops_tado.py or my_tado.py)."""
54
84
 
55
- if self._api is None:
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
- if self._http.device_activation_status == DeviceActivationStatus.COMPLETED:
76
- if self._http.is_x_line:
77
- self._api = API.TadoX(http=self._http, debug=self._debug)
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
- self._api = API.Tado(http=self._http, debug=self._debug)
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
+ ![Tado device flow: invoking](/screenshots/tado-device-flow-0.png)
52
+ ![Tado device flow: browser](/screenshots/tado-device-flow-1.png)
53
+ ![Tado device flow: complete](/screenshots/tado-device-flow-2.png)
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"
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 = 80
49
- target-version = ['py311']
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.7
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
+ ![Tado device flow: invoking](/screenshots/tado-device-flow-0.png)
762
+ ![Tado device flow: browser](/screenshots/tado-device-flow-1.png)
763
+ ![Tado device flow: complete](/screenshots/tado-device-flow-2.png)
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
- """Testcases for Http class."""
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