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.
- {python_tado-0.18.5/python_tado.egg-info → python_tado-0.18.7}/PKG-INFO +21 -2
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/__main__.py +11 -27
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/const.py +4 -11
- python_tado-0.18.7/PyTado/http.py +441 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/api/hops_tado.py +12 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/api/my_tado.py +28 -44
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/interface.py +42 -20
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/zone/my_zone.py +30 -91
- {python_tado-0.18.5 → python_tado-0.18.7}/README.md +19 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/pyproject.toml +1 -1
- {python_tado-0.18.5 → python_tado-0.18.7/python_tado.egg-info}/PKG-INFO +21 -2
- {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_hops_zone.py +8 -3
- {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_http.py +45 -40
- {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_my_tado.py +7 -4
- {python_tado-0.18.5 → python_tado-0.18.7}/tests/test_my_zone.py +8 -4
- python_tado-0.18.7/tests/test_tado_interface.py +81 -0
- python_tado-0.18.5/PyTado/http.py +0 -351
- python_tado-0.18.5/tests/test_tado_interface.py +0 -84
- {python_tado-0.18.5 → python_tado-0.18.7}/AUTHORS +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/LICENSE +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/__init__.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/exceptions.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/__init__.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/interface/api/__init__.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/logger.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/zone/__init__.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/PyTado/zone/hops_zone.py +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/SOURCES.txt +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/dependency_links.txt +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/entry_points.txt +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/not-zip-safe +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/requires.txt +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/python_tado.egg-info/top_level.txt +0 -0
- {python_tado-0.18.5 → python_tado-0.18.7}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: python-tado
|
|
3
|
-
Version: 0.18.
|
|
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(
|
|
14
|
-
t = Tado(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4
|
-
|
|
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
|
-
|
|
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)
|