aiohomematic 2025.10.21__py3-none-any.whl → 2025.10.24__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/central/__init__.py +29 -29
- aiohomematic/central/decorators.py +4 -4
- aiohomematic/client/__init__.py +17 -15
- aiohomematic/client/json_rpc.py +11 -6
- aiohomematic/client/rpc_proxy.py +2 -2
- aiohomematic/const.py +2 -2
- aiohomematic/hmcli.py +24 -9
- aiohomematic/model/calculated/data_point.py +6 -10
- aiohomematic/model/custom/data_point.py +2 -2
- aiohomematic/model/custom/definition.py +4 -1
- aiohomematic/model/custom/light.py +1 -1
- aiohomematic/model/data_point.py +19 -19
- aiohomematic/model/device.py +7 -7
- aiohomematic/model/event.py +7 -7
- aiohomematic/model/generic/data_point.py +2 -2
- aiohomematic/model/hub/__init__.py +2 -2
- aiohomematic/model/hub/data_point.py +3 -3
- aiohomematic/property_decorators.py +4 -4
- aiohomematic/store/dynamic.py +82 -102
- aiohomematic/store/visibility.py +4 -5
- {aiohomematic-2025.10.21.dist-info → aiohomematic-2025.10.24.dist-info}/METADATA +8 -2
- {aiohomematic-2025.10.21.dist-info → aiohomematic-2025.10.24.dist-info}/RECORD +26 -25
- aiohomematic-2025.10.24.dist-info/entry_points.txt +2 -0
- {aiohomematic-2025.10.21.dist-info → aiohomematic-2025.10.24.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.10.21.dist-info → aiohomematic-2025.10.24.dist-info}/licenses/LICENSE +0 -0
- {aiohomematic-2025.10.21.dist-info → aiohomematic-2025.10.24.dist-info}/top_level.txt +0 -0
aiohomematic/model/data_point.py
CHANGED
|
@@ -143,7 +143,7 @@ class CallbackDataPoint(ABC, LogContextMixin):
|
|
|
143
143
|
"_custom_id",
|
|
144
144
|
"_data_point_updated_callbacks",
|
|
145
145
|
"_device_removed_callbacks",
|
|
146
|
-
"
|
|
146
|
+
"_emitted_event_at",
|
|
147
147
|
"_modified_at",
|
|
148
148
|
"_path_data",
|
|
149
149
|
"_refreshed_at",
|
|
@@ -163,7 +163,7 @@ class CallbackDataPoint(ABC, LogContextMixin):
|
|
|
163
163
|
self._device_removed_callbacks: list[Callable] = []
|
|
164
164
|
self._custom_id: str | None = None
|
|
165
165
|
self._path_data = self._get_path_data()
|
|
166
|
-
self.
|
|
166
|
+
self._emitted_event_at: datetime = INIT_DATETIME
|
|
167
167
|
self._modified_at: datetime = INIT_DATETIME
|
|
168
168
|
self._refreshed_at: datetime = INIT_DATETIME
|
|
169
169
|
self._signature: Final = self._get_signature()
|
|
@@ -191,16 +191,16 @@ class CallbackDataPoint(ABC, LogContextMixin):
|
|
|
191
191
|
return self._custom_id
|
|
192
192
|
|
|
193
193
|
@property
|
|
194
|
-
def
|
|
195
|
-
"""Return the data point updated
|
|
196
|
-
return self.
|
|
194
|
+
def emitted_event_at(self) -> datetime:
|
|
195
|
+
"""Return the data point updated emitted an event at."""
|
|
196
|
+
return self._emitted_event_at
|
|
197
197
|
|
|
198
198
|
@state_property
|
|
199
|
-
def
|
|
200
|
-
"""Return the data point
|
|
201
|
-
if self.
|
|
199
|
+
def emitted_event_recently(self) -> bool:
|
|
200
|
+
"""Return the data point emitted an event within 500 milliseconds."""
|
|
201
|
+
if self._emitted_event_at == INIT_DATETIME:
|
|
202
202
|
return False
|
|
203
|
-
return (datetime.now() - self.
|
|
203
|
+
return (datetime.now() - self._emitted_event_at).total_seconds() < 0.5
|
|
204
204
|
|
|
205
205
|
@classmethod
|
|
206
206
|
def default_category(cls) -> DataPointCategory:
|
|
@@ -357,11 +357,11 @@ class CallbackDataPoint(ABC, LogContextMixin):
|
|
|
357
357
|
self._device_removed_callbacks.remove(cb)
|
|
358
358
|
|
|
359
359
|
@loop_check
|
|
360
|
-
def
|
|
360
|
+
def emit_data_point_updated_event(self, **kwargs: Any) -> None:
|
|
361
361
|
"""Do what is needed when the value of the data_point has been updated/refreshed."""
|
|
362
|
-
if not self.
|
|
362
|
+
if not self._should_emit_data_point_updated_callback:
|
|
363
363
|
return
|
|
364
|
-
self.
|
|
364
|
+
self._emitted_event_at = datetime.now()
|
|
365
365
|
for callback_handler, custom_id in self._data_point_updated_callbacks.items():
|
|
366
366
|
try:
|
|
367
367
|
# Add the data_point reference once to kwargs to avoid per-callback writes.
|
|
@@ -369,19 +369,19 @@ class CallbackDataPoint(ABC, LogContextMixin):
|
|
|
369
369
|
kwargs[KWARGS_ARG_CUSTOM_ID] = custom_id
|
|
370
370
|
callback_handler(**kwargs)
|
|
371
371
|
except Exception as exc:
|
|
372
|
-
_LOGGER.warning("
|
|
372
|
+
_LOGGER.warning("EMIT_DATA_POINT_UPDATED_EVENT failed: %s", extract_exc_args(exc=exc))
|
|
373
373
|
|
|
374
374
|
@loop_check
|
|
375
|
-
def
|
|
375
|
+
def emit_device_removed_event(self) -> None:
|
|
376
376
|
"""Do what is needed when the data_point has been removed."""
|
|
377
377
|
for callback_handler in self._device_removed_callbacks:
|
|
378
378
|
try:
|
|
379
379
|
callback_handler()
|
|
380
380
|
except Exception as exc:
|
|
381
|
-
_LOGGER.warning("
|
|
381
|
+
_LOGGER.warning("EMIT_DEVICE_REMOVED_EVENT failed: %s", extract_exc_args(exc=exc))
|
|
382
382
|
|
|
383
383
|
@property
|
|
384
|
-
def
|
|
384
|
+
def _should_emit_data_point_updated_callback(self) -> bool:
|
|
385
385
|
"""Check if a data point has been updated or refreshed."""
|
|
386
386
|
return True
|
|
387
387
|
|
|
@@ -895,7 +895,7 @@ class BaseParameterDataPoint[
|
|
|
895
895
|
if value == NO_CACHE_ENTRY:
|
|
896
896
|
if self.refreshed_at != INIT_DATETIME:
|
|
897
897
|
self._state_uncertain = True
|
|
898
|
-
self.
|
|
898
|
+
self.emit_data_point_updated_event()
|
|
899
899
|
return (old_value, None) # type: ignore[return-value]
|
|
900
900
|
|
|
901
901
|
new_value = self._convert_value(value=value)
|
|
@@ -906,7 +906,7 @@ class BaseParameterDataPoint[
|
|
|
906
906
|
self._previous_value = old_value
|
|
907
907
|
self._current_value = new_value
|
|
908
908
|
self._state_uncertain = False
|
|
909
|
-
self.
|
|
909
|
+
self.emit_data_point_updated_event()
|
|
910
910
|
return (old_value, new_value)
|
|
911
911
|
|
|
912
912
|
def write_temporary_value(self, *, value: Any, write_at: datetime) -> None:
|
|
@@ -920,7 +920,7 @@ class BaseParameterDataPoint[
|
|
|
920
920
|
self._set_temporary_modified_at(modified_at=write_at)
|
|
921
921
|
self._temporary_value = temp_value
|
|
922
922
|
self._state_uncertain = True
|
|
923
|
-
self.
|
|
923
|
+
self.emit_data_point_updated_event()
|
|
924
924
|
|
|
925
925
|
def update_parameter_data(self) -> None:
|
|
926
926
|
"""Update parameter data."""
|
aiohomematic/model/device.py
CHANGED
|
@@ -598,7 +598,7 @@ class Device(LogContextMixin, PayloadMixin):
|
|
|
598
598
|
if self._forced_availability != forced_availability:
|
|
599
599
|
self._forced_availability = forced_availability
|
|
600
600
|
for dp in self.generic_data_points:
|
|
601
|
-
dp.
|
|
601
|
+
dp.emit_data_point_updated_event()
|
|
602
602
|
|
|
603
603
|
@inspector
|
|
604
604
|
async def export_device_definition(self) -> None:
|
|
@@ -674,17 +674,17 @@ class Device(LogContextMixin, PayloadMixin):
|
|
|
674
674
|
await self._central.save_files(save_paramset_descriptions=True)
|
|
675
675
|
for dp in self.generic_data_points:
|
|
676
676
|
dp.update_parameter_data()
|
|
677
|
-
self.
|
|
677
|
+
self.emit_device_updated_callback()
|
|
678
678
|
|
|
679
679
|
@loop_check
|
|
680
|
-
def
|
|
680
|
+
def emit_device_updated_callback(self) -> None:
|
|
681
681
|
"""Do what is needed when the state of the device has been updated."""
|
|
682
682
|
self._set_modified_at()
|
|
683
683
|
for callback_handler in self._device_updated_callbacks:
|
|
684
684
|
try:
|
|
685
685
|
callback_handler()
|
|
686
686
|
except Exception as exc:
|
|
687
|
-
_LOGGER.warning("
|
|
687
|
+
_LOGGER.warning("EMIT_DEVICE_UPDATED failed: %s", extract_exc_args(exc=exc))
|
|
688
688
|
|
|
689
689
|
def __str__(self) -> str:
|
|
690
690
|
"""Provide some useful information."""
|
|
@@ -963,7 +963,7 @@ class Channel(LogContextMixin, PayloadMixin):
|
|
|
963
963
|
self._calculated_data_points[data_point.dpk] = data_point
|
|
964
964
|
if isinstance(data_point, GenericDataPoint):
|
|
965
965
|
self._generic_data_points[data_point.dpk] = data_point
|
|
966
|
-
self._device.register_device_updated_callback(cb=data_point.
|
|
966
|
+
self._device.register_device_updated_callback(cb=data_point.emit_data_point_updated_event)
|
|
967
967
|
if isinstance(data_point, hmce.CustomDataPoint):
|
|
968
968
|
self._custom_data_point = data_point
|
|
969
969
|
if isinstance(data_point, GenericEvent):
|
|
@@ -977,12 +977,12 @@ class Channel(LogContextMixin, PayloadMixin):
|
|
|
977
977
|
del self._calculated_data_points[data_point.dpk]
|
|
978
978
|
if isinstance(data_point, GenericDataPoint):
|
|
979
979
|
del self._generic_data_points[data_point.dpk]
|
|
980
|
-
self._device.unregister_device_updated_callback(cb=data_point.
|
|
980
|
+
self._device.unregister_device_updated_callback(cb=data_point.emit_data_point_updated_event)
|
|
981
981
|
if isinstance(data_point, hmce.CustomDataPoint):
|
|
982
982
|
self._custom_data_point = None
|
|
983
983
|
if isinstance(data_point, GenericEvent):
|
|
984
984
|
del self._generic_events[data_point.dpk]
|
|
985
|
-
data_point.
|
|
985
|
+
data_point.emit_device_removed_event()
|
|
986
986
|
|
|
987
987
|
def remove(self) -> None:
|
|
988
988
|
"""Remove data points from collections and central."""
|
aiohomematic/model/event.py
CHANGED
|
@@ -8,7 +8,7 @@ button presses, device errors, and impulse notifications to applications.
|
|
|
8
8
|
|
|
9
9
|
Included classes:
|
|
10
10
|
- GenericEvent: Base event that integrates with the common data point API
|
|
11
|
-
(category, usage, names/paths, callbacks) and provides
|
|
11
|
+
(category, usage, names/paths, callbacks) and provides emit_event handling.
|
|
12
12
|
- ClickEvent: Represents key press events (EventType.KEYPRESS).
|
|
13
13
|
- DeviceErrorEvent: Represents device error signaling with special value change
|
|
14
14
|
semantics before emitting an event (EventType.DEVICE_ERROR).
|
|
@@ -102,14 +102,14 @@ class GenericEvent(BaseParameterDataPoint[Any, Any]):
|
|
|
102
102
|
async def event(self, *, value: Any, received_at: datetime) -> None:
|
|
103
103
|
"""Handle event for which this handler has subscribed."""
|
|
104
104
|
if self.event_type in DATA_POINT_EVENTS:
|
|
105
|
-
self.
|
|
105
|
+
self.emit_data_point_updated_event()
|
|
106
106
|
self._set_modified_at(modified_at=received_at)
|
|
107
|
-
self.
|
|
107
|
+
self.emit_event(value=value)
|
|
108
108
|
|
|
109
109
|
@loop_check
|
|
110
|
-
def
|
|
111
|
-
"""Do what is needed to
|
|
112
|
-
self._central.
|
|
110
|
+
def emit_event(self, *, value: Any) -> None:
|
|
111
|
+
"""Do what is needed to emit an event."""
|
|
112
|
+
self._central.emit_homematic_callback(event_type=self.event_type, event_data=self.get_event_data(value=value))
|
|
113
113
|
|
|
114
114
|
def _get_data_point_name(self) -> DataPointNameData:
|
|
115
115
|
"""Create the name for the data_point."""
|
|
@@ -149,7 +149,7 @@ class DeviceErrorEvent(GenericEvent):
|
|
|
149
149
|
isinstance(new_value, int)
|
|
150
150
|
and ((old_value is None and new_value > 0) or (isinstance(old_value, int) and old_value != new_value))
|
|
151
151
|
):
|
|
152
|
-
self.
|
|
152
|
+
self.emit_event(value=new_value)
|
|
153
153
|
|
|
154
154
|
|
|
155
155
|
class ImpulseEvent(GenericEvent):
|
|
@@ -83,8 +83,8 @@ class GenericDataPoint[ParameterT: GenericParameterType, InputParameterT: Generi
|
|
|
83
83
|
Parameter.UN_REACH,
|
|
84
84
|
Parameter.STICKY_UN_REACH,
|
|
85
85
|
):
|
|
86
|
-
self._device.
|
|
87
|
-
self._central.
|
|
86
|
+
self._device.emit_device_updated_callback()
|
|
87
|
+
self._central.emit_homematic_callback(
|
|
88
88
|
event_type=EventType.DEVICE_AVAILABILITY,
|
|
89
89
|
event_data=self.get_event_data(value=new_value),
|
|
90
90
|
)
|
|
@@ -204,7 +204,7 @@ class Hub:
|
|
|
204
204
|
new_programs.append(program_dp.switch)
|
|
205
205
|
|
|
206
206
|
if new_programs:
|
|
207
|
-
self._central.
|
|
207
|
+
self._central.emit_backend_system_callback(
|
|
208
208
|
system_event=BackendSystemEvent.HUB_REFRESHED,
|
|
209
209
|
new_data_points=_get_new_hub_data_points(data_points=new_programs),
|
|
210
210
|
)
|
|
@@ -240,7 +240,7 @@ class Hub:
|
|
|
240
240
|
new_sysvars.append(self._create_system_variable(data=sysvar))
|
|
241
241
|
|
|
242
242
|
if new_sysvars:
|
|
243
|
-
self._central.
|
|
243
|
+
self._central.emit_backend_system_callback(
|
|
244
244
|
system_event=BackendSystemEvent.HUB_REFRESHED,
|
|
245
245
|
new_data_points=_get_new_hub_data_points(data_points=new_sysvars),
|
|
246
246
|
)
|
|
@@ -230,7 +230,7 @@ class GenericSysvarDataPoint(GenericHubDataPoint):
|
|
|
230
230
|
self._previous_value = old_value
|
|
231
231
|
self._current_value = new_value
|
|
232
232
|
self._state_uncertain = False
|
|
233
|
-
self.
|
|
233
|
+
self.emit_data_point_updated_event()
|
|
234
234
|
|
|
235
235
|
def _write_temporary_value(self, *, value: Any, write_at: datetime) -> None:
|
|
236
236
|
"""Update the temporary value of the data_point."""
|
|
@@ -243,7 +243,7 @@ class GenericSysvarDataPoint(GenericHubDataPoint):
|
|
|
243
243
|
self._set_temporary_modified_at(modified_at=write_at)
|
|
244
244
|
self._temporary_value = temp_value
|
|
245
245
|
self._state_uncertain = True
|
|
246
|
-
self.
|
|
246
|
+
self.emit_data_point_updated_event()
|
|
247
247
|
|
|
248
248
|
def _convert_value(self, *, old_value: Any, new_value: Any) -> Any:
|
|
249
249
|
"""Convert to value to SYSVAR_TYPE."""
|
|
@@ -333,7 +333,7 @@ class GenericProgramDataPoint(GenericHubDataPoint):
|
|
|
333
333
|
self._last_execute_time = data.last_execute_time
|
|
334
334
|
do_update = True
|
|
335
335
|
if do_update:
|
|
336
|
-
self.
|
|
336
|
+
self.emit_data_point_updated_event()
|
|
337
337
|
|
|
338
338
|
def _get_path_data(self) -> PathData:
|
|
339
339
|
"""Return the path data of the data_point."""
|
|
@@ -117,7 +117,7 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
117
117
|
kind=self.kind,
|
|
118
118
|
cached=self._cached,
|
|
119
119
|
log_context=self.log_context,
|
|
120
|
-
)
|
|
120
|
+
)
|
|
121
121
|
|
|
122
122
|
def setter(self, fset: Callable[[Any, SETTER], None], /) -> _GenericProperty:
|
|
123
123
|
"""Return generic setter."""
|
|
@@ -155,7 +155,7 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
155
155
|
return cast(GETTER, self)
|
|
156
156
|
|
|
157
157
|
if (fget := self.fget) is None:
|
|
158
|
-
raise AttributeError("unreadable attribute")
|
|
158
|
+
raise AttributeError("unreadable attribute")
|
|
159
159
|
|
|
160
160
|
if not self._cached:
|
|
161
161
|
return fget(instance)
|
|
@@ -194,7 +194,7 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
194
194
|
delattr(instance, self._cache_attr)
|
|
195
195
|
|
|
196
196
|
if self.fset is None:
|
|
197
|
-
raise AttributeError("can't set attribute")
|
|
197
|
+
raise AttributeError("can't set attribute")
|
|
198
198
|
self.fset(instance, value)
|
|
199
199
|
|
|
200
200
|
def __delete__(self, instance: Any, /) -> None:
|
|
@@ -210,7 +210,7 @@ class _GenericProperty[GETTER, SETTER](property):
|
|
|
210
210
|
delattr(instance, self._cache_attr)
|
|
211
211
|
|
|
212
212
|
if self.fdel is None:
|
|
213
|
-
raise AttributeError("can't delete attribute")
|
|
213
|
+
raise AttributeError("can't delete attribute")
|
|
214
214
|
self.fdel(instance)
|
|
215
215
|
|
|
216
216
|
|
aiohomematic/store/dynamic.py
CHANGED
|
@@ -392,36 +392,12 @@ class PingPongCache:
|
|
|
392
392
|
return self._allowed_delta
|
|
393
393
|
|
|
394
394
|
@property
|
|
395
|
-
def
|
|
396
|
-
"""Check, if store contains too many pending pongs."""
|
|
397
|
-
self._cleanup_pending_pongs()
|
|
398
|
-
return len(self._pending_pongs) > self._allowed_delta
|
|
399
|
-
|
|
400
|
-
@property
|
|
401
|
-
def high_unknown_pongs(self) -> bool:
|
|
402
|
-
"""Check, if store contains too many unknown pongs."""
|
|
403
|
-
self._cleanup_unknown_pongs()
|
|
404
|
-
return len(self._unknown_pongs) > self._allowed_delta
|
|
405
|
-
|
|
406
|
-
@property
|
|
407
|
-
def low_pending_pongs(self) -> bool:
|
|
408
|
-
"""Return True when pending pong count is at or below the allowed delta (i.e., not high)."""
|
|
409
|
-
self._cleanup_pending_pongs()
|
|
410
|
-
return len(self._pending_pongs) <= self._allowed_delta
|
|
411
|
-
|
|
412
|
-
@property
|
|
413
|
-
def low_unknown_pongs(self) -> bool:
|
|
414
|
-
"""Return True when unknown pong count is at or below the allowed delta (i.e., not high)."""
|
|
415
|
-
self._cleanup_unknown_pongs()
|
|
416
|
-
return len(self._unknown_pongs) <= self._allowed_delta
|
|
417
|
-
|
|
418
|
-
@property
|
|
419
|
-
def pending_pong_count(self) -> int:
|
|
395
|
+
def _pending_pong_count(self) -> int:
|
|
420
396
|
"""Return the pending pong count."""
|
|
421
397
|
return len(self._pending_pongs)
|
|
422
398
|
|
|
423
399
|
@property
|
|
424
|
-
def
|
|
400
|
+
def _unknown_pong_count(self) -> int:
|
|
425
401
|
"""Return the unknown pong count."""
|
|
426
402
|
return len(self._unknown_pongs)
|
|
427
403
|
|
|
@@ -435,18 +411,16 @@ class PingPongCache:
|
|
|
435
411
|
def handle_send_ping(self, *, ping_ts: datetime) -> None:
|
|
436
412
|
"""Handle send ping timestamp."""
|
|
437
413
|
self._pending_pongs.add(ping_ts)
|
|
414
|
+
self._cleanup_pending_pongs()
|
|
438
415
|
# Throttle event emission to every second ping to avoid spamming callbacks,
|
|
439
416
|
# but always emit when crossing the high threshold.
|
|
440
|
-
count = self.
|
|
417
|
+
count = self._pending_pong_count
|
|
441
418
|
if (count > self._allowed_delta) or (count % 2 == 0):
|
|
442
|
-
self.
|
|
443
|
-
event_type=InterfaceEventType.PENDING_PONG,
|
|
444
|
-
pong_mismatch_count=count,
|
|
445
|
-
)
|
|
419
|
+
self._check_and_emit_pong_event(event_type=InterfaceEventType.PENDING_PONG)
|
|
446
420
|
_LOGGER.debug(
|
|
447
421
|
"PING PONG CACHE: Increase pending PING count: %s - %i for ts: %s",
|
|
448
422
|
self._interface_id,
|
|
449
|
-
|
|
423
|
+
count,
|
|
450
424
|
ping_ts,
|
|
451
425
|
)
|
|
452
426
|
|
|
@@ -454,63 +428,61 @@ class PingPongCache:
|
|
|
454
428
|
"""Handle received pong timestamp."""
|
|
455
429
|
if pong_ts in self._pending_pongs:
|
|
456
430
|
self._pending_pongs.remove(pong_ts)
|
|
457
|
-
self.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
)
|
|
431
|
+
self._cleanup_pending_pongs()
|
|
432
|
+
count = self._pending_pong_count
|
|
433
|
+
self._check_and_emit_pong_event(event_type=InterfaceEventType.PENDING_PONG)
|
|
461
434
|
_LOGGER.debug(
|
|
462
435
|
"PING PONG CACHE: Reduce pending PING count: %s - %i for ts: %s",
|
|
463
436
|
self._interface_id,
|
|
464
|
-
|
|
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,
|
|
465
449
|
pong_ts,
|
|
466
450
|
)
|
|
467
|
-
return
|
|
468
|
-
|
|
469
|
-
self._unknown_pongs.add(pong_ts)
|
|
470
|
-
self._check_and_fire_pong_event(
|
|
471
|
-
event_type=InterfaceEventType.UNKNOWN_PONG,
|
|
472
|
-
pong_mismatch_count=self.unknown_pong_count,
|
|
473
|
-
)
|
|
474
|
-
_LOGGER.debug(
|
|
475
|
-
"PING PONG CACHE: Increase unknown PONG count: %s - %i for ts: %s",
|
|
476
|
-
self._interface_id,
|
|
477
|
-
self.unknown_pong_count,
|
|
478
|
-
pong_ts,
|
|
479
|
-
)
|
|
480
451
|
|
|
481
452
|
def _cleanup_pending_pongs(self) -> None:
|
|
482
453
|
"""Cleanup too old pending pongs."""
|
|
483
454
|
dt_now = datetime.now()
|
|
484
|
-
for
|
|
455
|
+
for pp_pong_ts in list(self._pending_pongs):
|
|
485
456
|
# Only expire entries that are actually older than the TTL.
|
|
486
|
-
if (dt_now -
|
|
487
|
-
self._pending_pongs.remove(
|
|
457
|
+
if (dt_now - pp_pong_ts).total_seconds() > self._ttl:
|
|
458
|
+
self._pending_pongs.remove(pp_pong_ts)
|
|
488
459
|
_LOGGER.debug(
|
|
489
460
|
"PING PONG CACHE: Removing expired pending PONG: %s - %i for ts: %s",
|
|
490
461
|
self._interface_id,
|
|
491
|
-
self.
|
|
492
|
-
|
|
462
|
+
self._pending_pong_count,
|
|
463
|
+
pp_pong_ts,
|
|
493
464
|
)
|
|
494
465
|
|
|
495
466
|
def _cleanup_unknown_pongs(self) -> None:
|
|
496
467
|
"""Cleanup too old unknown pongs."""
|
|
497
468
|
dt_now = datetime.now()
|
|
498
|
-
for
|
|
469
|
+
for up_pong_ts in list(self._unknown_pongs):
|
|
499
470
|
# Only expire entries that are actually older than the TTL.
|
|
500
|
-
if (dt_now -
|
|
501
|
-
self._unknown_pongs.remove(
|
|
471
|
+
if (dt_now - up_pong_ts).total_seconds() > self._ttl:
|
|
472
|
+
self._unknown_pongs.remove(up_pong_ts)
|
|
502
473
|
_LOGGER.debug(
|
|
503
474
|
"PING PONG CACHE: Removing expired unknown PONG: %s - %i or ts: %s",
|
|
504
475
|
self._interface_id,
|
|
505
|
-
self.
|
|
506
|
-
|
|
476
|
+
self._unknown_pong_count,
|
|
477
|
+
up_pong_ts,
|
|
507
478
|
)
|
|
508
479
|
|
|
509
|
-
def
|
|
510
|
-
"""
|
|
480
|
+
def _check_and_emit_pong_event(self, *, event_type: InterfaceEventType) -> None:
|
|
481
|
+
"""Emit an event about the pong status."""
|
|
511
482
|
|
|
512
|
-
def
|
|
513
|
-
|
|
483
|
+
def _emit_event(mismatch_count: int) -> None:
|
|
484
|
+
"""Emit event."""
|
|
485
|
+
self._central.emit_homematic_callback(
|
|
514
486
|
event_type=EventType.INTERFACE,
|
|
515
487
|
event_data=cast(
|
|
516
488
|
dict[EventKey, Any],
|
|
@@ -520,52 +492,60 @@ class PingPongCache:
|
|
|
520
492
|
EventKey.TYPE: event_type,
|
|
521
493
|
EventKey.DATA: {
|
|
522
494
|
EventKey.CENTRAL_NAME: self._central.name,
|
|
523
|
-
EventKey.
|
|
495
|
+
EventKey.PONG_MISMATCH_ACCEPTABLE: mismatch_count <= self._allowed_delta,
|
|
524
496
|
EventKey.PONG_MISMATCH_COUNT: mismatch_count,
|
|
525
497
|
},
|
|
526
498
|
}
|
|
527
499
|
),
|
|
528
500
|
),
|
|
529
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
|
+
)
|
|
530
509
|
|
|
531
|
-
if
|
|
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
|
|
532
525
|
# In low state:
|
|
533
526
|
# - If we previously logged a high state, emit a reset event (mismatch=0) exactly once.
|
|
534
527
|
# - Otherwise, throttle emission to every second ping (even counts > 0) to avoid spamming.
|
|
535
|
-
|
|
536
|
-
|
|
528
|
+
elif self._pending_pong_logged:
|
|
529
|
+
_emit_event(mismatch_count=0)
|
|
537
530
|
self._pending_pong_logged = False
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
)
|
|
560
|
-
self._pending_pong_logged = True
|
|
561
|
-
|
|
562
|
-
if self.high_unknown_pongs and event_type == InterfaceEventType.UNKNOWN_PONG:
|
|
563
|
-
if self._unknown_pong_logged is False:
|
|
564
|
-
_LOGGER.warning(
|
|
565
|
-
"Unknown PONG Mismatch: Your instance %s receives PONG events, that it hasn't send. "
|
|
566
|
-
"Possible reason 1: You are running multiple instances with the same instance name configured for this integration. "
|
|
567
|
-
"Re-add one instance! Otherwise the other instance will not receive update events from your CCU. "
|
|
568
|
-
"Possible reason 2: Something is stuck on the CCU or hasn't been cleaned up. Therefore, try a CCU restart.",
|
|
569
|
-
self._interface_id,
|
|
570
|
-
)
|
|
571
|
-
self._unknown_pong_logged = True
|
|
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
|
aiohomematic/store/visibility.py
CHANGED
|
@@ -317,7 +317,7 @@ _IGNORE_PARAMETERS_BY_DEVICE: Final[Mapping[Parameter, frozenset[TModelName]]] =
|
|
|
317
317
|
"HmIP-WGT",
|
|
318
318
|
}
|
|
319
319
|
),
|
|
320
|
-
Parameter.VALVE_STATE: frozenset({"HmIPW-FALMOT-C12", "HmIP-FALMOT-C12"}),
|
|
320
|
+
Parameter.VALVE_STATE: frozenset({"HmIP-FALMOT-C8", "HmIPW-FALMOT-C12", "HmIP-FALMOT-C12"}),
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
_IGNORE_PARAMETERS_BY_DEVICE_LOWER: Final[dict[TParameterName, frozenset[TModelName]]] = {
|
|
@@ -423,8 +423,7 @@ class ParameterVisibilityCache:
|
|
|
423
423
|
self, *, model_l: TModelName, mapping: Mapping[str, object], cache_dict: dict[TModelName, str | None]
|
|
424
424
|
) -> str | None:
|
|
425
425
|
"""Resolve and memoize the first key in mapping that prefixes model_l."""
|
|
426
|
-
dt_short_key
|
|
427
|
-
if dt_short_key is None and model_l not in cache_dict:
|
|
426
|
+
if (dt_short_key := cache_dict.get(model_l)) is None and model_l not in cache_dict:
|
|
428
427
|
dt_short_key = next((k for k in mapping if model_l.startswith(k)), None)
|
|
429
428
|
cache_dict[model_l] = dt_short_key
|
|
430
429
|
return dt_short_key
|
|
@@ -486,7 +485,7 @@ class ParameterVisibilityCache:
|
|
|
486
485
|
return False
|
|
487
486
|
|
|
488
487
|
if parameter in self._custom_un_ignore_complex[model_l][channel.no][ParamsetKey.MASTER]:
|
|
489
|
-
return False
|
|
488
|
+
return False
|
|
490
489
|
|
|
491
490
|
dt_short_key = self._resolve_prefix_key(
|
|
492
491
|
model_l=model_l,
|
|
@@ -545,7 +544,7 @@ class ParameterVisibilityCache:
|
|
|
545
544
|
for ml, cno in search_matrix:
|
|
546
545
|
if parameter in self._custom_un_ignore_complex[ml][cno][paramset_key]:
|
|
547
546
|
self._param_un_ignored_cache[cache_key] = True
|
|
548
|
-
return True
|
|
547
|
+
return True
|
|
549
548
|
|
|
550
549
|
# check if parameter is in _UN_IGNORE_PARAMETERS_BY_DEVICE
|
|
551
550
|
result = bool(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic
|
|
3
|
-
Version: 2025.10.
|
|
3
|
+
Version: 2025.10.24
|
|
4
4
|
Summary: Homematic interface for Home Assistant running on Python 3.
|
|
5
5
|
Home-page: https://github.com/sukramj/aiohomematic
|
|
6
6
|
Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
|
|
@@ -15,10 +15,16 @@ Classifier: Intended Audience :: End Users/Desktop
|
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
17
|
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
22
|
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Classifier: Framework :: AsyncIO
|
|
25
|
+
Classifier: Typing :: Typed
|
|
20
26
|
Classifier: Topic :: Home Automation
|
|
21
|
-
Requires-Python: >=3.13
|
|
27
|
+
Requires-Python: >=3.13
|
|
22
28
|
Description-Content-Type: text/markdown
|
|
23
29
|
License-File: LICENSE
|
|
24
30
|
Requires-Dist: aiohttp>=3.12.0
|