python-tado 0.18.6__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.6/python_tado.egg-info → python_tado-0.18.7}/PKG-INFO +1 -1
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/__main__.py +11 -27
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/const.py +4 -11
- python_tado-0.18.7/PyTado/http.py +441 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/interface/api/my_tado.py +14 -44
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/interface/interface.py +37 -20
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/zone/my_zone.py +30 -91
- {python_tado-0.18.6 → python_tado-0.18.7}/pyproject.toml +1 -1
- {python_tado-0.18.6 → python_tado-0.18.7/python_tado.egg-info}/PKG-INFO +1 -1
- {python_tado-0.18.6 → python_tado-0.18.7}/tests/test_hops_zone.py +8 -3
- {python_tado-0.18.6 → python_tado-0.18.7}/tests/test_http.py +45 -40
- {python_tado-0.18.6 → python_tado-0.18.7}/tests/test_my_tado.py +7 -4
- {python_tado-0.18.6 → 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.6/PyTado/http.py +0 -351
- python_tado-0.18.6/tests/test_tado_interface.py +0 -84
- {python_tado-0.18.6 → python_tado-0.18.7}/AUTHORS +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/LICENSE +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/__init__.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/exceptions.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/interface/__init__.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/interface/api/__init__.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/interface/api/hops_tado.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/logger.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/zone/__init__.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/PyTado/zone/hops_zone.py +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/README.md +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/python_tado.egg-info/SOURCES.txt +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/python_tado.egg-info/dependency_links.txt +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/python_tado.egg-info/entry_points.txt +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/python_tado.egg-info/not-zip-safe +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/python_tado.egg-info/requires.txt +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/python_tado.egg-info/top_level.txt +0 -0
- {python_tado-0.18.6 → python_tado-0.18.7}/setup.cfg +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.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
|
|
@@ -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"
|
|
@@ -2,22 +2,14 @@
|
|
|
2
2
|
PyTado interface implementation for app.tado.com.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
import enum
|
|
6
5
|
import datetime
|
|
6
|
+
import enum
|
|
7
7
|
import logging
|
|
8
|
-
|
|
9
8
|
from typing import Any
|
|
10
9
|
|
|
10
|
+
from ...exceptions import TadoException, TadoNotSupportedException
|
|
11
|
+
from ...http import Action, Domain, Endpoint, Http, Mode, TadoRequest
|
|
11
12
|
from ...logger import Logger
|
|
12
|
-
from ...exceptions import TadoNotSupportedException
|
|
13
|
-
from ...http import (
|
|
14
|
-
Action,
|
|
15
|
-
Domain,
|
|
16
|
-
Endpoint,
|
|
17
|
-
Http,
|
|
18
|
-
Mode,
|
|
19
|
-
TadoRequest,
|
|
20
|
-
)
|
|
21
13
|
from ...zone import TadoZone
|
|
22
14
|
|
|
23
15
|
|
|
@@ -155,9 +147,7 @@ class Tado:
|
|
|
155
147
|
# showSwitchToAutoGeofencingButton or currently enabled via the
|
|
156
148
|
# presence of presenceLocked = False
|
|
157
149
|
if "showSwitchToAutoGeofencingButton" in data:
|
|
158
|
-
self._auto_geofencing_supported = data[
|
|
159
|
-
"showSwitchToAutoGeofencingButton"
|
|
160
|
-
]
|
|
150
|
+
self._auto_geofencing_supported = data["showSwitchToAutoGeofencingButton"]
|
|
161
151
|
elif "presenceLocked" in data:
|
|
162
152
|
if not data["presenceLocked"]:
|
|
163
153
|
self._auto_geofencing_supported = True
|
|
@@ -210,9 +200,7 @@ class Tado:
|
|
|
210
200
|
data = self._http.request(request)
|
|
211
201
|
|
|
212
202
|
if "id" not in data:
|
|
213
|
-
raise TadoException(
|
|
214
|
-
f'Returned data did not contain "id" : {str(data)}'
|
|
215
|
-
)
|
|
203
|
+
raise TadoException(f'Returned data did not contain "id" : {str(data)}')
|
|
216
204
|
|
|
217
205
|
return Timetable(data["id"])
|
|
218
206
|
|
|
@@ -224,14 +212,10 @@ class Tado:
|
|
|
224
212
|
try:
|
|
225
213
|
day = datetime.datetime.strptime(date, "%Y-%m-%d")
|
|
226
214
|
except ValueError as err:
|
|
227
|
-
raise ValueError(
|
|
228
|
-
"Incorrect date format, should be YYYY-MM-DD"
|
|
229
|
-
) from err
|
|
215
|
+
raise ValueError("Incorrect date format, should be YYYY-MM-DD") from err
|
|
230
216
|
|
|
231
217
|
request = TadoRequest()
|
|
232
|
-
request.command = (
|
|
233
|
-
f"zones/{zone:d}/dayReport?date={day.strftime('%Y-%m-%d')}"
|
|
234
|
-
)
|
|
218
|
+
request.command = f"zones/{zone:d}/dayReport?date={day.strftime('%Y-%m-%d')}"
|
|
235
219
|
return self._http.request(request)
|
|
236
220
|
|
|
237
221
|
def set_timetable(self, zone: int, timetable: Timetable) -> None:
|
|
@@ -250,22 +234,16 @@ class Tado:
|
|
|
250
234
|
|
|
251
235
|
self._http.request(request)
|
|
252
236
|
|
|
253
|
-
def get_schedule(
|
|
254
|
-
self, zone: int, timetable: Timetable, day=None
|
|
255
|
-
) -> dict[str, Any]:
|
|
237
|
+
def get_schedule(self, zone: int, timetable: Timetable, day=None) -> dict[str, Any]:
|
|
256
238
|
"""
|
|
257
239
|
Get the JSON representation of the schedule for a zone.
|
|
258
240
|
Zone has 3 different schedules, one for each timetable (see setTimetable)
|
|
259
241
|
"""
|
|
260
242
|
request = TadoRequest()
|
|
261
243
|
if day:
|
|
262
|
-
request.command =
|
|
263
|
-
f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}"
|
|
264
|
-
)
|
|
244
|
+
request.command = f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}"
|
|
265
245
|
else:
|
|
266
|
-
request.command =
|
|
267
|
-
f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks"
|
|
268
|
-
)
|
|
246
|
+
request.command = f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks"
|
|
269
247
|
request.mode = Mode.PLAIN
|
|
270
248
|
|
|
271
249
|
return self._http.request(request)
|
|
@@ -276,9 +254,7 @@ class Tado:
|
|
|
276
254
|
"""
|
|
277
255
|
|
|
278
256
|
request = TadoRequest()
|
|
279
|
-
request.command =
|
|
280
|
-
f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}"
|
|
281
|
-
)
|
|
257
|
+
request.command = f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}"
|
|
282
258
|
request.action = Action.CHANGE
|
|
283
259
|
request.payload = data
|
|
284
260
|
request.mode = Mode.PLAIN
|
|
@@ -451,9 +427,7 @@ class Tado:
|
|
|
451
427
|
|
|
452
428
|
return self._http.request(request)
|
|
453
429
|
else:
|
|
454
|
-
raise TadoNotSupportedException(
|
|
455
|
-
"Auto mode is not known to be supported."
|
|
456
|
-
)
|
|
430
|
+
raise TadoNotSupportedException("Auto mode is not known to be supported.")
|
|
457
431
|
|
|
458
432
|
def get_window_state(self, zone):
|
|
459
433
|
"""
|
|
@@ -551,9 +525,7 @@ class Tado:
|
|
|
551
525
|
|
|
552
526
|
return self._http.request(request)
|
|
553
527
|
|
|
554
|
-
def set_eiq_meter_readings(
|
|
555
|
-
self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0
|
|
556
|
-
):
|
|
528
|
+
def set_eiq_meter_readings(self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0):
|
|
557
529
|
"""
|
|
558
530
|
Send Meter Readings to Tado, date format is YYYY-MM-DD, reading is without decimals
|
|
559
531
|
"""
|
|
@@ -636,9 +608,7 @@ class Tado:
|
|
|
636
608
|
|
|
637
609
|
return self._http.request(request)
|
|
638
610
|
|
|
639
|
-
def get_running_times(
|
|
640
|
-
self, date=datetime.datetime.now().strftime("%Y-%m-%d")
|
|
641
|
-
) -> dict:
|
|
611
|
+
def get_running_times(self, date=datetime.datetime.now().strftime("%Y-%m-%d")) -> dict:
|
|
642
612
|
"""
|
|
643
613
|
Get the running times from the Minder API
|
|
644
614
|
"""
|