pykoplenti 1.0.0__py3-none-any.whl → 1.2.1__py3-none-any.whl
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.
Potentially problematic release.
This version of pykoplenti might be problematic. Click here for more details.
- pykoplenti/__init__.py +37 -821
- pykoplenti/api.py +729 -0
- pykoplenti/cli.py +20 -14
- pykoplenti/extended.py +239 -0
- pykoplenti/model.py +99 -0
- {pykoplenti-1.0.0.dist-info → pykoplenti-1.2.1.dist-info}/METADATA +39 -29
- pykoplenti-1.2.1.dist-info/RECORD +12 -0
- {pykoplenti-1.0.0.dist-info → pykoplenti-1.2.1.dist-info}/WHEEL +1 -1
- pykoplenti-1.2.1.dist-info/entry_points.txt +2 -0
- kostal/plenticore/__init__.py +0 -659
- kostal/plenticore/cli.py +0 -352
- pykoplenti-1.0.0.dist-info/RECORD +0 -11
- pykoplenti-1.0.0.dist-info/entry_points.txt +0 -3
- /kostal/__init__.py → /pykoplenti/py.typed +0 -0
- {pykoplenti-1.0.0.dist-info → pykoplenti-1.2.1.dist-info}/LICENSE +0 -0
- {pykoplenti-1.0.0.dist-info → pykoplenti-1.2.1.dist-info}/top_level.txt +0 -0
pykoplenti/api.py
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
from base64 import b64decode, b64encode
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
import contextlib
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import functools
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import locale
|
|
9
|
+
import logging
|
|
10
|
+
from os import urandom
|
|
11
|
+
from typing import IO, Dict, Final, Iterable, List, Union, overload
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
from Crypto.Cipher import AES
|
|
15
|
+
from aiohttp import ClientResponse, ClientSession, ClientTimeout
|
|
16
|
+
from pydantic import parse_obj_as
|
|
17
|
+
from yarl import URL
|
|
18
|
+
|
|
19
|
+
from .model import (
|
|
20
|
+
EventData,
|
|
21
|
+
MeData,
|
|
22
|
+
ModuleData,
|
|
23
|
+
ProcessData,
|
|
24
|
+
ProcessDataCollection,
|
|
25
|
+
SettingsData,
|
|
26
|
+
VersionData,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_logger: Final = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiException(Exception):
|
|
33
|
+
"""Base exception for API calls."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, msg):
|
|
36
|
+
self.msg = msg
|
|
37
|
+
|
|
38
|
+
def __str__(self):
|
|
39
|
+
return f"API Error: {self.msg}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InternalCommunicationException(ApiException):
|
|
43
|
+
"""Exception for internal communication error response."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status_code: int, error: str):
|
|
46
|
+
super().__init__(f"Internal communication error ([{status_code}] - {error})")
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
self.error = error
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthenticationException(ApiException):
|
|
52
|
+
"""Exception for authentication or user error response."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, status_code: int, error: str):
|
|
55
|
+
super().__init__(
|
|
56
|
+
f"Invalid user/Authentication failed ([{status_code}] - {error})"
|
|
57
|
+
)
|
|
58
|
+
self.status_code = status_code
|
|
59
|
+
self.error = error
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NotAuthorizedException(ApiException):
|
|
63
|
+
"""Exception for calles without authentication."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, status_code: int, error: str):
|
|
66
|
+
super().__init__(f"Not authorized ([{status_code}] - {error})")
|
|
67
|
+
self.status_code = status_code
|
|
68
|
+
self.error = error
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UserLockedException(ApiException):
|
|
72
|
+
"""Exception for user locked error response."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, status_code: int, error: str):
|
|
75
|
+
super().__init__(f"User is locked ([{status_code}] - {error})")
|
|
76
|
+
self.status_code = status_code
|
|
77
|
+
self.error = error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ModuleNotFoundException(ApiException):
|
|
81
|
+
"""Exception for module or setting not found response."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, status_code: int, error: str):
|
|
84
|
+
super().__init__(f"Module or setting not found ([{status_code}] - {error})")
|
|
85
|
+
self.status_code = status_code
|
|
86
|
+
self.error = error
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ApiClient(contextlib.AbstractAsyncContextManager):
|
|
90
|
+
"""Client for the REST-API of Kostal Plenticore inverters.
|
|
91
|
+
|
|
92
|
+
The RESP-API provides several scopes of information. Each scope provides a
|
|
93
|
+
dynamic set of data which can be retrieved using this interface. The scopes
|
|
94
|
+
are:
|
|
95
|
+
|
|
96
|
+
- process data (readonly, dynamic values of the operation)
|
|
97
|
+
- settings (some are writable, static values for configuration)
|
|
98
|
+
|
|
99
|
+
The data are grouped into modules. For example the module `devices:local`
|
|
100
|
+
provides a process data `Dc_P` which contains the value of the current
|
|
101
|
+
DC power.
|
|
102
|
+
|
|
103
|
+
To get all process data or settings the methods `get_process_data` or
|
|
104
|
+
`get_settings` can be used. Depending of the current logged in user the
|
|
105
|
+
returned data can vary.
|
|
106
|
+
|
|
107
|
+
The methods `get_process_data_values` and `get_setting_values` can be used
|
|
108
|
+
to read process data or setting values from the inverter. You can use
|
|
109
|
+
`set_setting_values` to write new setting values to the inverter if the
|
|
110
|
+
setting is writable.
|
|
111
|
+
|
|
112
|
+
The authorization system of the inverter comprises three states:
|
|
113
|
+
* not logged in (is_active=False, authenticated=False)
|
|
114
|
+
* logged in and active (is_active=True, authenticated=True)
|
|
115
|
+
* logged in and inactive (is_active=False, authenticated=False)
|
|
116
|
+
|
|
117
|
+
The current state can be queried with the `get_me` method. Depending of
|
|
118
|
+
this state some operation might not be available.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
BASE_URL = "/api/v1/"
|
|
122
|
+
SUPPORTED_LANGUAGES = {
|
|
123
|
+
"de": ["de"],
|
|
124
|
+
"en": ["gb"],
|
|
125
|
+
"es": ["es"],
|
|
126
|
+
"fr": ["fr"],
|
|
127
|
+
"hu": ["hu"],
|
|
128
|
+
"it": ["it"],
|
|
129
|
+
"nl": ["nl"],
|
|
130
|
+
"pl": ["pl"],
|
|
131
|
+
"pt": ["pt"],
|
|
132
|
+
"cs": ["cz"],
|
|
133
|
+
"el": ["gr"],
|
|
134
|
+
"zh": ["cn"],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def __init__(self, websession: ClientSession, host: str, port: int = 80):
|
|
138
|
+
"""Create a new client.
|
|
139
|
+
|
|
140
|
+
:param websession: A aiohttp ClientSession for all requests
|
|
141
|
+
:param host: The hostname or ip of the inverter
|
|
142
|
+
:param port: The port of the API interface (default 80)
|
|
143
|
+
"""
|
|
144
|
+
self.websession = websession
|
|
145
|
+
self.host = host
|
|
146
|
+
self.port = port
|
|
147
|
+
self.session_id: Union[str, None] = None
|
|
148
|
+
self._key: Union[str, None] = None
|
|
149
|
+
self._service_code: Union[str, None] = None
|
|
150
|
+
self._user: Union[str, None] = None
|
|
151
|
+
|
|
152
|
+
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
153
|
+
"""Logout support for context manager."""
|
|
154
|
+
if self.session_id is not None:
|
|
155
|
+
await self.logout()
|
|
156
|
+
|
|
157
|
+
def _create_url(self, path: str) -> URL:
|
|
158
|
+
"""Creates a REST-API URL with the given path as suffix.
|
|
159
|
+
|
|
160
|
+
:param path: path suffix, must not start with '/'
|
|
161
|
+
:return: a URL instance
|
|
162
|
+
"""
|
|
163
|
+
base = URL.build(
|
|
164
|
+
scheme="http",
|
|
165
|
+
host=self.host,
|
|
166
|
+
port=self.port,
|
|
167
|
+
path=ApiClient.BASE_URL,
|
|
168
|
+
)
|
|
169
|
+
return base.join(URL(path))
|
|
170
|
+
|
|
171
|
+
async def initialize_virtual_process_data(self):
|
|
172
|
+
process_data = await self.get_process_data()
|
|
173
|
+
self._virt_process_data.initialize(process_data)
|
|
174
|
+
|
|
175
|
+
async def login(
|
|
176
|
+
self,
|
|
177
|
+
key: str,
|
|
178
|
+
service_code: Union[str, None] = None,
|
|
179
|
+
password: Union[str, None] = None,
|
|
180
|
+
user: Union[str, None] = None,
|
|
181
|
+
):
|
|
182
|
+
"""Login with the given password (key).
|
|
183
|
+
|
|
184
|
+
If a service code is provided user is 'master', else 'user'.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
:param key: The user password. If 'service_code' is given, 'key' is the
|
|
189
|
+
Master Key (also called Device ID).
|
|
190
|
+
:type key: str, None
|
|
191
|
+
:param service_code: Installer service code. If given the user is assumed to be
|
|
192
|
+
'master', else 'user'.
|
|
193
|
+
:type service_code: str, None
|
|
194
|
+
:param password: Deprecated, use key instead.
|
|
195
|
+
:param user: Deprecated, user is chosen automatically depending on service_code.
|
|
196
|
+
|
|
197
|
+
:raises AuthenticationException: if authentication failed
|
|
198
|
+
:raises aiohttp.client_exceptions.ClientConnectorError: if host is not reachable
|
|
199
|
+
:raises asyncio.exceptions.TimeoutError: if a timeout occurs
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
if password is None:
|
|
203
|
+
self._key = key
|
|
204
|
+
else:
|
|
205
|
+
warnings.warn(
|
|
206
|
+
"password is deprecated. Use key instead.", DeprecationWarning
|
|
207
|
+
)
|
|
208
|
+
self._key = password
|
|
209
|
+
|
|
210
|
+
if user is None:
|
|
211
|
+
self._user = "master" if service_code else "user"
|
|
212
|
+
else:
|
|
213
|
+
warnings.warn(
|
|
214
|
+
"user is deprecated. user is chosen automatically.", DeprecationWarning
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
self._service_code = service_code
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
await self._login()
|
|
221
|
+
except Exception:
|
|
222
|
+
self._key = None
|
|
223
|
+
self._user = None
|
|
224
|
+
self._service_code = None
|
|
225
|
+
raise
|
|
226
|
+
|
|
227
|
+
async def _login(self):
|
|
228
|
+
# Step 1 start authentication
|
|
229
|
+
client_nonce = urandom(12)
|
|
230
|
+
|
|
231
|
+
start_request = {
|
|
232
|
+
"username": self._user,
|
|
233
|
+
"nonce": b64encode(client_nonce).decode("utf-8"),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async with self.websession.request(
|
|
237
|
+
"POST", self._create_url("auth/start"), json=start_request
|
|
238
|
+
) as resp:
|
|
239
|
+
await self._check_response(resp)
|
|
240
|
+
start_response = await resp.json()
|
|
241
|
+
server_nonce = b64decode(start_response["nonce"])
|
|
242
|
+
transaction_id = b64decode(start_response["transactionId"])
|
|
243
|
+
salt = b64decode(start_response["salt"])
|
|
244
|
+
rounds = start_response["rounds"]
|
|
245
|
+
|
|
246
|
+
# Step 2 finish authentication (RFC5802)
|
|
247
|
+
salted_passwd = hashlib.pbkdf2_hmac(
|
|
248
|
+
"sha256", self._key.encode("utf-8"), salt, rounds
|
|
249
|
+
)
|
|
250
|
+
client_key = hmac.new(
|
|
251
|
+
salted_passwd, "Client Key".encode("utf-8"), hashlib.sha256
|
|
252
|
+
).digest()
|
|
253
|
+
stored_key = hashlib.sha256(client_key).digest()
|
|
254
|
+
|
|
255
|
+
auth_msg = (
|
|
256
|
+
"n={user},r={client_nonce},r={server_nonce},s={salt},i={rounds},"
|
|
257
|
+
"c=biws,r={server_nonce}".format(
|
|
258
|
+
user=self._user,
|
|
259
|
+
client_nonce=b64encode(client_nonce).decode("utf-8"),
|
|
260
|
+
server_nonce=b64encode(server_nonce).decode("utf-8"),
|
|
261
|
+
salt=b64encode(salt).decode("utf-8"),
|
|
262
|
+
rounds=rounds,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
client_signature = hmac.new(
|
|
266
|
+
stored_key, auth_msg.encode("utf-8"), hashlib.sha256
|
|
267
|
+
).digest()
|
|
268
|
+
client_proof = bytes([a ^ b for a, b in zip(client_key, client_signature)])
|
|
269
|
+
|
|
270
|
+
server_key = hmac.new(
|
|
271
|
+
salted_passwd, "Server Key".encode("utf-8"), hashlib.sha256
|
|
272
|
+
).digest()
|
|
273
|
+
server_signature = hmac.new(
|
|
274
|
+
server_key, auth_msg.encode("utf-8"), hashlib.sha256
|
|
275
|
+
).digest()
|
|
276
|
+
|
|
277
|
+
finish_request = {
|
|
278
|
+
"transactionId": b64encode(transaction_id).decode("utf-8"),
|
|
279
|
+
"proof": b64encode(client_proof).decode("utf-8"),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async with self.websession.request(
|
|
283
|
+
"POST", self._create_url("auth/finish"), json=finish_request
|
|
284
|
+
) as resp:
|
|
285
|
+
await self._check_response(resp)
|
|
286
|
+
finish_response = await resp.json()
|
|
287
|
+
token = finish_response["token"]
|
|
288
|
+
signature = b64decode(finish_response["signature"])
|
|
289
|
+
if signature != server_signature:
|
|
290
|
+
raise Exception("Server signature mismatch.")
|
|
291
|
+
|
|
292
|
+
# Step 3 create session
|
|
293
|
+
session_key_hmac = hmac.new(
|
|
294
|
+
stored_key, "Session Key".encode("utf-8"), hashlib.sha256
|
|
295
|
+
)
|
|
296
|
+
session_key_hmac.update(auth_msg.encode("utf-8"))
|
|
297
|
+
session_key_hmac.update(client_key)
|
|
298
|
+
protocol_key = session_key_hmac.digest()
|
|
299
|
+
session_nonce = urandom(16)
|
|
300
|
+
cipher = AES.new(protocol_key, AES.MODE_GCM, nonce=session_nonce)
|
|
301
|
+
|
|
302
|
+
if self._user == "master":
|
|
303
|
+
token = f"{token}:{self._service_code}"
|
|
304
|
+
|
|
305
|
+
cipher_text, auth_tag = cipher.encrypt_and_digest(token.encode("utf-8"))
|
|
306
|
+
|
|
307
|
+
session_request = {
|
|
308
|
+
# AES initialization vector
|
|
309
|
+
"iv": b64encode(session_nonce).decode("utf-8"),
|
|
310
|
+
# AES GCM tag
|
|
311
|
+
"tag": b64encode(auth_tag).decode("utf-8"),
|
|
312
|
+
# ID of authentication transaction
|
|
313
|
+
"transactionId": b64encode(transaction_id).decode("utf-8"),
|
|
314
|
+
# Only the token or token and service code (separated by colon). Encrypted
|
|
315
|
+
# with AES using the protocol key
|
|
316
|
+
"payload": b64encode(cipher_text).decode("utf-8"),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async with self.websession.request(
|
|
320
|
+
"POST", self._create_url("auth/create_session"), json=session_request
|
|
321
|
+
) as resp:
|
|
322
|
+
await self._check_response(resp)
|
|
323
|
+
session_response = await resp.json()
|
|
324
|
+
self.session_id = session_response["sessionId"]
|
|
325
|
+
|
|
326
|
+
def _session_request(self, path: str, method="GET", **kwargs):
|
|
327
|
+
"""Make an request on the current active session.
|
|
328
|
+
|
|
329
|
+
:param path: the URL suffix
|
|
330
|
+
:param method: the request method, defaults to 'GET'
|
|
331
|
+
:param **kwargs: all other args are forwarded to the request
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
headers: Dict[str, str] = {}
|
|
335
|
+
if self.session_id is not None:
|
|
336
|
+
headers["authorization"] = f"Session {self.session_id}"
|
|
337
|
+
|
|
338
|
+
return self.websession.request(
|
|
339
|
+
method, self._create_url(path), headers=headers, **kwargs
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def _check_response(self, resp: ClientResponse):
|
|
343
|
+
"""Check if the given response contains an error and throws
|
|
344
|
+
the appropriate exception."""
|
|
345
|
+
|
|
346
|
+
if resp.status != 200:
|
|
347
|
+
try:
|
|
348
|
+
response = await resp.json()
|
|
349
|
+
error = response["message"]
|
|
350
|
+
except Exception:
|
|
351
|
+
error = None
|
|
352
|
+
|
|
353
|
+
if resp.status == 400:
|
|
354
|
+
raise AuthenticationException(resp.status, error)
|
|
355
|
+
|
|
356
|
+
if resp.status == 401:
|
|
357
|
+
raise NotAuthorizedException(resp.status, error)
|
|
358
|
+
|
|
359
|
+
if resp.status == 403:
|
|
360
|
+
raise UserLockedException(resp.status, error)
|
|
361
|
+
|
|
362
|
+
if resp.status == 404:
|
|
363
|
+
raise ModuleNotFoundException(resp.status, error)
|
|
364
|
+
|
|
365
|
+
if resp.status == 503:
|
|
366
|
+
raise InternalCommunicationException(resp.status, error)
|
|
367
|
+
|
|
368
|
+
# we got an undocumented status code
|
|
369
|
+
raise ApiException(f"Unknown API response [{resp.status}] - {error}")
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _relogin(fn):
|
|
373
|
+
"""Decorator for automatic re-login if session was expired."""
|
|
374
|
+
|
|
375
|
+
@functools.wraps(fn)
|
|
376
|
+
async def _wrapper(self, *args, **kwargs):
|
|
377
|
+
try:
|
|
378
|
+
return await fn(self, *args, **kwargs)
|
|
379
|
+
except (AuthenticationException, NotAuthorizedException):
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
_logger.debug("Request failed - try to re-login")
|
|
383
|
+
await self._login()
|
|
384
|
+
return await fn(self, *args, **kwargs)
|
|
385
|
+
|
|
386
|
+
return _wrapper
|
|
387
|
+
|
|
388
|
+
async def logout(self):
|
|
389
|
+
"""Logs the current user out."""
|
|
390
|
+
self._key = None
|
|
391
|
+
self._service_code = None
|
|
392
|
+
async with self._session_request("auth/logout", method="POST") as resp:
|
|
393
|
+
await self._check_response(resp)
|
|
394
|
+
|
|
395
|
+
async def get_me(self) -> MeData:
|
|
396
|
+
"""Returns information about the user.
|
|
397
|
+
|
|
398
|
+
No login is required.
|
|
399
|
+
"""
|
|
400
|
+
async with self._session_request("auth/me") as resp:
|
|
401
|
+
await self._check_response(resp)
|
|
402
|
+
me_response = await resp.json()
|
|
403
|
+
return MeData(**me_response)
|
|
404
|
+
|
|
405
|
+
async def get_version(self) -> VersionData:
|
|
406
|
+
"""Returns information about the API of the inverter.
|
|
407
|
+
|
|
408
|
+
No login is required.
|
|
409
|
+
"""
|
|
410
|
+
async with self._session_request("info/version") as resp:
|
|
411
|
+
await self._check_response(resp)
|
|
412
|
+
response = await resp.json()
|
|
413
|
+
return VersionData(**response)
|
|
414
|
+
|
|
415
|
+
@_relogin
|
|
416
|
+
async def get_events(self, max_count=10, lang=None) -> Iterable[EventData]:
|
|
417
|
+
"""Returns a list with the latest localized events.
|
|
418
|
+
|
|
419
|
+
:param max_count: the max number of events to read
|
|
420
|
+
:param lang: the RFC1766 based language code, for example 'de_CH' or 'en'
|
|
421
|
+
"""
|
|
422
|
+
if lang is None:
|
|
423
|
+
lang = locale.getlocale()[0]
|
|
424
|
+
|
|
425
|
+
language = lang[0:2].lower()
|
|
426
|
+
variant = lang[3:5].lower()
|
|
427
|
+
if language not in ApiClient.SUPPORTED_LANGUAGES.keys():
|
|
428
|
+
# Fallback to default
|
|
429
|
+
language = "en"
|
|
430
|
+
variant = "gb"
|
|
431
|
+
else:
|
|
432
|
+
variants = ApiClient.SUPPORTED_LANGUAGES[language]
|
|
433
|
+
if variant not in variants:
|
|
434
|
+
variant = variants[0]
|
|
435
|
+
|
|
436
|
+
request = {"language": f"{language}-{variant}", "max": max_count}
|
|
437
|
+
|
|
438
|
+
async with self._session_request(
|
|
439
|
+
"events/latest", method="POST", json=request
|
|
440
|
+
) as resp:
|
|
441
|
+
await self._check_response(resp)
|
|
442
|
+
event_response = await resp.json()
|
|
443
|
+
return [EventData(**x) for x in event_response]
|
|
444
|
+
|
|
445
|
+
async def get_modules(self) -> Iterable[ModuleData]:
|
|
446
|
+
"""Return list of all available modules (providing process data or settings)."""
|
|
447
|
+
async with self._session_request("modules") as resp:
|
|
448
|
+
await self._check_response(resp)
|
|
449
|
+
modules_response = await resp.json()
|
|
450
|
+
return [ModuleData(**x) for x in modules_response]
|
|
451
|
+
|
|
452
|
+
@_relogin
|
|
453
|
+
async def get_process_data(self) -> Mapping[str, Iterable[str]]:
|
|
454
|
+
"""Return a dictionary of all processdata ids and its module ids.
|
|
455
|
+
|
|
456
|
+
:return: a dictionary with the module id as key and a list of process data ids
|
|
457
|
+
as value
|
|
458
|
+
"""
|
|
459
|
+
async with self._session_request("processdata") as resp:
|
|
460
|
+
await self._check_response(resp)
|
|
461
|
+
data_response = await resp.json()
|
|
462
|
+
return {x["moduleid"]: x["processdataids"] for x in data_response}
|
|
463
|
+
|
|
464
|
+
@overload
|
|
465
|
+
async def get_process_data_values(
|
|
466
|
+
self,
|
|
467
|
+
module_id: str,
|
|
468
|
+
processdata_id: str,
|
|
469
|
+
) -> Mapping[str, ProcessDataCollection]:
|
|
470
|
+
...
|
|
471
|
+
|
|
472
|
+
@overload
|
|
473
|
+
async def get_process_data_values(
|
|
474
|
+
self,
|
|
475
|
+
module_id: str,
|
|
476
|
+
processdata_id: Iterable[str],
|
|
477
|
+
) -> Mapping[str, ProcessDataCollection]:
|
|
478
|
+
...
|
|
479
|
+
|
|
480
|
+
@overload
|
|
481
|
+
async def get_process_data_values(
|
|
482
|
+
self,
|
|
483
|
+
module_id: str,
|
|
484
|
+
) -> Mapping[str, ProcessDataCollection]:
|
|
485
|
+
...
|
|
486
|
+
|
|
487
|
+
@overload
|
|
488
|
+
async def get_process_data_values(
|
|
489
|
+
self,
|
|
490
|
+
module_id: Mapping[str, Iterable[str]],
|
|
491
|
+
) -> Mapping[str, ProcessDataCollection]:
|
|
492
|
+
...
|
|
493
|
+
|
|
494
|
+
@overload
|
|
495
|
+
async def get_process_data_values(
|
|
496
|
+
self,
|
|
497
|
+
module_id: Union[str, Mapping[str, Iterable[str]]],
|
|
498
|
+
processdata_id: Union[str, Iterable[str], None] = None,
|
|
499
|
+
) -> Mapping[str, ProcessDataCollection]:
|
|
500
|
+
...
|
|
501
|
+
|
|
502
|
+
@_relogin
|
|
503
|
+
async def get_process_data_values(
|
|
504
|
+
self,
|
|
505
|
+
module_id: Union[str, Mapping[str, Iterable[str]]],
|
|
506
|
+
processdata_id: Union[str, Iterable[str], None] = None,
|
|
507
|
+
) -> Mapping[str, ProcessDataCollection]:
|
|
508
|
+
"""Return a dictionary of process data of one or more modules.
|
|
509
|
+
|
|
510
|
+
:param module_id: required, must be a module id or a mapping with the
|
|
511
|
+
module id as key and the process data ids as values.
|
|
512
|
+
:param processdata_id: optional, if given `module_id` must be string. Can
|
|
513
|
+
be either a string or a list of string. If missing
|
|
514
|
+
all process data ids are returned.
|
|
515
|
+
:return: a dictionary with the module id as key and a instance of
|
|
516
|
+
:py:class:`ProcessDataCollection` as value
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
if isinstance(module_id, str) and processdata_id is None:
|
|
520
|
+
# get all process data of a module
|
|
521
|
+
async with self._session_request(f"processdata/{module_id}") as resp:
|
|
522
|
+
await self._check_response(resp)
|
|
523
|
+
data_response = await resp.json()
|
|
524
|
+
return {
|
|
525
|
+
data_response[0]["moduleid"]: ProcessDataCollection(
|
|
526
|
+
parse_obj_as(list[ProcessData], data_response[0]["processdata"])
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if isinstance(module_id, str) and isinstance(processdata_id, str):
|
|
531
|
+
# get a single process data of a module
|
|
532
|
+
async with self._session_request(
|
|
533
|
+
f"processdata/{module_id}/{processdata_id}"
|
|
534
|
+
) as resp:
|
|
535
|
+
await self._check_response(resp)
|
|
536
|
+
data_response = await resp.json()
|
|
537
|
+
return {
|
|
538
|
+
data_response[0]["moduleid"]: ProcessDataCollection(
|
|
539
|
+
parse_obj_as(list[ProcessData], data_response[0]["processdata"])
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (
|
|
544
|
+
isinstance(module_id, str)
|
|
545
|
+
and processdata_id is not None
|
|
546
|
+
and hasattr(processdata_id, "__iter__")
|
|
547
|
+
):
|
|
548
|
+
# get multiple process data of a module
|
|
549
|
+
ids = ",".join(processdata_id)
|
|
550
|
+
async with self._session_request(f"processdata/{module_id}/{ids}") as resp:
|
|
551
|
+
await self._check_response(resp)
|
|
552
|
+
data_response = await resp.json()
|
|
553
|
+
return {
|
|
554
|
+
data_response[0]["moduleid"]: ProcessDataCollection(
|
|
555
|
+
parse_obj_as(list[ProcessData], data_response[0]["processdata"])
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if isinstance(module_id, dict) and processdata_id is None:
|
|
560
|
+
# get multiple process data of multiple modules
|
|
561
|
+
request = []
|
|
562
|
+
for mid, pids in module_id.items():
|
|
563
|
+
# the json encoder expects that iterables are either list or tuples,
|
|
564
|
+
# other types has to be converted
|
|
565
|
+
if isinstance(pids, list) or isinstance(pids, tuple):
|
|
566
|
+
request.append(dict(moduleid=mid, processdataids=pids))
|
|
567
|
+
else:
|
|
568
|
+
request.append(dict(moduleid=mid, processdataids=list(pids)))
|
|
569
|
+
|
|
570
|
+
async with self._session_request(
|
|
571
|
+
"processdata", method="POST", json=request
|
|
572
|
+
) as resp:
|
|
573
|
+
await self._check_response(resp)
|
|
574
|
+
data_response = await resp.json()
|
|
575
|
+
return {
|
|
576
|
+
x["moduleid"]: ProcessDataCollection(
|
|
577
|
+
parse_obj_as(List[ProcessData], x["processdata"])
|
|
578
|
+
)
|
|
579
|
+
for x in data_response
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
raise TypeError("Invalid combination of module_id and processdata_id.")
|
|
583
|
+
|
|
584
|
+
async def get_settings(self) -> Mapping[str, Iterable[SettingsData]]:
|
|
585
|
+
"""Return list of all modules with a list of available settings identifiers."""
|
|
586
|
+
async with self._session_request("settings") as resp:
|
|
587
|
+
await self._check_response(resp)
|
|
588
|
+
response = await resp.json()
|
|
589
|
+
result: Dict[str, List[SettingsData]] = {}
|
|
590
|
+
for module in response:
|
|
591
|
+
id = module["moduleid"]
|
|
592
|
+
data = list([SettingsData(**x) for x in module["settings"]])
|
|
593
|
+
result[id] = data
|
|
594
|
+
|
|
595
|
+
return result
|
|
596
|
+
|
|
597
|
+
@overload
|
|
598
|
+
async def get_setting_values(
|
|
599
|
+
self,
|
|
600
|
+
module_id: str,
|
|
601
|
+
setting_id: str,
|
|
602
|
+
) -> Mapping[str, Mapping[str, str]]:
|
|
603
|
+
...
|
|
604
|
+
|
|
605
|
+
@overload
|
|
606
|
+
async def get_setting_values(
|
|
607
|
+
self,
|
|
608
|
+
module_id: str,
|
|
609
|
+
setting_id: Iterable[str],
|
|
610
|
+
) -> Mapping[str, Mapping[str, str]]:
|
|
611
|
+
...
|
|
612
|
+
|
|
613
|
+
@overload
|
|
614
|
+
async def get_setting_values(
|
|
615
|
+
self,
|
|
616
|
+
module_id: str,
|
|
617
|
+
) -> Mapping[str, Mapping[str, str]]:
|
|
618
|
+
...
|
|
619
|
+
|
|
620
|
+
@overload
|
|
621
|
+
async def get_setting_values(
|
|
622
|
+
self,
|
|
623
|
+
module_id: Mapping[str, Iterable[str]],
|
|
624
|
+
) -> Mapping[str, Mapping[str, str]]:
|
|
625
|
+
...
|
|
626
|
+
|
|
627
|
+
@_relogin
|
|
628
|
+
async def get_setting_values(
|
|
629
|
+
self,
|
|
630
|
+
module_id: Union[str, Mapping[str, Iterable[str]]],
|
|
631
|
+
setting_id: Union[str, Iterable[str], None] = None,
|
|
632
|
+
) -> Mapping[str, Mapping[str, str]]:
|
|
633
|
+
"""Return a dictionary of setting values of one or more modules.
|
|
634
|
+
|
|
635
|
+
:param module_id: required, must be a module id or a dictionary with the
|
|
636
|
+
module id as key and the setting ids as values.
|
|
637
|
+
:param setting_id: optional, if given `module_id` must be string. Can
|
|
638
|
+
be either a string or a list of string. If missing
|
|
639
|
+
all setting ids are returned.
|
|
640
|
+
"""
|
|
641
|
+
if isinstance(module_id, str) and setting_id is None:
|
|
642
|
+
# get all setting data of a module
|
|
643
|
+
async with self._session_request(f"settings/{module_id}") as resp:
|
|
644
|
+
await self._check_response(resp)
|
|
645
|
+
data_response = await resp.json()
|
|
646
|
+
return {module_id: {data_response[0]["id"]: data_response[0]["value"]}}
|
|
647
|
+
|
|
648
|
+
if isinstance(module_id, str) and isinstance(setting_id, str):
|
|
649
|
+
# get a single setting of a module
|
|
650
|
+
async with self._session_request(
|
|
651
|
+
f"settings/{module_id}/{setting_id}"
|
|
652
|
+
) as resp:
|
|
653
|
+
await self._check_response(resp)
|
|
654
|
+
data_response = await resp.json()
|
|
655
|
+
return {module_id: {data_response[0]["id"]: data_response[0]["value"]}}
|
|
656
|
+
|
|
657
|
+
if (
|
|
658
|
+
isinstance(module_id, str)
|
|
659
|
+
and setting_id is not None
|
|
660
|
+
and hasattr(setting_id, "__iter__")
|
|
661
|
+
):
|
|
662
|
+
# get multiple settings of a module
|
|
663
|
+
ids = ",".join(setting_id)
|
|
664
|
+
async with self._session_request(f"settings/{module_id}/{ids}") as resp:
|
|
665
|
+
await self._check_response(resp)
|
|
666
|
+
data_response = await resp.json()
|
|
667
|
+
return {module_id: {x["id"]: x["value"] for x in data_response}}
|
|
668
|
+
|
|
669
|
+
if isinstance(module_id, dict) and setting_id is None:
|
|
670
|
+
# get multiple process data of multiple modules
|
|
671
|
+
request = []
|
|
672
|
+
for mid, pids in module_id.items():
|
|
673
|
+
# the json encoder expects that iterables are either list or tuples,
|
|
674
|
+
# other types has to be converted
|
|
675
|
+
if isinstance(pids, list) or isinstance(pids, tuple):
|
|
676
|
+
request.append(dict(moduleid=mid, settingids=pids))
|
|
677
|
+
else:
|
|
678
|
+
request.append(dict(moduleid=mid, settingids=list(pids)))
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
async with self._session_request(
|
|
682
|
+
"settings", method="POST", json=request
|
|
683
|
+
) as resp:
|
|
684
|
+
await self._check_response(resp)
|
|
685
|
+
data_response = await resp.json()
|
|
686
|
+
return {
|
|
687
|
+
x["moduleid"]: {y["id"]: y["value"] for y in x["settings"]}
|
|
688
|
+
for x in data_response
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
raise TypeError("Invalid combination of module_id and setting_id.")
|
|
692
|
+
|
|
693
|
+
@_relogin
|
|
694
|
+
async def set_setting_values(self, module_id: str, values: Mapping[str, str]):
|
|
695
|
+
"""Write a list of settings for one modules."""
|
|
696
|
+
request = [
|
|
697
|
+
{
|
|
698
|
+
"moduleid": module_id,
|
|
699
|
+
"settings": list([dict(value=v, id=k) for k, v in values.items()]),
|
|
700
|
+
}
|
|
701
|
+
]
|
|
702
|
+
async with self._session_request(
|
|
703
|
+
"settings", method="PUT", json=request
|
|
704
|
+
) as resp:
|
|
705
|
+
await self._check_response(resp)
|
|
706
|
+
|
|
707
|
+
@_relogin
|
|
708
|
+
async def download_logdata(
|
|
709
|
+
self,
|
|
710
|
+
writer: IO,
|
|
711
|
+
begin: Union[datetime, None] = None,
|
|
712
|
+
end: Union[datetime, None] = None,
|
|
713
|
+
):
|
|
714
|
+
"""Download logdata as tab-separated file."""
|
|
715
|
+
request = {}
|
|
716
|
+
if begin is not None:
|
|
717
|
+
request["begin"] = begin.strftime("%Y-%m-%d")
|
|
718
|
+
if end is not None:
|
|
719
|
+
request["end"] = end.strftime("%Y-%m-%d")
|
|
720
|
+
|
|
721
|
+
async with self._session_request(
|
|
722
|
+
"logdata/download",
|
|
723
|
+
method="POST",
|
|
724
|
+
json=request,
|
|
725
|
+
timeout=ClientTimeout(total=360),
|
|
726
|
+
) as resp:
|
|
727
|
+
await self._check_response(resp)
|
|
728
|
+
async for data in resp.content.iter_any():
|
|
729
|
+
writer.write(data.decode("UTF-8"))
|