aiohomematic 2025.8.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aiohomematic might be problematic. Click here for more details.

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