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.

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