pykoplenti 1.2.2__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pykoplenti might be problematic. Click here for more details.
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/PKG-INFO +8 -4
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/README.md +2 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti/api.py +55 -67
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti/cli.py +1 -1
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti/model.py +28 -6
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti.egg-info/PKG-INFO +8 -4
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti.egg-info/requires.txt +1 -1
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/setup.cfg +6 -4
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/tests/test_extendedapiclient.py +26 -10
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/tests/test_pykoplenti.py +15 -4
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/LICENSE +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti/__init__.py +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti/extended.py +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti/py.typed +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti.egg-info/SOURCES.txt +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti.egg-info/dependency_links.txt +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti.egg-info/entry_points.txt +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pykoplenti.egg-info/top_level.txt +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/pyproject.toml +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/setup.py +0 -0
- {pykoplenti-1.2.2 → pykoplenti-1.3.0}/tests/test_smoketest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pykoplenti
|
|
3
|
-
Version: 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.
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.
|
|
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
23
|
Requires-Dist: aiohttp~=3.8
|
|
22
24
|
Requires-Dist: pycryptodome~=3.19
|
|
23
|
-
Requires-Dist: pydantic
|
|
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"
|
|
@@ -155,9 +157,11 @@ await client.login(my_master_key, service_code=my_service_code)
|
|
|
155
157
|
- [click](https://click.palletsprojects.com/) - command line interface framework
|
|
156
158
|
- [black](https://github.com/psf/black) - Python code formatter
|
|
157
159
|
- [ruff](https://github.com/astral-sh/ruff) - Python linter
|
|
160
|
+
- [pydantic](https://docs.pydantic.dev/latest/) - Data validation library
|
|
158
161
|
- [pytest](https://docs.pytest.org/) - Python test framework
|
|
159
162
|
- [mypy](https://mypy-lang.org/) - Python type checker
|
|
160
163
|
- [setuptools](https://github.com/pypa/setuptools) - Python packager
|
|
164
|
+
- [tox](https://tox.wiki) - Automate testing
|
|
161
165
|
|
|
162
166
|
## License
|
|
163
167
|
|
|
@@ -128,9 +128,11 @@ await client.login(my_master_key, service_code=my_service_code)
|
|
|
128
128
|
- [click](https://click.palletsprojects.com/) - command line interface framework
|
|
129
129
|
- [black](https://github.com/psf/black) - Python code formatter
|
|
130
130
|
- [ruff](https://github.com/astral-sh/ruff) - Python linter
|
|
131
|
+
- [pydantic](https://docs.pydantic.dev/latest/) - Data validation library
|
|
131
132
|
- [pytest](https://docs.pytest.org/) - Python test framework
|
|
132
133
|
- [mypy](https://mypy-lang.org/) - Python type checker
|
|
133
134
|
- [setuptools](https://github.com/pypa/setuptools) - Python packager
|
|
135
|
+
- [tox](https://tox.wiki) - Automate testing
|
|
134
136
|
|
|
135
137
|
## License
|
|
136
138
|
|
|
@@ -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(
|
|
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
|
|
347
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
362
|
+
try:
|
|
363
|
+
response = await resp.json()
|
|
364
|
+
error = response["message"]
|
|
365
|
+
except Exception:
|
|
366
|
+
error = None
|
|
361
367
|
|
|
362
|
-
|
|
363
|
-
|
|
368
|
+
if resp.status == 400:
|
|
369
|
+
raise AuthenticationException(resp.status, error)
|
|
364
370
|
|
|
365
|
-
|
|
366
|
-
|
|
371
|
+
if resp.status == 401:
|
|
372
|
+
raise NotAuthorizedException(resp.status, error)
|
|
367
373
|
|
|
368
|
-
|
|
369
|
-
raise
|
|
374
|
+
if resp.status == 403:
|
|
375
|
+
raise UserLockedException(resp.status, error)
|
|
370
376
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
"""Decorator for automatic re-login if session was expired."""
|
|
377
|
+
if resp.status == 404:
|
|
378
|
+
raise ModuleNotFoundException(resp.status, error)
|
|
374
379
|
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
data =
|
|
593
|
-
result[
|
|
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
|
|
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":
|
|
687
|
+
"settings": [dict(value=v, id=k) for k, v in values.items()],
|
|
700
688
|
}
|
|
701
689
|
]
|
|
702
690
|
async with self._session_request(
|
|
@@ -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
|
|
81
|
-
max: str
|
|
82
|
-
default: str
|
|
81
|
+
min: Optional[str]
|
|
82
|
+
max: Optional[str]
|
|
83
|
+
default: Optional[str]
|
|
83
84
|
access: str
|
|
84
|
-
unit: str
|
|
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.
|
|
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.
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.
|
|
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
23
|
Requires-Dist: aiohttp~=3.8
|
|
22
24
|
Requires-Dist: pycryptodome~=3.19
|
|
23
|
-
Requires-Dist: pydantic
|
|
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"
|
|
@@ -155,9 +157,11 @@ await client.login(my_master_key, service_code=my_service_code)
|
|
|
155
157
|
- [click](https://click.palletsprojects.com/) - command line interface framework
|
|
156
158
|
- [black](https://github.com/psf/black) - Python code formatter
|
|
157
159
|
- [ruff](https://github.com/astral-sh/ruff) - Python linter
|
|
160
|
+
- [pydantic](https://docs.pydantic.dev/latest/) - Data validation library
|
|
158
161
|
- [pytest](https://docs.pytest.org/) - Python test framework
|
|
159
162
|
- [mypy](https://mypy-lang.org/) - Python type checker
|
|
160
163
|
- [setuptools](https://github.com/pypa/setuptools) - Python packager
|
|
164
|
+
- [tox](https://tox.wiki) - Automate testing
|
|
161
165
|
|
|
162
166
|
## License
|
|
163
167
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = pykoplenti
|
|
3
|
-
version = 1.
|
|
3
|
+
version = 1.3.0
|
|
4
4
|
description = Python REST-Client for Kostal Plenticore Solar Inverters
|
|
5
5
|
long_description = file: README.md
|
|
6
6
|
long_description_content_type = text/markdown
|
|
@@ -17,8 +17,10 @@ classifiers =
|
|
|
17
17
|
Intended Audience :: Developers
|
|
18
18
|
License :: OSI Approved :: Apache Software License
|
|
19
19
|
Programming Language :: Python :: 3
|
|
20
|
-
Programming Language :: Python :: 3.
|
|
21
|
-
Programming Language :: Python :: 3.
|
|
20
|
+
Programming Language :: Python :: 3.9
|
|
21
|
+
Programming Language :: Python :: 3.10
|
|
22
|
+
Programming Language :: Python :: 3.11
|
|
23
|
+
Programming Language :: Python :: 3.12
|
|
22
24
|
Topic :: Software Development :: Libraries
|
|
23
25
|
|
|
24
26
|
[options]
|
|
@@ -26,7 +28,7 @@ packages = pykoplenti
|
|
|
26
28
|
install_requires =
|
|
27
29
|
aiohttp ~= 3.8
|
|
28
30
|
pycryptodome ~= 3.19
|
|
29
|
-
pydantic
|
|
31
|
+
pydantic >= 1.10
|
|
30
32
|
|
|
31
33
|
[options.package_data]
|
|
32
34
|
pykoplenti = py.typed
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, Callable, Iterable
|
|
1
|
+
from typing import Any, Callable, Iterable, Union
|
|
2
2
|
from unittest.mock import ANY, MagicMock, call
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
@@ -40,7 +40,9 @@ class TestVirtualProcessDataValuesDcSum:
|
|
|
40
40
|
async def test_virtual_process_data(
|
|
41
41
|
self,
|
|
42
42
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
43
|
-
client_response_factory: Callable[
|
|
43
|
+
client_response_factory: Callable[
|
|
44
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
45
|
+
],
|
|
44
46
|
websession: MagicMock,
|
|
45
47
|
):
|
|
46
48
|
"""Test virtual process data for PV power if depencies are present."""
|
|
@@ -70,7 +72,9 @@ class TestVirtualProcessDataValuesDcSum:
|
|
|
70
72
|
async def test_virtual_process_data_value(
|
|
71
73
|
self,
|
|
72
74
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
73
|
-
client_response_factory: Callable[
|
|
75
|
+
client_response_factory: Callable[
|
|
76
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
77
|
+
],
|
|
74
78
|
websession: MagicMock,
|
|
75
79
|
):
|
|
76
80
|
"""Test virtual process data for PV power."""
|
|
@@ -142,7 +146,9 @@ class TestVirtualProcessDataValuesEnergyToGrid:
|
|
|
142
146
|
async def test_virtual_process_data(
|
|
143
147
|
self,
|
|
144
148
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
145
|
-
client_response_factory: Callable[
|
|
149
|
+
client_response_factory: Callable[
|
|
150
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
151
|
+
],
|
|
146
152
|
websession: MagicMock,
|
|
147
153
|
scope: str,
|
|
148
154
|
):
|
|
@@ -183,7 +189,9 @@ class TestVirtualProcessDataValuesEnergyToGrid:
|
|
|
183
189
|
async def test_virtual_process_data_value(
|
|
184
190
|
self,
|
|
185
191
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
186
|
-
client_response_factory: Callable[
|
|
192
|
+
client_response_factory: Callable[
|
|
193
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
194
|
+
],
|
|
187
195
|
websession: MagicMock,
|
|
188
196
|
scope: str,
|
|
189
197
|
):
|
|
@@ -269,10 +277,12 @@ class TestVirtualProcessDataValuesEnergyToGrid:
|
|
|
269
277
|
@pytest.mark.asyncio
|
|
270
278
|
async def test_virtual_process_data_no_dc_sum(
|
|
271
279
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
272
|
-
client_response_factory: Callable[
|
|
280
|
+
client_response_factory: Callable[
|
|
281
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
282
|
+
],
|
|
273
283
|
websession: MagicMock,
|
|
274
284
|
):
|
|
275
|
-
"""Test if no virtual process data is present if
|
|
285
|
+
"""Test if no virtual process data is present if dependencies are missing."""
|
|
276
286
|
client_response_factory(
|
|
277
287
|
200,
|
|
278
288
|
[
|
|
@@ -297,7 +307,9 @@ async def test_virtual_process_data_no_dc_sum(
|
|
|
297
307
|
@pytest.mark.asyncio
|
|
298
308
|
async def test_virtual_process_data_and_normal_process_data(
|
|
299
309
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
300
|
-
client_response_factory: Callable[
|
|
310
|
+
client_response_factory: Callable[
|
|
311
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
312
|
+
],
|
|
301
313
|
websession: MagicMock,
|
|
302
314
|
):
|
|
303
315
|
"""Test if virtual and non-virtual process values can be requested."""
|
|
@@ -362,7 +374,9 @@ async def test_virtual_process_data_and_normal_process_data(
|
|
|
362
374
|
@pytest.mark.asyncio
|
|
363
375
|
async def test_virtual_process_data_not_all_requested(
|
|
364
376
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
365
|
-
client_response_factory: Callable[
|
|
377
|
+
client_response_factory: Callable[
|
|
378
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
379
|
+
],
|
|
366
380
|
websession: MagicMock,
|
|
367
381
|
):
|
|
368
382
|
"""Test if not all available virtual process data are requested."""
|
|
@@ -429,7 +443,9 @@ async def test_virtual_process_data_not_all_requested(
|
|
|
429
443
|
@pytest.mark.asyncio
|
|
430
444
|
async def test_virtual_process_data_multiple_requested(
|
|
431
445
|
pykoplenti_extended_client: pykoplenti.ExtendedApiClient,
|
|
432
|
-
client_response_factory: Callable[
|
|
446
|
+
client_response_factory: Callable[
|
|
447
|
+
[int, Union[list[Any], dict[Any, Any]]], MagicMock
|
|
448
|
+
],
|
|
433
449
|
websession: MagicMock,
|
|
434
450
|
):
|
|
435
451
|
"""Test if multiple virtual process data are requested."""
|
|
@@ -3,7 +3,6 @@ import json
|
|
|
3
3
|
from typing import Any, Callable
|
|
4
4
|
from unittest.mock import ANY, MagicMock
|
|
5
5
|
|
|
6
|
-
from pydantic import parse_obj_as
|
|
7
6
|
import pytest
|
|
8
7
|
|
|
9
8
|
import pykoplenti
|
|
@@ -123,13 +122,25 @@ def test_settings_parsing():
|
|
|
123
122
|
assert settings_data.access == "readonly"
|
|
124
123
|
|
|
125
124
|
|
|
125
|
+
def test_process_data_list():
|
|
126
|
+
json = [
|
|
127
|
+
{"id": "Statistic:Yield:Day", "unit": "%", "value": 1},
|
|
128
|
+
{"id": "Statistic:Yield:Month", "unit": "%", "value": 2},
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
assert pykoplenti.model.process_data_list(json) == [
|
|
132
|
+
pykoplenti.ProcessData(id="Statistic:Yield:Day", unit="%", value="1"),
|
|
133
|
+
pykoplenti.ProcessData(id="Statistic:Yield:Month", unit="%", value="2"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
126
137
|
def test_process_data_collection_indicates_length():
|
|
127
138
|
raw_response = (
|
|
128
139
|
'[{"id": "Statistic:Yield:Day", "unit": "", "value": 1}, '
|
|
129
140
|
'{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]'
|
|
130
141
|
)
|
|
131
142
|
pdc = pykoplenti.ProcessDataCollection(
|
|
132
|
-
|
|
143
|
+
pykoplenti.model.process_data_list(json.loads(raw_response))
|
|
133
144
|
)
|
|
134
145
|
|
|
135
146
|
assert len(pdc) == 2
|
|
@@ -141,7 +152,7 @@ def test_process_data_collection_index_returns_processdata():
|
|
|
141
152
|
'{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]'
|
|
142
153
|
)
|
|
143
154
|
pdc = pykoplenti.ProcessDataCollection(
|
|
144
|
-
|
|
155
|
+
pykoplenti.model.process_data_list(json.loads(raw_response))
|
|
145
156
|
)
|
|
146
157
|
|
|
147
158
|
result = pdc["Statistic:Yield:Month"]
|
|
@@ -158,7 +169,7 @@ def test_process_data_collection_can_be_iterated():
|
|
|
158
169
|
'{"id": "Statistic:Yield:Month", "unit": "", "value": 2}]'
|
|
159
170
|
)
|
|
160
171
|
pdc = pykoplenti.ProcessDataCollection(
|
|
161
|
-
|
|
172
|
+
pykoplenti.model.process_data_list(json.loads(raw_response))
|
|
162
173
|
)
|
|
163
174
|
|
|
164
175
|
result = list(pdc)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|