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/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"))