pykoplenti 1.2.1__py3-none-any.whl → 1.3.0__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 CHANGED
@@ -13,17 +13,16 @@ import warnings
13
13
 
14
14
  from Crypto.Cipher import AES
15
15
  from aiohttp import ClientResponse, ClientSession, ClientTimeout
16
- from pydantic import parse_obj_as
17
16
  from yarl import URL
18
17
 
19
18
  from .model import (
20
19
  EventData,
21
20
  MeData,
22
21
  ModuleData,
23
- ProcessData,
24
22
  ProcessDataCollection,
25
23
  SettingsData,
26
24
  VersionData,
25
+ process_data_list,
27
26
  )
28
27
 
29
28
  _logger: Final = logging.getLogger(__name__)
@@ -86,6 +85,20 @@ class ModuleNotFoundException(ApiException):
86
85
  self.error = error
87
86
 
88
87
 
88
+ def _relogin(fn):
89
+ """Decorator for automatic re-login if session was expired."""
90
+
91
+ @functools.wraps(fn)
92
+ async def _wrapper(self: "ApiClient", *args, **kwargs):
93
+ with contextlib.suppress(AuthenticationException, NotAuthorizedException):
94
+ return await fn(self, *args, **kwargs)
95
+ _logger.debug("Request failed - try to re-login")
96
+ await self._login()
97
+ return await fn(self, *args, **kwargs)
98
+
99
+ return _wrapper
100
+
101
+
89
102
  class ApiClient(contextlib.AbstractAsyncContextManager):
90
103
  """Client for the REST-API of Kostal Plenticore inverters.
91
104
 
@@ -265,7 +278,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
265
278
  client_signature = hmac.new(
266
279
  stored_key, auth_msg.encode("utf-8"), hashlib.sha256
267
280
  ).digest()
268
- client_proof = bytes([a ^ b for a, b in zip(client_key, client_signature)])
281
+ client_proof = bytes(a ^ b for a, b in zip(client_key, client_signature))
269
282
 
270
283
  server_key = hmac.new(
271
284
  salted_passwd, "Server Key".encode("utf-8"), hashlib.sha256
@@ -343,47 +356,32 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
343
356
  """Check if the given response contains an error and throws
