aiohomematic 2025.8.6__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 aiohomematic might be problematic. Click here for more details.

Files changed (77) hide show
  1. aiohomematic/__init__.py +47 -0
  2. aiohomematic/async_support.py +146 -0
  3. aiohomematic/caches/__init__.py +10 -0
  4. aiohomematic/caches/dynamic.py +554 -0
  5. aiohomematic/caches/persistent.py +459 -0
  6. aiohomematic/caches/visibility.py +774 -0
  7. aiohomematic/central/__init__.py +2034 -0
  8. aiohomematic/central/decorators.py +110 -0
  9. aiohomematic/central/xml_rpc_server.py +267 -0
  10. aiohomematic/client/__init__.py +1746 -0
  11. aiohomematic/client/json_rpc.py +1193 -0
  12. aiohomematic/client/xml_rpc.py +222 -0
  13. aiohomematic/const.py +795 -0
  14. aiohomematic/context.py +8 -0
  15. aiohomematic/converter.py +82 -0
  16. aiohomematic/decorators.py +188 -0
  17. aiohomematic/exceptions.py +145 -0
  18. aiohomematic/hmcli.py +159 -0
  19. aiohomematic/model/__init__.py +137 -0
  20. aiohomematic/model/calculated/__init__.py +65 -0
  21. aiohomematic/model/calculated/climate.py +230 -0
  22. aiohomematic/model/calculated/data_point.py +319 -0
  23. aiohomematic/model/calculated/operating_voltage_level.py +311 -0
  24. aiohomematic/model/calculated/support.py +174 -0
  25. aiohomematic/model/custom/__init__.py +175 -0
  26. aiohomematic/model/custom/climate.py +1334 -0
  27. aiohomematic/model/custom/const.py +146 -0
  28. aiohomematic/model/custom/cover.py +741 -0
  29. aiohomematic/model/custom/data_point.py +318 -0
  30. aiohomematic/model/custom/definition.py +861 -0
  31. aiohomematic/model/custom/light.py +1092 -0
  32. aiohomematic/model/custom/lock.py +389 -0
  33. aiohomematic/model/custom/siren.py +268 -0
  34. aiohomematic/model/custom/support.py +40 -0
  35. aiohomematic/model/custom/switch.py +172 -0
  36. aiohomematic/model/custom/valve.py +112 -0
  37. aiohomematic/model/data_point.py +1109 -0
  38. aiohomematic/model/decorators.py +173 -0
  39. aiohomematic/model/device.py +1347 -0
  40. aiohomematic/model/event.py +210 -0
  41. aiohomematic/model/generic/__init__.py +211 -0
  42. aiohomematic/model/generic/action.py +32 -0
  43. aiohomematic/model/generic/binary_sensor.py +28 -0
  44. aiohomematic/model/generic/button.py +25 -0
  45. aiohomematic/model/generic/data_point.py +162 -0
  46. aiohomematic/model/generic/number.py +73 -0
  47. aiohomematic/model/generic/select.py +36 -0
  48. aiohomematic/model/generic/sensor.py +72 -0
  49. aiohomematic/model/generic/switch.py +52 -0
  50. aiohomematic/model/generic/text.py +27 -0
  51. aiohomematic/model/hub/__init__.py +334 -0
  52. aiohomematic/model/hub/binary_sensor.py +22 -0
  53. aiohomematic/model/hub/button.py +26 -0
  54. aiohomematic/model/hub/data_point.py +332 -0
  55. aiohomematic/model/hub/number.py +37 -0
  56. aiohomematic/model/hub/select.py +47 -0
  57. aiohomematic/model/hub/sensor.py +35 -0
  58. aiohomematic/model/hub/switch.py +42 -0
  59. aiohomematic/model/hub/text.py +28 -0
  60. aiohomematic/model/support.py +599 -0
  61. aiohomematic/model/update.py +136 -0
  62. aiohomematic/py.typed +0 -0
  63. aiohomematic/rega_scripts/fetch_all_device_data.fn +75 -0
  64. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  65. aiohomematic/rega_scripts/get_serial.fn +44 -0
  66. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  67. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  68. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  69. aiohomematic/support.py +482 -0
  70. aiohomematic/validator.py +65 -0
  71. aiohomematic-2025.8.6.dist-info/METADATA +69 -0
  72. aiohomematic-2025.8.6.dist-info/RECORD +77 -0
  73. aiohomematic-2025.8.6.dist-info/WHEEL +5 -0
  74. aiohomematic-2025.8.6.dist-info/licenses/LICENSE +21 -0
  75. aiohomematic-2025.8.6.dist-info/top_level.txt +2 -0
  76. aiohomematic_support/__init__.py +1 -0
  77. aiohomematic_support/client_local.py +349 -0
