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.

Files changed (77) hide show
  1. aiohomematic/__init__.py +61 -0
  2. aiohomematic/async_support.py +212 -0
  3. aiohomematic/central/__init__.py +2309 -0
  4. aiohomematic/central/decorators.py +155 -0
  5. aiohomematic/central/rpc_server.py +295 -0
  6. aiohomematic/client/__init__.py +1848 -0
  7. aiohomematic/client/_rpc_errors.py +81 -0
  8. aiohomematic/client/json_rpc.py +1326 -0
  9. aiohomematic/client/rpc_proxy.py +311 -0
  10. aiohomematic/const.py +1127 -0
  11. aiohomematic/context.py +18 -0
  12. aiohomematic/converter.py +108 -0
  13. aiohomematic/decorators.py +302 -0
  14. aiohomematic/exceptions.py +164 -0
  15. aiohomematic/hmcli.py +186 -0
  16. aiohomematic/model/__init__.py +140 -0
  17. aiohomematic/model/calculated/__init__.py +84 -0
  18. aiohomematic/model/calculated/climate.py +290 -0
  19. aiohomematic/model/calculated/data_point.py +327 -0
  20. aiohomematic/model/calculated/operating_voltage_level.py +299 -0
  21. aiohomematic/model/calculated/support.py +234 -0
  22. aiohomematic/model/custom/__init__.py +177 -0
  23. aiohomematic/model/custom/climate.py +1532 -0
  24. aiohomematic/model/custom/cover.py +792 -0
  25. aiohomematic/model/custom/data_point.py +334 -0
  26. aiohomematic/model/custom/definition.py +871 -0
  27. aiohomematic/model/custom/light.py +1128 -0
  28. aiohomematic/model/custom/lock.py +394 -0
  29. aiohomematic/model/custom/siren.py +275 -0
  30. aiohomematic/model/custom/support.py +41 -0
  31. aiohomematic/model/custom/switch.py +175 -0
  32. aiohomematic/model/custom/valve.py +114 -0
  33. aiohomematic/model/data_point.py +1123 -0
  34. aiohomematic/model/device.py +1445 -0
  35. aiohomematic/model/event.py +208 -0
  36. aiohomematic/model/generic/__init__.py +217 -0
  37. aiohomematic/model/generic/action.py +34 -0
  38. aiohomematic/model/generic/binary_sensor.py +30 -0
  39. aiohomematic/model/generic/button.py +27 -0
  40. aiohomematic/model/generic/data_point.py +171 -0
  41. aiohomematic/model/generic/dummy.py +147 -0
  42. aiohomematic/model/generic/number.py +76 -0
  43. aiohomematic/model/generic/select.py +39 -0
  44. aiohomematic/model/generic/sensor.py +74 -0
  45. aiohomematic/model/generic/switch.py +54 -0
  46. aiohomematic/model/generic/text.py +29 -0
  47. aiohomematic/model/hub/__init__.py +333 -0
  48. aiohomematic/model/hub/binary_sensor.py +24 -0
  49. aiohomematic/model/hub/button.py +28 -0
  50. aiohomematic/model/hub/data_point.py +340 -0
  51. aiohomematic/model/hub/number.py +39 -0
  52. aiohomematic/model/hub/select.py +49 -0
  53. aiohomematic/model/hub/sensor.py +37 -0
  54. aiohomematic/model/hub/switch.py +44 -0
  55. aiohomematic/model/hub/text.py +30 -0
  56. aiohomematic/model/support.py +586 -0
  57. aiohomematic/model/update.py +143 -0
  58. aiohomematic/property_decorators.py +496 -0
  59. aiohomematic/py.typed +0 -0
  60. aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
  61. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  62. aiohomematic/rega_scripts/get_serial.fn +44 -0
  63. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  64. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  65. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  66. aiohomematic/store/__init__.py +34 -0
  67. aiohomematic/store/dynamic.py +551 -0
  68. aiohomematic/store/persistent.py +988 -0
  69. aiohomematic/store/visibility.py +812 -0
  70. aiohomematic/support.py +664 -0
  71. aiohomematic/validator.py +112 -0
  72. aiohomematic-2025.11.3.dist-info/METADATA +144 -0
  73. aiohomematic-2025.11.3.dist-info/RECORD +77 -0
  74. aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
  75. aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
  76. aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
  77. aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1123 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """
4
+ Core data point model for AioHomematic.
5
+
6
+ This module defines the abstract base classes and concrete building blocks for
7
+ representing Homematic parameters as data points, handling their lifecycle,
8
+ I/O, and event propagation.
9
+
10
+ Highlights:
11
+ - CallbackDataPoint: Base for objects that expose callbacks and timestamps
12
+ (modified/refreshed) and manage registration of update and removal listeners.
13
+ - BaseDataPoint/ BaseParameterDataPoint: Concrete foundations for channel-bound
14
+ data points, including type/flag handling, unit and multiplier normalization,
15
+ value conversion, temporary write buffering, and path/name metadata.
16
+ - CallParameterCollector: Helper to batch multiple set/put operations and wait
17
+ for callbacks, optimizing command dispatch.
18
+ - bind_collector: Decorator to bind a collector to service methods conveniently.
19
+
20
+ The classes here are used by generic, custom, calculated, and hub data point
21
+ implementations to provide a uniform API for reading, writing, and observing
22
+ parameter values across all supported devices.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from abc import ABC, abstractmethod
28
+ from collections.abc import Callable, Mapping
29
+ from contextvars import Token
30
+ from datetime import datetime, timedelta
31
+ from functools import partial, wraps
32
+ from inspect import getfullargspec
33
+ import logging
34
+ from typing import Any, Final, cast
35
+
36
+ import voluptuous as vol
37
+
38
+ from aiohomematic import central as hmcu, client as hmcl, support as hms, validator as val
39
+ from aiohomematic.async_support import loop_check
40
+ from aiohomematic.const import (
41
+ CALLBACK_TYPE,
42
+ DEFAULT_MULTIPLIER,
43
+ DP_KEY_VALUE,
44
+ INIT_DATETIME,
45
+ KEY_CHANNEL_OPERATION_MODE_VISIBILITY,
46
+ KWARGS_ARG_CUSTOM_ID,
47
+ KWARGS_ARG_DATA_POINT,
48
+ NO_CACHE_ENTRY,
49
+ WAIT_FOR_CALLBACK,
50
+ CallSource,
51
+ DataPointCategory,
52
+ DataPointKey,
53
+ DataPointUsage,
54
+ EventKey,
55
+ Flag,
56
+ InternalCustomID,
57
+ Operations,
58
+ Parameter,
59
+ ParameterData,
60
+ ParameterType,
61
+ ParamsetKey,
62
+ ProductGroup,
63
+ check_ignore_parameter_on_initial_load,
64
+ )
65
+ from aiohomematic.context import IN_SERVICE_VAR
66
+ from aiohomematic.decorators import get_service_calls
67
+ from aiohomematic.exceptions import AioHomematicException, BaseHomematicException
68
+ from aiohomematic.model import device as hmd
69
+ from aiohomematic.model.support import (
70
+ DataPointNameData,
71
+ DataPointPathData,
72
+ GenericParameterType,
73
+ PathData,
74
+ convert_value,
75
+ generate_unique_id,
76
+ )
77
+ from aiohomematic.property_decorators import config_property, hm_property, state_property
78
+ from aiohomematic.support import LogContextMixin, PayloadMixin, extract_exc_args, log_boundary_error
79
+
80
+ __all__ = [
81
+ "BaseDataPoint",
82
+ "BaseParameterDataPoint",
83
+ "CallParameterCollector",
84
+ "CallbackDataPoint",
85
+ "bind_collector",
86
+ ]
87
+
88
+ _LOGGER: Final = logging.getLogger(__name__)
89
+
90
+ _CONFIGURABLE_CHANNEL: Final[tuple[str, ...]] = (
91
+ "KEY_TRANSCEIVER",
92
+ "MULTI_MODE_INPUT_TRANSMITTER",
93
+ )
94
+ _COLLECTOR_ARGUMENT_NAME: Final = "collector"
95
+ _FIX_UNIT_REPLACE: Final[Mapping[str, str]] = {
96
+ '"': "",
97
+ "100%": "%",
98
+ "% rF": "%",
99
+ "degree": "°C",
100
+ "Lux": "lx",
101
+ "m3": "m³",
102
+ }
103
+ _FIX_UNIT_BY_PARAM: Final[Mapping[str, str]] = {
104
+ Parameter.ACTUAL_TEMPERATURE: "°C",
105
+ Parameter.CURRENT_ILLUMINATION: "lx",
106
+ Parameter.HUMIDITY: "%",
107
+ Parameter.ILLUMINATION: "lx",
108
+ Parameter.LEVEL: "%",
109
+ Parameter.MASS_CONCENTRATION_PM_10_24H_AVERAGE: "µg/m³",
110
+ Parameter.MASS_CONCENTRATION_PM_1_24H_AVERAGE: "µg/m³",
111
+ Parameter.MASS_CONCENTRATION_PM_2_5_24H_AVERAGE: "µg/m³",
112
+ Parameter.OPERATING_VOLTAGE: "V",
113
+ Parameter.RSSI_DEVICE: "dBm",
114
+ Parameter.RSSI_PEER: "dBm",
115
+ Parameter.SUNSHINE_DURATION: "min",
116
+ Parameter.WIND_DIRECTION: "°",
117
+ Parameter.WIND_DIRECTION_RANGE: "°",
118
+ }
119
+ _MULTIPLIER_UNIT: Final[Mapping[str, float]] = {
120
+ "100%": 100.0,
121
+ }
122
+
123
+ EVENT_DATA_SCHEMA = vol.Schema(
124
+ {
125
+ vol.Required(str(EventKey.ADDRESS)): val.device_address,
126
+ vol.Required(str(EventKey.CHANNEL_NO)): val.channel_no,
127
+ vol.Required(str(EventKey.MODEL)): str,
128
+ vol.Required(str(EventKey.INTERFACE_ID)): str,
129
+ vol.Required(str(EventKey.PARAMETER)): str,
130
+ vol.Optional(str(EventKey.VALUE)): vol.Any(bool, int),
131
+ }
132
+ )
133
+
134
+
135
+ class CallbackDataPoint(ABC, LogContextMixin):
136
+ """Base class for callback data point."""
137
+
138
+ __slots__ = (
139
+ "_cached_enabled_default",
140
+ "_cached_service_methods",
141
+ "_cached_service_method_names",
142
+ "_central",
143
+ "_custom_id",
144
+ "_data_point_updated_callbacks",
145
+ "_device_removed_callbacks",
146
+ "_emitted_event_at",
147
+ "_modified_at",
148
+ "_path_data",
149
+ "_refreshed_at",
150
+ "_signature",
151
+ "_temporary_modified_at",
152
+ "_temporary_refreshed_at",
153
+ "_unique_id",
154
+ )
155
+
156
+ _category = DataPointCategory.UNDEFINED
157
+
158
+ def __init__(self, *, central: hmcu.CentralUnit, unique_id: str) -> None:
159
+ """Init the callback data_point."""
160
+ self._central: Final = central
161
+ self._unique_id: Final = unique_id
162
+ self._data_point_updated_callbacks: dict[Callable, str] = {}
163
+ self._device_removed_callbacks: list[Callable] = []
164
+ self._custom_id: str | None = None
165
+ self._path_data = self._get_path_data()
166
+ self._emitted_event_at: datetime = INIT_DATETIME
167
+ self._modified_at: datetime = INIT_DATETIME
168
+ self._refreshed_at: datetime = INIT_DATETIME
169
+ self._signature: Final = self._get_signature()
170
+ self._temporary_modified_at: datetime = INIT_DATETIME
171
+ self._temporary_refreshed_at: datetime = INIT_DATETIME
172
+
173
+ @state_property
174
+ def additional_information(self) -> dict[str, Any]:
175
+ """Return additional information about the entity."""
176
+ return {}
177
+
178
+ @state_property
179
+ @abstractmethod
180
+ def available(self) -> bool:
181
+ """Return the availability of the device."""
182
+
183
+ @property
184
+ def category(self) -> DataPointCategory:
185
+ """Return, the category of the data point."""
186
+ return self._category
187
+
188
+ @property
189
+ def custom_id(self) -> str | None:
190
+ """Return the custom id."""
191
+ return self._custom_id
192
+
193
+ @property
194
+ def emitted_event_at(self) -> datetime:
195
+ """Return the data point updated emitted an event at."""
196
+ return self._emitted_event_at
197
+
198
+ @state_property
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
+ return False
203
+ return (datetime.now() - self._emitted_event_at).total_seconds() < 0.5
204
+
205
+ @classmethod
206
+ def default_category(cls) -> DataPointCategory:
207
+ """Return, the default category of the data_point."""
208
+ return cls._category
209
+
210
+ @property
211
+ def central(self) -> hmcu.CentralUnit:
212
+ """Return the central unit."""
213
+ return self._central
214
+
215
+ @property
216
+ @abstractmethod
217
+ def full_name(self) -> str:
218
+ """Return the full name of the data_point."""
219
+
220
+ @property
221
+ def is_valid(self) -> bool:
222
+ """Return, if the value of the data_point is valid based on the refreshed at datetime."""
223
+ return self._refreshed_at > INIT_DATETIME
224
+
225
+ @state_property
226
+ def modified_at(self) -> datetime:
227
+ """Return the last update datetime value."""
228
+ if self._temporary_modified_at > self._modified_at:
229
+ return self._temporary_modified_at
230
+ return self._modified_at
231
+
232
+ @state_property
233
+ def modified_recently(self) -> bool:
234
+ """Return the data point modified within 500 milliseconds."""
235
+ if self._modified_at == INIT_DATETIME:
236
+ return False
237
+ return (datetime.now() - self._modified_at).total_seconds() < 0.5
238
+
239
+ @state_property
240
+ def refreshed_at(self) -> datetime:
241
+ """Return the last refresh datetime value."""
242
+ if self._temporary_refreshed_at > self._refreshed_at:
243
+ return self._temporary_refreshed_at
244
+ return self._refreshed_at
245
+
246
+ @state_property
247
+ def refreshed_recently(self) -> bool:
248
+ """Return the data point refreshed within 500 milliseconds."""
249
+ if self._refreshed_at == INIT_DATETIME:
250
+ return False
251
+ return (datetime.now() - self._refreshed_at).total_seconds() < 0.5
252
+
253
+ @config_property
254
+ @abstractmethod
255
+ def name(self) -> str:
256
+ """Return the name of the data_point."""
257
+
258
+ @property
259
+ def signature(self) -> str:
260
+ """Return the data_point signature."""
261
+ return self._signature
262
+
263
+ @config_property
264
+ def unique_id(self) -> str:
265
+ """Return the unique_id."""
266
+ return self._unique_id
267
+
268
+ @property
269
+ def usage(self) -> DataPointUsage:
270
+ """Return the data_point usage."""
271
+ return DataPointUsage.DATA_POINT
272
+
273
+ @hm_property(cached=True)
274
+ def enabled_default(self) -> bool:
275
+ """Return, if data_point should be enabled based on usage attribute."""
276
+ return self.usage in (
277
+ DataPointUsage.CDP_PRIMARY,
278
+ DataPointUsage.CDP_VISIBLE,
279
+ DataPointUsage.DATA_POINT,
280
+ DataPointUsage.EVENT,
281
+ )
282
+
283
+ @property
284
+ def is_registered(self) -> bool:
285
+ """Return if data_point is registered externally."""
286
+ return self._custom_id is not None
287
+
288
+ @property
289
+ def set_path(self) -> str:
290
+ """Return the base set path of the data_point."""
291
+ return self._path_data.set_path
292
+
293
+ @property
294
+ def state_path(self) -> str:
295
+ """Return the base state path of the data_point."""
296
+ return self._path_data.state_path
297
+
298
+ # @property
299
+ @hm_property(cached=True)
300
+ def service_methods(self) -> Mapping[str, Callable]:
301
+ """Return all service methods."""
302
+ return get_service_calls(obj=self)
303
+
304
+ @hm_property(cached=True)
305
+ def service_method_names(self) -> tuple[str, ...]:
306
+ """Return all service methods."""
307
+ return tuple(self.service_methods.keys())
308
+
309
+ def register_internal_data_point_updated_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
310
+ """Register internal data_point updated callback."""
311
+ return self.register_data_point_updated_callback(cb=cb, custom_id=InternalCustomID.DEFAULT)
312
+
313
+ def register_data_point_updated_callback(self, *, cb: Callable, custom_id: str) -> CALLBACK_TYPE:
314
+ """Register data_point updated callback."""
315
+ if custom_id not in InternalCustomID:
316
+ if self._custom_id is not None and self._custom_id != custom_id:
317
+ raise AioHomematicException(
318
+ f"REGISTER_data_point_updated_CALLBACK failed: hm_data_point: {self.full_name} is already registered by {self._custom_id}"
319
+ )
320
+ self._custom_id = custom_id
321
+
322
+ if callable(cb) and cb not in self._data_point_updated_callbacks:
323
+ self._data_point_updated_callbacks[cb] = custom_id
324
+ return partial(self._unregister_data_point_updated_callback, cb=cb, custom_id=custom_id)
325
+ return None
326
+
327
+ def _reset_temporary_timestamps(self) -> None:
328
+ """Reset the temporary timestamps."""
329
+ self._set_temporary_modified_at(modified_at=INIT_DATETIME)
330
+ self._set_temporary_refreshed_at(refreshed_at=INIT_DATETIME)
331
+
332
+ @abstractmethod
333
+ def _get_path_data(self) -> PathData:
334
+ """Return the path data."""
335
+
336
+ @abstractmethod
337
+ def _get_signature(self) -> str:
338
+ """Return the signature of the data_point."""
339
+
340
+ def _unregister_data_point_updated_callback(self, *, cb: Callable, custom_id: str) -> None:
341
+ """Unregister data_point updated callback."""
342
+ if cb in self._data_point_updated_callbacks:
343
+ del self._data_point_updated_callbacks[cb]
344
+ if self.custom_id == custom_id:
345
+ self._custom_id = None
346
+
347
+ def register_device_removed_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
348
+ """Register the device removed callback."""
349
+ if callable(cb) and cb not in self._device_removed_callbacks:
350
+ self._device_removed_callbacks.append(cb)
351
+ return partial(self._unregister_device_removed_callback, cb=cb)
352
+ return None
353
+
354
+ def _unregister_device_removed_callback(self, *, cb: Callable) -> None:
355
+ """Unregister the device removed callback."""
356
+ if cb in self._device_removed_callbacks:
357
+ self._device_removed_callbacks.remove(cb)
358
+
359
+ @loop_check
360
+ def emit_data_point_updated_event(self, **kwargs: Any) -> None:
361
+ """Do what is needed when the value of the data_point has been updated/refreshed."""
362
+ if not self._should_emit_data_point_updated_callback:
363
+ return
364
+ self._emitted_event_at = datetime.now()
365
+ for callback_handler, custom_id in self._data_point_updated_callbacks.items():
366
+ try:
367
+ # Add the data_point reference once to kwargs to avoid per-callback writes.
368
+ kwargs[KWARGS_ARG_DATA_POINT] = self
369
+ kwargs[KWARGS_ARG_CUSTOM_ID] = custom_id
370
+ callback_handler(**kwargs)
371
+ except Exception as exc:
372
+ _LOGGER.warning("EMIT_DATA_POINT_UPDATED_EVENT failed: %s", extract_exc_args(exc=exc))
373
+
374
+ @loop_check
375
+ def emit_device_removed_event(self) -> None:
376
+ """Do what is needed when the data_point has been removed."""
377
+ for callback_handler in self._device_removed_callbacks:
378
+ try:
379
+ callback_handler()
380
+ except Exception as exc:
381
+ _LOGGER.warning("EMIT_DEVICE_REMOVED_EVENT failed: %s", extract_exc_args(exc=exc))
382
+
383
+ @property
384
+ def _should_emit_data_point_updated_callback(self) -> bool:
385
+ """Check if a data point has been updated or refreshed."""
386
+ return True
387
+
388
+ def _set_modified_at(self, *, modified_at: datetime) -> None:
389
+ """Set modified_at to current datetime."""
390
+ self._modified_at = modified_at
391
+ self._set_refreshed_at(refreshed_at=modified_at)
392
+
393
+ def _set_refreshed_at(self, *, refreshed_at: datetime) -> None:
394
+ """Set refreshed_at to current datetime."""
395
+ self._refreshed_at = refreshed_at
396
+
397
+ def _set_temporary_modified_at(self, *, modified_at: datetime) -> None:
398
+ """Set temporary_modified_at to current datetime."""
399
+ self._temporary_modified_at = modified_at
400
+ self._set_temporary_refreshed_at(refreshed_at=modified_at)
401
+
402
+ def _set_temporary_refreshed_at(self, *, refreshed_at: datetime) -> None:
403
+ """Set temporary_refreshed_at to current datetime."""
404
+ self._temporary_refreshed_at = refreshed_at
405
+
406
+ def __str__(self) -> str:
407
+ """Provide some useful information."""
408
+ return f"path: {self.state_path}, name: {self.full_name}"
409
+
410
+
411
+ class BaseDataPoint(CallbackDataPoint, PayloadMixin):
412
+ """Base class for regular data point."""
413
+
414
+ __slots__ = (
415
+ "_cached_dpk",
416
+ "_cached_requires_polling",
417
+ "_channel",
418
+ "_client",
419
+ "_data_point_name_data",
420
+ "_device",
421
+ "_forced_usage",
422
+ "_is_in_multiple_channels",
423
+ "_timer_on_time",
424
+ "_timer_on_time_end",
425
+ )
426
+
427
+ _ignore_multiple_channels_for_name: bool = False
428
+
429
+ def __init__(
430
+ self,
431
+ *,
432
+ channel: hmd.Channel,
433
+ unique_id: str,
434
+ is_in_multiple_channels: bool,
435
+ ) -> None:
436
+ """Initialize the data_point."""
437
+ PayloadMixin.__init__(self)
438
+ self._channel: Final[hmd.Channel] = channel
439
+ self._device: Final[hmd.Device] = channel.device
440
+ super().__init__(central=channel.central, unique_id=unique_id)
441
+ self._is_in_multiple_channels: Final = is_in_multiple_channels
442
+ self._client: Final[hmcl.Client] = channel.device.client
443
+ self._forced_usage: DataPointUsage | None = None
444
+ self._data_point_name_data: Final = self._get_data_point_name()
445
+ self._timer_on_time: float | None = None
446
+ self._timer_on_time_end: datetime = INIT_DATETIME
447
+
448
+ @state_property
449
+ def available(self) -> bool:
450
+ """Return the availability of the device."""
451
+ return self._device.available
452
+
453
+ @hm_property(log_context=True)
454
+ def channel(self) -> hmd.Channel:
455
+ """Return the channel the data_point."""
456
+ return self._channel
457
+
458
+ @property
459
+ def device(self) -> hmd.Device:
460
+ """Return the device of the data_point."""
461
+ return self._device
462
+
463
+ @property
464
+ def full_name(self) -> str:
465
+ """Return the full name of the data_point."""
466
+ return self._data_point_name_data.full_name
467
+
468
+ @property
469
+ def function(self) -> str | None:
470
+ """Return the function."""
471
+ return self._channel.function
472
+
473
+ @property
474
+ def is_in_multiple_channels(self) -> bool:
475
+ """Return the parameter/CE is also in multiple channels."""
476
+ return self._is_in_multiple_channels
477
+
478
+ @config_property
479
+ def name(self) -> str:
480
+ """Return the name of the data_point."""
481
+ return self._data_point_name_data.name
482
+
483
+ @property
484
+ def name_data(self) -> DataPointNameData:
485
+ """Return the data_point name data of the data_point."""
486
+ return self._data_point_name_data
487
+
488
+ @property
489
+ def room(self) -> str | None:
490
+ """Return the room, if only one exists."""
491
+ return self._channel.room
492
+
493
+ @property
494
+ def rooms(self) -> set[str]:
495
+ """Return the rooms assigned to a data_point."""
496
+ return self._channel.rooms
497
+
498
+ @property
499
+ def timer_on_time(self) -> float | None:
500
+ """Return the on_time."""
501
+ return self._timer_on_time
502
+
503
+ @property
504
+ def timer_on_time_running(self) -> bool:
505
+ """Return if on_time is running."""
506
+ return datetime.now() <= self._timer_on_time_end
507
+
508
+ @property
509
+ def usage(self) -> DataPointUsage:
510
+ """Return the data_point usage."""
511
+ return self._get_data_point_usage()
512
+
513
+ def force_usage(self, *, forced_usage: DataPointUsage) -> None:
514
+ """Set the data_point usage."""
515
+ self._forced_usage = forced_usage
516
+
517
+ def get_and_start_timer(self) -> float | None:
518
+ """Return the on_time and set the end time."""
519
+ if self.timer_on_time_running and self._timer_on_time is not None and self._timer_on_time <= 0:
520
+ self.reset_timer_on_time()
521
+ return -1
522
+ if self._timer_on_time is None:
523
+ self.reset_timer_on_time()
524
+ return None
525
+ on_time = self._timer_on_time
526
+ self._timer_on_time = None
527
+ self._timer_on_time_end = datetime.now() + timedelta(seconds=on_time)
528
+ return on_time
529
+
530
+ @abstractmethod
531
+ async def load_data_point_value(self, *, call_source: CallSource, direct_call: bool = False) -> None:
532
+ """Init the data_point data."""
533
+
534
+ @abstractmethod
535
+ def _get_data_point_name(self) -> DataPointNameData:
536
+ """Generate the name for the data_point."""
537
+
538
+ @abstractmethod
539
+ def _get_data_point_usage(self) -> DataPointUsage:
540
+ """Generate the usage for the data_point."""
541
+
542
+ def set_timer_on_time(self, *, on_time: float) -> None:
543
+ """Set the on_time."""
544
+ self._timer_on_time = on_time
545
+ self._timer_on_time_end = INIT_DATETIME
546
+
547
+ def reset_timer_on_time(self) -> None:
548
+ """Set the on_time."""
549
+ self._timer_on_time = None
550
+ self._timer_on_time_end = INIT_DATETIME
551
+
552
+
553
+ class BaseParameterDataPoint[
554
+ ParameterT: GenericParameterType,
555
+ InputParameterT: GenericParameterType,
556
+ ](BaseDataPoint):
557
+ """Base class for stateless data point."""
558
+
559
+ __slots__ = (
560
+ "_cached__enabled_by_channel_operation_mode",
561
+ "_current_value",
562
+ "_default",
563
+ "_ignore_on_initial_load",
564
+ "_is_forced_sensor",
565
+ "_is_un_ignored",
566
+ "_max",
567
+ "_min",
568
+ "_multiplier",
569
+ "_operations",
570
+ "_parameter",
571
+ "_paramset_key",
572
+ "_previous_value",
573
+ "_raw_unit",
574
+ "_service",
575
+ "_special",
576
+ "_state_uncertain",
577
+ "_temporary_value",
578
+ "_type",
579
+ "_unit",
580
+ "_values",
581
+ "_visible",
582
+ )
583
+
584
+ def __init__(
585
+ self,
586
+ *,
587
+ channel: hmd.Channel,
588
+ paramset_key: ParamsetKey,
589
+ parameter: str,
590
+ parameter_data: ParameterData,
591
+ unique_id_prefix: str = "",
592
+ ) -> None:
593
+ """Initialize the data_point."""
594
+ self._paramset_key: Final = paramset_key
595
+ # required for name in BaseDataPoint
596
+ self._parameter: Final[str] = parameter
597
+ self._ignore_on_initial_load: Final[bool] = check_ignore_parameter_on_initial_load(parameter=parameter)
598
+
599
+ super().__init__(
600
+ channel=channel,
601
+ unique_id=generate_unique_id(
602
+ central=channel.central,
603
+ address=channel.address,
604
+ parameter=parameter,
605
+ prefix=unique_id_prefix,
606
+ ),
607
+ is_in_multiple_channels=channel.device.central.paramset_descriptions.is_in_multiple_channels(
608
+ channel_address=channel.address, parameter=parameter
609
+ ),
610
+ )
611
+ self._is_un_ignored: Final[bool] = self._central.parameter_visibility.parameter_is_un_ignored(
612
+ channel=channel,
613
+ paramset_key=self._paramset_key,
614
+ parameter=self._parameter,
615
+ custom_only=True,
616
+ )
617
+ self._current_value: ParameterT = None # type: ignore[assignment]
618
+ self._previous_value: ParameterT = None # type: ignore[assignment]
619
+ self._temporary_value: ParameterT = None # type: ignore[assignment]
620
+
621
+ self._state_uncertain: bool = True
622
+ self._is_forced_sensor: bool = False
623
+ self._assign_parameter_data(parameter_data=parameter_data)
624
+
625
+ def _assign_parameter_data(self, *, parameter_data: ParameterData) -> None:
626
+ """Assign parameter data to instance variables."""
627
+ self._type: ParameterType = ParameterType(parameter_data["TYPE"])
628
+ self._values = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
629
+ self._max: ParameterT = self._convert_value(value=parameter_data["MAX"])
630
+ self._min: ParameterT = self._convert_value(value=parameter_data["MIN"])
631
+ self._default: ParameterT = self._convert_value(value=parameter_data.get("DEFAULT")) or self._min
632
+ flags: int = parameter_data["FLAGS"]
633
+ self._visible: bool = flags & Flag.VISIBLE == Flag.VISIBLE
634
+ self._service: bool = flags & Flag.SERVICE == Flag.SERVICE
635
+ self._operations: int = parameter_data["OPERATIONS"]
636
+ self._special: Mapping[str, Any] | None = parameter_data.get("SPECIAL")
637
+ self._raw_unit: str | None = parameter_data.get("UNIT")
638
+ self._unit: str | None = self._cleanup_unit(raw_unit=self._raw_unit)
639
+ self._multiplier: float = self._get_multiplier(raw_unit=self._raw_unit)
640
+
641
+ @property
642
+ def default(self) -> ParameterT:
643
+ """Return default value."""
644
+ return self._default
645
+
646
+ @property
647
+ def hmtype(self) -> ParameterType:
648
+ """Return the Homematic type."""
649
+ return self._type
650
+
651
+ @property
652
+ def ignore_on_initial_load(self) -> bool:
653
+ """Return if parameter should be ignored on initial load."""
654
+ return self._ignore_on_initial_load
655
+
656
+ @property
657
+ def is_unit_fixed(self) -> bool:
658
+ """Return if the unit is fixed."""
659
+ return self._raw_unit != self._unit
660
+
661
+ @property
662
+ def is_un_ignored(self) -> bool:
663
+ """Return if the parameter is un ignored."""
664
+ return self._is_un_ignored
665
+
666
+ @hm_property(cached=True)
667
+ def dpk(self) -> DataPointKey:
668
+ """Return data_point key value."""
669
+ return DataPointKey(
670
+ interface_id=self._device.interface_id,
671
+ channel_address=self._channel.address,
672
+ paramset_key=self._paramset_key,
673
+ parameter=self._parameter,
674
+ )
675
+
676
+ @config_property
677
+ def max(self) -> ParameterT:
678
+ """Return max value."""
679
+ return self._max
680
+
681
+ @config_property
682
+ def min(self) -> ParameterT:
683
+ """Return min value."""
684
+ return self._min
685
+
686
+ @property
687
+ def multiplier(self) -> float:
688
+ """Return multiplier value."""
689
+ return self._multiplier
690
+
691
+ @hm_property(log_context=True)
692
+ def parameter(self) -> str:
693
+ """Return parameter name."""
694
+ return self._parameter
695
+
696
+ @property
697
+ def paramset_key(self) -> ParamsetKey:
698
+ """Return paramset_key name."""
699
+ return self._paramset_key
700
+
701
+ @property
702
+ def raw_unit(self) -> str | None:
703
+ """Return raw unit value."""
704
+ return self._raw_unit
705
+
706
+ @hm_property(cached=True)
707
+ def requires_polling(self) -> bool:
708
+ """Return whether the data_point requires polling."""
709
+ return not self._channel.device.client.supports_push_updates or (
710
+ self._channel.device.product_group in (ProductGroup.HM, ProductGroup.HMW)
711
+ and self._paramset_key == ParamsetKey.MASTER
712
+ )
713
+
714
+ @property
715
+ def is_forced_sensor(self) -> bool:
716
+ """Return, if data_point is forced to read only."""
717
+ return self._is_forced_sensor
718
+
719
+ @property
720
+ def is_readable(self) -> bool:
721
+ """Return, if data_point is readable."""
722
+ return bool(self._operations & Operations.READ)
723
+
724
+ @property
725
+ def is_writeable(self) -> bool:
726
+ """Return, if data_point is writeable."""
727
+ return False if self._is_forced_sensor else bool(self._operations & Operations.WRITE)
728
+
729
+ @property
730
+ def unconfirmed_last_value_send(self) -> ParameterT:
731
+ """Return the unconfirmed value send for the data_point."""
732
+ return cast(
733
+ ParameterT,
734
+ self._client.last_value_send_cache.get_last_value_send(dpk=self.dpk),
735
+ )
736
+
737
+ @property
738
+ def previous_value(self) -> ParameterT:
739
+ """Return the previous value of the data_point."""
740
+ return self._previous_value
741
+
742
+ @property
743
+ def category(self) -> DataPointCategory:
744
+ """Return, the category of the data_point."""
745
+ return DataPointCategory.SENSOR if self._is_forced_sensor else self._category
746
+
747
+ @property
748
+ def state_uncertain(self) -> bool:
749
+ """Return, if the state is uncertain."""
750
+ return self._state_uncertain
751
+
752
+ @property
753
+ def _value(self) -> ParameterT:
754
+ """Return the value of the data_point."""
755
+ return self._temporary_value if self._temporary_refreshed_at > self._refreshed_at else self._current_value
756
+
757
+ @state_property
758
+ def value(self) -> ParameterT:
759
+ """Return the value of the data_point."""
760
+ return self._value
761
+
762
+ @property
763
+ def service(self) -> bool:
764
+ """Return the if data_point is relevant for service messages in the backend."""
765
+ return self._service
766
+
767
+ @property
768
+ def supports_events(self) -> bool:
769
+ """Return, if data_point is supports events."""
770
+ return bool(self._operations & Operations.EVENT)
771
+
772
+ @config_property
773
+ def unique_id(self) -> str:
774
+ """Return the unique_id."""
775
+ return f"{self._unique_id}_{DataPointCategory.SENSOR}" if self._is_forced_sensor else self._unique_id
776
+
777
+ @config_property
778
+ def unit(self) -> str | None:
779
+ """Return unit value."""
780
+ return self._unit
781
+
782
+ @config_property
783
+ def values(self) -> tuple[str, ...] | None:
784
+ """Return the values."""
785
+ return self._values
786
+
787
+ @property
788
+ def visible(self) -> bool:
789
+ """Return the if data_point is visible in the backend."""
790
+ return self._visible
791
+
792
+ @hm_property(cached=True)
793
+ def _enabled_by_channel_operation_mode(self) -> bool | None:
794
+ """Return, if the data_point/event must be enabled."""
795
+ if self._channel.type_name not in _CONFIGURABLE_CHANNEL:
796
+ return None
797
+ if self._parameter not in KEY_CHANNEL_OPERATION_MODE_VISIBILITY:
798
+ return None
799
+ if (cop := self._channel.operation_mode) is None:
800
+ return None
801
+ return cop in KEY_CHANNEL_OPERATION_MODE_VISIBILITY[self._parameter]
802
+
803
+ def _get_path_data(self) -> PathData:
804
+ """Return the path data of the data_point."""
805
+ return DataPointPathData(
806
+ interface=self._device.client.interface,
807
+ address=self._device.address,
808
+ channel_no=self._channel.no,
809
+ kind=self._parameter,
810
+ )
811
+
812
+ def force_to_sensor(self) -> None:
813
+ """Change the category of the data_point."""
814
+ if self.category == DataPointCategory.SENSOR:
815
+ _LOGGER.debug(
816
+ "Category for %s is already %s. Doing nothing",
817
+ self.full_name,
818
+ DataPointCategory.SENSOR,
819
+ )
820
+ return
821
+ if self.category not in (
822
+ DataPointCategory.NUMBER,
823
+ DataPointCategory.SELECT,
824
+ DataPointCategory.TEXT,
825
+ ):
826
+ _LOGGER.debug(
827
+ "Category %s for %s cannot be changed to %s",
828
+ self.category,
829
+ self.full_name,
830
+ DataPointCategory.SENSOR,
831
+ )
832
+ _LOGGER.debug(
833
+ "Changing the category of %s to %s (read-only)",
834
+ self.full_name,
835
+ DataPointCategory.SENSOR,
836
+ )
837
+ self._is_forced_sensor = True
838
+
839
+ def _cleanup_unit(self, *, raw_unit: str | None) -> str | None:
840
+ """Replace given unit."""
841
+ if new_unit := _FIX_UNIT_BY_PARAM.get(self._parameter):
842
+ return new_unit
843
+ if not raw_unit:
844
+ return None
845
+ for check, fix in _FIX_UNIT_REPLACE.items():
846
+ if check in raw_unit:
847
+ return fix
848
+ return raw_unit
849
+
850
+ def _get_multiplier(self, *, raw_unit: str | None) -> float:
851
+ """Replace given unit."""
852
+ if not raw_unit:
853
+ return DEFAULT_MULTIPLIER
854
+ if multiplier := _MULTIPLIER_UNIT.get(raw_unit):
855
+ return multiplier
856
+ return DEFAULT_MULTIPLIER
857
+
858
+ def _get_signature(self) -> str:
859
+ """Return the signature of the data_point."""
860
+ return f"{self._category}/{self._channel.device.model}/{self._parameter}"
861
+
862
+ @abstractmethod
863
+ async def event(self, *, value: Any, received_at: datetime) -> None:
864
+ """Handle event for which this handler has subscribed."""
865
+
866
+ async def load_data_point_value(self, *, call_source: CallSource, direct_call: bool = False) -> None:
867
+ """Init the data_point data."""
868
+ if (self._ignore_on_initial_load or self._channel.device.ignore_on_initial_load) and call_source in (
869
+ CallSource.HM_INIT,
870
+ CallSource.HA_INIT,
871
+ ):
872
+ return
873
+
874
+ if direct_call is False and hms.changed_within_seconds(last_change=self._refreshed_at):
875
+ return
876
+
877
+ # Check, if data_point is readable
878
+ if not self.is_readable:
879
+ return
880
+
881
+ self.write_value(
882
+ value=await self._device.value_cache.get_value(
883
+ dpk=self.dpk,
884
+ call_source=call_source,
885
+ direct_call=direct_call,
886
+ ),
887
+ write_at=datetime.now(),
888
+ )
889
+
890
+ def write_value(self, *, value: Any, write_at: datetime) -> tuple[ParameterT, ParameterT]:
891
+ """Update value of the data_point."""
892
+ self._reset_temporary_value()
893
+
894
+ old_value = self._current_value
895
+ if value == NO_CACHE_ENTRY:
896
+ if self.refreshed_at != INIT_DATETIME:
897
+ self._state_uncertain = True
898
+ self.emit_data_point_updated_event()
899
+ return (old_value, None) # type: ignore[return-value]
900
+
901
+ new_value = self._convert_value(value=value)
902
+ if old_value == new_value:
903
+ self._set_refreshed_at(refreshed_at=write_at)
904
+ else:
905
+ self._set_modified_at(modified_at=write_at)
906
+ self._previous_value = old_value
907
+ self._current_value = new_value
908
+ self._state_uncertain = False
909
+ self.emit_data_point_updated_event()
910
+ return (old_value, new_value)
911
+
912
+ def write_temporary_value(self, *, value: Any, write_at: datetime) -> None:
913
+ """Update the temporary value of the data_point."""
914
+ self._reset_temporary_value()
915
+
916
+ temp_value = self._convert_value(value=value)
917
+ if self._value == temp_value:
918
+ self._set_temporary_refreshed_at(refreshed_at=write_at)
919
+ else:
920
+ self._set_temporary_modified_at(modified_at=write_at)
921
+ self._temporary_value = temp_value
922
+ self._state_uncertain = True
923
+ self.emit_data_point_updated_event()
924
+
925
+ def update_parameter_data(self) -> None:
926
+ """Update parameter data."""
927
+ if parameter_data := self._central.paramset_descriptions.get_parameter_data(
928
+ interface_id=self._device.interface_id,
929
+ channel_address=self._channel.address,
930
+ paramset_key=self._paramset_key,
931
+ parameter=self._parameter,
932
+ ):
933
+ self._assign_parameter_data(parameter_data=parameter_data)
934
+
935
+ def _convert_value(self, *, value: Any) -> ParameterT:
936
+ """Convert to value to ParameterT."""
937
+ if value is None:
938
+ return None # type: ignore[return-value]
939
+ try:
940
+ if (
941
+ self._type == ParameterType.BOOL
942
+ and self._values is not None
943
+ and value is not None
944
+ and isinstance(value, str)
945
+ ):
946
+ return cast(
947
+ ParameterT,
948
+ convert_value(
949
+ value=self._values.index(value),
950
+ target_type=self._type,
951
+ value_list=self.values,
952
+ ),
953
+ )
954
+ return cast(ParameterT, convert_value(value=value, target_type=self._type, value_list=self.values))
955
+ except (ValueError, TypeError): # pragma: no cover
956
+ _LOGGER.debug(
957
+ "CONVERT_VALUE: conversion failed for %s, %s, %s, value: [%s]",
958
+ self._device.interface_id,
959
+ self._channel.address,
960
+ self._parameter,
961
+ value,
962
+ )
963
+ return None # type: ignore[return-value]
964
+
965
+ def _reset_temporary_value(self) -> None:
966
+ """Reset the temp storage."""
967
+ self._temporary_value = None # type: ignore[assignment]
968
+ self._reset_temporary_timestamps()
969
+
970
+ def get_event_data(self, *, value: Any = None) -> dict[EventKey, Any]:
971
+ """Get the event_data."""
972
+ event_data = {
973
+ EventKey.ADDRESS: self._device.address,
974
+ EventKey.CHANNEL_NO: self._channel.no,
975
+ EventKey.MODEL: self._device.model,
976
+ EventKey.INTERFACE_ID: self._device.interface_id,
977
+ EventKey.PARAMETER: self._parameter,
978
+ }
979
+ if value is not None:
980
+ event_data[EventKey.VALUE] = value
981
+ return cast(dict[EventKey, Any], EVENT_DATA_SCHEMA(event_data))
982
+
983
+
984
+ class CallParameterCollector:
985
+ """Create a Paramset based on given generic data point."""
986
+
987
+ __slots__ = (
988
+ "_central",
989
+ "_client",
990
+ "_paramsets",
991
+ )
992
+
993
+ def __init__(self, *, client: hmcl.Client) -> None:
994
+ """Init the generator."""
995
+ self._client: Final = client
996
+ self._central: Final = client.central
997
+ # {"VALUES": {50: {"00021BE9957782:3": {"STATE3": True}}}}
998
+ self._paramsets: Final[dict[ParamsetKey, dict[int, dict[str, dict[str, Any]]]]] = {}
999
+
1000
+ def add_data_point(
1001
+ self,
1002
+ *,
1003
+ data_point: BaseParameterDataPoint,
1004
+ value: Any,
1005
+ collector_order: int,
1006
+ ) -> None:
1007
+ """Add a generic data_point."""
1008
+ if data_point.paramset_key not in self._paramsets:
1009
+ self._paramsets[data_point.paramset_key] = {}
1010
+ if collector_order not in self._paramsets[data_point.paramset_key]:
1011
+ self._paramsets[data_point.paramset_key][collector_order] = {}
1012
+ if data_point.channel.address not in self._paramsets[data_point.paramset_key][collector_order]:
1013
+ self._paramsets[data_point.paramset_key][collector_order][data_point.channel.address] = {}
1014
+ self._paramsets[data_point.paramset_key][collector_order][data_point.channel.address][data_point.parameter] = (
1015
+ value
1016
+ )
1017
+
1018
+ async def send_data(self, *, wait_for_callback: int | None) -> set[DP_KEY_VALUE]:
1019
+ """Send data to the backend."""
1020
+ dpk_values: set[DP_KEY_VALUE] = set()
1021
+ for paramset_key, paramsets in self._paramsets.items():
1022
+ for _, paramset_no in sorted(paramsets.items()):
1023
+ for channel_address, paramset in paramset_no.items():
1024
+ if len(paramset) == 1:
1025
+ for parameter, value in paramset.items():
1026
+ dpk_values.update(
1027
+ await self._client.set_value(
1028
+ channel_address=channel_address,
1029
+ paramset_key=paramset_key,
1030
+ parameter=parameter,
1031
+ value=value,
1032
+ wait_for_callback=wait_for_callback,
1033
+ )
1034
+ )
1035
+ else:
1036
+ dpk_values.update(
1037
+ await self._client.put_paramset(
1038
+ channel_address=channel_address,
1039
+ paramset_key_or_link_address=paramset_key,
1040
+ values=paramset,
1041
+ wait_for_callback=wait_for_callback,
1042
+ )
1043
+ )
1044
+ return dpk_values
1045
+
1046
+
1047
+ def bind_collector(
1048
+ *,
1049
+ wait_for_callback: int | None = WAIT_FOR_CALLBACK,
1050
+ enabled: bool = True,
1051
+ log_level: int = logging.ERROR,
1052
+ ) -> Callable:
1053
+ """
1054
+ Decorate function to automatically add collector if not set.
1055
+
1056
+ Additionally, thrown exceptions are logged.
1057
+ """
1058
+
1059
+ def bind_decorator[CallableT: Callable[..., Any]](func: CallableT) -> CallableT:
1060
+ """Decorate function to automatically add collector if not set."""
1061
+ spec = getfullargspec(func)
1062
+ # Support both positional and keyword-only 'collector' parameters
1063
+ if _COLLECTOR_ARGUMENT_NAME in spec.args:
1064
+ argument_index: int | None = spec.args.index(_COLLECTOR_ARGUMENT_NAME)
1065
+ else:
1066
+ argument_index = None
1067
+
1068
+ @wraps(func)
1069
+ async def bind_wrapper(*args: Any, **kwargs: Any) -> Any:
1070
+ """Wrap method to add collector."""
1071
+ token: Token | None = None
1072
+ if not IN_SERVICE_VAR.get():
1073
+ token = IN_SERVICE_VAR.set(True)
1074
+ try:
1075
+ if not enabled:
1076
+ return_value = await func(*args, **kwargs)
1077
+ if token:
1078
+ IN_SERVICE_VAR.reset(token)
1079
+ return return_value
1080
+ try:
1081
+ collector_exists = (
1082
+ argument_index is not None and len(args) > argument_index and args[argument_index] is not None
1083
+ ) or kwargs.get(_COLLECTOR_ARGUMENT_NAME) is not None
1084
+ except Exception:
1085
+ collector_exists = kwargs.get(_COLLECTOR_ARGUMENT_NAME) is not None
1086
+
1087
+ if collector_exists:
1088
+ return_value = await func(*args, **kwargs)
1089
+ if token:
1090
+ IN_SERVICE_VAR.reset(token)
1091
+ return return_value
1092
+ collector = CallParameterCollector(client=args[0].channel.device.client)
1093
+ kwargs[_COLLECTOR_ARGUMENT_NAME] = collector
1094
+ return_value = await func(*args, **kwargs)
1095
+ await collector.send_data(wait_for_callback=wait_for_callback)
1096
+ except BaseHomematicException as bhexc:
1097
+ if token:
1098
+ IN_SERVICE_VAR.reset(token)
1099
+ in_service = IN_SERVICE_VAR.get()
1100
+ if not in_service and log_level > logging.NOTSET:
1101
+ context_obj = args[0]
1102
+ logger = logging.getLogger(context_obj.__module__)
1103
+ log_context = context_obj.log_context if isinstance(context_obj, LogContextMixin) else None
1104
+ # Reuse centralized boundary logging to ensure consistent 'extra' structure
1105
+ log_boundary_error(
1106
+ logger=logger,
1107
+ boundary="service",
1108
+ action=func.__name__,
1109
+ err=bhexc,
1110
+ level=log_level,
1111
+ log_context=log_context,
1112
+ )
1113
+ # Re-raise domain-specific exceptions so callers and tests can handle them
1114
+ raise
1115
+ else:
1116
+ if token:
1117
+ IN_SERVICE_VAR.reset(token)
1118
+ return return_value
1119
+
1120
+ setattr(bind_wrapper, "ha_service", True)
1121
+ return bind_wrapper # type: ignore[return-value]
1122
+
1123
+ return bind_decorator