344
357
  the appropriate exception."""
345
358
 
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)
359
+ if resp.status == 200:
360
+ return
355
361
 
356
- if resp.status == 401:
357
- raise NotAuthorizedException(resp.status, error)
358
-
359
- if resp.status == 403:
360
- raise UserLockedException(resp.status, error)
362
+ try:
363
+ response = await resp.json()
364
+ error = response["message"]
365
+ except Exception:
366
+ error = None
361
367
 
362
- if resp.status == 404:
363
- raise ModuleNotFoundException(resp.status, error)
368
+ if resp.status == 400:
369
+ raise AuthenticationException(resp.status, error)
364
370
 
365
- if resp.status == 503:
366
- raise InternalCommunicationException(resp.status, error)
371
+ if resp.status == 401:
372
+ raise NotAuthorizedException(resp.status, error)
367
373
 
368
- # we got an undocumented status code
369
- raise ApiException(f"Unknown API response [{resp.status}] - {error}")
374
+ if resp.status == 403:
375
+ raise UserLockedException(resp.status, error)
370
376
 
371
- @staticmethod
372
- def _relogin(fn):
373
- """Decorator for automatic re-login if session was expired."""
377
+ if resp.status == 404:
378
+ raise ModuleNotFoundException(resp.status, error)
374
379
 
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
380
+ if resp.status == 503:
381
+ raise InternalCommunicationException(resp.status, error)
381
382
 
382
- _logger.debug("Request failed - try to re-login")
383
- await self._login()
384
- return await fn(self, *args, **kwargs)
385
-
386
- return _wrapper
383
+ # we got an undocumented status code
384
+ raise ApiException(f"Unknown API response [{resp.status}] - {error}")
387
385
 
388
386
  async def logout(self):
389
387
  """Logs the current user out."""
@@ -422,7 +420,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
422
420
  if lang is None:
423
421
  lang = locale.getlocale()[0]
424
422
 
425
- language = lang[0:2].lower()
423
+ language = lang[:2].lower()
426
424
  variant = lang[3:5].lower()
427
425
  if language not in ApiClient.SUPPORTED_LANGUAGES.keys():
428
426
  # Fallback to default
@@ -466,38 +464,33 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
466
464
  self,
467
465
  module_id: str,
468
466
  processdata_id: str,
469
- ) -> Mapping[str, ProcessDataCollection]:
470
- ...
467
+ ) -> Mapping[str, ProcessDataCollection]: ...
471
468
 
472
469
  @overload
473
470
  async def get_process_data_values(
474
471
  self,
475
472
  module_id: str,
476
473
  processdata_id: Iterable[str],
477
- ) -> Mapping[str, ProcessDataCollection]:
478
- ...
474
+ ) -> Mapping[str, ProcessDataCollection]: ...
479
475
 
480
476
  @overload
481
477
  async def get_process_data_values(
482
478
  self,
483
479
  module_id: str,
484
- ) -> Mapping[str, ProcessDataCollection]:
485
- ...
480
+ ) -> Mapping[str, ProcessDataCollection]: ...
486
481
 
487
482
  @overload
488
483
  async def get_process_data_values(
489
484
  self,
490
485
  module_id: Mapping[str, Iterable[str]],
491
- ) -> Mapping[str, ProcessDataCollection]:
492
- ...
486
+ ) -> Mapping[str, ProcessDataCollection]: ...
493
487
 
494
488
  @overload
495
489
  async def get_process_data_values(
496
490
  self,
497
491
  module_id: Union[str, Mapping[str, Iterable[str]]],
498
492
  processdata_id: Union[str, Iterable[str], None] = None,
499
- ) -> Mapping[str, ProcessDataCollection]:
500
- ...
493
+ ) -> Mapping[str, ProcessDataCollection]: ...
501
494
 
502
495
  @_relogin
503
496
  async def get_process_data_values(
@@ -523,7 +516,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
523
516
  data_response = await resp.json()
524
517
  return {
525
518
  data_response[0]["moduleid"]: ProcessDataCollection(
526
- parse_obj_as(list[ProcessData], data_response[0]["processdata"])
519
+ process_data_list(data_response[0]["processdata"])
527
520
  )
528
521
  }
529
522
 
@@ -536,7 +529,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
536
529
  data_response = await resp.json()
537
530
  return {
538
531
  data_response[0]["moduleid"]: ProcessDataCollection(
539
- parse_obj_as(list[ProcessData], data_response[0]["processdata"])
532
+ process_data_list(data_response[0]["processdata"])
540
533
  )
541
534
  }
542
535
 
@@ -552,7 +545,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
552
545
  data_response = await resp.json()
553
546
  return {
554
547
  data_response[0]["moduleid"]: ProcessDataCollection(
555
- parse_obj_as(list[ProcessData], data_response[0]["processdata"])
548
+ process_data_list(data_response[0]["processdata"])
556
549
  )
557
550
  }
558
551
 
@@ -562,7 +555,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
562
555
  for mid, pids in module_id.items():
563
556
  # the json encoder expects that iterables are either list or tuples,
564
557
  # other types has to be converted
565
- if isinstance(pids, list) or isinstance(pids, tuple):
558
+ if isinstance(pids, (list, tuple)):
566
559
  request.append(dict(moduleid=mid, processdataids=pids))
567
560
  else:
568
561
  request.append(dict(moduleid=mid, processdataids=list(pids)))
@@ -574,7 +567,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
574
567
  data_response = await resp.json()
575
568
  return {
576
569
  x["moduleid"]: ProcessDataCollection(
577
- parse_obj_as(List[ProcessData], x["processdata"])
570
+ process_data_list(x["processdata"])
578
571
  )
579
572
  for x in data_response
580
573
  }
@@ -588,9 +581,9 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
588
581
  response = await resp.json()
589
582
  result: Dict[str, List[SettingsData]] = {}
590
583
  for module in response:
591
- id = module["moduleid"]
592
- data = list([SettingsData(**x) for x in module["settings"]])
593
- result[id] = data
584
+ mid = module["moduleid"]
585
+ data = [SettingsData(**x) for x in module["settings"]]
586
+ result[mid] = data
594
587
 
595
588
  return result
596
589
 
@@ -599,30 +592,26 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
599
592
  self,
600
593
  module_id: str,
601
594
  setting_id: str,
602
- ) -> Mapping[str, Mapping[str, str]]:
603
- ...
595
+ ) -> Mapping[str, Mapping[str, str]]: ...
604
596
 
605
597
  @overload
606
598
  async def get_setting_values(
607
599
  self,
608
600
  module_id: str,
609
601
  setting_id: Iterable[str],
610
- ) -> Mapping[str, Mapping[str, str]]:
611
- ...
602
+ ) -> Mapping[str, Mapping[str, str]]: ...
612
603
 
613
604
  @overload
614
605
  async def get_setting_values(
615
606
  self,
616
607
  module_id: str,
617
- ) -> Mapping[str, Mapping[str, str]]:
618
- ...
608
+ ) -> Mapping[str, Mapping[str, str]]: ...
619
609
 
620
610
  @overload
621
611
  async def get_setting_values(
622
612
  self,
623
613
  module_id: Mapping[str, Iterable[str]],
624
- ) -> Mapping[str, Mapping[str, str]]:
625
- ...
614
+ ) -> Mapping[str, Mapping[str, str]]: ...
626
615
 
627
616
  @_relogin
628
617
  async def get_setting_values(
@@ -672,12 +661,11 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
672
661
  for mid, pids in module_id.items():
673
662
  # the json encoder expects that iterables are either list or tuples,
674
663
  # other types has to be converted
675
- if isinstance(pids, list) or isinstance(pids, tuple):
664
+ if isinstance(pids, (list, tuple)):
676
665
  request.append(dict(moduleid=mid, settingids=pids))
677
666
  else:
678
667
  request.append(dict(moduleid=mid, settingids=list(pids)))
679
668
 
680
-
681
669
  async with self._session_request(
682
670
  "settings", method="POST", json=request
683
671
  ) as resp:
@@ -696,7 +684,7 @@ class ApiClient(contextlib.AbstractAsyncContextManager):
696
684
  request = [
697
685
  {
698
686
  "moduleid": module_id,
699
- "settings": list([dict(value=v, id=k) for k, v in values.items()]),
687
+ "settings": [dict(value=v, id=k) for k, v in values.items()],
700
688
  }
701
689
  ]
702
690
  async with self._session_request(
pykoplenti/cli.py CHANGED
@@ -236,7 +236,7 @@ def read_events(global_args, lang, count):
236
236
  for event in data:
237
237
  print(
238
238
  f"{event.is_active < 5} {event.start_time} {event.end_time} "
239
- "{event.description}"
239
+ f"{event.description}"
240
240
  )
241
241
 
242
242
  asyncio.run(
pykoplenti/model.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from datetime import datetime
2
- from typing import Iterator, Mapping
2
+ from typing import Final, Iterator, Mapping, Optional
3
3
 
4
+ import pydantic
4
5
  from pydantic import BaseModel, Field
5
6
 
6
7
 
@@ -55,7 +56,7 @@ class ProcessDataCollection(Mapping):
55
56
  try:
56
57
  return next(x for x in self._process_data if x.id == item)
57
58
  except StopIteration:
58
- raise KeyError(item)
59
+ raise KeyError(item) from None
59
60
 
60
61
  def __eq__(self, __other: object) -> bool:
61
62
  if not isinstance(__other, ProcessDataCollection):
@@ -77,11 +78,11 @@ class ProcessDataCollection(Mapping):
77
78
  class SettingsData(BaseModel):
78
79
  """Represents a single settings data."""
79
80
 
80
- min: str | None
81
- max: str | None
82
- default: str | None
81
+ min: Optional[str]
82
+ max: Optional[str]
83
+ default: Optional[str]
83
84
  access: str
84
- unit: str | None
85
+ unit: Optional[str]
85
86
  id: str
86
87
  type: str
87
88
 
@@ -97,3 +98,24 @@ class EventData(BaseModel):
97
98
  description: str
98
99
  group: str
99
100
  is_active: bool
101
+
102
+
103
+ # pydantic version specific code
104
+ # In pydantic 2.x `parse_obj_as` is no longer supported. To stay compatible to
105
+ # both version a small wrapper function is used.
106
+
107
+ if pydantic.VERSION.startswith("2."):
108
+ from pydantic import TypeAdapter
109
+
110
+ _process_list_adapter: Final = TypeAdapter(list[ProcessData])
111
+
112
+ def process_data_list(json) -> list[ProcessData]:
113
+ """Process json as a list of ProcessData objects."""
114
+ return _process_list_adapter.validate_python(json)
115
+
116
+ else:
117
+ from pydantic import parse_obj_as
118
+
119
+ def process_data_list(json) -> list[ProcessData]:
120
+ """Process json as a list of ProcessData objects."""
121
+ return parse_obj_as(list[ProcessData], json)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pykoplenti
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Python REST-Client for Kostal Plenticore Solar Inverters
5
5
  Home-page: https://github.com/stegm/pyclient_koplenti
6
6
  Author: @stegm
@@ -13,14 +13,16 @@ Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: Apache Software License
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.7
17
- Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
18
20
  Classifier: Topic :: Software Development :: Libraries
19
21
  Description-Content-Type: text/markdown
20
22
  License-File: LICENSE
21
- Requires-Dist: aiohttp ~=3.8.5
23
+ Requires-Dist: aiohttp ~=3.8
22
24
  Requires-Dist: pycryptodome ~=3.19
23
- Requires-Dist: pydantic ~=1.10
25
+ Requires-Dist: pydantic >=1.10
24
26
  Provides-Extra: cli
25
27
  Requires-Dist: prompt-toolkit >=3.0 ; extra == 'cli'
26
28
  Requires-Dist: click >=7.1 ; extra == 'cli'
@@ -147,6 +149,7 @@ await client.login(my_master_key, service_code=my_service_code)
147
149
  - [Command Line Interface](doc/command_line.md)
148
150
  - [Examples](examples/)
149
151
  - [Virtual Process Data](doc/virtual_process_data.md)
152
+ - [Notes about Process Data](doc/process_data.md)
150
153
 
151
154
  ## Built With
152
155
 
@@ -154,9 +157,11 @@ await client.login(my_master_key, service_code=my_service_code)
154
157
  - [click](https://click.palletsprojects.com/) - command line interface framework
155
158
  - [black](https://github.com/psf/black) - Python code formatter
156
159
  - [ruff](https://github.com/astral-sh/ruff) - Python linter
160
+ - [pydantic](https://docs.pydantic.dev/latest/) - Data validation library
157
161
  - [pytest](https://docs.pytest.org/) - Python test framework
158
162
  - [mypy](https://mypy-lang.org/) - Python type checker
159
163
  - [setuptools](https://github.com/pypa/setuptools) - Python packager
164
+ - [tox](https://tox.wiki) - Automate testing
160
165
 
161
166
  ## License
162
167
 
@@ -0,0 +1,12 @@
1
+ pykoplenti/__init__.py,sha256=w9ooy6_JaT9lGvMDAaBHyvYu1uTFTkb1g21WRxp3Kzw,756
2
+ pykoplenti/api.py,sha256=ViT25KQt3caH78fO30G014IawcWQ1lMvy28M9QLBypg,26111
3
+ pykoplenti/cli.py,sha256=KIgpQ1QdM9_nz7S-oQpfpU6ov3o8N8tobw9_R4X0_Nw,12973
4
+ pykoplenti/extended.py,sha256=_MCDtP-6BaAiTFqJ44CLK_Ihkh6nCC0vWWv1lsQfEFs,9311
5
+ pykoplenti/model.py,sha256=lFOHDJvWyhOdQrcoun6HeT-4XaGY5I2gy1j6M5u3u6A,3016
6
+ pykoplenti/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pykoplenti-1.3.0.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ pykoplenti-1.3.0.dist-info/METADATA,sha256=llPxFNe-fpRJN5cIrWt-T2EOT3PpCt1T_BqQUZw4kI8,5847
9
+ pykoplenti-1.3.0.dist-info/WHEEL,sha256=R06PA3UVYHThwHvxuRWMqaGcr-PuniXahwjmQRFMEkY,91
10
+ pykoplenti-1.3.0.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
11
+ pykoplenti-1.3.0.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
12
+ pykoplenti-1.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.41.3)
2
+ Generator: setuptools (75.5.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,12 +0,0 @@
1
- pykoplenti/__init__.py,sha256=w9ooy6_JaT9lGvMDAaBHyvYu1uTFTkb1g21WRxp3Kzw,756
2
- pykoplenti/api.py,sha256=gYWWd8yZ9winj7qPt1-rzx4stX_DLPfy5nOduuamtH4,26431
3
- pykoplenti/cli.py,sha256=LAiQHlSgoJz07kTtFh0bNyahyYz7gCenhRfradex5wE,12972
4
- pykoplenti/extended.py,sha256=_MCDtP-6BaAiTFqJ44CLK_Ihkh6nCC0vWWv1lsQfEFs,9311
5
- pykoplenti/model.py,sha256=g-KyYTF1M1p6OAebyA74OAP_-561u6Hylhgy_jnpMto,2266
6
- pykoplenti/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- pykoplenti-1.2.1.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
- pykoplenti-1.2.1.dist-info/METADATA,sha256=ztvYvxRUUOWJYIbUJ4icUf1X14C_55BJMoA6GiN3ABI,5577
9
- pykoplenti-1.2.1.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
10
- pykoplenti-1.2.1.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
11
- pykoplenti-1.2.1.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
12
- pykoplenti-1.2.1.dist-info/RECORD,,