pystuderxcom 3.4.0__tar.gz → 3.4.2__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.
Files changed (51) hide show
  1. {pystuderxcom-3.4.0/src/pystuderxcom.egg-info → pystuderxcom-3.4.2}/PKG-INFO +1 -1
  2. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/pyproject.toml +1 -1
  3. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/__init__.py +1 -1
  4. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/api_base_async.py +106 -17
  5. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/api_base_sync.py +107 -18
  6. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/const.py +8 -1
  7. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/datapoints.py +14 -0
  8. pystuderxcom-3.4.2/src/pystuderxcom/datapoints_xcom.json +44 -0
  9. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/discover_async.py +5 -1
  10. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/discover_sync.py +5 -1
  11. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/factory_async.py +6 -2
  12. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/factory_sync.py +6 -2
  13. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/families.py +9 -0
  14. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2/src/pystuderxcom.egg-info}/PKG-INFO +1 -1
  15. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom.egg-info/SOURCES.txt +1 -0
  16. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_base_async.py +165 -23
  17. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_base_sync.py +165 -23
  18. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_datapoints_async.py +20 -7
  19. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_datapoints_sync.py +20 -7
  20. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_discover_async.py +13 -13
  21. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_discover_sync.py +13 -13
  22. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_families.py +7 -1
  23. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/LICENSE +0 -0
  24. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/README.md +0 -0
  25. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/setup.cfg +0 -0
  26. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/api_serial.py +0 -0
  27. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/api_tcp.py +0 -0
  28. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/api_udp.py +0 -0
  29. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/data.py +0 -0
  30. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/datapoints_120v.json +0 -0
  31. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/datapoints_240v.json +0 -0
  32. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/messages.py +0 -0
  33. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/messages_en.json +0 -0
  34. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/protocol.py +0 -0
  35. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom/values.py +0 -0
  36. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom.egg-info/dependency_links.txt +0 -0
  37. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom.egg-info/requires.txt +0 -0
  38. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/src/pystuderxcom.egg-info/top_level.txt +0 -0
  39. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_serial_async.py +0 -0
  40. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_serial_sync.py +0 -0
  41. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_tcp_async.py +0 -0
  42. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_tcp_sync.py +0 -0
  43. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_udp_async.py +0 -0
  44. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_api_udp_sync.py +0 -0
  45. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_const.py +0 -0
  46. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_data.py +0 -0
  47. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_messageset_async.py +0 -0
  48. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_messageset_sync.py +0 -0
  49. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_package_async.py +0 -0
  50. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_package_sync.py +0 -0
  51. {pystuderxcom-3.4.0 → pystuderxcom-3.4.2}/tests/test_values.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pystuderxcom
3
- Version: 3.4.0
3
+ Version: 3.4.2
4
4
  Summary: Python library to communicate with Studer-Innotec devices on the local network via Xcom protocol
5
5
  Author-email: Anko Hanse <anko_hanse@hotmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankohanse/pystuderxcom
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pystuderxcom"
3
- version = "3.4.0"
3
+ version = "3.4.2"
4
4
  authors = [
5
5
  { name="Anko Hanse", email="anko_hanse@hotmail.com" },
6
6
  ]
@@ -10,7 +10,7 @@ from .api_base_sync import XcomApiBase
10
10
  from .discover_sync import XcomDiscover
11
11
  from .factory_sync import XcomFactory
12
12
 
13
- from .const import XcomApiTcpMode, XcomVoltage, XcomLevel, XcomFormat, XcomCategory, XcomAggregationType
13
+ from .const import XcomApiTcpMode, XcomVoltage, XcomLevel, XcomFormat, XcomTarget, XcomCategory, XcomAggregationType
14
14
  from .const import XcomApiWriteException, XcomApiReadException, XcomApiTimeoutException, XcomApiUnpackException, XcomApiResponseIsError, XcomDiscoverNotConnected, XcomParamException
15
15
  from .data import XcomDiscoveredClient, XcomDiscoveredDevice
16
16
  from .datapoints import XcomDataset, XcomDatapoint, XcomDatapointUnknownException
