pykoplenti 1.3.0__tar.gz → 1.4.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.3.0 → pykoplenti-1.4.0}/PKG-INFO +17 -10
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/README.md +13 -7
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/cli.py +199 -59
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/PKG-INFO +17 -10
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/SOURCES.txt +1 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/requires.txt +1 -1
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/setup.cfg +2 -2
- pykoplenti-1.4.0/tests/test_cli.py +151 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/LICENSE +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/__init__.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/api.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/extended.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/model.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/py.typed +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/dependency_links.txt +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/entry_points.txt +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/top_level.txt +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pyproject.toml +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/setup.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/tests/test_extendedapiclient.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/tests/test_pykoplenti.py +0 -0
- {pykoplenti-1.3.0 → pykoplenti-1.4.0}/tests/test_smoketest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pykoplenti
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
@@ -25,7 +25,8 @@ Requires-Dist: pycryptodome~=3.19
|
|
|
25
25
|
Requires-Dist: pydantic>=1.10
|
|
26
26
|
Provides-Extra: cli
|
|
27
27
|
Requires-Dist: prompt_toolkit>=3.0; extra == "cli"
|
|
28
|
-
Requires-Dist: click>=
|
|
28
|
+
Requires-Dist: click>=8.0; extra == "cli"
|
|
29
|
+
Dynamic: license-file
|
|
29
30
|
|
|
30
31
|
# Python Library for Accessing Kostal Plenticore Inverters
|
|
31
32
|
|
|
@@ -77,22 +78,28 @@ Installing the libray with `CLI` provides a new command.
|
|
|
77
78
|
|
|
78
79
|
```shell
|
|
79
80
|
$ pykoplenti --help
|
|
80
|
-
Usage: pykoplenti [OPTIONS] COMMAND [ARGS]...
|
|
81
|
+
Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]...
|
|
81
82
|
|
|
82
83
|
Handling of global arguments with click
|
|
83
84
|
|
|
84
85
|
Options:
|
|
85
|
-
--host TEXT
|
|
86
|
-
--port INTEGER
|
|
87
|
-
--password TEXT
|
|
88
|
-
--
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
--host TEXT Hostname or IP of the inverter
|
|
87
|
+
--port INTEGER Port of the inverter [default: 80]
|
|
88
|
+
--password TEXT Password or master key (also device id)
|
|
89
|
+
--service-code TEXT service code for installer access
|
|
90
|
+
--password-file FILE Path to password file - deprecated, use --credentials
|
|
91
|
+
[default: secrets]
|
|
92
|
+
--credentials FILE Path to the credentials file. This has a simple ini-
|
|
93
|
+
format without sections. For user access, use the
|
|
94
|
+
'password'. For installer access, use the 'master-key'
|
|
95
|
+
and 'service-key'.
|
|
91
96
|
--help Show this message and exit.
|
|
92
97
|
|
|
93
98
|
Commands:
|
|
94
99
|
all-processdata Returns a list of all available process data.
|
|
95
100
|
all-settings Returns the ids of all settings.
|
|
101
|
+
download-log Download the log data from the inverter to a file.
|
|
102
|
+
read-events Returns the last events
|
|
96
103
|
read-processdata Returns the values of the given process data.
|
|
97
104
|
read-settings Read the value of the given settings.
|
|
98
105
|
repl Provides a simple REPL for executing API requests to...
|
|
@@ -48,22 +48,28 @@ Installing the libray with `CLI` provides a new command.
|
|
|
48
48
|
|
|
49
49
|
```shell
|
|
50
50
|
$ pykoplenti --help
|
|
51
|
-
Usage: pykoplenti [OPTIONS] COMMAND [ARGS]...
|
|
51
|
+
Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]...
|
|
52
52
|
|
|
53
53
|
Handling of global arguments with click
|
|
54
54
|
|
|
55
55
|
Options:
|
|
56
|
-
--host TEXT
|
|
57
|
-
--port INTEGER
|
|
58
|
-
--password TEXT
|
|
59
|
-
--
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
--host TEXT Hostname or IP of the inverter
|
|
57
|
+
--port INTEGER Port of the inverter [default: 80]
|
|
58
|
+
--password TEXT Password or master key (also device id)
|
|
59
|
+
--service-code TEXT service code for installer access
|
|
60
|
+
--password-file FILE Path to password file - deprecated, use --credentials
|
|
61
|
+
[default: secrets]
|
|
62
|
+
--credentials FILE Path to the credentials file. This has a simple ini-
|
|
63
|
+
format without sections. For user access, use the
|
|
64
|
+
'password'. For installer access, use the 'master-key'
|
|
65
|
+
and 'service-key'.
|
|
62
66
|
--help Show this message and exit.
|
|
63
67
|
|
|
64
68
|
Commands:
|
|
65
69
|
all-processdata Returns a list of all available process data.
|
|
66
70
|
all-settings Returns the ids of all settings.
|
|
71
|
+
download-log Download the log data from the inverter to a file.
|
|
72
|
+
read-events Returns the last events
|
|
67
73
|
read-processdata Returns the values of the given process data.
|
|
68
74
|
read-settings Read the value of the given settings.
|
|
69
75
|
repl Provides a simple REPL for executing API requests to...
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
from ast import literal_eval
|
|
2
2
|
import asyncio
|
|
3
3
|
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from inspect import iscoroutinefunction
|
|
5
6
|
import os
|
|
7
|
+
from pathlib import Path
|
|
6
8
|
from pprint import pprint
|
|
7
9
|
import re
|
|
8
10
|
import tempfile
|
|
9
11
|
import traceback
|
|
10
|
-
from typing import Any, Awaitable, Callable, Dict, Union
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Union
|
|
13
|
+
import warnings
|
|
11
14
|
|
|
12
15
|
from aiohttp import ClientSession, ClientTimeout
|
|
13
16
|
import click
|
|
@@ -20,40 +23,43 @@ from pykoplenti.extended import ExtendedApiClient
|
|
|
20
23
|
class SessionCache:
|
|
21
24
|
"""Persistent the session in a temporary file."""
|
|
22
25
|
|
|
23
|
-
def __init__(self, host):
|
|
24
|
-
self.
|
|
26
|
+
def __init__(self, host: str, user: str):
|
|
27
|
+
self._cache_file = Path(
|
|
28
|
+
tempfile.gettempdir(), f"pykoplenti-session-{host}-{user}"
|
|
29
|
+
)
|
|
25
30
|
|
|
26
31
|
def read_session_id(self) -> Union[str, None]:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
with open(file, "rt") as f:
|
|
32
|
+
if self._cache_file.is_file():
|
|
33
|
+
with self._cache_file.open("rt") as f:
|
|
30
34
|
return f.readline(256)
|
|
31
35
|
else:
|
|
32
36
|
return None
|
|
33
37
|
|
|
34
38
|
def write_session_id(self, id: str):
|
|
35
|
-
|
|
36
|
-
f = os.open(file, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode=0o600)
|
|
39
|
+
f = os.open(self._cache_file, os.O_WRONLY | os.O_TRUNC | os.O_CREAT, mode=0o600)
|
|
37
40
|
try:
|
|
38
41
|
os.write(f, id.encode("ascii"))
|
|
39
42
|
finally:
|
|
40
43
|
os.close(f)
|
|
41
44
|
|
|
45
|
+
def remove(self):
|
|
46
|
+
self._cache_file.unlink(missing_ok=True)
|
|
47
|
+
|
|
42
48
|
|
|
43
49
|
class ApiShell:
|
|
44
50
|
"""Provides a shell-like access to the inverter."""
|
|
45
51
|
|
|
46
|
-
def __init__(self, client: ApiClient):
|
|
52
|
+
def __init__(self, client: ApiClient, user: str):
|
|
47
53
|
super().__init__()
|
|
48
54
|
self.client = client
|
|
49
|
-
self._session_cache = SessionCache(self.client.host)
|
|
55
|
+
self._session_cache = SessionCache(self.client.host, user)
|
|
50
56
|
|
|
51
|
-
async def prepare_client(self,
|
|
57
|
+
async def prepare_client(self, key: Optional[str], service_code: Optional[str]):
|
|
52
58
|
# first try to reuse existing session
|
|
53
59
|
session_id = self._session_cache.read_session_id()
|
|
54
60
|
if session_id is not None:
|
|
55
61
|
self.client.session_id = session_id
|
|
56
|
-
print_formatted_text("Trying to reuse existing session... ", end=
|
|
62
|
+
print_formatted_text("Trying to reuse existing session... ", end="")
|
|
57
63
|
me = await self.client.get_me()
|
|
58
64
|
if me.is_authenticated:
|
|
59
65
|
print_formatted_text("Success")
|
|
@@ -61,18 +67,21 @@ class ApiShell:
|
|
|
61
67
|
|
|
62
68
|
print_formatted_text("Failed")
|
|
63
69
|
|
|
64
|
-
if
|
|
65
|
-
print_formatted_text("Logging in... ", end=
|
|
66
|
-
await self.client.login(
|
|
67
|
-
self.
|
|
70
|
+
if key is not None:
|
|
71
|
+
print_formatted_text("Logging in... ", end="")
|
|
72
|
+
await self.client.login(key=key, service_code=service_code)
|
|
73
|
+
if self.client.session_id is not None:
|
|
74
|
+
self._session_cache.write_session_id(self.client.session_id)
|
|
68
75
|
print_formatted_text("Success")
|
|
76
|
+
else:
|
|
77
|
+
print_formatted_text("Session could not be reused and no key given")
|
|
69
78
|
|
|
70
79
|
def print_exception(self):
|
|
71
80
|
"""Prints an excpetion from executing a method."""
|
|
72
81
|
print_formatted_text(traceback.format_exc())
|
|
73
82
|
|
|
74
|
-
async def run(self,
|
|
75
|
-
session = PromptSession()
|
|
83
|
+
async def run(self, key: Optional[str], service_code: Optional[str]):
|
|
84
|
+
session = PromptSession[str]()
|
|
76
85
|
print_formatted_text(flush=True) # Initialize output
|
|
77
86
|
|
|
78
87
|
# Test commands:
|
|
@@ -83,7 +92,7 @@ class ApiShell:
|
|
|
83
92
|
# get_setting_values 'scb:time'
|
|
84
93
|
# set_setting_values 'devices:local' {'Battery:MinSoc':'15'}
|
|
85
94
|
|
|
86
|
-
await self.prepare_client(
|
|
95
|
+
await self.prepare_client(key, service_code)
|
|
87
96
|
|
|
88
97
|
while True:
|
|
89
98
|
try:
|
|
@@ -153,82 +162,171 @@ class ApiShell:
|
|
|
153
162
|
self.print_exception()
|
|
154
163
|
|
|
155
164
|
|
|
156
|
-
async def repl_main(
|
|
165
|
+
async def repl_main(
|
|
166
|
+
host: str, port: int, key: Optional[str], service_code: Optional[str]
|
|
167
|
+
):
|
|
157
168
|
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
|
|
158
169
|
client = ExtendedApiClient(session, host=host, port=port)
|
|
159
170
|
|
|
160
|
-
shell = ApiShell(client)
|
|
161
|
-
await shell.run(
|
|
171
|
+
shell = ApiShell(client, "user" if service_code is None else "master")
|
|
172
|
+
await shell.run(key, service_code)
|
|
162
173
|
|
|
163
174
|
|
|
164
175
|
async def command_main(
|
|
165
|
-
host: str,
|
|
176
|
+
host: str,
|
|
177
|
+
port: int,
|
|
178
|
+
key: Optional[str],
|
|
179
|
+
service_code: Optional[str],
|
|
180
|
+
fn: Callable[[ApiClient], Awaitable[Any]],
|
|
166
181
|
):
|
|
167
182
|
async with ClientSession(timeout=ClientTimeout(total=10)) as session:
|
|
168
183
|
client = ExtendedApiClient(session, host=host, port=port)
|
|
169
|
-
session_cache = SessionCache(host)
|
|
184
|
+
session_cache = SessionCache(host, "user" if service_code is None else "master")
|
|
170
185
|
|
|
171
186
|
# Try to reuse an existing session
|
|
172
187
|
client.session_id = session_cache.read_session_id()
|
|
173
188
|
me = await client.get_me()
|
|
174
189
|
if not me.is_authenticated:
|
|
190
|
+
if key is None:
|
|
191
|
+
raise ValueError("Could not reuse session and no login key is given.")
|
|
192
|
+
|
|
175
193
|
# create a new session
|
|
176
|
-
await client.login(
|
|
194
|
+
await client.login(key=key, service_code=service_code)
|
|
195
|
+
|
|
177
196
|
if client.session_id is not None:
|
|
178
197
|
session_cache.write_session_id(client.session_id)
|
|
179
198
|
|
|
180
199
|
await fn(client)
|
|
181
200
|
|
|
182
201
|
|
|
202
|
+
@dataclass
|
|
183
203
|
class GlobalArgs:
|
|
184
204
|
"""Global arguments over all sub commands."""
|
|
185
205
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
206
|
+
host: str = ""
|
|
207
|
+
"""The hostname or ip of the inverter."""
|
|
208
|
+
|
|
209
|
+
port: int = 0
|
|
210
|
+
"""The port on which the API listens on the inverter."""
|
|
211
|
+
|
|
212
|
+
key: Optional[str] = None
|
|
213
|
+
"""The key (password or master key) to login into the API.
|
|
214
|
+
|
|
215
|
+
If None, a previous session cache is used. If the session
|
|
216
|
+
cache has no valid session, no login is executed.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
service_code: Optional[str] = None
|
|
220
|
+
"""The service code for master access.
|
|
221
|
+
|
|
222
|
+
Only necessary for master access. If missing, user acess is used.
|
|
223
|
+
"""
|
|
191
224
|
|
|
192
225
|
|
|
193
226
|
pass_global_args = click.make_pass_decorator(GlobalArgs, ensure=True)
|
|
194
227
|
|
|
195
228
|
|
|
229
|
+
def _parse_credentials_file(path: Path) -> tuple[Optional[str], Optional[str]]:
|
|
230
|
+
"""Parse credentials file returning (key, service_code)"""
|
|
231
|
+
key = service_code = None
|
|
232
|
+
for line in path.read_text().splitlines():
|
|
233
|
+
if "=" not in line:
|
|
234
|
+
return line.strip(), None
|
|
235
|
+
|
|
236
|
+
name, _, value = line.partition("=")
|
|
237
|
+
name = name.strip()
|
|
238
|
+
if name in ("password", "key", "master-key"):
|
|
239
|
+
key = value.strip()
|
|
240
|
+
elif name == "service-code":
|
|
241
|
+
service_code = value.strip()
|
|
242
|
+
return key, service_code
|
|
243
|
+
|
|
244
|
+
|
|
196
245
|
@click.group()
|
|
197
|
-
@click.option("--host", help="
|
|
198
|
-
@click.option("--port", default=80, help="
|
|
199
|
-
@click.option(
|
|
246
|
+
@click.option("--host", help="Hostname or IP of the inverter")
|
|
247
|
+
@click.option("--port", default=80, help="Port of the inverter", show_default=True)
|
|
248
|
+
@click.option(
|
|
249
|
+
"--password", default=None, help="Password or master key (also device id)"
|
|
250
|
+
)
|
|
251
|
+
@click.option("--service-code", default=None, help="service code for installer access")
|
|
200
252
|
@click.option(
|
|
201
253
|
"--password-file",
|
|
202
254
|
default="secrets",
|
|
203
|
-
help=
|
|
255
|
+
help="Path to password file - deprecated, use --credentials",
|
|
256
|
+
show_default=True,
|
|
257
|
+
type=click.Path(exists=False, dir_okay=False, readable=True, path_type=Path),
|
|
258
|
+
)
|
|
259
|
+
@click.option(
|
|
260
|
+
"--credentials",
|
|
261
|
+
default=None,
|
|
262
|
+
help="Path to the credentials file. This has a simple ini-format without sections. "
|
|
263
|
+
"For user access, use the 'password'. For installer access, use the 'master-key' "
|
|
264
|
+
"and 'service-key'.",
|
|
265
|
+
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
|
|
204
266
|
)
|
|
205
267
|
@pass_global_args
|
|
206
|
-
def cli(
|
|
268
|
+
def cli(
|
|
269
|
+
global_args: GlobalArgs,
|
|
270
|
+
host: str,
|
|
271
|
+
port: int,
|
|
272
|
+
password: Optional[str],
|
|
273
|
+
service_code: Optional[str],
|
|
274
|
+
password_file: Path,
|
|
275
|
+
credentials: Path,
|
|
276
|
+
):
|
|
207
277
|
"""Handling of global arguments with click"""
|
|
208
|
-
if password is not None:
|
|
209
|
-
global_args.passwd = password
|
|
210
|
-
elif os.path.isfile(password_file):
|
|
211
|
-
with open(password_file, "rt") as f:
|
|
212
|
-
global_args.passwd = f.readline()
|
|
213
|
-
else:
|
|
214
|
-
global_args.passwd = None
|
|
215
|
-
|
|
216
278
|
global_args.host = host
|
|
217
279
|
global_args.port = port
|
|
218
280
|
|
|
281
|
+
if password is not None:
|
|
282
|
+
global_args.key = password
|
|
283
|
+
elif password_file.is_file():
|
|
284
|
+
with password_file.open("rt") as f:
|
|
285
|
+
global_args.key = f.readline()
|
|
286
|
+
warnings.warn(
|
|
287
|
+
"--password-file is deprecated. Use --credentials instead.",
|
|
288
|
+
DeprecationWarning,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if service_code is not None:
|
|
292
|
+
global_args.service_code = service_code
|
|
293
|
+
|
|
294
|
+
if credentials is not None:
|
|
295
|
+
if password is not None:
|
|
296
|
+
raise click.BadOptionUsage(
|
|
297
|
+
"password", "password cannot be used with credentials"
|
|
298
|
+
)
|
|
299
|
+
if password_file is not None and password_file.is_file():
|
|
300
|
+
raise click.BadOptionUsage(
|
|
301
|
+
"password-file", "password-file cannot be used with credentials"
|
|
302
|
+
)
|
|
303
|
+
if service_code is not None:
|
|
304
|
+
raise click.BadOptionUsage(
|
|
305
|
+
"service_code", "service_code cannot be used with credentials"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
global_args.key, global_args.service_code = _parse_credentials_file(credentials)
|
|
309
|
+
|
|
219
310
|
|
|
220
311
|
@cli.command()
|
|
221
312
|
@pass_global_args
|
|
222
|
-
def repl(global_args):
|
|
313
|
+
def repl(global_args: GlobalArgs):
|
|
223
314
|
"""Provides a simple REPL for executing API requests to the inverter."""
|
|
224
|
-
asyncio.run(
|
|
315
|
+
asyncio.run(
|
|
316
|
+
repl_main(
|
|
317
|
+
global_args.host,
|
|
318
|
+
global_args.port,
|
|
319
|
+
global_args.key,
|
|
320
|
+
global_args.service_code,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
225
323
|
|
|
226
324
|
|
|
227
325
|
@cli.command()
|
|
228
326
|
@click.option("--lang", default=None, help="language for events")
|
|
229
327
|
@click.option("--count", default=10, help="number of events to read")
|
|
230
328
|
@pass_global_args
|
|
231
|
-
def read_events(global_args, lang, count):
|
|
329
|
+
def read_events(global_args: GlobalArgs, lang, count):
|
|
232
330
|
"""Returns the last events"""
|
|
233
331
|
|
|
234
332
|
async def fn(client: ApiClient):
|
|
@@ -240,7 +338,13 @@ def read_events(global_args, lang, count):
|
|
|
240
338
|
)
|
|
241
339
|
|
|
242
340
|
asyncio.run(
|
|
243
|
-
command_main(
|
|
341
|
+
command_main(
|
|
342
|
+
global_args.host,
|
|
343
|
+
global_args.port,
|
|
344
|
+
global_args.key,
|
|
345
|
+
global_args.service_code,
|
|
346
|
+
fn,
|
|
347
|
+
)
|
|
244
348
|
)
|
|
245
349
|
|
|
246
350
|
|
|
@@ -254,20 +358,26 @@ def read_events(global_args, lang, count):
|
|
|
254
358
|
@click.option("--begin", type=click.DateTime(["%Y-%m-%d"]), help="first day to export")
|
|
255
359
|
@click.option("--end", type=click.DateTime(["%Y-%m-%d"]), help="last day to export")
|
|
256
360
|
@pass_global_args
|
|
257
|
-
def download_log(global_args, out, begin, end):
|
|
361
|
+
def download_log(global_args: GlobalArgs, out, begin, end):
|
|
258
362
|
"""Download the log data from the inverter to a file."""
|
|
259
363
|
|
|
260
364
|
async def fn(client: ApiClient):
|
|
261
365
|
await client.download_logdata(writer=out, begin=begin, end=end)
|
|
262
366
|
|
|
263
367
|
asyncio.run(
|
|
264
|
-
command_main(
|
|
368
|
+
command_main(
|
|
369
|
+
global_args.host,
|
|
370
|
+
global_args.port,
|
|
371
|
+
global_args.key,
|
|
372
|
+
global_args.service_code,
|
|
373
|
+
fn,
|
|
374
|
+
)
|
|
265
375
|
)
|
|
266
376
|
|
|
267
377
|
|
|
268
378
|
@cli.command()
|
|
269
379
|
@pass_global_args
|
|
270
|
-
def all_processdata(global_args):
|
|
380
|
+
def all_processdata(global_args: GlobalArgs):
|
|
271
381
|
"""Returns a list of all available process data."""
|
|
272
382
|
|
|
273
383
|
async def fn(client: ApiClient):
|
|
@@ -277,14 +387,20 @@ def all_processdata(global_args):
|
|
|
277
387
|
print(f"{k}/{x}")
|
|
278
388
|
|
|
279
389
|
asyncio.run(
|
|
280
|
-
command_main(
|
|
390
|
+
command_main(
|
|
391
|
+
global_args.host,
|
|
392
|
+
global_args.port,
|
|
393
|
+
global_args.key,
|
|
394
|
+
global_args.service_code,
|
|
395
|
+
fn,
|
|
396
|
+
)
|
|
281
397
|
)
|
|
282
398
|
|
|
283
399
|
|
|
284
400
|
@cli.command()
|
|
285
401
|
@click.argument("ids", required=True, nargs=-1)
|
|
286
402
|
@pass_global_args
|
|
287
|
-
def read_processdata(global_args, ids):
|
|
403
|
+
def read_processdata(global_args: GlobalArgs, ids):
|
|
288
404
|
"""Returns the values of the given process data.
|
|
289
405
|
|
|
290
406
|
IDS is the identifier (<module_id>/<processdata_id>) of one or more processdata
|
|
@@ -318,7 +434,13 @@ def read_processdata(global_args, ids):
|
|
|
318
434
|
print(f"{k}/{x.id}={x.value}")
|
|
319
435
|
|
|
320
436
|
asyncio.run(
|
|
321
|
-
command_main(
|
|
437
|
+
command_main(
|
|
438
|
+
global_args.host,
|
|
439
|
+
global_args.port,
|
|
440
|
+
global_args.key,
|
|
441
|
+
global_args.service_code,
|
|
442
|
+
fn,
|
|
443
|
+
)
|
|
322
444
|
)
|
|
323
445
|
|
|
324
446
|
|
|
@@ -327,7 +449,7 @@ def read_processdata(global_args, ids):
|
|
|
327
449
|
"--rw", is_flag=True, default=False, help="display only writable settings"
|
|
328
450
|
)
|
|
329
451
|
@pass_global_args
|
|
330
|
-
def all_settings(global_args, rw):
|
|
452
|
+
def all_settings(global_args: GlobalArgs, rw: bool):
|
|
331
453
|
"""Returns the ids of all settings."""
|
|
332
454
|
|
|
333
455
|
async def fn(client: ApiClient):
|
|
@@ -338,14 +460,20 @@ def all_settings(global_args, rw):
|
|
|
338
460
|
print(f"{k}/{x.id}")
|
|
339
461
|
|
|
340
462
|
asyncio.run(
|
|
341
|
-
command_main(
|
|
463
|
+
command_main(
|
|
464
|
+
global_args.host,
|
|
465
|
+
global_args.port,
|
|
466
|
+
global_args.key,
|
|
467
|
+
global_args.service_code,
|
|
468
|
+
fn,
|
|
469
|
+
)
|
|
342
470
|
)
|
|
343
471
|
|
|
344
472
|
|
|
345
473
|
@cli.command()
|
|
346
474
|
@click.argument("ids", required=True, nargs=-1)
|
|
347
475
|
@pass_global_args
|
|
348
|
-
def read_settings(global_args, ids):
|
|
476
|
+
def read_settings(global_args: GlobalArgs, ids):
|
|
349
477
|
"""Read the value of the given settings.
|
|
350
478
|
|
|
351
479
|
IDS is the identifier (<module_id>/<setting_id>) of one or more settings to read
|
|
@@ -376,14 +504,20 @@ def read_settings(global_args, ids):
|
|
|
376
504
|
print(f"{k}/{i}={v}")
|
|
377
505
|
|
|
378
506
|
asyncio.run(
|
|
379
|
-
command_main(
|
|
507
|
+
command_main(
|
|
508
|
+
global_args.host,
|
|
509
|
+
global_args.port,
|
|
510
|
+
global_args.key,
|
|
511
|
+
global_args.service_code,
|
|
512
|
+
fn,
|
|
513
|
+
)
|
|
380
514
|
)
|
|
381
515
|
|
|
382
516
|
|
|
383
517
|
@cli.command()
|
|
384
518
|
@click.argument("id_values", required=True, nargs=-1)
|
|
385
519
|
@pass_global_args
|
|
386
|
-
def write_settings(global_args, id_values):
|
|
520
|
+
def write_settings(global_args: GlobalArgs, id_values):
|
|
387
521
|
"""Write the values of the given settings.
|
|
388
522
|
|
|
389
523
|
ID_VALUES is the identifier plus the the value to write
|
|
@@ -412,7 +546,13 @@ def write_settings(global_args, id_values):
|
|
|
412
546
|
await client.set_setting_values(module_id, setting_values)
|
|
413
547
|
|
|
414
548
|
asyncio.run(
|
|
415
|
-
command_main(
|
|
549
|
+
command_main(
|
|
550
|
+
global_args.host,
|
|
551
|
+
global_args.port,
|
|
552
|
+
global_args.key,
|
|
553
|
+
global_args.service_code,
|
|
554
|
+
fn,
|
|
555
|
+
)
|
|
416
556
|
)
|
|
417
557
|
|
|
418
558
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pykoplenti
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
@@ -25,7 +25,8 @@ Requires-Dist: pycryptodome~=3.19
|
|
|
25
25
|
Requires-Dist: pydantic>=1.10
|
|
26
26
|
Provides-Extra: cli
|
|
27
27
|
Requires-Dist: prompt_toolkit>=3.0; extra == "cli"
|
|
28
|
-
Requires-Dist: click>=
|
|
28
|
+
Requires-Dist: click>=8.0; extra == "cli"
|
|
29
|
+
Dynamic: license-file
|
|
29
30
|
|
|
30
31
|
# Python Library for Accessing Kostal Plenticore Inverters
|
|
31
32
|
|
|
@@ -77,22 +78,28 @@ Installing the libray with `CLI` provides a new command.
|
|
|
77
78
|
|
|
78
79
|
```shell
|
|
79
80
|
$ pykoplenti --help
|
|
80
|
-
Usage: pykoplenti [OPTIONS] COMMAND [ARGS]...
|
|
81
|
+
Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]...
|
|
81
82
|
|
|
82
83
|
Handling of global arguments with click
|
|
83
84
|
|
|
84
85
|
Options:
|
|
85
|
-
--host TEXT
|
|
86
|
-
--port INTEGER
|
|
87
|
-
--password TEXT
|
|
88
|
-
--
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
--host TEXT Hostname or IP of the inverter
|
|
87
|
+
--port INTEGER Port of the inverter [default: 80]
|
|
88
|
+
--password TEXT Password or master key (also device id)
|
|
89
|
+
--service-code TEXT service code for installer access
|
|
90
|
+
--password-file FILE Path to password file - deprecated, use --credentials
|
|
91
|
+
[default: secrets]
|
|
92
|
+
--credentials FILE Path to the credentials file. This has a simple ini-
|
|
93
|
+
format without sections. For user access, use the
|
|
94
|
+
'password'. For installer access, use the 'master-key'
|
|
95
|
+
and 'service-key'.
|
|
91
96
|
--help Show this message and exit.
|
|
92
97
|
|
|
93
98
|
Commands:
|
|
94
99
|
all-processdata Returns a list of all available process data.
|
|
95
100
|
all-settings Returns the ids of all settings.
|
|
101
|
+
download-log Download the log data from the inverter to a file.
|
|
102
|
+
read-events Returns the last events
|
|
96
103
|
read-processdata Returns the values of the given process data.
|
|
97
104
|
read-settings Read the value of the given settings.
|
|
98
105
|
repl Provides a simple REPL for executing API requests to...
|
|
@@ -15,6 +15,7 @@ pykoplenti.egg-info/dependency_links.txt
|
|
|
15
15
|
pykoplenti.egg-info/entry_points.txt
|
|
16
16
|
pykoplenti.egg-info/requires.txt
|
|
17
17
|
pykoplenti.egg-info/top_level.txt
|
|
18
|
+
tests/test_cli.py
|
|
18
19
|
tests/test_extendedapiclient.py
|
|
19
20
|
tests/test_pykoplenti.py
|
|
20
21
|
tests/test_smoketest.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = pykoplenti
|
|
3
|
-
version = 1.
|
|
3
|
+
version = 1.4.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
|
|
@@ -36,7 +36,7 @@ pykoplenti = py.typed
|
|
|
36
36
|
[options.extras_require]
|
|
37
37
|
CLI =
|
|
38
38
|
prompt_toolkit >= 3.0
|
|
39
|
-
click >=
|
|
39
|
+
click >= 8.0
|
|
40
40
|
|
|
41
41
|
[options.entry_points]
|
|
42
42
|
console_scripts =
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from click.testing import CliRunner
|
|
3
|
+
import pytest
|
|
4
|
+
from pykoplenti.cli import cli, SessionCache
|
|
5
|
+
import os
|
|
6
|
+
from conftest import only_smoketest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def credentials(tmp_path: Path, smoketest_config: tuple[str, int, str]):
|
|
11
|
+
_, _, password = smoketest_config
|
|
12
|
+
credentials_path = tmp_path / "credentials"
|
|
13
|
+
credentials_path.write_text(f"password={password}")
|
|
14
|
+
return credentials_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def dummy_credentials(tmp_path: Path):
|
|
19
|
+
credentials_path = tmp_path / "credentials"
|
|
20
|
+
credentials_path.write_text("password=dummy")
|
|
21
|
+
return credentials_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def session_cache(smoketest_config: tuple[str, int, str]):
|
|
26
|
+
host, _, _ = smoketest_config
|
|
27
|
+
session_cache = SessionCache(host, "user")
|
|
28
|
+
session_cache.remove()
|
|
29
|
+
yield session_cache
|
|
30
|
+
session_cache.remove()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestInvalidGlobalOptions:
|
|
34
|
+
"""Test invalid global options."""
|
|
35
|
+
|
|
36
|
+
def test_crendentials_and_password(self, dummy_credentials: Path):
|
|
37
|
+
runner = CliRunner()
|
|
38
|
+
result = runner.invoke(
|
|
39
|
+
cli,
|
|
40
|
+
[
|
|
41
|
+
"--credentials",
|
|
42
|
+
str(dummy_credentials),
|
|
43
|
+
"--password",
|
|
44
|
+
"topsecret",
|
|
45
|
+
"all-processdata",
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
assert result.exit_code == 2
|
|
49
|
+
assert "password cannot be used with credentials" in result.output
|
|
50
|
+
|
|
51
|
+
@pytest.mark.filterwarnings(
|
|
52
|
+
"ignore:--password-file is deprecated. Use --credentials instead."
|
|
53
|
+
)
|
|
54
|
+
def test_crendentials_and_password_file(self, dummy_credentials: Path):
|
|
55
|
+
runner = CliRunner()
|
|
56
|
+
result = runner.invoke(
|
|
57
|
+
cli,
|
|
58
|
+
[
|
|
59
|
+
"--credentials",
|
|
60
|
+
str(dummy_credentials),
|
|
61
|
+
"--password-file",
|
|
62
|
+
str(dummy_credentials),
|
|
63
|
+
"all-processdata",
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert result.exit_code == 2
|
|
68
|
+
assert "password-file cannot be used with credentials" in result.output
|
|
69
|
+
|
|
70
|
+
def test_crendentials_and_service_code(
|
|
71
|
+
self, dummy_credentials: Path, tmp_path: Path
|
|
72
|
+
):
|
|
73
|
+
# As --password-file has a default value, this ensures
|
|
74
|
+
# that no default password-file exists.
|
|
75
|
+
os.chdir(tmp_path)
|
|
76
|
+
|
|
77
|
+
runner = CliRunner()
|
|
78
|
+
result = runner.invoke(
|
|
79
|
+
cli,
|
|
80
|
+
[
|
|
81
|
+
"--credentials",
|
|
82
|
+
str(dummy_credentials),
|
|
83
|
+
"--service-code",
|
|
84
|
+
"topsecret",
|
|
85
|
+
"all-processdata",
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
assert result.exit_code == 2
|
|
89
|
+
assert "service_code cannot be used with credentials" in result.output
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@only_smoketest
|
|
93
|
+
def test_read_process_data(
|
|
94
|
+
credentials: Path,
|
|
95
|
+
session_cache: SessionCache,
|
|
96
|
+
smoketest_config: tuple[str, int, str],
|
|
97
|
+
):
|
|
98
|
+
# As --password-file has a default value, this ensures
|
|
99
|
+
# that no default password-file exists.
|
|
100
|
+
os.chdir(credentials.parent)
|
|
101
|
+
|
|
102
|
+
host, port, _ = smoketest_config
|
|
103
|
+
|
|
104
|
+
runner = CliRunner()
|
|
105
|
+
result = runner.invoke(
|
|
106
|
+
cli,
|
|
107
|
+
[
|
|
108
|
+
"--host",
|
|
109
|
+
host,
|
|
110
|
+
"--port",
|
|
111
|
+
str(port),
|
|
112
|
+
"--credentials",
|
|
113
|
+
str(credentials),
|
|
114
|
+
"all-processdata",
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
assert result.exit_code == 0
|
|
118
|
+
# check any data which is most likely present on most inverter
|
|
119
|
+
assert "devices:local/Inverter:State" in result.stdout.splitlines()
|
|
120
|
+
assert session_cache.read_session_id() is not None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@only_smoketest
|
|
124
|
+
def test_read_settings_data(
|
|
125
|
+
credentials: Path,
|
|
126
|
+
session_cache: SessionCache,
|
|
127
|
+
smoketest_config: tuple[str, int, str],
|
|
128
|
+
):
|
|
129
|
+
# As --password-file has a default value, this ensures
|
|
130
|
+
# that no default password-file exists.
|
|
131
|
+
os.chdir(credentials.parent)
|
|
132
|
+
|
|
133
|
+
host, port, _ = smoketest_config
|
|
134
|
+
|
|
135
|
+
runner = CliRunner()
|
|
136
|
+
result = runner.invoke(
|
|
137
|
+
cli,
|
|
138
|
+
[
|
|
139
|
+
"--host",
|
|
140
|
+
host,
|
|
141
|
+
"--port",
|
|
142
|
+
str(port),
|
|
143
|
+
"--credentials",
|
|
144
|
+
str(credentials),
|
|
145
|
+
"all-settings",
|
|
146
|
+
],
|
|
147
|
+
)
|
|
148
|
+
assert result.exit_code == 0
|
|
149
|
+
# check any data which is most likely present on most inverter
|
|
150
|
+
assert "devices:local/Branding:ProductName1" in result.stdout.splitlines()
|
|
151
|
+
assert session_cache.read_session_id() is not None
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|