@@ -0,0 +1,554 @@
1
+ """
2
+ Dynamic caches used at runtime by the central unit and clients.
3
+
4
+ This module provides short-lived, in-memory caches that support robust and efficient
5
+ communication with HomeMatic interfaces:
6
+
7
+ - CommandCache: Tracks recently sent commands and their values per data point,
8
+ allowing suppression of immediate echo updates or reconciliation with incoming
9
+ events. Supports set_value, put_paramset, and combined parameters.
10
+ - DeviceDetailsCache: Enriches devices with human-readable names, interface
11
+ mapping, rooms, functions, and address IDs fetched via the backend.
12
+ - CentralDataCache: Stores recently fetched device/channel parameter values from
13
+ interfaces for quick lookup and periodic refresh.
14
+ - PingPongCache: Tracks ping/pong timestamps to detect connection health issues
15
+ and emits interface events on mismatch thresholds.
16
+
17
+ The caches are intentionally ephemeral and cleared/aged according to the rules in
18
+ constants to keep memory footprint predictable while improving responsiveness.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections import defaultdict
24
+ from collections.abc import Mapping
25
+ from datetime import datetime
26
+ import logging
27
+ from typing import Any, Final, cast
28
+
29
+ from aiohomematic import central as hmcu
30
+ from aiohomematic.const import (
31
+ DP_KEY_VALUE,
32
+ INIT_DATETIME,
33
+ LAST_COMMAND_SEND_STORE_TIMEOUT,
34
+ MAX_CACHE_AGE,
35
+ NO_CACHE_ENTRY,
36
+ PING_PONG_MISMATCH_COUNT,
37
+ PING_PONG_MISMATCH_COUNT_TTL,
38
+ CallSource,
39
+ DataPointKey,
40
+ EventKey,
41
+ EventType,
42
+ Interface,
43
+ InterfaceEventType,
44
+ ParamsetKey,
45
+ )
46
+ from aiohomematic.converter import CONVERTABLE_PARAMETERS, convert_combined_parameter_to_paramset
47
+ from aiohomematic.model.device import Device
48
+ from aiohomematic.support import changed_within_seconds, get_device_address
49
+
50
+ _LOGGER: Final = logging.getLogger(__name__)
51
+
52
+
53
+ class CommandCache:
54
+ """Cache for send commands."""
55
+
56
+ __slots__ = (
57
+ "_interface_id",
58
+ "_last_send_command",
59
+ )
60
+
61
+ def __init__(self, interface_id: str) -> None:
62
+ """Init command cache."""
63
+ self._interface_id: Final = interface_id
64
+ # (paramset_key, device_address, channel_no, parameter)
65
+ self._last_send_command: Final[dict[DataPointKey, tuple[Any, datetime]]] = {}
66
+
67
+ def add_set_value(
68
+ self,
69
+ channel_address: str,
70
+ parameter: str,
71
+ value: Any,
72
+ ) -> set[DP_KEY_VALUE]:
73
+ """Add data from set value command."""
74
+ if parameter in CONVERTABLE_PARAMETERS:
75
+ return self.add_combined_parameter(
76
+ parameter=parameter, channel_address=channel_address, combined_parameter=value
77
+ )
78
+
79
+ now_ts = datetime.now()
80
+ dpk = DataPointKey(
81
+ interface_id=self._interface_id,
82
+ channel_address=channel_address,
83
+ paramset_key=ParamsetKey.VALUES,
84
+ parameter=parameter,
85
+ )
86
+ self._last_send_command[dpk] = (value, now_ts)
87
+ return {(dpk, value)}
88
+
89
+ def add_put_paramset(
90
+ self, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
91
+ ) -> set[DP_KEY_VALUE]:
92
+ """Add data from put paramset command."""
93
+ dpk_values: set[DP_KEY_VALUE] = set()
94
+ now_ts = datetime.now()
95
+ for parameter, value in values.items():
96
+ dpk = DataPointKey(
97
+ interface_id=self._interface_id,
98
+ channel_address=channel_address,
99
+ paramset_key=paramset_key,
100
+ parameter=parameter,
101
+ )
102
+ self._last_send_command[dpk] = (value, now_ts)
103
+ dpk_values.add((dpk, value))
104
+ return dpk_values
105
+
106
+ def add_combined_parameter(
107
+ self, parameter: str, channel_address: str, combined_parameter: str
108
+ ) -> set[DP_KEY_VALUE]:
109
+ """Add data from combined parameter."""
110
+ if values := convert_combined_parameter_to_paramset(parameter=parameter, cpv=combined_parameter):
111
+ return self.add_put_paramset(
112
+ channel_address=channel_address,
113
+ paramset_key=ParamsetKey.VALUES,
114
+ values=values,
115
+ )
116
+ return set()
117
+
118
+ def get_last_value_send(self, dpk: DataPointKey, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT) -> Any:
119
+ """Return the last send values."""
120
+ if result := self._last_send_command.get(dpk):
121
+ value, last_send_dt = result
122
+ if last_send_dt and changed_within_seconds(last_change=last_send_dt, max_age=max_age):
123
+ return value
124
+ self.remove_last_value_send(
125
+ dpk=dpk,
126
+ max_age=max_age,
127
+ )
128
+ return None
129
+
130
+ def remove_last_value_send(
131
+ self,
132
+ dpk: DataPointKey,
133
+ value: Any = None,
134
+ max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT,
135
+ ) -> None:
136
+ """Remove the last send value."""
137
+ if result := self._last_send_command.get(dpk):
138
+ stored_value, last_send_dt = result
139
+ if not changed_within_seconds(last_change=last_send_dt, max_age=max_age) or (
140
+ value is not None and stored_value == value
141
+ ):
142
+ del self._last_send_command[dpk]
143
+
144
+
145
+ class DeviceDetailsCache:
146
+ """Cache for device/channel details."""
147
+
148
+ __slots__ = (
149
+ "_central",
150
+ "_channel_rooms",
151
+ "_device_channel_ids",
152
+ "_device_rooms",
153
+ "_functions",
154
+ "_interface_cache",
155
+ "_names_cache",
156
+ "_refreshed_at",
157
+ )
158
+
159
+ def __init__(self, central: hmcu.CentralUnit) -> None:
160
+ """Init the device details cache."""
161
+ self._central: Final = central
162
+ self._channel_rooms: Final[dict[str, set[str]]] = defaultdict(set)
163
+ self._device_channel_ids: Final[dict[str, str]] = {}
164
+ self._device_rooms: Final[dict[str, set[str]]] = defaultdict(set)
165
+ self._functions: Final[dict[str, set[str]]] = {}
166
+ self._interface_cache: Final[dict[str, Interface]] = {}
167
+ self._names_cache: Final[dict[str, str]] = {}
168
+ self._refreshed_at = INIT_DATETIME
169
+
170
+ async def load(self, direct_call: bool = False) -> None:
171
+ """Fetch names from backend."""
172
+ if direct_call is False and changed_within_seconds(
173
+ last_change=self._refreshed_at, max_age=int(MAX_CACHE_AGE / 3)
174
+ ):
175
+ return
176
+ self.clear()
177
+ _LOGGER.debug("LOAD: Loading names for %s", self._central.name)
178
+ if client := self._central.primary_client:
179
+ await client.fetch_device_details()
180
+ _LOGGER.debug("LOAD: Loading rooms for %s", self._central.name)
181
+ self._channel_rooms.clear()
182
+ self._channel_rooms.update(await self._get_all_rooms())
183
+ self._device_rooms.clear()
184
+ self._device_rooms.update(self._prepare_device_rooms())
185
+ _LOGGER.debug("LOAD: Loading functions for %s", self._central.name)
186
+ self._functions.clear()
187
+ self._functions.update(await self._get_all_functions())
188
+ self._refreshed_at = datetime.now()
189
+
190
+ @property
191
+ def device_channel_ids(self) -> Mapping[str, str]:
192
+ """Return device channel ids."""
193
+ return self._device_channel_ids
194
+
195
+ def add_name(self, address: str, name: str) -> None:
196
+ """Add name to cache."""
197
+ self._names_cache[address] = name
198
+
199
+ def get_name(self, address: str) -> str | None:
200
+ """Get name from cache."""
201
+ return self._names_cache.get(address)
202
+
203
+ def add_interface(self, address: str, interface: Interface) -> None:
204
+ """Add interface to cache."""
205
+ self._interface_cache[address] = interface
206
+
207
+ def get_interface(self, address: str) -> Interface:
208
+ """Get interface from cache."""
209
+ return self._interface_cache.get(address) or Interface.BIDCOS_RF
210
+
211
+ def add_address_id(self, address: str, hmid: str) -> None:
212
+ """Add channel id for a channel."""
213
+ self._device_channel_ids[address] = hmid
214
+
215
+ def get_address_id(self, address: str) -> str:
216
+ """Get id for address."""
217
+ return self._device_channel_ids.get(address) or "0"
218
+
219
+ async def _get_all_rooms(self) -> Mapping[str, set[str]]:
220
+ """Get all rooms, if available."""
221
+ if client := self._central.primary_client:
222
+ return await client.get_all_rooms()
223
+ return {}
224
+
225
+ def _prepare_device_rooms(self) -> dict[str, set[str]]:
226
+ """Return rooms by device_address."""
227
+ _device_rooms: Final[dict[str, set[str]]] = defaultdict(set)
228
+ for channel_address, rooms in self._channel_rooms.items():
229
+ if rooms:
230
+ _device_rooms[get_device_address(address=channel_address)].update(rooms)
231
+ return _device_rooms
232
+
233
+ def get_device_rooms(self, device_address: str) -> set[str]:
234
+ """Return all rooms by device_address."""
235
+ return set(self._device_rooms.get(device_address, ()))
236
+
237
+ def get_channel_rooms(self, channel_address: str) -> set[str]:
238
+ """Return rooms by channel_address."""
239
+ return self._channel_rooms[channel_address]
240
+
241
+ async def _get_all_functions(self) -> Mapping[str, set[str]]:
242
+ """Get all functions, if available."""
243
+ if client := self._central.primary_client:
244
+ return await client.get_all_functions()
245
+ return {}
246
+
247
+ def get_function_text(self, address: str) -> str | None:
248
+ """Return function by address."""
249
+ if functions := self._functions.get(address):
250
+ return ",".join(functions)
251
+ return None
252
+
253
+ def remove_device(self, device: Device) -> None:
254
+ """Remove name from cache."""
255
+ if device.address in self._names_cache:
256
+ del self._names_cache[device.address]
257
+ for channel_address in device.channels:
258
+ if channel_address in self._names_cache:
259
+ del self._names_cache[channel_address]
260
+
261
+ def clear(self) -> None:
262
+ """Clear the cache."""
263
+ self._names_cache.clear()
264
+ self._channel_rooms.clear()
265
+ self._device_rooms.clear()
266
+ self._functions.clear()
267
+ self._refreshed_at = INIT_DATETIME
268
+
269
+
270
+ class CentralDataCache:
271
+ """Central cache for device/channel initial data."""
272
+
273
+ __slots__ = (
274
+ "_central",
275
+ "_escaped_channel_cache",
276
+ "_refreshed_at",
277
+ "_value_cache",
278
+ )
279
+
280
+ def __init__(self, central: hmcu.CentralUnit) -> None:
281
+ """Init the central data cache."""
282
+ self._central: Final = central
283
+ # { key, value}
284
+ self._value_cache: Final[dict[Interface, Mapping[str, Any]]] = {}
285
+ self._refreshed_at: Final[dict[Interface, datetime]] = {}
286
+ self._escaped_channel_cache: Final[dict[str, str]] = {}
287
+
288
+ async def load(self, direct_call: bool = False, interface: Interface | None = None) -> None:
289
+ """Fetch data from backend."""
290
+ _LOGGER.debug("load: Loading device data for %s", self._central.name)
291
+ for client in self._central.clients:
292
+ if interface and interface != client.interface:
293
+ continue
294
+ if direct_call is False and changed_within_seconds(
295
+ last_change=self._get_refreshed_at(interface=client.interface),
296
+ max_age=int(MAX_CACHE_AGE / 3),
297
+ ):
298
+ return
299
+ await client.fetch_all_device_data()
300
+
301
+ async def refresh_data_point_data(
302
+ self,
303
+ paramset_key: ParamsetKey | None = None,
304
+ interface: Interface | None = None,
305
+ direct_call: bool = False,
306
+ ) -> None:
307
+ """Refresh data_point data."""
308
+ for dp in self._central.get_readable_generic_data_points(paramset_key=paramset_key, interface=interface):
309
+ await dp.load_data_point_value(call_source=CallSource.HM_INIT, direct_call=direct_call)
310
+
311
+ def add_data(self, interface: Interface, all_device_data: Mapping[str, Any]) -> None:
312
+ """Add data to cache."""
313
+ self._value_cache[interface] = all_device_data
314
+ self._refreshed_at[interface] = datetime.now()
315
+
316
+ def get_data(
317
+ self,
318
+ interface: Interface,
319
+ channel_address: str,
320
+ parameter: str,
321
+ ) -> Any:
322
+ """Get data from cache."""
323
+ if not self._is_empty(interface=interface):
324
+ # Escape channel address only once per unique address
325
+ if (escaped := self._escaped_channel_cache.get(channel_address)) is None:
326
+ escaped = channel_address.replace(":", "%3A") if ":" in channel_address else channel_address
327
+ self._escaped_channel_cache[channel_address] = escaped
328
+ key = f"{interface}.{escaped}.{parameter}"
329
+ if (iface_cache := self._value_cache.get(interface)) is not None:
330
+ return iface_cache.get(key, NO_CACHE_ENTRY)
331
+ return NO_CACHE_ENTRY
332
+
333
+ def clear(self, interface: Interface | None = None) -> None:
334
+ """Clear the cache."""
335
+ if interface:
336
+ self._value_cache[interface] = {}
337
+ self._refreshed_at[interface] = INIT_DATETIME
338
+ self._escaped_channel_cache.clear()
339
+ else:
340
+ for _interface in self._central.interfaces:
341
+ self.clear(interface=_interface)
342
+
343
+ def _get_refreshed_at(self, interface: Interface) -> datetime:
344
+ """Return when cache has been refreshed."""
345
+ return self._refreshed_at.get(interface, INIT_DATETIME)
346
+
347
+ def _is_empty(self, interface: Interface) -> bool:
348
+ """Return if cache is empty for the given interface."""
349
+ # If there is no data stored for the requested interface, treat as empty.
350
+ if not self._value_cache.get(interface):
351
+ return True
352
+ # Auto-expire stale cache by interface.
353
+ if not changed_within_seconds(last_change=self._get_refreshed_at(interface=interface)):
354
+ self.clear(interface=interface)
355
+ return True
356
+ return False
357
+
358
+
359
+ class PingPongCache:
360
+ """Cache to collect ping/pong events with ttl."""
361
+
362
+ __slots__ = (
363
+ "_allowed_delta",
364
+ "_central",
365
+ "_interface_id",
366
+ "_pending_pong_logged",
367
+ "_pending_pongs",
368
+ "_ttl",
369
+ "_unknown_pong_logged",
370
+ "_unknown_pongs",
371
+ )
372
+
373
+ def __init__(
374
+ self,
375
+ central: hmcu.CentralUnit,
376
+ interface_id: str,
377
+ allowed_delta: int = PING_PONG_MISMATCH_COUNT,
378
+ ttl: int = PING_PONG_MISMATCH_COUNT_TTL,
379
+ ):
380
+ """Initialize the cache with ttl."""
381
+ assert ttl > 0
382
+ self._central: Final = central
383
+ self._interface_id: Final = interface_id
384
+ self._allowed_delta: Final = allowed_delta
385
+ self._ttl: Final = ttl
386
+ self._pending_pongs: Final[set[datetime]] = set()
387
+ self._unknown_pongs: Final[set[datetime]] = set()
388
+ self._pending_pong_logged: bool = False
389
+ self._unknown_pong_logged: bool = False
390
+
391
+ @property
392
+ def high_pending_pongs(self) -> bool:
393
+ """Check, if store contains too many pending pongs."""
394
+ self._cleanup_pending_pongs()
395
+ return len(self._pending_pongs) > self._allowed_delta
396
+
397
+ @property
398
+ def high_unknown_pongs(self) -> bool:
399
+ """Check, if store contains too many unknown pongs."""
400
+ self._cleanup_unknown_pongs()
401
+ return len(self._unknown_pongs) > self._allowed_delta
402
+
403
+ @property
404
+ def low_pending_pongs(self) -> bool:
405
+ """Return the pending pong count is low."""
406
+ self._cleanup_pending_pongs()
407
+ return len(self._pending_pongs) < (self._allowed_delta / 2)
408
+
409
+ @property
410
+ def low_unknown_pongs(self) -> bool:
411
+ """Return the unknown pong count is low."""
412
+ self._cleanup_unknown_pongs()
413
+ return len(self._unknown_pongs) < (self._allowed_delta / 2)
414
+
415
+ @property
416
+ def pending_pong_count(self) -> int:
417
+ """Return the pending pong count."""
418
+ return len(self._pending_pongs)
419
+
420
+ @property
421
+ def unknown_pong_count(self) -> int:
422
+ """Return the unknown pong count."""
423
+ return len(self._unknown_pongs)
424
+
425
+ def clear(self) -> None:
426
+ """Clear the cache."""
427
+ self._pending_pongs.clear()
428
+ self._unknown_pongs.clear()
429
+ self._pending_pong_logged = False
430
+ self._unknown_pong_logged = False
431
+
432
+ def handle_send_ping(self, ping_ts: datetime) -> None:
433
+ """Handle send ping timestamp."""
434
+ self._pending_pongs.add(ping_ts)
435
+ self._check_and_fire_pong_event(
436
+ event_type=InterfaceEventType.PENDING_PONG,
437
+ pong_mismatch_count=self.pending_pong_count,
438
+ )
439
+ _LOGGER.debug(
440
+ "PING PONG CACHE: Increase pending PING count: %s - %i for ts: %s",
441
+ self._interface_id,
442
+ self.pending_pong_count,
443
+ ping_ts,
444
+ )
445
+
446
+ def handle_received_pong(self, pong_ts: datetime) -> None:
447
+ """Handle received pong timestamp."""
448
+ if pong_ts in self._pending_pongs:
449
+ self._pending_pongs.remove(pong_ts)
450
+ self._check_and_fire_pong_event(
451
+ event_type=InterfaceEventType.PENDING_PONG,
452
+ pong_mismatch_count=self.pending_pong_count,
453
+ )
454
+ _LOGGER.debug(
455
+ "PING PONG CACHE: Reduce pending PING count: %s - %i for ts: %s",
456
+ self._interface_id,
457
+ self.pending_pong_count,
458
+ pong_ts,
459
+ )
460
+ return
461
+
462
+ self._unknown_pongs.add(pong_ts)
463
+ self._check_and_fire_pong_event(
464
+ event_type=InterfaceEventType.UNKNOWN_PONG,
465
+ pong_mismatch_count=self.unknown_pong_count,
466
+ )
467
+ _LOGGER.debug(
468
+ "PING PONG CACHE: Increase unknown PONG count: %s - %i for ts: %s",
469
+ self._interface_id,
470
+ self.unknown_pong_count,
471
+ pong_ts,
472
+ )
473
+
474
+ def _cleanup_pending_pongs(self) -> None:
475
+ """Cleanup too old pending pongs."""
476
+ dt_now = datetime.now()
477
+ for pong_ts in list(self._pending_pongs):
478
+ delta = dt_now - pong_ts
479
+ if delta.seconds > self._ttl:
480
+ self._pending_pongs.remove(pong_ts)
481
+ _LOGGER.debug(
482
+ "PING PONG CACHE: Removing expired pending PONG: %s - %i for ts: %s",
483
+ self._interface_id,
484
+ self.pending_pong_count,
485
+ pong_ts,
486
+ )
487
+
488
+ def _cleanup_unknown_pongs(self) -> None:
489
+ """Cleanup too old unknown pongs."""
490
+ dt_now = datetime.now()
491
+ for pong_ts in list(self._unknown_pongs):
492
+ delta = dt_now - pong_ts
493
+ if delta.seconds > self._ttl:
494
+ self._unknown_pongs.remove(pong_ts)
495
+ _LOGGER.debug(
496
+ "PING PONG CACHE: Removing expired unknown PONG: %s - %i or ts: %s",
497
+ self._interface_id,
498
+ self.unknown_pong_count,
499
+ pong_ts,
500
+ )
501
+
502
+ def _check_and_fire_pong_event(self, event_type: InterfaceEventType, pong_mismatch_count: int) -> None:
503
+ """Fire an event about the pong status."""
504
+
505
+ def _fire_event(mismatch_count: int) -> None:
506
+ self._central.fire_homematic_callback(
507
+ event_type=EventType.INTERFACE,
508
+ event_data=cast(
509
+ dict[EventKey, Any],
510
+ hmcu.INTERFACE_EVENT_SCHEMA(
511
+ {
512
+ EventKey.INTERFACE_ID: self._interface_id,
513
+ EventKey.TYPE: event_type,
514
+ EventKey.DATA: {
515
+ EventKey.CENTRAL_NAME: self._central.name,
516
+ EventKey.PONG_MISMATCH_COUNT: mismatch_count,
517
+ },
518
+ }
519
+ ),
520
+ ),
521
+ )
522
+
523
+ if self.low_pending_pongs and event_type == InterfaceEventType.PENDING_PONG:
524
+ _fire_event(mismatch_count=0)
525
+ self._pending_pong_logged = False
526
+ return
527
+
528
+ if self.low_unknown_pongs and event_type == InterfaceEventType.UNKNOWN_PONG:
529
+ self._unknown_pong_logged = False
530
+ return
531
+
532
+ if self.high_pending_pongs and event_type == InterfaceEventType.PENDING_PONG:
533
+ _fire_event(mismatch_count=pong_mismatch_count)
534
+ if self._pending_pong_logged is False:
535
+ _LOGGER.warning(
536
+ "Pending PONG mismatch: There is a mismatch between send ping events and received pong events for instance %s. "
537
+ "Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
538
+ "Re-add one instance! Otherwise this instance will not receive update events from your CCU. "
539
+ "Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart."
540
+ "Possible reason 3: Your setup is misconfigured and this instance is not able to receive events from the CCU.",
541
+ self._interface_id,
542
+ )
543
+ self._pending_pong_logged = True
544
+
545
+ if self.high_unknown_pongs and event_type == InterfaceEventType.UNKNOWN_PONG:
546
+ if self._unknown_pong_logged is False:
547
+ _LOGGER.warning(
548
+ "Unknown PONG Mismatch: Your instance %s receives PONG events, that it hasn't send. "
549
+ "Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
550
+ "Re-add one instance! Otherwise the other instance will not receive update events from your CCU. "
551
+ "Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart.",
552
+ self._interface_id,
553
+ )
554
+ self._unknown_pong_logged = True