python-tado 0.18.5__tar.gz → 0.18.7__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 (34) hide show
  1. {python_tado-0.18.5/python_tado.egg-info → python_tado-0.18.7}/PKG-INFO +21 -2
  2. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/__main__.py +11 -27
  3. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/const.py +4 -11
  4. python_tado-0.18.7/PyTado/http.py +441 -0
  5. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/api/hops_tado.py +12 -0
  6. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/api/my_tado.py +28 -44
  7. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/interface.py +42 -20
  8. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/zone/my_zone.py +30 -91
  9. {python_tado-0.18.5 → python_tado-0.18.7}/README.md +19 -0
  10. {python_tado-0.18.5 → python_tado-0.18.7}/pyproject.toml +1 -1
  11. {python_tado-0.18.5 → python_tado-0.18.7/python_tado.egg-info}/PKG-INFO +21 -2
  12. {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_hops_zone.py +8 -3
  13. {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_http.py +45 -40
  14. {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_my_tado.py +7 -4
  15. {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_my_zone.py +8 -4
  16. python_tado-0.18.7/tests/test_tado_interface.py +81 -0
  17. python_tado-0.18.5/PyTado/http.py +0 -351
  18. python_tado-0.18.5/tests/test_tado_interface.py +0 -84
  19. {python_tado-0.18.5 → python_tado-0.18.7}/AUTHORS +0 -0
  20. {python_tado-0.18.5 → python_tado-0.18.7}/LICENSE +0 -0
  21. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/__init__.py +0 -0
  22. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/exceptions.py +0 -0
  23. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/__init__.py +0 -0
  24. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/api/__init__.py +0 -0
  25. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/logger.py +0 -0
  26. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/zone/__init__.py +0 -0
  27. {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/zone/hops_zone.py +0 -0
  28. {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/SOURCES.txt +0 -0
  29. {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/dependency_links.txt +0 -0
  30. {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/entry_points.txt +0 -0
  31. {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/not-zip-safe +0 -0
  32. {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/requires.txt +0 -0
  33. {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/top_level.txt +0 -0
  34. {python_tado-0.18.5 → python_tado-0.18.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: python-tado
3
- Version: 0.18.5
3
+ Version: 0.18.7
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
@@ -743,6 +743,25 @@ cause discomfort and inconvenience to others.
743
743
  >>> t = Tado('my@username.com', 'mypassword')
744
744
  >>> climate = t.get_climate(zone=1)
745
745
 
746
+ ## Usage
747
+ ```python
748
+ """Example client for PyTado"""
749
+
750
+ from PyTado.interface.interface import Tado
751
+
752
+
753
+ def main() -> None:
754
+ """Retrieve all zones, once successfully logged in"""
755
+ tado = Tado(username="mail@email.com", password="password") # nosec
756
+ zones = tado.get_zones()
757
+ print(zones)
758
+
759
+
760
+ if __name__ == "__main__":
761
+ main()
762
+ ```
763
+ Note: for developers, you can create an `example_dev.py` file in the root of the project to test your changes. This file will be ignored by the `.gitignore` file.
764
+
746
765
  ## Contributing
747
766
 
748
767
  We are very open to the community's contributions - be it a quick fix of a typo, or a completely new feature!
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
 
4
3
  """Module for querying and controlling Tado smart thermostats."""
5
4
 
@@ -10,8 +9,9 @@ import sys
10
9
  from PyTado.interface import Tado
11
10
 
12
11
 
13
- def log_in(email, password):
14
- t = Tado(email, password)
12
+ def log_in(username, password):
13
+ t = Tado()
14
+ t.device_activation()
15
15
  return t
16
16
 
17
17
 
@@ -54,14 +54,10 @@ def main():
54
54
  required=True,
55
55
  help=("Tado username in the form of an email address."),
56
56
  )
57
- required_flags.add_argument(
58
- "--password", required=True, help="Tado password."
59
- )
57
+ required_flags.add_argument("--password", required=True, help="Tado password.")
60
58
 
61
59
  # Flags with default values go here.
62
- log_levels = dict(
63
- (logging.getLevelName(level), level) for level in [10, 20, 30, 40, 50]
64
- )
60
+ log_levels = {logging.getLevelName(level): level for level in [10, 20, 30, 40, 50]}
65
61
  parser.add_argument(
66
62
  "--loglevel",
67
63
  default="INFO",
@@ -71,30 +67,18 @@ def main():
71
67
 
72
68
  subparsers = parser.add_subparsers()
73
69
 
74
- show_config_parser = subparsers.add_parser(
75
- "get_me", help="Get home information."
76
- )
70
+ show_config_parser = subparsers.add_parser("get_me", help="Get home information.")
77
71
  show_config_parser.set_defaults(func=get_me)
78
72
 
79
- start_activity_parser = subparsers.add_parser(
80
- "get_state", help="Get state of zone."
81
- )
82
- start_activity_parser.add_argument(
83
- "--zone", help="Zone to get the state of."
84
- )
73
+ start_activity_parser = subparsers.add_parser("get_state", help="Get state of zone.")
74
+ start_activity_parser.add_argument("--zone", help="Zone to get the state of.")
85
75
  start_activity_parser.set_defaults(func=get_state)
86
76
 
87
- start_activity_parser = subparsers.add_parser(
88
- "get_states", help="Get states of all zones."
89
- )
77
+ start_activity_parser = subparsers.add_parser("get_states", help="Get states of all zones.")
90
78
  start_activity_parser.set_defaults(func=get_states)
91
79
 
92
- start_activity_parser = subparsers.add_parser(
93
- "get_capabilities", help="Get capabilities of zone."
94
- )
95
- start_activity_parser.add_argument(
96
- "--zone", help="Zone to get the capabilities of."
97
- )
80
+ start_activity_parser = subparsers.add_parser("get_capabilities", help="Get capabilities of zone.")
81
+ start_activity_parser.add_argument("--zone", help="Zone to get the capabilities of.")
98
82
  start_activity_parser.set_defaults(func=get_capabilities)
99
83
 
100
84
  args = parser.parse_args()
@@ -1,10 +1,7 @@
1
1
  """Constant values for the Tado component."""
2
2
 
3
- # Api credentials
4
- CLIENT_ID = "tado-web-app"
5
- CLIENT_SECRET = (
6
- "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc"
7
- )
3
+ # API client ID
4
+ CLIENT_ID_DEVICE = "1bb50063-6b0c-4d11-bd99-387f4a91cc46" # nosec B105
8
5
 
9
6
  # Types
10
7
  TYPE_AIR_CONDITIONING = "AIR_CONDITIONING"
@@ -49,12 +46,8 @@ CONST_HORIZONTAL_SWING_MID_RIGHT = "MID_RIGHT"
49
46
  CONST_HORIZONTAL_SWING_RIGHT = "RIGHT"
50
47
 
51
48
  # When we change the temperature setting, we need an overlay mode
52
- CONST_OVERLAY_TADO_MODE = (
53
- "NEXT_TIME_BLOCK" # wait until tado changes the mode automatic
54
- )
55
- CONST_OVERLAY_MANUAL = (
56
- "MANUAL" # the user has changed the temperature or mode manually
57
- )
49
+ CONST_OVERLAY_TADO_MODE = "NEXT_TIME_BLOCK" # wait until tado changes the mode automatic
50
+ CONST_OVERLAY_MANUAL = "MANUAL" # the user has changed the temperature or mode manually
58
51
  CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan
59
52
 
60
53
  # Heat always comes first since we get the
@@ -0,0 +1,441 @@
1
+ """
2
+ Do all the API HTTP heavy lifting in this file
3
+ """
4
+
5
+ import enum
6
+ import json
7
+ import logging
8
+ import pprint
9
+ import time
10
+ from datetime import datetime, timedelta
11
+ from typing import Any
12
+ from urllib.parse import urlencode
13
+
14
+ import requests
15
+
16
+ from PyTado.const import CLIENT_ID_DEVICE
17
+ from PyTado.exceptions import TadoException, TadoWrongCredentialsException
18
+ from PyTado.logger import Logger
19
+
20
+ _LOGGER = Logger(__name__)
21
+
22
+
23
+ class Endpoint(enum.StrEnum):
24
+ """Endpoint URL Enum"""
25
+
26
+ MY_API = "https://my.tado.com/api/v2/"
27
+ HOPS_API = "https://hops.tado.com/"
28
+ MOBILE = "https://my.tado.com/mobile/1.9/"
29
+ EIQ = "https://energy-insights.tado.com/api/"
30
+ TARIFF = "https://tariff-experience.tado.com/api/"
31
+ GENIE = "https://genie.tado.com/api/v2/"
32
+ MINDER = "https://minder.tado.com/v1/"
33
+
34
+
35
+ class Domain(enum.StrEnum):
36
+ """API Request Domain Enum"""
37
+
38
+ HOME = "homes"
39
+ DEVICES = "devices"
40
+ ME = "me"
41
+ HOME_BY_BRIDGE = "homeByBridge"
42
+
43
+
44
+ class Action(enum.StrEnum):
45
+ """API Request Action Enum"""
46
+
47
+ GET = "GET"
48
+ SET = "POST"
49
+ RESET = "DELETE"
50
+ CHANGE = "PUT"
51
+
52
+
53
+ class Mode(enum.Enum):
54
+ """API Response Format Enum"""
55
+
56
+ OBJECT = 1
57
+ PLAIN = 2
58
+
59
+
60
+ class DeviceActivationStatus(enum.StrEnum):
61
+ NOT_STARTED = "NOT_STARTED"
62
+ PENDING = "PENDING"
63
+ COMPLETED = "COMPLETED"
64
+
65
+
66
+ class TadoRequest:
67
+ """Data Container for my.tado.com API Requests"""
68
+
69
+ def __init__(
70
+ self,
71
+ endpoint: Endpoint = Endpoint.MY_API,
72
+ command: str | None = None,
73
+ action: Action | str = Action.GET,
74
+ payload: dict[str, Any] | None = None,
75
+ domain: Domain = Domain.HOME,
76
+ device: int | str | None = None,
77
+ mode: Mode = Mode.OBJECT,
78
+ params: dict[str, Any] | None = None,
79
+ ) -> None:
80
+ self.endpoint = endpoint
81
+ self.command = command
82
+ self.action = action
83
+ self.payload = payload
84
+ self.domain = domain
85
+ self.device = device
86
+ self.mode = mode
87
+ self.params = params
88
+
89
+
90
+ class TadoXRequest(TadoRequest):
91
+ """Data Container for hops.tado.com (Tado X) API Requests"""
92
+
93
+ def __init__(
94
+ self,
95
+ endpoint: Endpoint = Endpoint.HOPS_API,
96
+ command: str | None = None,
97
+ action: Action | str = Action.GET,
98
+ payload: dict[str, Any] | None = None,
99
+ domain: Domain = Domain.HOME,
100
+ device: int | str | None = None,
101
+ mode: Mode = Mode.OBJECT,
102
+ params: dict[str, Any] | None = None,
103
+ ) -> None:
104
+ super().__init__(
105
+ endpoint=endpoint,
106
+ command=command,
107
+ action=action,
108
+ payload=payload,
109
+ domain=domain,
110
+ device=device,
111
+ mode=mode,
112
+ params=params,
113
+ )
114
+ self._action = action
115
+
116
+ @property
117
+ def action(self) -> Action | str:
118
+ """Get request action for Tado X"""
119
+ if self._action == Action.CHANGE:
120
+ return "PATCH"
121
+ return self._action
122
+
123
+ @action.setter
124
+ def action(self, value: Action | str) -> None:
125
+ """Set request action"""
126
+ self._action = value
127
+
128
+
129
+ class TadoResponse:
130
+ """Unimplemented Response Container
131
+ todo: implement response parser"""
132
+
133
+ pass
134
+
135
+
136
+ _DEFAULT_TIMEOUT = 10
137
+ _DEFAULT_RETRIES = 5
138
+
139
+
140
+ class Http:
141
+ """API Request Class"""
142
+
143
+ def __init__(
144
+ self,
145
+ http_session: requests.Session | None = None,
146
+ debug: bool = False,
147
+ ) -> None:
148
+ if debug:
149
+ _LOGGER.setLevel(logging.DEBUG)
150
+ else:
151
+ _LOGGER.setLevel(logging.WARNING)
152
+
153
+ self._refresh_at = datetime.now() + timedelta(minutes=10)
154
+ self._session = http_session or requests.Session()
155
+ self._session.hooks["response"].append(self._log_response)
156
+ self._headers = {"Referer": "https://app.tado.com/"}
157
+
158
+ self._user_code: str | None = None
159
+ self._device_verification_url: str | None = None
160
+ self._device_activation_status = DeviceActivationStatus.NOT_STARTED
161
+ self._expires_at: datetime | None = None
162
+
163
+ self._id: int | None = None
164
+ self._token_refresh: str | None = None
165
+ self._x_api: bool | None = None
166
+ self._device_activation_status = self._login_device_flow()
167
+
168
+ @property
169
+ def is_x_line(self) -> bool | None:
170
+ return self._x_api
171
+
172
+ @property
173
+ def user_code(self) -> str | None:
174
+ return self._user_code
175
+
176
+ @property
177
+ def device_activation_status(self) -> DeviceActivationStatus:
178
+ return self._device_activation_status
179
+
180
+ @property
181
+ def device_verification_url(self) -> str | None:
182
+ return self._device_verification_url
183
+
184
+ def _log_response(self, response: requests.Response, *args, **kwargs) -> None:
185
+ og_request_method = response.request.method
186
+ og_request_url = response.request.url
187
+ og_request_headers = response.request.headers
188
+ response_status = response.status_code
189
+
190
+ if response.text is None or response.text == "":
191
+ response_data = {}
192
+ else:
193
+ response_data = response.json()
194
+
195
+ _LOGGER.debug(
196
+ f"\nRequest:\n\tMethod:{og_request_method}"
197
+ f"\n\tURL: {og_request_url}"
198
+ f"\n\tHeaders: {pprint.pformat(og_request_headers)}"
199
+ f"\nResponse:\n\tStatusCode: {response_status}"
200
+ f"\n\tData: {response_data}"
201
+ )
202
+
203
+ def request(self, request: TadoRequest) -> dict[str, Any]:
204
+ """Request something from the API with a TadoRequest"""
205
+ self._refresh_token()
206
+
207
+ headers = self._headers
208
+ data = self._configure_payload(headers, request)
209
+ url = self._configure_url(request)
210
+
211
+ http_request = requests.Request(method=request.action, url=url, headers=headers, data=data)
212
+ prepped = http_request.prepare()
213
+ prepped.hooks["response"].append(self._log_response)
214
+
215
+ retries = _DEFAULT_RETRIES
216
+
217
+ while retries >= 0:
218
+ try:
219
+ response = self._session.send(prepped)
220
+ break
221
+ except TadoWrongCredentialsException as e:
222
+ _LOGGER.error("Credentials Exception: %s", e)
223
+ raise e
224
+ except requests.exceptions.ConnectionError as e:
225
+ if retries > 0:
226
+ _LOGGER.warning("Connection error: %s", e)
227
+ self._session.close()
228
+ self._session = requests.Session()
229
+ self._session.hooks["response"].append(self._log_response)
230
+ retries -= 1
231
+ else:
232
+ _LOGGER.error(
233
+ "Connection failed after %d retries: %s",
234
+ _DEFAULT_RETRIES,
235
+ e,
236
+ )
237
+ raise TadoException(e) from e
238
+
239
+ if response.text is None or response.text == "":
240
+ return {}
241
+
242
+ return response.json()
243
+
244
+ def _configure_url(self, request: TadoRequest) -> str:
245
+ if request.endpoint == Endpoint.MOBILE:
246
+ url = f"{request.endpoint}{request.command}"
247
+ elif request.domain == Domain.DEVICES or request.domain == Domain.HOME_BY_BRIDGE:
248
+ url = f"{request.endpoint}{request.domain}/{request.device}/{request.command}"
249
+ elif request.domain == Domain.ME:
250
+ url = f"{request.endpoint}{request.domain}"
251
+ else:
252
+ url = f"{request.endpoint}{request.domain}/{self._id:d}/{request.command}"
253
+
254
+ if request.params is not None:
255
+ params = request.params
256
+ url += f"?{urlencode(params)}"
257
+
258
+ return url
259
+
260
+ def _configure_payload(self, headers: dict[str, str], request: TadoRequest) -> bytes:
261
+ if request.payload is None:
262
+ return b""
263
+
264
+ if request.mode == Mode.PLAIN:
265
+ headers["Content-Type"] = "text/plain;charset=UTF-8"
266
+ else:
267
+ headers["Content-Type"] = "application/json;charset=UTF-8"
268
+ headers["Mime-Type"] = "application/json;charset=UTF-8"
269
+ return json.dumps(request.payload).encode("utf8")
270
+
271
+ def _set_oauth_header(self, data: dict[str, Any]) -> str:
272
+ """Set the OAuth header and return the refresh token"""
273
+
274
+ access_token = data["access_token"]
275
+ expires_in = float(data["expires_in"])
276
+ refresh_token = data["refresh_token"]
277
+
278
+ self._token_refresh = refresh_token
279
+ self._refresh_at = datetime.now()
280
+ self._refresh_at = self._refresh_at + timedelta(seconds=expires_in)
281
+ # We subtract 30 seconds from the correct refresh time.
282
+ # Then we have a 30 seconds timespan to get a new refresh_token
283
+ self._refresh_at = self._refresh_at - timedelta(seconds=30)
284
+
285
+ self._headers["Authorization"] = f"Bearer {access_token}"
286
+ return refresh_token
287
+
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
292
+
293
+ url = "https://login.tado.com/oauth2/token"
294
+ data = {
295
+ "client_id": CLIENT_ID_DEVICE,
296
+ "grant_type": "refresh_token",
297
+ "refresh_token": self._token_refresh,
298
+ }
299
+ self._session.close()
300
+ self._session = requests.Session()
301
+ self._session.hooks["response"].append(self._log_response)
302
+
303
+ try:
304
+ response = self._session.request(
305
+ "post",
306
+ url,
307
+ params=data,
308
+ timeout=_DEFAULT_TIMEOUT,
309
+ data=json.dumps({}).encode("utf8"),
310
+ headers={
311
+ "Content-Type": "application/json",
312
+ "Referer": "https://app.tado.com/",
313
+ },
314
+ )
315
+ except requests.exceptions.ConnectionError as e:
316
+ _LOGGER.error("Connection error: %s", e)
317
+ raise TadoException(e)
318
+
319
+ if response.status_code != 200:
320
+ raise TadoWrongCredentialsException(
321
+ "Failed to refresh token, probably wrong credentials. " f"Status code: {response.status_code}"
322
+ )
323
+
324
+ self._set_oauth_header(response.json())
325
+
326
+ def _login_device_flow(self) -> DeviceActivationStatus:
327
+ """Start the login to the API using the device flow"""
328
+
329
+ if self._device_activation_status != DeviceActivationStatus.NOT_STARTED:
330
+ raise TadoException("The device has been started already")
331
+
332
+ url = "https://login.tado.com/oauth2/device_authorize"
333
+ data = {
334
+ "client_id": CLIENT_ID_DEVICE,
335
+ "scope": "offline_access",
336
+ }
337
+
338
+ try:
339
+ response = self._session.request(
340
+ method="post",
341
+ url=url,
342
+ params=data,
343
+ timeout=_DEFAULT_TIMEOUT,
344
+ data=json.dumps({}).encode("utf8"),
345
+ headers={
346
+ "Content-Type": "application/json",
347
+ "Referer": "https://app.tado.com/",
348
+ },
349
+ )
350
+ except requests.exceptions.ConnectionError as e:
351
+ raise TadoException(e) from e
352
+
353
+ if response.status_code != 200:
354
+ raise TadoException(f"Login failed. Status code: {response.status_code} and reason: {response.reason}")
355
+
356
+ self._device_flow_data = response.json()
357
+ _LOGGER.debug("Device flow response: %s", self._device_flow_data)
358
+
359
+ user_code = urlencode({"user_code": self._device_flow_data["user_code"]})
360
+ visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}"
361
+ self._user_code = self._device_flow_data["user_code"]
362
+ self._device_verification_url = visit_url
363
+
364
+ _LOGGER.info("Please visit the following URL: %s", visit_url)
365
+
366
+ expires_in_seconds = self._device_flow_data["expires_in"]
367
+ self._expires_at = datetime.now() + timedelta(seconds=expires_in_seconds)
368
+
369
+ _LOGGER.info(
370
+ "Waiting for user to authorize the device. Expires at %s",
371
+ self._expires_at.strftime("%Y-%m-%d %H:%M:%S"),
372
+ )
373
+
374
+ return DeviceActivationStatus.PENDING
375
+
376
+ def _check_device_activation(self) -> bool:
377
+ if self._expires_at is not None and datetime.timestamp(datetime.now()) > datetime.timestamp(self._expires_at):
378
+ raise TadoException("User took too long to enter key")
379
+
380
+ # Await the desired interval, before polling the API again
381
+ time.sleep(self._device_flow_data["interval"])
382
+
383
+ try:
384
+ token_response = self._session.request(
385
+ method="post",
386
+ url="https://login.tado.com/oauth2/token",
387
+ params={
388
+ "client_id": CLIENT_ID_DEVICE,
389
+ "device_code": self._device_flow_data["device_code"],
390
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
391
+ },
392
+ )
393
+ except requests.exceptions.ConnectionError as e:
394
+ raise TadoException(e) from e
395
+
396
+ if token_response.status_code == 200:
397
+ self._set_oauth_header(token_response.json())
398
+ return True
399
+
400
+ # The user has not yet authorized the device, let's continue
401
+ if token_response.status_code == 400 and token_response.json()["error"] == "authorization_pending":
402
+ _LOGGER.info("Authorization pending, waiting for user to authorize. Continue polling.")
403
+ return False
404
+
405
+ raise TadoException(f"Login failed. Reason: {token_response.reason}")
406
+
407
+ def device_activation(self) -> None:
408
+ """Activate the device and get the refresh token"""
409
+
410
+ if self._device_activation_status == DeviceActivationStatus.NOT_STARTED:
411
+ raise TadoException("The device flow has not yet started")
412
+
413
+ while True:
414
+ if self._check_device_activation():
415
+ break
416
+
417
+ self._id = self._get_id()
418
+ self._x_api = self._check_x_line_generation()
419
+ self._user_code = None
420
+ self._device_verification_url = None
421
+ self._device_activation_status = DeviceActivationStatus.COMPLETED
422
+
423
+ def _get_id(self) -> int:
424
+ request = TadoRequest()
425
+ request.action = Action.GET
426
+ request.domain = Domain.ME
427
+
428
+ homes_ = self.request(request)["homes"]
429
+
430
+ return homes_[0]["id"]
431
+
432
+ def _check_x_line_generation(self):
433
+ # get home info
434
+ request = TadoRequest()
435
+ request.action = Action.GET
436
+ request.domain = Domain.HOME
437
+ request.command = ""
438
+
439
+ home_ = self.request(request)
440
+
441
+ return "generation" in home_ and home_["generation"] == "LINE_X"
@@ -335,3 +335,15 @@ class TadoX(Tado):
335
335
  request.payload = {"temperatureOffset": offset}
336
336
 
337
337
  return self._http.request(request)
338
+
339
+ def set_child_lock(self, device_id, child_lock):
340
+ """ "
341
+ Set and toggle the child lock on the device.
342
+ """
343
+
344
+ request = TadoXRequest()
345
+ request.command = f"roomsAndDevices/devices/{device_id}"
346
+ request.action = Action.CHANGE
347
+ request.payload = {"childLockEnabled": child_lock}
348
+
349
+ self._http.request(request)