@@ -14,6 +14,7 @@ from .const import (
14
14
  REQ_BURST_PERIOD,
15
15
  ScomAddress,
16
16
  XcomFormat,
17
+ XcomTarget,
17
18
  XcomCategory,
18
19
  XcomAggregationType,
19
20
  ScomFrameFlag,
@@ -196,6 +197,10 @@ class AsyncXcomApiBase:
196
197
  XcomApiResponseIsError
197
198
  """
198
199
 
200
+ # Sanity check
201
+ if parameter.target != XcomTarget.STANDARD:
202
+ raise XcomParamException(f"Invalid datapoint passed to request_value; must have target STANDARD. Violated by datapoint '{parameter.name}' ({parameter.nr})")
203
+
199
204
  # Check/convert input parameters
200
205
  if type(dstAddr) is str:
201
206
  dstAddr = XcomDeviceFamilies.get_addr_by_code(dstAddr)
@@ -220,6 +225,45 @@ class AsyncXcomApiBase:
220
225
  msg = f"Failed to unpack response package for {parameter.nr}:{dstAddr}, data={response.frame_data.service_data.property_data.hex()}: {e}"
221
226
  raise XcomApiUnpackException(msg) from None
222
227
 
228
+
229
+ async def request_virtual(self, parameter: XcomDatapoint, dstAddr = 100, retries = None, timeout = None, verbose=False):
230
+ """
231
+ Request a virtual param or info.
232
+ Returns None if not connected, otherwise returns the requested value
233
+ Throws
234
+ XcomApiWriteException
235
+ XcomApiReadException
236
+ XcomApiTimeoutException
237
+ XcomApiResponseIsError
238
+ """
239
+ # Sanity check
240
+ if not self.connected:
241
+ return None
242
+
243
+ if parameter.target != XcomTarget.VIRTUAL:
244
+ raise XcomParamException(f"Invalid datapoint passed to request_value; must have target VIRTUAL. Violated by datapoint '{parameter.name}' ({parameter.nr})")
245
+
246
+ # Virtual request
247
+ match parameter.nr:
248
+ case 99000: return self.is_message_pending
249
+ case 99001: return self.was_rcc_reseted
250
+ case 99002: return self.is_sd_card_present
251
+ case 99003: return self.is_sd_card_full
252
+ case 99004: return self.is_new_datalogger_file_present
253
+
254
+ case 99020 | 99021 | 99022:
255
+ msg = await self.request_message(index=0, retries=retries, timeout=timeout, verbose=verbose)
256
+ if msg:
257
+ match parameter.nr:
258
+ case 99020: return msg.message_string
259
+ case 99021: return datetime.fromtimestamp(msg.timestamp)
260
+ case 99022: return msg.source_address
261
+
262
+ case _:
263
+ raise XcomParamException(f"Invalid datapoint passed to request_virtual; nr not recognized. Violated by datapoint '{parameter.name}' ({parameter.nr})")
264
+
265
+ return None
266
+
223
267
 
224
268
  async def request_infos(self, request_data: XcomValues, retries = None, timeout = None, verbose=False) -> XcomValues:
225
269
  """
@@ -239,6 +283,9 @@ class AsyncXcomApiBase:
239
283
 
240
284
  # Sanity check
241
285
  for item in request_data.items:
286
+ if item.datapoint.target != XcomTarget.STANDARD:
287
+ raise XcomParamException(f"Invalid datapoint passed to request_infos; must have target STANDARD. Violated by datapoint '{item.datapoint.name}' ({item.datapoint.nr})")
288
+
242
289
  if item.datapoint.category != XcomCategory.INFO:
243
290
  raise XcomParamException(f"Invalid datapoint passed to request_infos; must have type INFO. Violated by datapoint '{item.datapoint.name}' ({item.datapoint.nr})")
244
291
 
@@ -268,12 +315,12 @@ class AsyncXcomApiBase:
268
315
 
269
316
  async def request_values(self, request_data: XcomValues, retries = None, timeout = None, verbose=False) -> XcomValues:
270
317
  """
271
- Request multiple infos and params in one call.
318
+ Request multiple infos, params or virtuals in one call.
272
319
  Can only retrieve actual device values, NOT the average or sum over multiple devices.
273
320
 
274
321
  The function will try to be as efficient as possible and combine retrieval of multiple infos in one call.
275
322
  When the xcom-client does not support multiple-infos in one call, they are retried one by one.
276
- Requested params are always retrieved one by one, so the function can take a while to finish.
323
+ Requested params and virtuals are always retrieved one by one, so the function can take a while to finish.
277
324
 
278
325
  Returns None if not connected, otherwise returns the list of requested values
279
326
  Throws
@@ -284,6 +331,7 @@ class AsyncXcomApiBase:
284
331
  """
285
332
 
286
333
  # Sort out which XcomValues can be done via multi request_values and which must be done via single request_value
334
+ req_virtuals: list[XcomValuesItem] = []
287
335
  req_singles: list[XcomValuesItem] = []
288
336
  req_multi_items: list[XcomValuesItem] = []
289
337
  req_multis: list[XcomValues] = []
@@ -291,26 +339,37 @@ class AsyncXcomApiBase:
291
339
 
292
340
  for idx,item in enumerate(request_data.items):
293
341
 
294
- match item.datapoint.category:
295
- case XcomCategory.INFO:
296
- if item.aggregation_type is not None and item.aggregation_type in range(XcomAggregationType.DEVICE1, XcomAggregationType.DEVICE15+1):
297
- # Can be combined with other infos in a request_values call
298
- req_multi_items.append(item)
299
-
300
- elif item.address is not None:
301
- # Any others need to be done via an individual request_value cal
302
- req_singles.append(item)
342
+ match item.datapoint.target:
343
+ case XcomTarget.VIRTUAL:
344
+ if item.address is not None:
345
+ # Needs to be done via an individual request_virtual call
346
+ req_virtuals.append(item)
303
347
 
304
348
  else:
305
349
  raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
306
350
 
307
- case XcomCategory.PARAMETER:
308
- if item.address is not None:
309
- # Needs to be done via an individual request_value call
310
- req_singles.append(item)
351
+ case XcomTarget.STANDARD:
352
+ # Standard datapoints are handled depending on category and aggregation type
353
+ match item.datapoint.category:
354
+ case XcomCategory.INFO:
355
+ if item.aggregation_type is not None and item.aggregation_type in range(XcomAggregationType.DEVICE1, XcomAggregationType.DEVICE15+1):
356
+ # Can be combined with other infos in a request_values call
357
+ req_multi_items.append(item)
311
358
 
312
- else:
313
- raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
359
+ elif item.address is not None:
360
+ # Any others need to be done via an individual request_value cal
361
+ req_singles.append(item)
362
+
363
+ else:
364
+ raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
365
+
366
+ case XcomCategory.PARAMETER:
367
+ if item.address is not None:
368
+ # Needs to be done via an individual request_value call
369
+ req_singles.append(item)
370
+
371
+ else:
372
+ raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
314
373
 
315
374
  if (len(req_multi_items) == MULTI_INFO_REQ_MAX) or \
316
375
  (len(req_multi_items) > 0 and idx == idx_last):
@@ -380,6 +439,36 @@ class AsyncXcomApiBase:
380
439
  await asyncio.sleep(1)
381
440
  burst_start = datetime.now()
382
441
 
442
+ # Finally perform all the virtual request_value requests
443
+ for req_virtual in req_virtuals:
444
+ try:
445
+ error = None
446
+ value = await self.request_virtual(req_virtual.datapoint, req_virtual.address, retries=retries, timeout=timeout, verbose=verbose)
447
+
448
+ except Exception as ex:
449
+ value = None
450
+ error = str(ex)
451
+
452
+ if error is not None:
453
+ _LOGGER.debug(f"Failed to retrieve virtual info or param {req_virtual.datapoint.nr}:{req_virtual.address}; {error}")
454
+
455
+ # Add to results
456
+ rsp_virtual = XcomValuesItem(
457
+ datapoint = req_virtual.datapoint,
458
+ code = req_virtual.code,
459
+ address = req_virtual.address,
460
+ aggregation_type=req_virtual.aggregation_type,
461
+ value = value,
462
+ error = error,
463
+ )
464
+ result_items.append(rsp_virtual)
465
+
466
+ # Periodically wait for a second. This will make sure we do not block Xcom-LAN with
467
+ # too many requests at once and prevent it from uploading data to the Studer portal.
468
+ if (datetime.now() - burst_start).total_seconds() > REQ_BURST_PERIOD:
469
+ await asyncio.sleep(1)
470
+ burst_start = datetime.now()
471
+
383
472
  # Return all reponse items as one XcomValues object
384
473
  return XcomValues(result_items)
385
474
 
@@ -16,6 +16,7 @@ from .const import (
16
16
  REQ_BURST_PERIOD,
17
17
  ScomAddress,
18
18
  XcomFormat,
19
+ XcomTarget,
19
20
  XcomCategory,
20
21
  XcomAggregationType,
21
22
  ScomFrameFlag,
@@ -58,8 +59,8 @@ from .values import (
58
59
  XcomValues,
59
60
  XcomValuesItem,
60
61
  )
61
- import time
62
62
  import threading
63
+ import time
63
64
 
64
65
 
65
66
  _LOGGER = logging.getLogger(__name__)
@@ -200,6 +201,10 @@ class XcomApiBase:
200
201
  XcomApiResponseIsError
201
202
  """
202
203
 
204
+ # Sanity check
205
+ if parameter.target != XcomTarget.STANDARD:
206
+ raise XcomParamException(f"Invalid datapoint passed to request_value; must have target STANDARD. Violated by datapoint '{parameter.name}' ({parameter.nr})")
207
+
203
208
  # Check/convert input parameters
204
209
  if type(dstAddr) is str:
205
210
  dstAddr = XcomDeviceFamilies.get_addr_by_code(dstAddr)
@@ -224,6 +229,45 @@ class XcomApiBase:
224
229
  msg = f"Failed to unpack response package for {parameter.nr}:{dstAddr}, data={response.frame_data.service_data.property_data.hex()}: {e}"
225
230
  raise XcomApiUnpackException(msg) from None
226
231
 
232
+
233
+ def request_virtual(self, parameter: XcomDatapoint, dstAddr = 100, retries = None, timeout = None, verbose=False):
234
+ """
235
+ Request a virtual param or info.
236
+ Returns None if not connected, otherwise returns the requested value
237
+ Throws
238
+ XcomApiWriteException
239
+ XcomApiReadException
240
+ XcomApiTimeoutException
241
+ XcomApiResponseIsError
242
+ """
243
+ # Sanity check
244
+ if not self.connected:
245
+ return None
246
+
247
+ if parameter.target != XcomTarget.VIRTUAL:
248
+ raise XcomParamException(f"Invalid datapoint passed to request_value; must have target VIRTUAL. Violated by datapoint '{parameter.name}' ({parameter.nr})")
249
+
250
+ # Virtual request
251
+ match parameter.nr:
252
+ case 99000: return self.is_message_pending
253
+ case 99001: return self.was_rcc_reseted
254
+ case 99002: return self.is_sd_card_present
255
+ case 99003: return self.is_sd_card_full
256
+ case 99004: return self.is_new_datalogger_file_present
257
+
258
+ case 99020 | 99021 | 99022:
259
+ msg = self.request_message(index=0, retries=retries, timeout=timeout, verbose=verbose)
260
+ if msg:
261
+ match parameter.nr:
262
+ case 99020: return msg.message_string
263
+ case 99021: return datetime.fromtimestamp(msg.timestamp)
264
+ case 99022: return msg.source_address
265
+
266
+ case _:
267
+ raise XcomParamException(f"Invalid datapoint passed to request_virtual; nr not recognized. Violated by datapoint '{parameter.name}' ({parameter.nr})")
268
+
269
+ return None
270
+
227
271
 
228
272
  def request_infos(self, request_data: XcomValues, retries = None, timeout = None, verbose=False) -> XcomValues:
229
273
  """
@@ -243,6 +287,9 @@ class XcomApiBase:
243
287
 
244
288
  # Sanity check
245
289
  for item in request_data.items:
290
+ if item.datapoint.target != XcomTarget.STANDARD:
291
+ raise XcomParamException(f"Invalid datapoint passed to request_infos; must have target STANDARD. Violated by datapoint '{item.datapoint.name}' ({item.datapoint.nr})")
292
+
246
293
  if item.datapoint.category != XcomCategory.INFO:
247
294
  raise XcomParamException(f"Invalid datapoint passed to request_infos; must have type INFO. Violated by datapoint '{item.datapoint.name}' ({item.datapoint.nr})")
248
295
 
@@ -272,12 +319,12 @@ class XcomApiBase:
272
319
 
273
320
  def request_values(self, request_data: XcomValues, retries = None, timeout = None, verbose=False) -> XcomValues:
274
321
  """
275
- Request multiple infos and params in one call.
322
+ Request multiple infos, params or virtuals in one call.
276
323
  Can only retrieve actual device values, NOT the average or sum over multiple devices.
277
324
 
278
325
  The function will try to be as efficient as possible and combine retrieval of multiple infos in one call.
279
326
  When the xcom-client does not support multiple-infos in one call, they are retried one by one.
280
- Requested params are always retrieved one by one, so the function can take a while to finish.
327
+ Requested params and virtuals are always retrieved one by one, so the function can take a while to finish.
281
328
 
282
329
  Returns None if not connected, otherwise returns the list of requested values
283
330
  Throws
@@ -288,6 +335,7 @@ class XcomApiBase:
288
335
  """
289
336
 
290
337
  # Sort out which XcomValues can be done via multi request_values and which must be done via single request_value
338
+ req_virtuals: list[XcomValuesItem] = []
291
339
  req_singles: list[XcomValuesItem] = []
292
340
  req_multi_items: list[XcomValuesItem] = []
293
341
  req_multis: list[XcomValues] = []
@@ -295,26 +343,37 @@ class XcomApiBase:
295
343
 
296
344
  for idx,item in enumerate(request_data.items):
297
345
 
298
- match item.datapoint.category:
299
- case XcomCategory.INFO:
300
- if item.aggregation_type is not None and item.aggregation_type in range(XcomAggregationType.DEVICE1, XcomAggregationType.DEVICE15+1):
301
- # Can be combined with other infos in a request_values call
302
- req_multi_items.append(item)
303
-
304
- elif item.address is not None:
305
- # Any others need to be done via an individual request_value cal
306
- req_singles.append(item)
346
+ match item.datapoint.target:
347
+ case XcomTarget.VIRTUAL:
348
+ if item.address is not None:
349
+ # Needs to be done via an individual request_virtual call
350
+ req_virtuals.append(item)
307
351
 
308
352
  else:
309
353
  raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
310
354
 
311
- case XcomCategory.PARAMETER:
312
- if item.address is not None:
313
- # Needs to be done via an individual request_value call
314
- req_singles.append(item)
355
+ case XcomTarget.STANDARD:
356
+ # Standard datapoints are handled depending on category and aggregation type
357
+ match item.datapoint.category:
358
+ case XcomCategory.INFO:
359
+ if item.aggregation_type is not None and item.aggregation_type in range(XcomAggregationType.DEVICE1, XcomAggregationType.DEVICE15+1):
360
+ # Can be combined with other infos in a request_values call
361
+ req_multi_items.append(item)
315
362
 
316
- else:
317
- raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
363
+ elif item.address is not None:
364
+ # Any others need to be done via an individual request_value cal
365
+ req_singles.append(item)
366
+
367
+ else:
368
+ raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
369
+
370
+ case XcomCategory.PARAMETER:
371
+ if item.address is not None:
372
+ # Needs to be done via an individual request_value call
373
+ req_singles.append(item)
374
+
375
+ else:
376
+ raise XcomParamException(f"Invalid XcomValuesItem passed to request_values; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
318
377
 
319
378
  if (len(req_multi_items) == MULTI_INFO_REQ_MAX) or \
320
379
  (len(req_multi_items) > 0 and idx == idx_last):
@@ -384,6 +443,36 @@ class XcomApiBase:
384
443
  time.sleep(1)
385
444
  burst_start = datetime.now()
386
445
 
446
+ # Finally perform all the virtual request_value requests
447
+ for req_virtual in req_virtuals:
448
+ try:
449
+ error = None
450
+ value = self.request_virtual(req_virtual.datapoint, req_virtual.address, retries=retries, timeout=timeout, verbose=verbose)
451
+
452
+ except Exception as ex:
453
+ value = None
454
+ error = str(ex)
455
+
456
+ if error is not None:
457
+ _LOGGER.debug(f"Failed to retrieve virtual info or param {req_virtual.datapoint.nr}:{req_virtual.address}; {error}")
458
+
459
+ # Add to results
460
+ rsp_virtual = XcomValuesItem(
461
+ datapoint = req_virtual.datapoint,
462
+ code = req_virtual.code,
463
+ address = req_virtual.address,
464
+ aggregation_type=req_virtual.aggregation_type,
465
+ value = value,
466
+ error = error,
467
+ )
468
+ result_items.append(rsp_virtual)
469
+
470
+ # Periodically wait for a second. This will make sure we do not block Xcom-LAN with
471
+ # too many requests at once and prevent it from uploading data to the Studer portal.
472
+ if (datetime.now() - burst_start).total_seconds() > REQ_BURST_PERIOD:
473
+ time.sleep(1)
474
+ burst_start = datetime.now()
475
+
387
476
  # Return all reponse items as one XcomValues object
388
477
  return XcomValues(result_items)
389
478
 
@@ -37,6 +37,9 @@ REQ_TIMEOUT = 3
37
37
  REQ_RETRIES = 3
38
38
  REQ_BURST_PERIOD = 5 # do burst of requests for 5 seconds, then wait a second, then the next burst
39
39
 
40
+ NR_VIRTUAL_START = 98000
41
+ NR_VIRTUAL_END = 99999
42
+
40
43
 
41
44
  class XcomApiTcpMode(StrEnum):
42
45
  SERVER = "server"
@@ -187,6 +190,11 @@ class XcomMultiInfoFlags(IntEnum):
187
190
  VT_PRESENT = 0x00000080,
188
191
  VS_PRESENT = 0x00000100,
189
192
 
193
+ ### values for target
194
+ class XcomTarget(StrEnum):
195
+ STANDARD = "standard"
196
+ VIRTUAL = "virtual"
197
+
190
198
  ### values for category
191
199
  class XcomCategory:
192
200
  INFO = 0x0001
@@ -207,7 +215,6 @@ class ScomObjType:
207
215
  def __repr__(self):
208
216
  return self.name
209
217
 
210
- @staticmethod
211
218
  @staticmethod
212
219
  def from_str(s: str, default: int|None = None):
213
220
  for e in XcomAggregationType:
@@ -9,9 +9,15 @@ import logging
9
9
  from dataclasses import dataclass
10
10
 
11
11
  from .const import (
12
+ NR_VIRTUAL_END,
13
+ NR_VIRTUAL_START,
12
14
  XcomLevel,
13
15
  XcomFormat,
14
16
  XcomCategory,
17
+ XcomTarget,
18
+ )
19
+ from .families import (
20
+ XcomDeviceFamilies,
15
21
  )
16
22
 
17
23
 
@@ -91,6 +97,13 @@ class XcomDatapoint:
91
97
  _LOGGER.debug(f"Unknown category for datapoint {self.nr} with level {self.level} and format {self.format}")
92
98
  return XcomCategory.INFO
93
99
 
100
+ @property
101
+ def target(self) -> XcomTarget:
102
+ if NR_VIRTUAL_START <= self.nr <= NR_VIRTUAL_END:
103
+ return XcomTarget.VIRTUAL
104
+ else:
105
+ return XcomTarget.STANDARD
106
+
94
107
  def enum_value(self, key):
95
108
  if self.format not in [XcomFormat.LONG_ENUM, XcomFormat.SHORT_ENUM]:
96
109
  return None
@@ -117,6 +130,7 @@ class XcomDataset:
117
130
 
118
131
  PATH_120V = __file__.replace('.py', '_120v.json')
119
132
  PATH_240V = __file__.replace('.py', '_240v.json')
133
+ PATH_XCOM = __file__.replace('.py', '_xcom.json')
120
134
 
121
135
  def __init__(self, datapoints: list[XcomDatapoint] | None = None):
122
136
  self._datapoints = datapoints
@@ -0,0 +1,44 @@
1
+ [
2
+ { "_rem": "Extra 'virtual' Xcom datapoints"},
3
+
4
+ { "_rem": "These are not included in 'Studer Technical Specification - Xtender serial protocol appendix'"},
5
+ { "_rem": "But are defined by us based on other parameters exposed by the Xcom protocol"},
6
+
7
+ { "_rem": "Column descriptions:"},
8
+ { "_rem": " fam = Device Family"},
9
+ { "_rem": " lvl = User level required to access the param or info"},
10
+ { "_rem": " pnr = Parent number in menu structure"},
11
+ { "_rem": " nr = Number of the param or info"},
12
+ { "_rem": " name = Friendly name of the param or info"},
13
+ { "_rem": " urcc = Data unit of measurement as displayed on RCC"},
14
+ { "_rem": " unit = Data unit of measurement"},
15
+ { "_rem": " def = Default value"},
16
+ { "_rem": " min = Minimum value"},
17
+ { "_rem": " max = Maximum value"},
18
+ { "_rem": " fmt = Data format"},
19
+ { "_rem": " inc = Increment step"},
20
+ { "_rem": " opt = Options for selection in Enums"},
21
+ { "_rem": " rem = Remarks"},
22
+
23
+
24
+ { "_rem": "###"},
25
+ { "_rem": "### Root Menu"},
26
+ { "_rem": "###"},
27
+ { "fam": "xcom", "lvl": "Info", "pnr": 0, "nr": 990, "name": "Xcom infos", "fmt": "ONLY LEVEL", "inc": "Menu" },
28
+
29
+ { "_rem": "###"},
30
+ { "_rem": "### Xcom specific params."},
31
+ { "_rem": "###"},
32
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99000, "name": "Is message pending", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
33
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99001, "name": "Was RCC reseted", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
34
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99002, "name": "Is sd-card present", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
35
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99003, "name": "Is sd-card full", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
36
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99004, "name": "Is new datalogger file present", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
37
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99020, "name": "Latest message", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
38
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99021, "name": "Latest message time", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
39
+ { "fam": "xcom", "lvl": "Info", "pnr": 990, "nr": 99022, "name": "Latest message source", "short": "", "urcc": "", "unit": "", "fmt": "BOOL" },
40
+
41
+ { "_rem": "###"},
42
+ { "_rem": "### End of file"},
43
+ { "_rem": "###"}
44
+ ]
@@ -17,6 +17,7 @@ from .api_base_sync import (
17
17
  )
18
18
  from .const import (
19
19
  XcomDiscoverNotConnected,
20
+ XcomTarget,
20
21
  )
21
22
  from .data import (
22
23
  XcomDiscoveredDevice,
@@ -86,7 +87,10 @@ class AsyncXcomDiscover:
86
87
  param = self._dataset.get_by_nr(nr, family.id_for_nr)
87
88
 
88
89
  _LOGGER.info(f"Trying device {device_code} on {device_addr} for nr {nr}")
89
- value = await self._api.request_value(param, device_addr, verbose=verbose)
90
+ match param.target:
91
+ case XcomTarget.STANDARD: value = await self._api.request_value(param, device_addr, verbose=verbose)
92
+ case XcomTarget.VIRTUAL : value = await self._api.request_virtual(param, device_addr, verbose=verbose)
93
+
90
94
  if value is not None:
91
95
  _LOGGER.info(f" Found device {device_code} via {nr}:{device_addr}")
92
96
 
@@ -19,6 +19,7 @@ from .api_base_sync import (
19
19
  )
20
20
  from .const import (
21
21
  XcomDiscoverNotConnected,
22
+ XcomTarget,
22
23
  )
23
24
  from .data import (
24
25
  XcomDiscoveredDevice,
@@ -89,7 +90,10 @@ class XcomDiscover:
89
90
  param = self._dataset.get_by_nr(nr, family.id_for_nr)
90
91
 
91
92
  _LOGGER.info(f"Trying device {device_code} on {device_addr} for nr {nr}")
92
- value = self._api.request_value(param, device_addr, verbose=verbose)
93
+ match param.target:
94
+ case XcomTarget.STANDARD: value = self._api.request_value(param, device_addr, verbose=verbose)
95
+ case XcomTarget.VIRTUAL : value = self._api.request_virtual(param, device_addr, verbose=verbose)
96
+
93
97
  if value is not None:
94
98
  _LOGGER.info(f" Found device {device_code} via {nr}:{device_addr}")
95
99
 
@@ -54,15 +54,19 @@ class AsyncXcomFactory:
54
54
  text_120vac = await file_120vac.read()
55
55
  async with aiofiles_open(XcomDataset.PATH_240V, "r", encoding="UTF-8") as file_240vac:
56
56
  text_240vac = await file_240vac.read()
57
+ async with aiofiles_open(XcomDataset.PATH_XCOM, "r", encoding="UTF-8") as file_xcom:
58
+ text_xcom = await file_xcom.read()
57
59
 
58
60
  values_120vac = orjson.loads(text_120vac)
59
61
  values_240vac = orjson.loads(text_240vac)
62
+ values_xcom = orjson.loads(text_xcom)
60
63
 
61
64
  datapoints_120vac = list(filter(None, [XcomDatapoint.from_dict(val) for val in values_120vac]))
62
65
  datapoints_240vac = list(filter(None, [XcomDatapoint.from_dict(val) for val in values_240vac]))
66
+ datapoints_xcom = list(filter(None, [XcomDatapoint.from_dict(val) for val in values_xcom]))
63
67
 
64
- # start with the 240v list as base
65
- datapoints = datapoints_240vac
68
+ # start with the merged 240v + xcom lists as base
69
+ datapoints = datapoints_240vac + datapoints_xcom
66
70
 
67
71
  match voltageAC:
68
72
  case XcomVoltage.AC240:
@@ -56,15 +56,19 @@ class XcomFactory:
56
56
  text_120vac = file_120vac.read()
57
57
  with open(XcomDataset.PATH_240V, "r", encoding="UTF-8") as file_240vac:
58
58
  text_240vac = file_240vac.read()
59
+ with open(XcomDataset.PATH_XCOM, "r", encoding="UTF-8") as file_xcom:
60
+ text_xcom = file_xcom.read()
59
61
 
60
62
  values_120vac = orjson.loads(text_120vac)
61
63
  values_240vac = orjson.loads(text_240vac)
64
+ values_xcom = orjson.loads(text_xcom)
62
65
 
63
66
  datapoints_120vac = list(filter(None, [XcomDatapoint.from_dict(val) for val in values_120vac]))
64
67
  datapoints_240vac = list(filter(None, [XcomDatapoint.from_dict(val) for val in values_240vac]))
68
+ datapoints_xcom = list(filter(None, [XcomDatapoint.from_dict(val) for val in values_xcom]))
65
69
 
66
- # start with the 240v list as base
67
- datapoints = datapoints_240vac
70
+ # start with the merged 240v + xcom lists as base
71
+ datapoints = datapoints_240vac + datapoints_xcom
68
72
 
69
73
  match voltageAC:
70
74
  case XcomVoltage.AC240:
@@ -137,6 +137,15 @@ class XcomDeviceFamilies:
137
137
  15000, 15999, # nr for infos, start to end
138
138
  15000, # nr for discovery
139
139
  )
140
+ XCOM = XcomDeviceFamily(
141
+ "xcom", "xcom", # virtual device to expose additional values in a uniform way
142
+ "Xcom-LAN/232i",
143
+ 990, # addr multicast to all devices (write only)
144
+ 990, 990, # addr devices, start to end
145
+ 98000, 98999, # nr for params, start to end
146
+ 99000, 99999, # nr for infos, start to end
147
+ 99000, # nr for discovery
148
+ )
140
149
 
141
150
 
142
151
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pystuderxcom
3
- Version: 3.4.0
3
+ Version: 3.4.2
4
4
  Summary: Python library to communicate with Studer-Innotec devices on the local network via Xcom protocol
5
5
  Author-email: Anko Hanse <anko_hanse@hotmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankohanse/pystuderxcom
@@ -12,6 +12,7 @@ src/pystuderxcom/data.py
12
12
  src/pystuderxcom/datapoints.py
13
13
  src/pystuderxcom/datapoints_120v.json
14
14
  src/pystuderxcom/datapoints_240v.json
15
+ src/pystuderxcom/datapoints_xcom.json
15
16
  src/pystuderxcom/discover_async.py
16
17
  src/pystuderxcom/discover_sync.py
17
18
  src/pystuderxcom/factory_async.py