pykoplenti 1.2.2__py3-none-any.whl → 1.4.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
@@ -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):
@@ -236,11 +334,17 @@ def read_events(global_args, lang, count):
236
334
  for event in data:
237
335
  print(
238
336
  f"{event.is_active < 5} {event.start_time} {event.end_time} "
239
- "{event.description}"
337
+ f"{event.description}"
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
 
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
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pykoplenti
3
- Version: 1.2.2
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
@@ -13,17 +13,20 @@ 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
22
- Requires-Dist: pycryptodome ~=3.19
23
- Requires-Dist: pydantic ~=1.10
23
+ Requires-Dist: aiohttp~=3.8
24
+ Requires-Dist: pycryptodome~=3.19
25
+ Requires-Dist: pydantic>=1.10
24
26
  Provides-Extra: cli
25
- Requires-Dist: prompt-toolkit >=3.0 ; extra == 'cli'
26
- Requires-Dist: click >=7.1 ; extra == 'cli'
27
+ Requires-Dist: prompt_toolkit>=3.0; extra == "cli"
28
+ Requires-Dist: click>=8.0; extra == "cli"
29
+ Dynamic: license-file
27
30
 
28
31
  # Python Library for Accessing Kostal Plenticore Inverters
29
32
 
@@ -75,22 +78,28 @@ Installing the libray with `CLI` provides a new command.
75
78
 
76
79
  ```shell
77
80
  $ pykoplenti --help
78
- Usage: pykoplenti [OPTIONS] COMMAND [ARGS]...
81
+ Usage: python -m pykoplenti.cli [OPTIONS] COMMAND [ARGS]...
79
82
 
80
83
  Handling of global arguments with click
81
84
 
82
85
  Options:
83
- --host TEXT hostname or ip of the inverter
84
- --port INTEGER port of the inverter (default 80)
85
- --password TEXT the password
86
- --password-file TEXT password file (default "secrets" in the current
87
- working directory)
88
-
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'.
89
96
  --help Show this message and exit.
90
97
 
91
98
  Commands:
92
99
  all-processdata Returns a list of all available process data.
93
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
94
103
  read-processdata Returns the values of the given process data.
95
104
  read-settings Read the value of the given settings.
96
105
  repl Provides a simple REPL for executing API requests to...
@@ -155,9 +164,11 @@ await client.login(my_master_key, service_code=my_service_code)
155
164
  - [click](https://click.palletsprojects.com/) - command line interface framework
156
165
  - [black](https://github.com/psf/black) - Python code formatter
157
166
  - [ruff](https://github.com/astral-sh/ruff) - Python linter
167
+ - [pydantic](https://docs.pydantic.dev/latest/) - Data validation library
158
168
  - [pytest](https://docs.pytest.org/) - Python test framework
159
169
  - [mypy](https://mypy-lang.org/) - Python type checker
160
170
  - [setuptools](https://github.com/pypa/setuptools) - Python packager
171
+ - [tox](https://tox.wiki) - Automate testing
161
172
 
162
173
  ## License
163
174
 
@@ -0,0 +1,12 @@
1
+ pykoplenti/__init__.py,sha256=w9ooy6_JaT9lGvMDAaBHyvYu1uTFTkb1g21WRxp3Kzw,756
2
+ pykoplenti/api.py,sha256=ViT25KQt3caH78fO30G014IawcWQ1lMvy28M9QLBypg,26111
3
+ pykoplenti/cli.py,sha256=AIc2sdzEAhexYwU8C9UTQvKtu0gPYiEnz8fJDcEILI4,17042
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.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ pykoplenti-1.4.0.dist-info/METADATA,sha256=elvtTQ9SMFTl29_j_WHUaboSpmdlCc49T1iv7ciH5Ms,6356
9
+ pykoplenti-1.4.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
10
+ pykoplenti-1.4.0.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
11
+ pykoplenti-1.4.0.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
12
+ pykoplenti-1.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.41.3)
2
+ Generator: setuptools (78.1.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.2.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
- pykoplenti-1.2.2.dist-info/METADATA,sha256=YCvoky9ow6vbvRA_qTQpbkm3hMHvMAwnpewjjQmBuGc,5625
9
- pykoplenti-1.2.2.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
10
- pykoplenti-1.2.2.dist-info/entry_points.txt,sha256=Ix-p1uzKNyOMTK0TOlAC0nsfWS6ATXVuaNDJ5-TwBJw,56
11
- pykoplenti-1.2.2.dist-info/top_level.txt,sha256=Bi915FGIFYzCujwn5Kwhu3B-sxElgc7gX3gNaYjl4j8,11
12
- pykoplenti-1.2.2.dist-info/RECORD,,