aiohomematic 2025.11.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of aiohomematic might be problematic. Click here for more details.
- aiohomematic/__init__.py +61 -0
- aiohomematic/async_support.py +212 -0
- aiohomematic/central/__init__.py +2309 -0
- aiohomematic/central/decorators.py +155 -0
- aiohomematic/central/rpc_server.py +295 -0
- aiohomematic/client/__init__.py +1848 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +1326 -0
- aiohomematic/client/rpc_proxy.py +311 -0
- aiohomematic/const.py +1127 -0
- aiohomematic/context.py +18 -0
- aiohomematic/converter.py +108 -0
- aiohomematic/decorators.py +302 -0
- aiohomematic/exceptions.py +164 -0
- aiohomematic/hmcli.py +186 -0
- aiohomematic/model/__init__.py +140 -0
- aiohomematic/model/calculated/__init__.py +84 -0
- aiohomematic/model/calculated/climate.py +290 -0
- aiohomematic/model/calculated/data_point.py +327 -0
- aiohomematic/model/calculated/operating_voltage_level.py +299 -0
- aiohomematic/model/calculated/support.py +234 -0
- aiohomematic/model/custom/__init__.py +177 -0
- aiohomematic/model/custom/climate.py +1532 -0
- aiohomematic/model/custom/cover.py +792 -0
- aiohomematic/model/custom/data_point.py +334 -0
- aiohomematic/model/custom/definition.py +871 -0
- aiohomematic/model/custom/light.py +1128 -0
- aiohomematic/model/custom/lock.py +394 -0
- aiohomematic/model/custom/siren.py +275 -0
- aiohomematic/model/custom/support.py +41 -0
- aiohomematic/model/custom/switch.py +175 -0
- aiohomematic/model/custom/valve.py +114 -0
- aiohomematic/model/data_point.py +1123 -0
- aiohomematic/model/device.py +1445 -0
- aiohomematic/model/event.py +208 -0
- aiohomematic/model/generic/__init__.py +217 -0
- aiohomematic/model/generic/action.py +34 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +27 -0
- aiohomematic/model/generic/data_point.py +171 -0
- aiohomematic/model/generic/dummy.py +147 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +39 -0
- aiohomematic/model/generic/sensor.py +74 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +29 -0
- aiohomematic/model/hub/__init__.py +333 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/data_point.py +340 -0
- aiohomematic/model/hub/number.py +39 -0
- aiohomematic/model/hub/select.py +49 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +44 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/support.py +586 -0
- aiohomematic/model/update.py +143 -0
- aiohomematic/property_decorators.py +496 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
- aiohomematic/rega_scripts/set_program_state.fn +12 -0
- aiohomematic/rega_scripts/set_system_variable.fn +15 -0
- aiohomematic/store/__init__.py +34 -0
- aiohomematic/store/dynamic.py +551 -0
- aiohomematic/store/persistent.py +988 -0
- aiohomematic/store/visibility.py +812 -0
- aiohomematic/support.py +664 -0
- aiohomematic/validator.py +112 -0
- aiohomematic-2025.11.3.dist-info/METADATA +144 -0
- aiohomematic-2025.11.3.dist-info/RECORD +77 -0
- aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
- aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
- aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|