clevertouch 0.4.0__tar.gz → 0.4.2b2__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.
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/PKG-INFO +1 -1
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/pyproject.toml +1 -1
- clevertouch-0.4.2b2/src/clevertouch/api.py +257 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/devices/radiator.py +12 -19
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/info.py +1 -1
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/objects.py +4 -5
- clevertouch-0.4.0/src/clevertouch/api.py +0 -223
- clevertouch-0.4.0/tests/.pytest_cache/.gitignore +0 -2
- clevertouch-0.4.0/tests/.pytest_cache/CACHEDIR.TAG +0 -4
- clevertouch-0.4.0/tests/.pytest_cache/README.md +0 -8
- clevertouch-0.4.0/tests/.pytest_cache/v/cache/lastfailed +0 -4
- clevertouch-0.4.0/tests/.pytest_cache/v/cache/nodeids +0 -5
- clevertouch-0.4.0/tests/.pytest_cache/v/cache/stepwise +0 -1
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/LICENSE +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/README.md +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/__init__.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/devices/__init__.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/devices/const.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/devices/device.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/devices/factory.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/devices/onoff.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/src/clevertouch/util.py +0 -0
- {clevertouch-0.4.0 → clevertouch-0.4.2b2}/tests/authenticate_test.py +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Interface to the cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, NamedTuple, Any
|
|
6
|
+
from hashlib import md5
|
|
7
|
+
from aiohttp import ClientSession, ClientConnectionError, ClientError
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from .util import ApiError
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiStatus(NamedTuple):
|
|
16
|
+
"""Status of an API call."""
|
|
17
|
+
|
|
18
|
+
code: int
|
|
19
|
+
key: str
|
|
20
|
+
value: str
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
return f"{self.key}({self.code}): {self.value}"
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"code={self.code}, key={self.key}, value={self.value}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiResult(NamedTuple):
|
|
30
|
+
"""Result of an API call."""
|
|
31
|
+
|
|
32
|
+
status: ApiStatus
|
|
33
|
+
data: dict[str, Any]
|
|
34
|
+
parameters: dict[str, Any]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiCallError(ApiError):
|
|
38
|
+
"""Exception raised on errors when calling the cloud API."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, status: ApiStatus, *args):
|
|
41
|
+
super().__init__(*args)
|
|
42
|
+
self.status: ApiStatus = status
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ApiAuthError(ApiError):
|
|
46
|
+
"""Exception raised on authentication errors when calling the cloud API."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ApiConnectError(ApiError):
|
|
50
|
+
"""Exception raised on connection errors when calling the cloud API."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ApiSession:
|
|
54
|
+
"""Interface to the cloud API."""
|
|
55
|
+
|
|
56
|
+
API_LANG = "en_GB"
|
|
57
|
+
API_PATH = "/api/v0.1/"
|
|
58
|
+
CLIENT_ID = "app-front"
|
|
59
|
+
|
|
60
|
+
_DEFAULT_HOST = "e3.lvi.eu"
|
|
61
|
+
_DEFAULT_MANUFACTURER = "purmo"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
email: Optional[str] = None,
|
|
66
|
+
refresh_token: Optional[str] = None,
|
|
67
|
+
*,
|
|
68
|
+
host: Optional[str] = None,
|
|
69
|
+
manufacturer: Optional[str] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
host = host or self._DEFAULT_HOST
|
|
72
|
+
manufacturer = manufacturer or self._DEFAULT_MANUFACTURER
|
|
73
|
+
self._token_url = (
|
|
74
|
+
f"https://auth.{host}/realms/{manufacturer}/protocol/openid-connect/token"
|
|
75
|
+
)
|
|
76
|
+
self._api_base = f"https://{host}{self.API_PATH}"
|
|
77
|
+
self._http_session: ClientSession = ClientSession()
|
|
78
|
+
self.email: Optional[str] = email
|
|
79
|
+
self.access_token: Optional[str] = None
|
|
80
|
+
self.refresh_token: Optional[str] = refresh_token
|
|
81
|
+
self.expires_at: float = 0.0
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> "ApiSession":
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *excinfo) -> None:
|
|
87
|
+
await self.close()
|
|
88
|
+
|
|
89
|
+
async def close(self):
|
|
90
|
+
"""Close the connection to the cloud API."""
|
|
91
|
+
if self._http_session:
|
|
92
|
+
await self._http_session.close()
|
|
93
|
+
self._http_session = None
|
|
94
|
+
|
|
95
|
+
async def authenticate(self, email: str, password: str):
|
|
96
|
+
"""Authenticate with the cloud API.
|
|
97
|
+
On success, sets self.access_token, self.refresh_token, self.expires_at.
|
|
98
|
+
"""
|
|
99
|
+
self.email = email
|
|
100
|
+
|
|
101
|
+
data = {
|
|
102
|
+
"grant_type": "password",
|
|
103
|
+
"client_id": self.CLIENT_ID,
|
|
104
|
+
"username": email,
|
|
105
|
+
"password": password,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
_LOGGER.debug("Login to openid at '%s' with data '%s'", self._token_url, data)
|
|
110
|
+
async with self._http_session.post(self._token_url, data=data) as response:
|
|
111
|
+
if response.status != 200:
|
|
112
|
+
_LOGGER.error("OIDC login failed (%s)", response.status)
|
|
113
|
+
raise ApiAuthError("Invalid credentials or error fetching token.")
|
|
114
|
+
token_data = await response.json()
|
|
115
|
+
except ClientError as exc:
|
|
116
|
+
raise ApiConnectError(
|
|
117
|
+
f"Error connecting to {self._token_url}: {exc}"
|
|
118
|
+
) from exc
|
|
119
|
+
|
|
120
|
+
self.access_token = token_data["access_token"]
|
|
121
|
+
self.refresh_token = token_data.get("refresh_token")
|
|
122
|
+
expires_in = token_data.get("expires_in", 3600) # default 1h
|
|
123
|
+
self.expires_at = time.time() + expires_in
|
|
124
|
+
|
|
125
|
+
async def refresh_openid(self) -> None:
|
|
126
|
+
"""Refresh the existing token using the 'refresh_token' grant."""
|
|
127
|
+
if not self.refresh_token:
|
|
128
|
+
raise ApiAuthError("No refresh token stored; cannot refresh.")
|
|
129
|
+
|
|
130
|
+
data = {
|
|
131
|
+
"grant_type": "refresh_token",
|
|
132
|
+
"client_id": self.CLIENT_ID,
|
|
133
|
+
"refresh_token": self.refresh_token,
|
|
134
|
+
}
|
|
135
|
+
try:
|
|
136
|
+
async with self._http_session.post(self._token_url, data=data) as response:
|
|
137
|
+
if response.status != 200:
|
|
138
|
+
_LOGGER.error("OIDC refresh failed (%s)", response.status)
|
|
139
|
+
raise ApiAuthError("Refresh token invalid or expired.")
|
|
140
|
+
token_data = await response.json()
|
|
141
|
+
except ClientError as exc:
|
|
142
|
+
raise ApiConnectError(
|
|
143
|
+
f"Error connecting to {self._token_url}: {exc}"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
self.access_token = token_data["access_token"]
|
|
147
|
+
self.refresh_token = token_data.get("refresh_token")
|
|
148
|
+
expires_in = token_data.get("expires_in", 3600)
|
|
149
|
+
self.expires_at = time.time() + expires_in
|
|
150
|
+
|
|
151
|
+
async def _ensure_valid_token(self) -> None:
|
|
152
|
+
"""Refresh if our token is expired (simple check)."""
|
|
153
|
+
if time.time() >= self.expires_at:
|
|
154
|
+
await self.refresh_openid()
|
|
155
|
+
|
|
156
|
+
async def _read(
|
|
157
|
+
self,
|
|
158
|
+
endpoint: str,
|
|
159
|
+
payload: dict,
|
|
160
|
+
*,
|
|
161
|
+
throw_on_error: bool = True,
|
|
162
|
+
) -> ApiResult:
|
|
163
|
+
result = await self._post(endpoint, payload)
|
|
164
|
+
if throw_on_error:
|
|
165
|
+
if result.status.code not in [1, 8]:
|
|
166
|
+
raise ApiCallError(result.status, f"Read failed with {result.status}")
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
async def _write(
|
|
170
|
+
self,
|
|
171
|
+
endpoint: str,
|
|
172
|
+
payload: dict,
|
|
173
|
+
*,
|
|
174
|
+
throw_on_error: bool = True,
|
|
175
|
+
) -> ApiResult:
|
|
176
|
+
result = await self._post(endpoint, payload)
|
|
177
|
+
if throw_on_error:
|
|
178
|
+
if result.status.code != 8:
|
|
179
|
+
raise ApiCallError(result.status, f"Write failed with {result.status}")
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
async def read_user_data(self) -> dict[str, Any]:
|
|
183
|
+
"""Read account user data from the cloud API."""
|
|
184
|
+
endpoint = "human/user/read/"
|
|
185
|
+
payload = {
|
|
186
|
+
"email": self.email,
|
|
187
|
+
}
|
|
188
|
+
result = await self._read(endpoint, payload)
|
|
189
|
+
return result.data
|
|
190
|
+
|
|
191
|
+
async def read_home_data(self, home_id: str) -> dict[str, Any]:
|
|
192
|
+
"""Read home data from the cloud API."""
|
|
193
|
+
endpoint = "human/smarthome/read/"
|
|
194
|
+
payload = {
|
|
195
|
+
"smarthome_id": home_id,
|
|
196
|
+
}
|
|
197
|
+
result = await self._read(endpoint, payload)
|
|
198
|
+
return result.data
|
|
199
|
+
|
|
200
|
+
async def write_query(self, home_id: str, query_params: dict[str, Any]):
|
|
201
|
+
"""Write an update query to the cloud API."""
|
|
202
|
+
endpoint = "human/query/push/"
|
|
203
|
+
payload = {
|
|
204
|
+
"smarthome_id": home_id,
|
|
205
|
+
"context": 1,
|
|
206
|
+
"peremption": 15000,
|
|
207
|
+
}
|
|
208
|
+
for key, value in query_params.items():
|
|
209
|
+
payload[f"query[{key}]"] = value
|
|
210
|
+
|
|
211
|
+
return await self._write(endpoint, payload)
|
|
212
|
+
|
|
213
|
+
def _parse_api_json(self, json: dict) -> ApiResult:
|
|
214
|
+
try:
|
|
215
|
+
code = json["code"]
|
|
216
|
+
data = json["data"]
|
|
217
|
+
parameters = json.get("parameters")
|
|
218
|
+
except Exception as ex:
|
|
219
|
+
raise ApiError("Unexpected JSON format in API response") from ex
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
code_num = int(code["code"])
|
|
223
|
+
code_key = code["key"]
|
|
224
|
+
code_value = code["value"]
|
|
225
|
+
status = ApiStatus(code_num, code_key, code_value)
|
|
226
|
+
except Exception as ex:
|
|
227
|
+
raise ApiError("API status is malformed") from ex
|
|
228
|
+
|
|
229
|
+
return ApiResult(status, data, parameters)
|
|
230
|
+
|
|
231
|
+
async def _post(self, endpoint: str, payload: dict | None = None) -> dict:
|
|
232
|
+
"""Make an authenticated API request"""
|
|
233
|
+
await self._ensure_valid_token()
|
|
234
|
+
headers = {
|
|
235
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
url = f"{self._api_base}{endpoint.lstrip('/')}"
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
async with self._http_session.post(
|
|
242
|
+
url, data=payload or {}, headers=headers
|
|
243
|
+
) as resp:
|
|
244
|
+
resp.raise_for_status()
|
|
245
|
+
json_data = await resp.json()
|
|
246
|
+
except ClientError as exc:
|
|
247
|
+
_LOGGER.exception("API request failed: %s", exc)
|
|
248
|
+
raise ApiConnectError(f"API request failed: {exc}") from exc
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
result = self._parse_api_json(json_data)
|
|
252
|
+
_LOGGER.debug("%s called with status %s", endpoint, result)
|
|
253
|
+
except Exception as ex:
|
|
254
|
+
_LOGGER.exception("Could not parse JSON result from API request")
|
|
255
|
+
result = None
|
|
256
|
+
|
|
257
|
+
return result
|
|
@@ -147,25 +147,18 @@ class Radiator(Device):
|
|
|
147
147
|
)
|
|
148
148
|
# Read boost settings
|
|
149
149
|
# 'boost_time' is the user writable boost time
|
|
150
|
-
|
|
151
|
-
self.boost_time = int(data["time_boost"])
|
|
152
|
-
except KeyError: # The key doesn't exist
|
|
153
|
-
self.boost_time = 0
|
|
154
|
-
except ValueError: # The value can not be converted to an int
|
|
155
|
-
self.boost_time = 0
|
|
150
|
+
self.boost_time = int(data.get("time_boost") or 0)
|
|
156
151
|
# 'time_boost_format_chrono' holds remaining boost time with higher resolution
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
node = data.get("time_boost_format_chrono")
|
|
153
|
+
if node:
|
|
159
154
|
self.boost_remaining = (
|
|
160
|
-
int(node
|
|
161
|
-
+ int(node
|
|
162
|
-
+ int(node
|
|
163
|
-
+ int(node
|
|
155
|
+
int(node.get("d") or 0) * 24 * 60 * 60
|
|
156
|
+
+ int(node.get("h") or 0) * 60 * 60
|
|
157
|
+
+ int(node.get("m") or 0) * 60
|
|
158
|
+
+ int(node.get("s") or 0)
|
|
164
159
|
)
|
|
165
|
-
|
|
166
|
-
self.boost_remaining =
|
|
167
|
-
except ValueError:
|
|
168
|
-
self.boost_remaining = 0
|
|
160
|
+
else:
|
|
161
|
+
self.boost_remaining = None
|
|
169
162
|
|
|
170
163
|
async def set_temperature(self, temp_type: str, temp_value: float, unit: str):
|
|
171
164
|
"""Set a specific temperature for a radiator"""
|
|
@@ -252,8 +245,8 @@ class Radiator(Device):
|
|
|
252
245
|
|
|
253
246
|
if heat_mode not in self._HEAT_MODE_TO_DEVICE:
|
|
254
247
|
raise ApiError(f"Heating mode {heat_mode} not available.")
|
|
255
|
-
if
|
|
256
|
-
raise ApiError("
|
|
248
|
+
if temp_value and not temp_unit:
|
|
249
|
+
raise ApiError("Unit must be set when a temp value is provided.")
|
|
257
250
|
if temp_value and heat_mode not in self._HEAT_MODE_TO_WRITABLE_TEMP_TYPE:
|
|
258
251
|
raise ApiError(f"Temperature can not be set for {heat_mode}.")
|
|
259
252
|
if boost_time and heat_mode != HeatMode.BOOST:
|
|
@@ -264,7 +257,7 @@ class Radiator(Device):
|
|
|
264
257
|
query_params["gv_mode"] = self._HEAT_MODE_TO_DEVICE[heat_mode]
|
|
265
258
|
query_params["nv_mode"] = self._HEAT_MODE_TO_DEVICE[heat_mode]
|
|
266
259
|
|
|
267
|
-
if temp_value:
|
|
260
|
+
if temp_value and temp_unit:
|
|
268
261
|
temp_type = self._HEAT_MODE_TO_WRITABLE_TEMP_TYPE[heat_mode]
|
|
269
262
|
new_temp = Temperature(
|
|
270
263
|
temp_value, temp_unit, is_writable=True, name=temp_type
|
|
@@ -37,11 +37,10 @@ class Account:
|
|
|
37
37
|
async def authenticate(
|
|
38
38
|
self,
|
|
39
39
|
email: str,
|
|
40
|
-
password: Optional[str] = None
|
|
41
|
-
password_hash: Optional[str] = None,
|
|
40
|
+
password: Optional[str] = None
|
|
42
41
|
) -> None:
|
|
43
42
|
"""Authenticate with the cloud API and store credentials."""
|
|
44
|
-
await self._api_session.authenticate(email, password
|
|
43
|
+
await self._api_session.authenticate(email, password)
|
|
45
44
|
self.email = self._api_session.email
|
|
46
45
|
|
|
47
46
|
async def get_user(self) -> User:
|
|
@@ -91,7 +90,7 @@ class Home:
|
|
|
91
90
|
def _update(self, data):
|
|
92
91
|
self.info.update(data)
|
|
93
92
|
|
|
94
|
-
for device_data in data["devices"]
|
|
93
|
+
for device_data in data["devices"]:
|
|
95
94
|
device_id = Device.get_id(device_data)
|
|
96
95
|
device = self.devices.get(device_id)
|
|
97
96
|
if device is None:
|
|
@@ -126,6 +125,6 @@ class User:
|
|
|
126
125
|
self.homes = {
|
|
127
126
|
home.home_id: home
|
|
128
127
|
for home in [
|
|
129
|
-
HomeInfo(data=home_data) for home_data in data["smarthomes"]
|
|
128
|
+
HomeInfo(data=home_data) for home_data in data["smarthomes"]
|
|
130
129
|
]
|
|
131
130
|
}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
"""Interface to the cloud API."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
import logging
|
|
4
|
-
from typing import Optional, NamedTuple, Any
|
|
5
|
-
from hashlib import md5
|
|
6
|
-
from aiohttp import ClientSession, ClientConnectionError, ClientError
|
|
7
|
-
|
|
8
|
-
from .util import ApiError
|
|
9
|
-
|
|
10
|
-
_LOGGER = logging.getLogger(__name__)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class ApiStatus(NamedTuple):
|
|
14
|
-
"""Status of an API call."""
|
|
15
|
-
|
|
16
|
-
code: int
|
|
17
|
-
key: str
|
|
18
|
-
value: str
|
|
19
|
-
|
|
20
|
-
def __str__(self) -> str:
|
|
21
|
-
return f"{self.key}({self.code}): {self.value}"
|
|
22
|
-
|
|
23
|
-
def __repr__(self) -> str:
|
|
24
|
-
return f"code={self.code}, key={self.key}, value={self.value}"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class ApiResult(NamedTuple):
|
|
28
|
-
"""Result of an API call."""
|
|
29
|
-
|
|
30
|
-
status: ApiStatus
|
|
31
|
-
data: dict[str, Any]
|
|
32
|
-
parameters: dict[str, Any]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class ApiCallError(ApiError):
|
|
36
|
-
"""Exception raised on errors when calling the cloud API."""
|
|
37
|
-
|
|
38
|
-
def __init__(self, status: ApiStatus, *args):
|
|
39
|
-
super().__init__(*args)
|
|
40
|
-
self.status: ApiStatus = status
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class ApiAuthError(ApiError):
|
|
44
|
-
"""Exception raised on authentication errors when calling the cloud API."""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class ApiConnectError(ApiError):
|
|
48
|
-
"""Exception raised on connection errors when calling the cloud API."""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class ApiSession:
|
|
52
|
-
"""Interface to the cloud API."""
|
|
53
|
-
|
|
54
|
-
API_LANG = "en_GB"
|
|
55
|
-
_LVI_API_BASE = "https://e3.lvi.eu"
|
|
56
|
-
API_PATH = "/api/v0.1/"
|
|
57
|
-
|
|
58
|
-
def __init__(
|
|
59
|
-
self,
|
|
60
|
-
email: Optional[str] = None,
|
|
61
|
-
token: Optional[str] = None,
|
|
62
|
-
*,
|
|
63
|
-
host: Optional[str] = None
|
|
64
|
-
) -> None:
|
|
65
|
-
api_base = host or self._LVI_API_BASE
|
|
66
|
-
self._http_session: ClientSession = ClientSession(api_base)
|
|
67
|
-
self.email: Optional[str] = email
|
|
68
|
-
self.token: Optional[str] = token
|
|
69
|
-
|
|
70
|
-
async def __aenter__(self):
|
|
71
|
-
return self
|
|
72
|
-
|
|
73
|
-
async def __aexit__(self, *excinfo):
|
|
74
|
-
await self.close()
|
|
75
|
-
|
|
76
|
-
async def close(self):
|
|
77
|
-
"""Close the connection to the cloud API."""
|
|
78
|
-
await self._http_session.close()
|
|
79
|
-
|
|
80
|
-
async def authenticate(
|
|
81
|
-
self,
|
|
82
|
-
email: str,
|
|
83
|
-
password: Optional[str] = None,
|
|
84
|
-
password_hash: Optional[str] = None,
|
|
85
|
-
):
|
|
86
|
-
"""Authenticate with the cloud API."""
|
|
87
|
-
self.email = email
|
|
88
|
-
if password_hash is None or password_hash == "":
|
|
89
|
-
if password is None:
|
|
90
|
-
raise ApiError("No password provided")
|
|
91
|
-
password_hash = md5(password.encode()).hexdigest()
|
|
92
|
-
|
|
93
|
-
endpoint = "human/user/auth/"
|
|
94
|
-
payload = {
|
|
95
|
-
"email": email,
|
|
96
|
-
"password": password_hash,
|
|
97
|
-
"remember_me": "true",
|
|
98
|
-
"lang": self.API_LANG,
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
result = await self._post(endpoint, payload, authenticate=False)
|
|
102
|
-
|
|
103
|
-
status = result.status
|
|
104
|
-
if status.key != "OK":
|
|
105
|
-
if status.key == "ERR_ROUTE":
|
|
106
|
-
raise ApiConnectError(status)
|
|
107
|
-
if status.key == "ERR_PARAM":
|
|
108
|
-
raise ApiAuthError("Invalid email or password")
|
|
109
|
-
raise ApiCallError(status, "Authentication failed %s", status)
|
|
110
|
-
|
|
111
|
-
self.email = result.data["user_infos"]["email"]
|
|
112
|
-
self.token = result.data["token"]
|
|
113
|
-
|
|
114
|
-
async def _read(
|
|
115
|
-
self,
|
|
116
|
-
endpoint: str,
|
|
117
|
-
payload: dict,
|
|
118
|
-
*,
|
|
119
|
-
authenticate: bool = True,
|
|
120
|
-
throw_on_error: bool = True,
|
|
121
|
-
) -> ApiResult:
|
|
122
|
-
result = await self._post(endpoint, payload, authenticate=authenticate)
|
|
123
|
-
if throw_on_error:
|
|
124
|
-
if result.status.code != 1:
|
|
125
|
-
raise ApiCallError(result.status, f"Read failed with {result.status}")
|
|
126
|
-
return result
|
|
127
|
-
|
|
128
|
-
async def _write(
|
|
129
|
-
self,
|
|
130
|
-
endpoint: str,
|
|
131
|
-
payload: dict,
|
|
132
|
-
*,
|
|
133
|
-
authenticate: bool = True,
|
|
134
|
-
throw_on_error: bool = True,
|
|
135
|
-
) -> ApiResult:
|
|
136
|
-
result = await self._post(endpoint, payload, authenticate=authenticate)
|
|
137
|
-
if throw_on_error:
|
|
138
|
-
if result.status.code != 8:
|
|
139
|
-
raise ApiCallError(result.status, f"Write failed with {result.status}")
|
|
140
|
-
return result
|
|
141
|
-
|
|
142
|
-
async def read_user_data(self) -> dict[str, Any]:
|
|
143
|
-
"""Read account user data from the cloud API."""
|
|
144
|
-
endpoint = "human/user/read/"
|
|
145
|
-
payload = {
|
|
146
|
-
"email": self.email,
|
|
147
|
-
}
|
|
148
|
-
result = await self._read(endpoint, payload)
|
|
149
|
-
return result.data
|
|
150
|
-
|
|
151
|
-
async def read_home_data(self, home_id: str) -> dict[str, Any]:
|
|
152
|
-
"""Read home data from the cloud API."""
|
|
153
|
-
endpoint = "human/smarthome/read/"
|
|
154
|
-
payload = {
|
|
155
|
-
"smarthome_id": home_id,
|
|
156
|
-
}
|
|
157
|
-
result = await self._read(endpoint, payload)
|
|
158
|
-
return result.data
|
|
159
|
-
|
|
160
|
-
async def write_query(self, home_id: str, query_params: dict[str, Any]):
|
|
161
|
-
"""Write an update query to the cloud API."""
|
|
162
|
-
endpoint = "human/query/push/"
|
|
163
|
-
payload = {
|
|
164
|
-
"smarthome_id": home_id,
|
|
165
|
-
"context": 1,
|
|
166
|
-
"peremption": 15000,
|
|
167
|
-
}
|
|
168
|
-
for key, value in query_params.items():
|
|
169
|
-
payload[f"query[{key}]"] = value
|
|
170
|
-
|
|
171
|
-
return await self._write(endpoint, payload)
|
|
172
|
-
|
|
173
|
-
def _parse_api_json(self, json: dict) -> ApiResult:
|
|
174
|
-
try:
|
|
175
|
-
code = json["code"]
|
|
176
|
-
data = json["data"]
|
|
177
|
-
parameters = json["parameters"]
|
|
178
|
-
except Exception as ex:
|
|
179
|
-
raise ApiError("Unexpected JSON format in API response") from ex
|
|
180
|
-
|
|
181
|
-
try:
|
|
182
|
-
code_num = int(code["code"])
|
|
183
|
-
code_key = code["key"]
|
|
184
|
-
code_value = code["value"]
|
|
185
|
-
status = ApiStatus(code_num, code_key, code_value)
|
|
186
|
-
except Exception as ex:
|
|
187
|
-
raise ApiError("API status is malformed") from ex
|
|
188
|
-
|
|
189
|
-
return ApiResult(status, data, parameters)
|
|
190
|
-
|
|
191
|
-
async def _post(
|
|
192
|
-
self, endpoint: str, payload: dict[str, Any], *, authenticate: bool
|
|
193
|
-
) -> ApiResult:
|
|
194
|
-
if authenticate:
|
|
195
|
-
payload.update(
|
|
196
|
-
{
|
|
197
|
-
"token": self.token,
|
|
198
|
-
"lang": self.API_LANG,
|
|
199
|
-
}
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
async with self._http_session.post(
|
|
204
|
-
self.API_PATH + endpoint, data=payload
|
|
205
|
-
) as response:
|
|
206
|
-
response.raise_for_status()
|
|
207
|
-
json_data = await response.json()
|
|
208
|
-
except ClientConnectionError as ex:
|
|
209
|
-
raise ApiConnectError(f"Connection error - {ex}") from ex
|
|
210
|
-
except ClientError as ex:
|
|
211
|
-
raise ApiConnectError(f"Unexpected cloud API error - {ex}") from ex
|
|
212
|
-
|
|
213
|
-
result = self._parse_api_json(json_data)
|
|
214
|
-
|
|
215
|
-
_LOGGER.debug(
|
|
216
|
-
"%s called with status %s (%s): %s",
|
|
217
|
-
endpoint,
|
|
218
|
-
result.status.key,
|
|
219
|
-
result.status.code,
|
|
220
|
-
result.status.value,
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
return result
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# pytest cache directory #
|
|
2
|
-
|
|
3
|
-
This directory contains data from the pytest's cache plugin,
|
|
4
|
-
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
|
5
|
-
|
|
6
|
-
**Do not** commit this to version control.
|
|
7
|
-
|
|
8
|
-
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|