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