pystuderxcom 3.0.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.
@@ -0,0 +1,26 @@
1
+ from .api_tcp import AsyncXcomApiTcp, XcomApiTcp
2
+ from .api_udp import AsyncXcomApiUdp, XcomApiUdp
3
+ from .api_serial import AsyncXcomApiSerial, XcomApiSerial
4
+
5
+ from .api_base_async import AsyncXcomApiBase
6
+ from .discover_async import AsyncXcomDiscover
7
+ from .factory_async import AsyncXcomFactory
8
+
9
+ from .api_base_sync import XcomApiBase
10
+ from .discover_sync import XcomDiscover
11
+ from .factory_sync import XcomFactory
12
+
13
+ from .const import XcomVoltage, XcomLevel, XcomFormat, XcomCategory, XcomAggregationType
14
+ from .const import XcomApiWriteException, XcomApiReadException, XcomApiTimeoutException, XcomApiUnpackException, XcomApiResponseIsError, XcomDiscoverNotConnected, XcomParamException
15
+ from .data import XcomDiscoveredClient, XcomDiscoveredDevice
16
+ from .datapoints import XcomDataset, XcomDatapoint, XcomDatapointUnknownException
17
+ from .families import XcomDeviceFamily, XcomDeviceFamilies, XcomDeviceFamilyUnknownException, XcomDeviceCodeUnknownException, XcomDeviceAddrUnknownException
18
+ from .messages import XcomMessage, XcomMessageUnknownException
19
+ from .values import XcomValues, XcomValuesItem
20
+
21
+ # For unit testing
22
+ from .const import ScomObjType, ScomObjId, ScomService, ScomQspId, ScomQspLevel, ScomAddress, ScomErrorCode
23
+ from .data import XcomData, XcomDataMessageRsp, XcomDataMultiInfoReq, XcomDataMultiInfoReqItem, XcomDataMultiInfoRsp, XcomDataMultiInfoRspItem
24
+ from .messages import XcomMessageDef, XcomMessageSet
25
+ from .protocol import XcomHeader, XcomFrame, XcomService, XcomPackage
26
+
@@ -0,0 +1,592 @@
1
+ """xcom_api.py: communication api to Studer Xcom via LAN."""
2
+
3
+ import asyncio
4
+ import binascii
5
+ import logging
6
+
7
+ from datetime import datetime, timedelta
8
+
9
+ from .const import (
10
+ START_TIMEOUT,
11
+ STOP_TIMEOUT,
12
+ REQ_TIMEOUT,
13
+ REQ_RETRIES,
14
+ REQ_BURST_PERIOD,
15
+ ScomAddress,
16
+ XcomFormat,
17
+ XcomCategory,
18
+ XcomAggregationType,
19
+ ScomObjType,
20
+ ScomObjId,
21
+ ScomService,
22
+ ScomQspId,
23
+ XcomApiReadException,
24
+ XcomApiWriteException,
25
+ XcomApiUnpackException,
26
+ XcomApiTimeoutException,
27
+ XcomApiResponseIsError,
28
+ XcomParamException,
29
+ safe_len,
30
+ )
31
+ from .data import (
32
+ XcomData,
33
+ XcomDataMessageRsp,
34
+ MULTI_INFO_REQ_MAX,
35
+ )
36
+ from .datapoints import (
37
+ XcomDatapoint,
38
+ )
39
+ from .factory_async import (
40
+ AsyncXcomFactory,
41
+ )
42
+ from .factory_sync import (
43
+ XcomFactory,
44
+ )
45
+ from .families import (
46
+ XcomDeviceFamilies
47
+ )
48
+ from .messages import (
49
+ XcomMessage,
50
+ )
51
+ from .protocol import (
52
+ XcomPackage,
53
+ )
54
+ from .values import (
55
+ XcomValues,
56
+ XcomValuesItem,
57
+ )
58
+
59
+
60
+ _LOGGER = logging.getLogger(__name__)
61
+
62
+
63
+ ##
64
+ ## Base cass abstracting Xcom Api
65
+ ##
66
+ class AsyncXcomApiBase:
67
+
68
+ def __init__(self):
69
+ """
70
+ MOXA is connecting to the TCP Server we are creating here.
71
+ Once it is connected we can send package requests.
72
+ """
73
+ self._connected = False
74
+ self._remote_ip = None
75
+ self._request_id = 0
76
+ self._sendRequestLock = asyncio.Lock() # to make sure _sendRequest_inner is never called concurrently
77
+
78
+ # Cached values
79
+ self._msg_set = None
80
+
81
+ # Diagnostics gathering
82
+ self._diag_retries = {}
83
+ self._diag_durations = {}
84
+
85
+
86
+ async def start(self, timeout=START_TIMEOUT) -> bool:
87
+ """
88
+ Start the Xcom Server and listening to the Xcom client.
89
+ """
90
+ raise NotImplementedError()
91
+
92
+
93
+ async def stop(self):
94
+ """
95
+ Stop listening to the the Xcom Client and stop the Xcom Server.
96
+ """
97
+ raise NotImplementedError()
98
+
99
+
100
+ @property
101
+ def connected(self) -> bool:
102
+ """Returns True if the Xcom client is connected, otherwise False"""
103
+ return self._connected
104
+
105
+
106
+ @property
107
+ def remote_ip(self) -> str|None:
108
+ """Returns the IP address of the connected Xcom client, otherwise None"""
109
+ return self._remote_ip
110
+
111
+
112
+ async def _wait_until_connected(self, timeout) -> bool:
113
+ """Wait for Xcom client to connect. Or timout."""
114
+ try:
115
+ for i in range(timeout):
116
+ if self._connected:
117
+ return True
118
+
119
+ await asyncio.sleep(1)
120
+
121
+ except Exception as e:
122
+ _LOGGER.warning(f"Exception while checking connection to Xcom client: {e}")
123
+
124
+ return False
125
+
126
+
127
+ async def requestGuid(self, retries = None, timeout = None, verbose=False):
128
+ """
129
+ Request the GUID that is used for remotely identifying the installation.
130
+ This function is only for the Modem or Ethernet mode
131
+
132
+ Returns None if not connected, otherwise returns the requested value
133
+ Throws
134
+ XcomApiWriteException
135
+ XcomApiReadException
136
+ XcomApiTimeoutException
137
+ XcomApiResponseIsError
138
+ """
139
+
140
+ # Compose the request and send it
141
+ request: XcomPackage = XcomPackage.genPackage(
142
+ service_id = ScomService.READ,
143
+ object_type = ScomObjType.GUID,
144
+ object_id = ScomObjId.NONE,
145
+ property_id = ScomQspId.NONE,
146
+ property_data = XcomData.NONE,
147
+ dst_addr = ScomAddress.RCC
148
+ )
149
+
150
+ response = await self._sendRequest(request, retries=retries, timeout=timeout, verbose=verbose)
151
+ if response is not None:
152
+ # Unpack the response value
153
+ try:
154
+ return XcomData.unpack(response.frame_data.service_data.property_data, XcomFormat.GUID)
155
+
156
+ except Exception as e:
157
+ msg = f"Failed to unpack response package for GUID:{request.header.dst_addr}, data={response.frame_data.service_data.property_data.hex()}: {e}"
158
+ raise XcomApiUnpackException(msg) from None
159
+
160
+
161
+ async def requestValue(self, parameter: XcomDatapoint, dstAddr = 100, retries = None, timeout = None, verbose=False):
162
+ """
163
+ Request a param or info.
164
+ Returns None if not connected, otherwise returns the requested value
165
+ Throws
166
+ XcomApiWriteException
167
+ XcomApiReadException
168
+ XcomApiTimeoutException
169
+ XcomApiResponseIsError
170
+ """
171
+
172
+ # Check/convert input parameters
173
+ if type(dstAddr) is str:
174
+ dstAddr = XcomDeviceFamilies.getAddrByCode(dstAddr)
175
+
176
+ # Compose the request and send it
177
+ request: XcomPackage = XcomPackage.genPackage(
178
+ service_id = ScomService.READ,
179
+ object_type = ScomObjType.PARAMETER if parameter.category == XcomCategory.PARAMETER else ScomObjType.INFO,
180
+ object_id = parameter.nr,
181
+ property_id = ScomQspId.UNSAVED_VALUE if parameter.category == XcomCategory.PARAMETER else ScomQspId.VALUE,
182
+ property_data = XcomData.NONE,
183
+ dst_addr = dstAddr
184
+ )
185
+
186
+ response = await self._sendRequest(request, retries=retries, timeout=timeout, verbose=verbose)
187
+ if response is not None:
188
+ # Unpack the response value
189
+ try:
190
+ return XcomData.unpack(response.frame_data.service_data.property_data, parameter.format)
191
+
192
+ except Exception as e:
193
+ msg = f"Failed to unpack response package for {parameter.nr}:{dstAddr}, data={response.frame_data.service_data.property_data.hex()}: {e}"
194
+ raise XcomApiUnpackException(msg) from None
195
+
196
+
197
+ async def requestInfos(self, request_data: XcomValues, retries = None, timeout = None, verbose=False) -> XcomValues:
198
+ """
199
+ Request multiple infos in one call.
200
+ Per info you can indicate what device to get it from, or to get Average or Sum of multiple devices
201
+
202
+ Returns None if not connected, otherwise returns the list of requested values
203
+ Throws
204
+ XcomApiWriteException
205
+ XcomApiReadException
206
+ XcomApiTimeoutException
207
+ XcomApiResponseIsError
208
+
209
+ Note: this requires at least firmware version 1.6.74 on your Xcom-232i/Xcom-LAN.
210
+ On older versions it results in a 'Service not supported' response from the Xcom client
211
+ """
212
+
213
+ # Sanity check
214
+ for item in request_data.items:
215
+ if item.datapoint.category != XcomCategory.INFO:
216
+ raise XcomParamException(f"Invalid datapoint passed to requestInfos; must have type INFO. Violated by datapoint '{item.datapoint.name}' ({item.datapoint.nr})")
217
+
218
+ if item.aggregation_type not in XcomAggregationType:
219
+ raise XcomParamException(f"Invalid aggregation_type passed to requestInfos; violated by '{item.aggregation_type}'")
220
+
221
+ # Compose the request and send it
222
+ request: XcomPackage = XcomPackage.genPackage(
223
+ service_id = ScomService.READ,
224
+ object_type = ScomObjType.MULTI_INFO,
225
+ object_id = ScomObjId.MULTI_INFO,
226
+ property_id = self._getNextRequestId() & 0xffff,
227
+ property_data = request_data.packRequest(),
228
+ dst_addr = ScomAddress.RCC
229
+ )
230
+
231
+ response = await self._sendRequest(request, retries=retries, timeout=timeout, verbose=verbose)
232
+ if response is not None:
233
+ try:
234
+ # Unpack the response value
235
+ return XcomValues.unpackResponse(response.frame_data.service_data.property_data, request_data)
236
+
237
+ except Exception as e:
238
+ msg = f"Failed to unpack response package for multi-info request, data={response.frame_data.service_data.property_data.hex()}: {e}"
239
+ raise XcomApiUnpackException(msg) from None
240
+
241
+
242
+ async def requestValues(self, request_data: XcomValues, retries = None, timeout = None, verbose=False) -> XcomValues:
243
+ """
244
+ Request multiple infos and params in one call.
245
+ Can only retrieve actual device values, NOT the average or sum over multiple devices.
246
+
247
+ The function will try to be as efficient as possible and combine retrieval of multiple infos in one call.
248
+ When the xcom-client does not support multiple-infos in one call, they are retried one by one.
249
+ Requested params are always retrieved one by one, so the function can take a while to finish.
250
+
251
+ Returns None if not connected, otherwise returns the list of requested values
252
+ Throws
253
+ XcomApiWriteException
254
+ XcomApiReadException
255
+ XcomApiTimeoutException
256
+ XcomApiResponseIsError
257
+ """
258
+
259
+ # Sort out which XcomValues can be done via multi requestValues and which must be done via single requestValue
260
+ req_singles: list[XcomValuesItem] = []
261
+ req_multi_items: list[XcomValuesItem] = []
262
+ req_multis: list[XcomValues] = []
263
+ idx_last = safe_len(request_data.items)-1
264
+
265
+ for idx,item in enumerate(request_data.items):
266
+
267
+ match item.datapoint.category:
268
+ case XcomCategory.INFO:
269
+ if item.aggregation_type is not None and item.aggregation_type in range(XcomAggregationType.DEVICE1, XcomAggregationType.DEVICE15+1):
270
+ # Can be combined with other infos in a requestValues call
271
+ req_multi_items.append(item)
272
+
273
+ elif item.address is not None:
274
+ # Any others need to be done via an individual requestValue cal
275
+ req_singles.append(item)
276
+
277
+ else:
278
+ raise XcomParamException(f"Invalid XcomValuesItem passed to requestValues; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
279
+
280
+ case XcomCategory.PARAMETER:
281
+ if item.address is not None:
282
+ # Needs to be done via an individual requestValue call
283
+ req_singles.append(item)
284
+
285
+ else:
286
+ raise XcomParamException(f"Invalid XcomValuesItem passed to requestValues; violated by code='{item.code}', address={item.address}, aggregation_type={item.aggregation_type}")
287
+
288
+ if (len(req_multi_items) == MULTI_INFO_REQ_MAX) or \
289
+ (len(req_multi_items) > 0 and idx == idx_last):
290
+
291
+ # Start a new multi-items if current one if full or on last item of enumerate
292
+ req_multis.append( XcomValues(items=req_multi_items) )
293
+ req_multi_items = []
294
+
295
+ # Now perform all the multi requestValues requests
296
+ result_items: list[XcomValues] = []
297
+ burst_start = datetime.now()
298
+
299
+ for req_multi in req_multis:
300
+ try:
301
+ rsp_multi = await self.requestInfos(req_multi, retries=retries, timeout=timeout, verbose=verbose)
302
+
303
+ # Success; gather the returned response items
304
+ result_items.extend(rsp_multi.items)
305
+
306
+ except XcomApiTimeoutException as tex:
307
+ _LOGGER.debug(f"Failed to retrieve infos via single call. {tex}")
308
+
309
+ # Fail; do not retry as single requestValue also expected to give timeout
310
+ value = None
311
+ error = str(tex)
312
+ result_items.extend( [XcomValuesItem(req.datapoint, code=req.code, address=req.address, aggregation_type=req.aggregation_type, value=value, error=error) for req in req_multi.items] )
313
+
314
+ except Exception as ex:
315
+ _LOGGER.debug(f"Failed to retrieve infos via single call; will retry retrieve one-by-one. {ex}")
316
+
317
+ # Fail; retry all items as single requestValue
318
+ req_singles.extend(req_multi.items)
319
+
320
+ # Periodically wait for a second. This will make sure we do not block Xcom-LAN with
321
+ # too many requests at once and prevent it from uploading data to the Studer portal.
322
+ if (datetime.now() - burst_start).total_seconds() > REQ_BURST_PERIOD:
323
+ await asyncio.sleep(1)
324
+ burst_start = datetime.now()
325
+
326
+ # Next perform all the single requestValue requests
327
+ for req_single in req_singles:
328
+ try:
329
+ error = None
330
+ value = await self.requestValue(req_single.datapoint, req_single.address, retries=retries, timeout=timeout, verbose=verbose)
331
+
332
+ except Exception as ex:
333
+ value = None
334
+ error = str(ex)
335
+
336
+ if error is not None:
337
+ _LOGGER.debug(f"Failed to retrieve info or param {req_single.datapoint.nr}:{req_single.address}; {error}")
338
+
339
+ # Add to results
340
+ rsp_single = XcomValuesItem(
341
+ datapoint = req_single.datapoint,
342
+ code = req_single.code,
343
+ address = req_single.address,
344
+ aggregation_type=req_single.aggregation_type,
345
+ value = value,
346
+ error = error,
347
+ )
348
+ result_items.append(rsp_single)
349
+
350
+ # Periodically wait for a second. This will make sure we do not block Xcom-LAN with
351
+ # too many requests at once and prevent it from uploading data to the Studer portal.
352
+ if (datetime.now() - burst_start).total_seconds() > REQ_BURST_PERIOD:
353
+ await asyncio.sleep(1)
354
+ burst_start = datetime.now()
355
+
356
+ # Return all reponse items as one XcomValues object
357
+ return XcomValues(result_items)
358
+
359
+
360
+ async def updateValue(self, parameter: XcomDatapoint, value, dstAddr = 100, retries = None, timeout = None, verbose=False):
361
+ """
362
+ Update a param
363
+ Returns None if not connected, otherwise returns True on success
364
+ Throws
365
+ XcomApiWriteException
366
+ XcomApiReadException
367
+ XcomApiTimeoutException
368
+ XcomApiResponseIsError
369
+ """
370
+ # Sanity check: the parameter/datapoint must have category == XcomDatapointType.PARAMETER
371
+ if parameter.category != XcomCategory.PARAMETER:
372
+ _LOGGER.warning(f"Ignoring attempt to update readonly infos value {parameter}")
373
+ return None
374
+
375
+ if type(dstAddr) is str:
376
+ dstAddr = XcomDeviceFamilies.getAddrByCode(dstAddr)
377
+
378
+ _LOGGER.debug(f"Update value {parameter} on addr {dstAddr}")
379
+
380
+ # Compose the request and send it
381
+ request: XcomPackage = XcomPackage.genPackage(
382
+ service_id = ScomService.WRITE,
383
+ object_type = ScomObjType.PARAMETER,
384
+ object_id = parameter.nr,
385
+ property_id = ScomQspId.UNSAVED_VALUE,
386
+ property_data = XcomData.pack(value, parameter.format),
387
+ dst_addr = dstAddr
388
+ )
389
+
390
+ response = await self._sendRequest(request, retries=retries, timeout=timeout, verbose=verbose)
391
+ if response is not None:
392
+ # No need to unpack the response value
393
+ return True
394
+
395
+ return False
396
+
397
+
398
+ async def requestMessage(self, index:int = 0, retries = None, timeout = None, verbose=False) -> XcomMessage:
399
+ """
400
+ Request a Message from the RCC.
401
+ Reading a message with index 0 will return the last saved message in the flash memory of
402
+ the Xcom-232i and will also return the the number of remaining messages.
403
+ A side effect of reading a message with index 0 is that it will erase the flag informing
404
+ that there are new messages.
405
+
406
+ Reading a message with an index above 0 will return the message saved with that index.
407
+
408
+ Note: this requires at least firmware version 1.5.0 on your Xcom-232i/Xcom-LAN.
409
+ On older versions it results in a 'Service not supported' response from the Xcom client
410
+
411
+ Returns None if not connected, otherwise returns the requested value
412
+ Throws
413
+ XcomApiWriteException
414
+ XcomApiReadException
415
+ XcomApiTimeoutException
416
+ XcomApiResponseIsError
417
+ """
418
+
419
+ # Make sure we have access to the message_set
420
+ if self._msg_set is None:
421
+ self._msg_set = await AsyncXcomFactory.create_messageset()
422
+
423
+ # Compose the request and send it
424
+ request: XcomPackage = XcomPackage.genPackage(
425
+ service_id = ScomService.READ,
426
+ object_type = ScomObjType.MESSAGE,
427
+ object_id = index,
428
+ property_id = ScomQspId.NONE,
429
+ property_data = XcomData.NONE,
430
+ dst_addr = ScomAddress.RCC,
431
+ )
432
+
433
+ response = await self._sendRequest(request, retries=retries, timeout=timeout, verbose=verbose)
434
+ if response is not None:
435
+ # Unpack the response value
436
+ try:
437
+ # Unpack the response value
438
+ rsp_data = XcomDataMessageRsp.unpack(response.frame_data.service_data.property_data)
439
+ return XcomMessage(rsp_data, self._msg_set)
440
+
441
+ except Exception as e:
442
+ msg = f"Failed to unpack response package for message request, data={response.frame_data.service_data.property_data.hex()}: {e}"
443
+ raise XcomApiUnpackException(msg) from None
444
+
445
+
446
+ async def _sendRequest(self, request: XcomPackage, retries = None, timeout = None, verbose=False):
447
+
448
+ # Sometimes the Xcom client does not seem to pickup a request
449
+ # so retry if needed
450
+ if not self._connected:
451
+ _LOGGER.warning(f"_sendRequest - not connected")
452
+ return None
453
+
454
+ last_exception = None
455
+ retries = retries or REQ_RETRIES
456
+ timeout = timeout or REQ_TIMEOUT
457
+
458
+ for retry in range(retries):
459
+ try:
460
+ async with self._sendRequestLock:
461
+ ts_start = datetime.now()
462
+
463
+ response = await self._sendRequest_inner(request, timeout=timeout, verbose=verbose)
464
+
465
+ # Update diagnostics
466
+ ts_end = datetime.now()
467
+ await self._addDiagnostics(retries = retry, duration = ts_end-ts_start)
468
+
469
+ # Check the response
470
+ if response is None:
471
+ return None
472
+
473
+ if response.isError():
474
+ raise XcomApiResponseIsError(response.getError())
475
+
476
+ # Success
477
+ return response
478
+
479
+ except Exception as e:
480
+ last_exception = e
481
+
482
+ # Update diagnostics in case of timeout of each retry
483
+ await self._addDiagnostics(retries = retry)
484
+
485
+ if last_exception:
486
+ raise last_exception from None
487
+
488
+
489
+ async def _sendRequest_inner(self, request: XcomPackage, retries = None, timeout = None, verbose=False):
490
+ """
491
+ Send a request package to the Xcom client and wait for the correct response package
492
+ """
493
+
494
+ # Send the request package to the Xcom client
495
+ try:
496
+ if verbose:
497
+ data = request.getBytes()
498
+ _LOGGER.debug(f"send {len(data)} bytes ({binascii.hexlify(data).decode('ascii')}), decoded: {request}")
499
+
500
+ await self._sendPackage(request)
501
+
502
+ except Exception as e:
503
+ msg = f"Exception while sending request package to Xcom client: {e}"
504
+ raise XcomApiWriteException(msg) from None
505
+
506
+ # Receive packages until we get the one we expect
507
+ # We implement our own primitive timeout mechanism that
508
+ # is robust when converted from async to sync via unasyncd tool
509
+ ts_end = datetime.now() + timedelta(seconds=timeout)
510
+
511
+ while datetime.now() < ts_end:
512
+ try:
513
+ response = await self._receivePackage()
514
+
515
+ if response is None:
516
+ continue
517
+
518
+ if response.isResponse() and \
519
+ response.frame_data.service_id == request.frame_data.service_id and \
520
+ response.frame_data.service_data.object_id == request.frame_data.service_data.object_id and \
521
+ response.frame_data.service_data.property_id == request.frame_data.service_data.property_id:
522
+
523
+ # Yes, this is the answer to our request
524
+ if verbose:
525
+ data = response.getBytes()
526
+ _LOGGER.debug(f"recv {len(data)} bytes ({binascii.hexlify(data).decode('ascii')}), decoded: {response}")
527
+
528
+ return response
529
+ else:
530
+ # No, not an answer to our request, continue loop for next answer (or timeout)
531
+ if verbose:
532
+ data = response.getBytes()
533
+ _LOGGER.debug(f"skip {len(data)} bytes ({binascii.hexlify(data).decode('ascii')}), decoded: {response}")
534
+
535
+ except Exception as e:
536
+ msg = f"Exception while listening for response package from Xcom client: {e}"
537
+ raise XcomApiReadException() from None
538
+
539
+ # If we reach this point then there was a timeout
540
+ msg = f"Timeout while listening for response package from Xcom client"
541
+ raise XcomApiTimeoutException(msg) from None
542
+
543
+
544
+ async def _sendPackage(self, package: XcomPackage):
545
+ """
546
+ Send an Xcom package.
547
+ Exception handling is dealed with by the caller.
548
+
549
+ Must be implemented in derived classes.
550
+ """
551
+ raise NotImplementedError()
552
+
553
+
554
+ async def _receivePackage(self) -> XcomPackage | None:
555
+ """
556
+ Attempt to receive an Xcom package.
557
+ Return None of nothing was received within REQ_TIMEOUT.
558
+ Exception handling is dealed with by the caller.
559
+
560
+ Must be implemented in derived classes.
561
+ """
562
+ raise NotImplementedError()
563
+
564
+
565
+ def _getNextRequestId(self) -> int:
566
+ self._request_id += 1
567
+ return self._request_id
568
+
569
+
570
+ async def _addDiagnostics(self, retries: int = None, duration: timedelta = None):
571
+ if retries is not None:
572
+ if retries not in self._diag_retries:
573
+ self._diag_retries[retries] = 1
574
+ else:
575
+ self._diag_retries[retries] += 1
576
+
577
+ if duration is not None:
578
+ duration = round(duration.total_seconds(), 1)
579
+ if duration not in self._diag_durations:
580
+ self._diag_durations[duration] = 1
581
+ else:
582
+ self._diag_durations[duration] += 1
583
+
584
+
585
+ async def getDiagnostics(self):
586
+ return {
587
+ "statistics": {
588
+ "retries": dict(sorted(self._diag_retries.items())),
589
+ "durations": dict(sorted(self._diag_durations.items())),
590
+ }
591
+ }
592
+