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.

Files changed (22) hide show
  1. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/PKG-INFO +17 -10
  2. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/README.md +13 -7
  3. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/cli.py +199 -59
  4. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/PKG-INFO +17 -10
  5. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/SOURCES.txt +1 -0
  6. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/requires.txt +1 -1
  7. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/setup.cfg +2 -2
  8. pykoplenti-1.4.0/tests/test_cli.py +151 -0
  9. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/LICENSE +0 -0
  10. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/__init__.py +0 -0
  11. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/api.py +0 -0
  12. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/extended.py +0 -0
  13. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/model.py +0 -0
  14. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti/py.typed +0 -0
  15. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/dependency_links.txt +0 -0
  16. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/entry_points.txt +0 -0
  17. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pykoplenti.egg-info/top_level.txt +0 -0
  18. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/pyproject.toml +0 -0
  19. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/setup.py +0 -0
  20. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/tests/test_extendedapiclient.py +0 -0
  21. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/tests/test_pykoplenti.py +0 -0
  22. {pykoplenti-1.3.0 → pykoplenti-1.4.0}/tests/test_smoketest.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pykoplenti
3
- Version: 1.3.0
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>=7.1; extra == "cli"
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 hostname or ip of the inverter
86
- --port INTEGER port of the inverter (default 80)
87
- --password TEXT the password
88
- --password-file TEXT password file (default "secrets" in the current
89
- working directory)
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 hostname or ip of the inverter
57
- --port INTEGER port of the inverter (default 80)
58
- --password TEXT the password
59
- --password-file TEXT password file (default "secrets" in the current
60
- working directory)
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.host = host
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
- file = os.path.join(tempfile.gettempdir(), f"pykoplenti-session-{self.host}")
28
- if os.path.isfile(file):
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
- file = os.path.join(tempfile.gettempdir(), f"pykoplenti-session-{self.host}")
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, passwd):
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=None)
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 passwd is not None:
65
- print_formatted_text("Logging in... ", end=None)
66
- await self.client.login(passwd)
67
- self._session_cache.write_session_id(self.client.session_id)
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, passwd):
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(passwd)
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(host, port, passwd):
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(passwd)
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, port: int, passwd: str, fn: Callable[[ApiClient], Awaitable[Any]]
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(passwd)
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
- def __init__(self):
187
- self.host = None
188
- self.port = None
189
- self.password = None
190
- self.password_file = None
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="hostname or ip of the inverter")
198
- @click.option("--port", default=80, help="port of the inverter (default 80)")
199
- @click.option("--password", default=None, help="the password")
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='password file (default "secrets" in the current working directory)',
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(global_args, host, port, password, password_file):
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(repl_main(global_args.host, global_args.port, global_args.passwd))
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(global_args.host, global_args.port, global_args.passwd, fn)
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(global_args.host, global_args.port, global_args.passwd, fn)
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(global_args.host, global_args.port, global_args.passwd, fn)
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(global_args.host, global_args.port, global_args.passwd, fn)
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(global_args.host, global_args.port, global_args.passwd, fn)
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(global_args.host, global_args.port, global_args.passwd, fn)
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(global_args.host, global_args.port, global_args.passwd, fn)
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
1
+ Metadata-Version: 2.4
2
2
  Name: pykoplenti
3
- Version: 1.3.0
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>=7.1; extra == "cli"
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 hostname or ip of the inverter
86
- --port INTEGER port of the inverter (default 80)
87
- --password TEXT the password
88
- --password-file TEXT password file (default "secrets" in the current
89
- working directory)
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
@@ -4,4 +4,4 @@ pydantic>=1.10
4
4
 
5
5
  [CLI]
6
6
  prompt_toolkit>=3.0
7
- click>=7.1
7
+ click>=8.0
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = pykoplenti
3
- version = 1.3.0
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 >= 7.1
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