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,1445 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
|
+
"""
|
|
4
|
+
Device and channel model for AioHomematic.
|
|
5
|
+
|
|
6
|
+
This module implements the runtime representation of a Homematic device and its
|
|
7
|
+
channels, including creation and lookup of data points/events, firmware and
|
|
8
|
+
availability handling, link management, value caching, and exporting of device
|
|
9
|
+
definitions for diagnostics.
|
|
10
|
+
|
|
11
|
+
Key classes:
|
|
12
|
+
- Device: Encapsulates metadata, channels, and operations for a single device.
|
|
13
|
+
- Channel: Represents a functional channel with its data points and events.
|
|
14
|
+
|
|
15
|
+
Other components:
|
|
16
|
+
- _ValueCache: Lazy loading and caching of parameter values to minimize RPCs.
|
|
17
|
+
- _DefinitionExporter: Utility to export device and paramset descriptions.
|
|
18
|
+
|
|
19
|
+
The Device/Channel classes are the anchor used by generic, custom, calculated,
|
|
20
|
+
and hub model code to attach data points and events.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from functools import partial
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import random
|
|
32
|
+
from typing import Any, Final
|
|
33
|
+
|
|
34
|
+
import orjson
|
|
35
|
+
|
|
36
|
+
from aiohomematic import central as hmcu, client as hmcl
|
|
37
|
+
from aiohomematic.async_support import loop_check
|
|
38
|
+
from aiohomematic.const import (
|
|
39
|
+
ADDRESS_SEPARATOR,
|
|
40
|
+
CALLBACK_TYPE,
|
|
41
|
+
CLICK_EVENTS,
|
|
42
|
+
DEVICE_DESCRIPTIONS_DIR,
|
|
43
|
+
IDENTIFIER_SEPARATOR,
|
|
44
|
+
INIT_DATETIME,
|
|
45
|
+
NO_CACHE_ENTRY,
|
|
46
|
+
PARAMSET_DESCRIPTIONS_DIR,
|
|
47
|
+
RELEVANT_INIT_PARAMETERS,
|
|
48
|
+
REPORT_VALUE_USAGE_DATA,
|
|
49
|
+
REPORT_VALUE_USAGE_VALUE_ID,
|
|
50
|
+
VIRTUAL_REMOTE_MODELS,
|
|
51
|
+
CallSource,
|
|
52
|
+
DataOperationResult,
|
|
53
|
+
DataPointCategory,
|
|
54
|
+
DataPointKey,
|
|
55
|
+
DataPointUsage,
|
|
56
|
+
DeviceDescription,
|
|
57
|
+
DeviceFirmwareState,
|
|
58
|
+
EventType,
|
|
59
|
+
ForcedDeviceAvailability,
|
|
60
|
+
Interface,
|
|
61
|
+
Manufacturer,
|
|
62
|
+
Parameter,
|
|
63
|
+
ParameterData,
|
|
64
|
+
ParamsetKey,
|
|
65
|
+
ProductGroup,
|
|
66
|
+
RxMode,
|
|
67
|
+
check_ignore_model_on_initial_load,
|
|
68
|
+
get_link_source_categories,
|
|
69
|
+
get_link_target_categories,
|
|
70
|
+
)
|
|
71
|
+
from aiohomematic.decorators import inspector
|
|
72
|
+
from aiohomematic.exceptions import AioHomematicException, BaseHomematicException
|
|
73
|
+
from aiohomematic.model.calculated import CalculatedDataPoint
|
|
74
|
+
from aiohomematic.model.custom import data_point as hmce, definition as hmed
|
|
75
|
+
from aiohomematic.model.data_point import BaseParameterDataPoint, CallbackDataPoint
|
|
76
|
+
from aiohomematic.model.event import GenericEvent
|
|
77
|
+
from aiohomematic.model.generic import GenericDataPoint
|
|
78
|
+
from aiohomematic.model.support import (
|
|
79
|
+
ChannelNameData,
|
|
80
|
+
generate_channel_unique_id,
|
|
81
|
+
get_channel_name_data,
|
|
82
|
+
get_device_name,
|
|
83
|
+
)
|
|
84
|
+
from aiohomematic.model.update import DpUpdate
|
|
85
|
+
from aiohomematic.property_decorators import hm_property, info_property, state_property
|
|
86
|
+
from aiohomematic.support import (
|
|
87
|
+
CacheEntry,
|
|
88
|
+
LogContextMixin,
|
|
89
|
+
PayloadMixin,
|
|
90
|
+
check_or_create_directory,
|
|
91
|
+
extract_exc_args,
|
|
92
|
+
get_channel_address,
|
|
93
|
+
get_channel_no,
|
|
94
|
+
get_rx_modes,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
__all__ = ["Channel", "Device"]
|
|
98
|
+
|
|
99
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Device(LogContextMixin, PayloadMixin):
|
|
103
|
+
"""Object to hold information about a device and associated data points."""
|
|
104
|
+
|
|
105
|
+
__slots__ = (
|
|
106
|
+
"_address",
|
|
107
|
+
"_cached_relevant_for_central_link_management",
|
|
108
|
+
"_central",
|
|
109
|
+
"_channel_group",
|
|
110
|
+
"_channels",
|
|
111
|
+
"_client",
|
|
112
|
+
"_description",
|
|
113
|
+
"_device_updated_callbacks",
|
|
114
|
+
"_firmware_update_callbacks",
|
|
115
|
+
"_forced_availability",
|
|
116
|
+
"_group_channels",
|
|
117
|
+
"_has_custom_data_point_definition",
|
|
118
|
+
"_id",
|
|
119
|
+
"_ignore_for_custom_data_point",
|
|
120
|
+
"_ignore_on_initial_load",
|
|
121
|
+
"_interface",
|
|
122
|
+
"_interface_id",
|
|
123
|
+
"_is_updatable",
|
|
124
|
+
"_manufacturer",
|
|
125
|
+
"_model",
|
|
126
|
+
"_modified_at",
|
|
127
|
+
"_name",
|
|
128
|
+
"_product_group",
|
|
129
|
+
"_rooms",
|
|
130
|
+
"_rx_modes",
|
|
131
|
+
"_sub_model",
|
|
132
|
+
"_update_data_point",
|
|
133
|
+
"_value_cache",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def __init__(self, *, central: hmcu.CentralUnit, interface_id: str, device_address: str) -> None:
|
|
137
|
+
"""Initialize the device object."""
|
|
138
|
+
PayloadMixin.__init__(self)
|
|
139
|
+
self._central: Final = central
|
|
140
|
+
self._interface_id: Final = interface_id
|
|
141
|
+
self._address: Final = device_address
|
|
142
|
+
self._channel_group: Final[dict[int | None, int]] = {}
|
|
143
|
+
self._group_channels: Final[dict[int, set[int | None]]] = {}
|
|
144
|
+
self._id: Final = self._central.device_details.get_address_id(address=device_address)
|
|
145
|
+
self._interface: Final = central.device_details.get_interface(address=device_address)
|
|
146
|
+
self._client: Final = central.get_client(interface_id=interface_id)
|
|
147
|
+
self._description = self._central.device_descriptions.get_device_description(
|
|
148
|
+
interface_id=interface_id, address=device_address
|
|
149
|
+
)
|
|
150
|
+
_LOGGER.debug(
|
|
151
|
+
"__INIT__: Initializing device: %s, %s",
|
|
152
|
+
interface_id,
|
|
153
|
+
device_address,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self._modified_at: datetime = INIT_DATETIME
|
|
157
|
+
self._forced_availability: ForcedDeviceAvailability = ForcedDeviceAvailability.NOT_SET
|
|
158
|
+
self._device_updated_callbacks: Final[list[Callable]] = []
|
|
159
|
+
self._firmware_update_callbacks: Final[list[Callable]] = []
|
|
160
|
+
self._model: Final[str] = self._description["TYPE"]
|
|
161
|
+
self._ignore_on_initial_load: Final[bool] = check_ignore_model_on_initial_load(model=self._model)
|
|
162
|
+
self._is_updatable: Final = self._description.get("UPDATABLE") or False
|
|
163
|
+
self._rx_modes: Final = get_rx_modes(mode=self._description.get("RX_MODE", 0))
|
|
164
|
+
self._sub_model: Final[str | None] = self._description.get("SUBTYPE")
|
|
165
|
+
self._ignore_for_custom_data_point: Final[bool] = central.parameter_visibility.model_is_ignored(
|
|
166
|
+
model=self._model
|
|
167
|
+
)
|
|
168
|
+
self._manufacturer = self._identify_manufacturer()
|
|
169
|
+
self._product_group: Final = self._client.get_product_group(model=self._model)
|
|
170
|
+
# marker if device will be created as custom data_point
|
|
171
|
+
self._has_custom_data_point_definition: Final = (
|
|
172
|
+
hmed.data_point_definition_exists(model=self._model) and not self._ignore_for_custom_data_point
|
|
173
|
+
)
|
|
174
|
+
self._name: Final = get_device_name(
|
|
175
|
+
central=central,
|
|
176
|
+
device_address=device_address,
|
|
177
|
+
model=self._model,
|
|
178
|
+
)
|
|
179
|
+
channel_addresses = tuple(
|
|
180
|
+
[device_address] + [address for address in self._description["CHILDREN"] if address != ""]
|
|
181
|
+
)
|
|
182
|
+
self._channels: Final[dict[str, Channel]] = {
|
|
183
|
+
address: Channel(device=self, channel_address=address) for address in channel_addresses
|
|
184
|
+
}
|
|
185
|
+
self._value_cache: Final[_ValueCache] = _ValueCache(device=self)
|
|
186
|
+
self._rooms: Final = central.device_details.get_device_rooms(device_address=device_address)
|
|
187
|
+
self._update_data_point: Final = DpUpdate(device=self) if self.is_updatable else None
|
|
188
|
+
_LOGGER.debug(
|
|
189
|
+
"__INIT__: Initialized device: %s, %s, %s, %s",
|
|
190
|
+
self._interface_id,
|
|
191
|
+
self._address,
|
|
192
|
+
self._model,
|
|
193
|
+
self._name,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _identify_manufacturer(self) -> Manufacturer:
|
|
197
|
+
"""Identify the manufacturer of a device."""
|
|
198
|
+
if self._model.lower().startswith("hb"):
|
|
199
|
+
return Manufacturer.HB
|
|
200
|
+
if self._model.lower().startswith("alpha"):
|
|
201
|
+
return Manufacturer.MOEHLENHOFF
|
|
202
|
+
return Manufacturer.EQ3
|
|
203
|
+
|
|
204
|
+
@info_property(log_context=True)
|
|
205
|
+
def address(self) -> str:
|
|
206
|
+
"""Return the address of the device."""
|
|
207
|
+
return self._address
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def allow_undefined_generic_data_points(self) -> bool:
|
|
211
|
+
"""Return if undefined generic data points of this device are allowed."""
|
|
212
|
+
return bool(
|
|
213
|
+
all(
|
|
214
|
+
channel.custom_data_point.allow_undefined_generic_data_points
|
|
215
|
+
for channel in self._channels.values()
|
|
216
|
+
if channel.custom_data_point is not None
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@state_property
|
|
221
|
+
def available(self) -> bool:
|
|
222
|
+
"""Return the availability of the device."""
|
|
223
|
+
if self._forced_availability != ForcedDeviceAvailability.NOT_SET:
|
|
224
|
+
return self._forced_availability == ForcedDeviceAvailability.FORCE_TRUE
|
|
225
|
+
if (un_reach := self._dp_un_reach) is None:
|
|
226
|
+
un_reach = self._dp_sticky_un_reach
|
|
227
|
+
if un_reach is not None and un_reach.value is not None:
|
|
228
|
+
return not un_reach.value
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def available_firmware(self) -> str | None:
|
|
233
|
+
"""Return the available firmware of the device."""
|
|
234
|
+
return str(self._description.get("AVAILABLE_FIRMWARE", ""))
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def calculated_data_points(self) -> tuple[CalculatedDataPoint, ...]:
|
|
238
|
+
"""Return the generic data points."""
|
|
239
|
+
data_points: list[CalculatedDataPoint] = []
|
|
240
|
+
for channel in self._channels.values():
|
|
241
|
+
data_points.extend(channel.calculated_data_points)
|
|
242
|
+
return tuple(data_points)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def central(self) -> hmcu.CentralUnit:
|
|
246
|
+
"""Return the central of the device."""
|
|
247
|
+
return self._central
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def channels(self) -> Mapping[str, Channel]:
|
|
251
|
+
"""Return the channels."""
|
|
252
|
+
return self._channels
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def client(self) -> hmcl.Client:
|
|
256
|
+
"""Return the client of the device."""
|
|
257
|
+
return self._client
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def config_pending(self) -> bool:
|
|
261
|
+
"""Return if a config change of the device is pending."""
|
|
262
|
+
if self._dp_config_pending is not None and self._dp_config_pending.value is not None:
|
|
263
|
+
return self._dp_config_pending.value is True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def custom_data_points(self) -> tuple[hmce.CustomDataPoint, ...]:
|
|
268
|
+
"""Return the custom data points."""
|
|
269
|
+
return tuple(
|
|
270
|
+
channel.custom_data_point for channel in self._channels.values() if channel.custom_data_point is not None
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@info_property
|
|
274
|
+
def firmware(self) -> str:
|
|
275
|
+
"""Return the firmware of the device."""
|
|
276
|
+
return self._description.get("FIRMWARE") or "0.0"
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def firmware_updatable(self) -> bool:
|
|
280
|
+
"""Return the firmware update state of the device."""
|
|
281
|
+
return self._description.get("FIRMWARE_UPDATABLE") or False
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def firmware_update_state(self) -> DeviceFirmwareState:
|
|
285
|
+
"""Return the firmware update state of the device."""
|
|
286
|
+
return DeviceFirmwareState(self._description.get("FIRMWARE_UPDATE_STATE") or DeviceFirmwareState.UNKNOWN)
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def generic_events(self) -> tuple[GenericEvent, ...]:
|
|
290
|
+
"""Return the generic events."""
|
|
291
|
+
events: list[GenericEvent] = []
|
|
292
|
+
for channel in self._channels.values():
|
|
293
|
+
events.extend(channel.generic_events)
|
|
294
|
+
return tuple(events)
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def generic_data_points(self) -> tuple[GenericDataPoint, ...]:
|
|
298
|
+
"""Return the generic data points."""
|
|
299
|
+
data_points: list[GenericDataPoint] = []
|
|
300
|
+
for channel in self._channels.values():
|
|
301
|
+
data_points.extend(channel.generic_data_points)
|
|
302
|
+
return tuple(data_points)
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def has_custom_data_point_definition(self) -> bool:
|
|
306
|
+
"""Return if custom_data_point definition is available for the device."""
|
|
307
|
+
return self._has_custom_data_point_definition
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def has_sub_devices(self) -> bool:
|
|
311
|
+
"""Return if device has multiple sub device channels."""
|
|
312
|
+
# If there is only one channel group, no sub devices are needed
|
|
313
|
+
if len(self._group_channels) <= 1:
|
|
314
|
+
return False
|
|
315
|
+
count = 0
|
|
316
|
+
# If there are multiple channel groups with more than one channel, there are sub devices
|
|
317
|
+
for gcs in self._group_channels.values():
|
|
318
|
+
if len(gcs) > 1:
|
|
319
|
+
count += 1
|
|
320
|
+
if count > 1:
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def id(self) -> str:
|
|
327
|
+
"""Return the id of the device."""
|
|
328
|
+
return self._id
|
|
329
|
+
|
|
330
|
+
@info_property
|
|
331
|
+
def identifier(self) -> str:
|
|
332
|
+
"""Return the identifier of the device."""
|
|
333
|
+
return f"{self._address}{IDENTIFIER_SEPARATOR}{self._interface_id}"
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def ignore_on_initial_load(self) -> bool:
|
|
337
|
+
"""Return if model should be ignored on initial load."""
|
|
338
|
+
return self._ignore_on_initial_load
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def interface(self) -> Interface:
|
|
342
|
+
"""Return the interface of the device."""
|
|
343
|
+
return self._interface
|
|
344
|
+
|
|
345
|
+
@hm_property(log_context=True)
|
|
346
|
+
def interface_id(self) -> str:
|
|
347
|
+
"""Return the interface_id of the device."""
|
|
348
|
+
return self._interface_id
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def ignore_for_custom_data_point(self) -> bool:
|
|
352
|
+
"""Return if device should be ignored for custom data_point."""
|
|
353
|
+
return self._ignore_for_custom_data_point
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def info(self) -> Mapping[str, Any]:
|
|
357
|
+
"""Return the device info."""
|
|
358
|
+
device_info = dict(self.info_payload)
|
|
359
|
+
device_info["central"] = self._central.info_payload
|
|
360
|
+
return device_info
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def is_updatable(self) -> bool:
|
|
364
|
+
"""Return if the device is updatable."""
|
|
365
|
+
return self._is_updatable
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def link_peer_channels(self) -> Mapping[Channel, tuple[Channel, ...]]:
|
|
369
|
+
"""Return the link peer channels."""
|
|
370
|
+
return {
|
|
371
|
+
channel: channel.link_peer_channels for channel in self._channels.values() if channel.link_peer_channels
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
@info_property
|
|
375
|
+
def manufacturer(self) -> str:
|
|
376
|
+
"""Return the manufacturer of the device."""
|
|
377
|
+
return self._manufacturer
|
|
378
|
+
|
|
379
|
+
@info_property(log_context=True)
|
|
380
|
+
def model(self) -> str:
|
|
381
|
+
"""Return the model of the device."""
|
|
382
|
+
return self._model
|
|
383
|
+
|
|
384
|
+
@info_property
|
|
385
|
+
def name(self) -> str:
|
|
386
|
+
"""Return the name of the device."""
|
|
387
|
+
return self._name
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def product_group(self) -> ProductGroup:
|
|
391
|
+
"""Return the product group of the device."""
|
|
392
|
+
return self._product_group
|
|
393
|
+
|
|
394
|
+
@info_property
|
|
395
|
+
def room(self) -> str | None:
|
|
396
|
+
"""Return the room of the device, if only one assigned in the backend."""
|
|
397
|
+
if self._rooms and len(self._rooms) == 1:
|
|
398
|
+
return list(self._rooms)[0]
|
|
399
|
+
if (maintenance_channel := self.get_channel(channel_address=f"{self._address}:0")) is not None:
|
|
400
|
+
return maintenance_channel.room
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def rooms(self) -> set[str]:
|
|
405
|
+
"""Return all rooms of the device."""
|
|
406
|
+
return self._rooms
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def rx_modes(self) -> tuple[RxMode, ...]:
|
|
410
|
+
"""Return the rx mode."""
|
|
411
|
+
return self._rx_modes
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def sub_model(self) -> str | None:
|
|
415
|
+
"""Return the sub model of the device."""
|
|
416
|
+
return self._sub_model
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def update_data_point(self) -> DpUpdate | None:
|
|
420
|
+
"""Return the device firmware update data_point of the device."""
|
|
421
|
+
return self._update_data_point
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def value_cache(self) -> _ValueCache:
|
|
425
|
+
"""Return the value_cache of the device."""
|
|
426
|
+
return self._value_cache
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def _dp_un_reach(self) -> GenericDataPoint | None:
|
|
430
|
+
"""Return th UN REACH data_point."""
|
|
431
|
+
return self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.UN_REACH)
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def _dp_sticky_un_reach(self) -> GenericDataPoint | None:
|
|
435
|
+
"""Return th STICKY_UN_REACH data_point."""
|
|
436
|
+
return self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.STICKY_UN_REACH)
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def _dp_config_pending(self) -> GenericDataPoint | None:
|
|
440
|
+
"""Return th CONFIG_PENDING data_point."""
|
|
441
|
+
return self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.CONFIG_PENDING)
|
|
442
|
+
|
|
443
|
+
def add_channel_to_group(self, *, group_no: int, channel_no: int | None) -> None:
|
|
444
|
+
"""Add channel to group."""
|
|
445
|
+
if group_no not in self._group_channels:
|
|
446
|
+
self._group_channels[group_no] = set()
|
|
447
|
+
self._group_channels[group_no].add(channel_no)
|
|
448
|
+
|
|
449
|
+
if group_no not in self._channel_group:
|
|
450
|
+
self._channel_group[group_no] = group_no
|
|
451
|
+
if channel_no not in self._channel_group:
|
|
452
|
+
self._channel_group[channel_no] = group_no
|
|
453
|
+
|
|
454
|
+
@inspector
|
|
455
|
+
async def create_central_links(self) -> None:
|
|
456
|
+
"""Create a central links to support press events on all channels with click events."""
|
|
457
|
+
if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
|
|
458
|
+
for channel in self._channels.values():
|
|
459
|
+
await channel.create_central_link()
|
|
460
|
+
|
|
461
|
+
@inspector
|
|
462
|
+
async def remove_central_links(self) -> None:
|
|
463
|
+
"""Remove central links."""
|
|
464
|
+
if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
|
|
465
|
+
for channel in self._channels.values():
|
|
466
|
+
await channel.remove_central_link()
|
|
467
|
+
|
|
468
|
+
@hm_property(cached=True)
|
|
469
|
+
def relevant_for_central_link_management(self) -> bool:
|
|
470
|
+
"""Return if channel is relevant for central link management."""
|
|
471
|
+
return (
|
|
472
|
+
self._interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED, Interface.HMIP_RF)
|
|
473
|
+
and self._model not in VIRTUAL_REMOTE_MODELS
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def get_channel_group_no(self, *, channel_no: int | None) -> int | None:
|
|
477
|
+
"""Return the group no of the channel."""
|
|
478
|
+
return self._channel_group.get(channel_no)
|
|
479
|
+
|
|
480
|
+
def is_in_multi_channel_group(self, *, channel_no: int | None) -> bool:
|
|
481
|
+
"""Return if multiple channels are in the group."""
|
|
482
|
+
if channel_no is None:
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
return len([s for s, m in self._channel_group.items() if m == self._channel_group.get(channel_no)]) > 1
|
|
486
|
+
|
|
487
|
+
def get_channel(self, *, channel_address: str) -> Channel | None:
|
|
488
|
+
"""Get channel of device."""
|
|
489
|
+
return self._channels.get(channel_address)
|
|
490
|
+
|
|
491
|
+
async def re_init_link_peers(self) -> None:
|
|
492
|
+
"""Initiate link peers."""
|
|
493
|
+
for channel in self._channels.values():
|
|
494
|
+
await channel.init_link_peer()
|
|
495
|
+
|
|
496
|
+
def identify_channel(self, *, text: str) -> Channel | None:
|
|
497
|
+
"""Identify channel within a text."""
|
|
498
|
+
for channel_address, channel in self._channels.items():
|
|
499
|
+
if text.endswith(channel_address):
|
|
500
|
+
return channel
|
|
501
|
+
if channel.id in text:
|
|
502
|
+
return channel
|
|
503
|
+
if channel.device.id in text:
|
|
504
|
+
return channel
|
|
505
|
+
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
def remove(self) -> None:
|
|
509
|
+
"""Remove data points from collections and central."""
|
|
510
|
+
for channel in self._channels.values():
|
|
511
|
+
channel.remove()
|
|
512
|
+
|
|
513
|
+
def register_device_updated_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
|
|
514
|
+
"""Register update callback."""
|
|
515
|
+
if callable(cb) and cb not in self._device_updated_callbacks:
|
|
516
|
+
self._device_updated_callbacks.append(cb)
|
|
517
|
+
return partial(self.unregister_device_updated_callback, cb=cb)
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def unregister_device_updated_callback(self, *, cb: Callable) -> None:
|
|
521
|
+
"""Remove update callback."""
|
|
522
|
+
if cb in self._device_updated_callbacks:
|
|
523
|
+
self._device_updated_callbacks.remove(cb)
|
|
524
|
+
|
|
525
|
+
def register_firmware_update_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
|
|
526
|
+
"""Register firmware update callback."""
|
|
527
|
+
if callable(cb) and cb not in self._firmware_update_callbacks:
|
|
528
|
+
self._firmware_update_callbacks.append(cb)
|
|
529
|
+
return partial(self.unregister_firmware_update_callback, cb=cb)
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
def unregister_firmware_update_callback(self, *, cb: Callable) -> None:
|
|
533
|
+
"""Remove firmware update callback."""
|
|
534
|
+
if cb in self._firmware_update_callbacks:
|
|
535
|
+
self._firmware_update_callbacks.remove(cb)
|
|
536
|
+
|
|
537
|
+
def _set_modified_at(self) -> None:
|
|
538
|
+
self._modified_at = datetime.now()
|
|
539
|
+
|
|
540
|
+
def get_data_points(
|
|
541
|
+
self,
|
|
542
|
+
*,
|
|
543
|
+
category: DataPointCategory | None = None,
|
|
544
|
+
exclude_no_create: bool = True,
|
|
545
|
+
registered: bool | None = None,
|
|
546
|
+
) -> tuple[CallbackDataPoint, ...]:
|
|
547
|
+
"""Get all data points of the device."""
|
|
548
|
+
all_data_points: list[CallbackDataPoint] = []
|
|
549
|
+
if (
|
|
550
|
+
self._update_data_point
|
|
551
|
+
and (category is None or self._update_data_point.category == category)
|
|
552
|
+
and (
|
|
553
|
+
(exclude_no_create and self._update_data_point.usage != DataPointUsage.NO_CREATE)
|
|
554
|
+
or exclude_no_create is False
|
|
555
|
+
)
|
|
556
|
+
and (registered is None or self._update_data_point.is_registered == registered)
|
|
557
|
+
):
|
|
558
|
+
all_data_points.append(self._update_data_point)
|
|
559
|
+
for channel in self._channels.values():
|
|
560
|
+
all_data_points.extend(
|
|
561
|
+
channel.get_data_points(category=category, exclude_no_create=exclude_no_create, registered=registered)
|
|
562
|
+
)
|
|
563
|
+
return tuple(all_data_points)
|
|
564
|
+
|
|
565
|
+
def get_events(
|
|
566
|
+
self, *, event_type: EventType, registered: bool | None = None
|
|
567
|
+
) -> Mapping[int | None, tuple[GenericEvent, ...]]:
|
|
568
|
+
"""Return a list of specific events of a channel."""
|
|
569
|
+
events: dict[int | None, tuple[GenericEvent, ...]] = {}
|
|
570
|
+
for channel in self._channels.values():
|
|
571
|
+
if (values := channel.get_events(event_type=event_type, registered=registered)) and len(values) > 0:
|
|
572
|
+
events[channel.no] = values
|
|
573
|
+
return events
|
|
574
|
+
|
|
575
|
+
def get_calculated_data_point(self, *, channel_address: str, parameter: str) -> CalculatedDataPoint | None:
|
|
576
|
+
"""Return a calculated data_point from device."""
|
|
577
|
+
if channel := self.get_channel(channel_address=channel_address):
|
|
578
|
+
return channel.get_calculated_data_point(parameter=parameter)
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
def get_custom_data_point(self, *, channel_no: int) -> hmce.CustomDataPoint | None:
|
|
582
|
+
"""Return a custom data_point from device."""
|
|
583
|
+
if channel := self.get_channel(
|
|
584
|
+
channel_address=get_channel_address(device_address=self._address, channel_no=channel_no)
|
|
585
|
+
):
|
|
586
|
+
return channel.custom_data_point
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
def get_generic_data_point(
|
|
590
|
+
self, *, channel_address: str, parameter: str, paramset_key: ParamsetKey | None = None
|
|
591
|
+
) -> GenericDataPoint | None:
|
|
592
|
+
"""Return a generic data_point from device."""
|
|
593
|
+
if channel := self.get_channel(channel_address=channel_address):
|
|
594
|
+
return channel.get_generic_data_point(parameter=parameter, paramset_key=paramset_key)
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
def get_generic_event(self, *, channel_address: str, parameter: str) -> GenericEvent | None:
|
|
598
|
+
"""Return a generic event from device."""
|
|
599
|
+
if channel := self.get_channel(channel_address=channel_address):
|
|
600
|
+
return channel.get_generic_event(parameter=parameter)
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPoint, ...]:
|
|
604
|
+
"""Return the list of readable master data points."""
|
|
605
|
+
data_points: list[GenericDataPoint] = []
|
|
606
|
+
for channel in self._channels.values():
|
|
607
|
+
data_points.extend(channel.get_readable_data_points(paramset_key=paramset_key))
|
|
608
|
+
return tuple(data_points)
|
|
609
|
+
|
|
610
|
+
def set_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
|
|
611
|
+
"""Set the availability of the device."""
|
|
612
|
+
if self._forced_availability != forced_availability:
|
|
613
|
+
self._forced_availability = forced_availability
|
|
614
|
+
for dp in self.generic_data_points:
|
|
615
|
+
dp.emit_data_point_updated_event()
|
|
616
|
+
|
|
617
|
+
@inspector
|
|
618
|
+
async def export_device_definition(self) -> None:
|
|
619
|
+
"""Export the device definition for current device."""
|
|
620
|
+
try:
|
|
621
|
+
device_exporter = _DefinitionExporter(device=self)
|
|
622
|
+
await device_exporter.export_data()
|
|
623
|
+
except Exception as exc:
|
|
624
|
+
raise AioHomematicException(f"EXPORT_DEVICE_DEFINITION failed: {extract_exc_args(exc=exc)}") from exc
|
|
625
|
+
|
|
626
|
+
def refresh_firmware_data(self) -> None:
|
|
627
|
+
"""Refresh firmware data of the device."""
|
|
628
|
+
old_available_firmware = self.available_firmware
|
|
629
|
+
old_firmware = self.firmware
|
|
630
|
+
old_firmware_update_state = self.firmware_update_state
|
|
631
|
+
old_firmware_updatable = self.firmware_updatable
|
|
632
|
+
|
|
633
|
+
self._description = self._central.device_descriptions.get_device_description(
|
|
634
|
+
interface_id=self._interface_id, address=self._address
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if (
|
|
638
|
+
old_available_firmware != self.available_firmware
|
|
639
|
+
or old_firmware != self.firmware
|
|
640
|
+
or old_firmware_update_state != self.firmware_update_state
|
|
641
|
+
or old_firmware_updatable != self.firmware_updatable
|
|
642
|
+
):
|
|
643
|
+
for callback_handler in self._firmware_update_callbacks:
|
|
644
|
+
callback_handler()
|
|
645
|
+
|
|
646
|
+
@inspector
|
|
647
|
+
async def update_firmware(self, *, refresh_after_update_intervals: tuple[int, ...]) -> bool:
|
|
648
|
+
"""Update the firmware of the Homematic device."""
|
|
649
|
+
update_result = await self._client.update_device_firmware(device_address=self._address)
|
|
650
|
+
|
|
651
|
+
async def refresh_data() -> None:
|
|
652
|
+
for refresh_interval in refresh_after_update_intervals:
|
|
653
|
+
await asyncio.sleep(refresh_interval)
|
|
654
|
+
await self._central.refresh_firmware_data(device_address=self._address)
|
|
655
|
+
|
|
656
|
+
if refresh_after_update_intervals:
|
|
657
|
+
self._central.looper.create_task(target=refresh_data, name="refresh_firmware_data")
|
|
658
|
+
|
|
659
|
+
return update_result
|
|
660
|
+
|
|
661
|
+
@inspector
|
|
662
|
+
async def load_value_cache(self) -> None:
|
|
663
|
+
"""Init the parameter cache."""
|
|
664
|
+
if len(self.generic_data_points) > 0:
|
|
665
|
+
await self._value_cache.init_base_data_points()
|
|
666
|
+
if len(self.generic_events) > 0:
|
|
667
|
+
await self._value_cache.init_readable_events()
|
|
668
|
+
_LOGGER.debug(
|
|
669
|
+
"INIT_DATA: Skipping load_data, missing data points for %s",
|
|
670
|
+
self._address,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
@inspector
|
|
674
|
+
async def reload_paramset_descriptions(self) -> None:
|
|
675
|
+
"""Reload paramset for device."""
|
|
676
|
+
for (
|
|
677
|
+
paramset_key,
|
|
678
|
+
channel_addresses,
|
|
679
|
+
) in self._central.paramset_descriptions.get_channel_addresses_by_paramset_key(
|
|
680
|
+
interface_id=self._interface_id,
|
|
681
|
+
device_address=self._address,
|
|
682
|
+
).items():
|
|
683
|
+
for channel_address in channel_addresses:
|
|
684
|
+
await self._client.fetch_paramset_description(
|
|
685
|
+
channel_address=channel_address,
|
|
686
|
+
paramset_key=paramset_key,
|
|
687
|
+
)
|
|
688
|
+
await self._central.save_files(save_paramset_descriptions=True)
|
|
689
|
+
for dp in self.generic_data_points:
|
|
690
|
+
dp.update_parameter_data()
|
|
691
|
+
self.emit_device_updated_callback()
|
|
692
|
+
|
|
693
|
+
@loop_check
|
|
694
|
+
def emit_device_updated_callback(self) -> None:
|
|
695
|
+
"""Do what is needed when the state of the device has been updated."""
|
|
696
|
+
self._set_modified_at()
|
|
697
|
+
for callback_handler in self._device_updated_callbacks:
|
|
698
|
+
try:
|
|
699
|
+
callback_handler()
|
|
700
|
+
except Exception as exc:
|
|
701
|
+
_LOGGER.warning("EMIT_DEVICE_UPDATED failed: %s", extract_exc_args(exc=exc))
|
|
702
|
+
|
|
703
|
+
def __str__(self) -> str:
|
|
704
|
+
"""Provide some useful information."""
|
|
705
|
+
return (
|
|
706
|
+
f"address: {self._address}, "
|
|
707
|
+
f"model: {self._model}, "
|
|
708
|
+
f"name: {self._name}, "
|
|
709
|
+
f"generic dps: {len(self.generic_data_points)}, "
|
|
710
|
+
f"calculated dps: {len(self.calculated_data_points)}, "
|
|
711
|
+
f"custom dps: {len(self.custom_data_points)}, "
|
|
712
|
+
f"events: {len(self.generic_events)}"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class Channel(LogContextMixin, PayloadMixin):
|
|
717
|
+
"""Object to hold information about a channel and associated data points."""
|
|
718
|
+
|
|
719
|
+
__slots__ = (
|
|
720
|
+
"_address",
|
|
721
|
+
"_calculated_data_points",
|
|
722
|
+
"_central",
|
|
723
|
+
"_custom_data_point",
|
|
724
|
+
"_description",
|
|
725
|
+
"_device",
|
|
726
|
+
"_function",
|
|
727
|
+
"_generic_data_points",
|
|
728
|
+
"_generic_events",
|
|
729
|
+
"_group_master",
|
|
730
|
+
"_group_no",
|
|
731
|
+
"_id",
|
|
732
|
+
"_is_in_multi_group",
|
|
733
|
+
"_link_peer_addresses",
|
|
734
|
+
"_link_peer_changed_callbacks",
|
|
735
|
+
"_link_source_categories",
|
|
736
|
+
"_link_source_roles",
|
|
737
|
+
"_link_target_categories",
|
|
738
|
+
"_link_target_roles",
|
|
739
|
+
"_modified_at",
|
|
740
|
+
"_name_data",
|
|
741
|
+
"_no",
|
|
742
|
+
"_paramset_keys",
|
|
743
|
+
"_rooms",
|
|
744
|
+
"_type_name",
|
|
745
|
+
"_unique_id",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def __init__(self, *, device: Device, channel_address: str) -> None:
|
|
749
|
+
"""Initialize the channel object."""
|
|
750
|
+
PayloadMixin.__init__(self)
|
|
751
|
+
|
|
752
|
+
self._device: Final = device
|
|
753
|
+
self._central: Final = device.central
|
|
754
|
+
self._address: Final = channel_address
|
|
755
|
+
self._id: Final = self._central.device_details.get_address_id(address=channel_address)
|
|
756
|
+
self._no: Final[int | None] = get_channel_no(address=channel_address)
|
|
757
|
+
self._name_data: Final = get_channel_name_data(channel=self)
|
|
758
|
+
self._description: DeviceDescription = self._central.device_descriptions.get_device_description(
|
|
759
|
+
interface_id=self._device.interface_id, address=channel_address
|
|
760
|
+
)
|
|
761
|
+
self._type_name: Final[str] = self._description["TYPE"]
|
|
762
|
+
self._paramset_keys: Final = tuple(ParamsetKey(paramset_key) for paramset_key in self._description["PARAMSETS"])
|
|
763
|
+
|
|
764
|
+
self._unique_id: Final = generate_channel_unique_id(central=self._central, address=channel_address)
|
|
765
|
+
self._group_no: int | None = None
|
|
766
|
+
self._group_master: Channel | None = None
|
|
767
|
+
self._is_in_multi_group: bool | None = None
|
|
768
|
+
self._calculated_data_points: Final[dict[DataPointKey, CalculatedDataPoint]] = {}
|
|
769
|
+
self._custom_data_point: hmce.CustomDataPoint | None = None
|
|
770
|
+
self._generic_data_points: Final[dict[DataPointKey, GenericDataPoint]] = {}
|
|
771
|
+
self._generic_events: Final[dict[DataPointKey, GenericEvent]] = {}
|
|
772
|
+
self._link_peer_addresses: tuple[str, ...] = ()
|
|
773
|
+
self._link_peer_changed_callbacks: list[Callable] = []
|
|
774
|
+
self._link_source_roles: tuple[str, ...] = (
|
|
775
|
+
tuple(source_roles.split(" ")) if (source_roles := self._description.get("LINK_SOURCE_ROLES")) else ()
|
|
776
|
+
)
|
|
777
|
+
self._link_source_categories: Final = get_link_source_categories(
|
|
778
|
+
source_roles=self._link_source_roles, channel_type_name=self._type_name
|
|
779
|
+
)
|
|
780
|
+
self._link_target_roles: tuple[str, ...] = (
|
|
781
|
+
tuple(target_roles.split(" ")) if (target_roles := self._description.get("LINK_TARGET_ROLES")) else ()
|
|
782
|
+
)
|
|
783
|
+
self._link_target_categories: Final = get_link_target_categories(
|
|
784
|
+
target_roles=self._link_target_roles, channel_type_name=self._type_name
|
|
785
|
+
)
|
|
786
|
+
self._modified_at: datetime = INIT_DATETIME
|
|
787
|
+
self._rooms: Final = self._central.device_details.get_channel_rooms(channel_address=channel_address)
|
|
788
|
+
self._function: Final = self._central.device_details.get_function_text(address=self._address)
|
|
789
|
+
self.init_channel()
|
|
790
|
+
|
|
791
|
+
def init_channel(self) -> None:
|
|
792
|
+
"""Init the channel."""
|
|
793
|
+
self._central.looper.create_task(target=self.init_link_peer(), name=f"init_channel_{self._address}")
|
|
794
|
+
|
|
795
|
+
async def init_link_peer(self) -> None:
|
|
796
|
+
"""Init the link partners."""
|
|
797
|
+
if self._link_source_categories and self._device.model not in VIRTUAL_REMOTE_MODELS:
|
|
798
|
+
link_peer_addresses = await self._device.client.get_link_peers(address=self._address)
|
|
799
|
+
if self._link_peer_addresses != link_peer_addresses:
|
|
800
|
+
self._link_peer_addresses = link_peer_addresses
|
|
801
|
+
self.emit_link_peer_changed_event()
|
|
802
|
+
|
|
803
|
+
@info_property
|
|
804
|
+
def address(self) -> str:
|
|
805
|
+
"""Return the address of the channel."""
|
|
806
|
+
return self._address
|
|
807
|
+
|
|
808
|
+
@property
|
|
809
|
+
def calculated_data_points(self) -> tuple[CalculatedDataPoint, ...]:
|
|
810
|
+
"""Return the generic data points."""
|
|
811
|
+
return tuple(self._calculated_data_points.values())
|
|
812
|
+
|
|
813
|
+
@property
|
|
814
|
+
def central(self) -> hmcu.CentralUnit:
|
|
815
|
+
"""Return the central."""
|
|
816
|
+
return self._central
|
|
817
|
+
|
|
818
|
+
@property
|
|
819
|
+
def custom_data_point(self) -> hmce.CustomDataPoint | None:
|
|
820
|
+
"""Return the custom data point."""
|
|
821
|
+
return self._custom_data_point
|
|
822
|
+
|
|
823
|
+
@property
|
|
824
|
+
def description(self) -> DeviceDescription:
|
|
825
|
+
"""Return the device description for the channel."""
|
|
826
|
+
return self._description
|
|
827
|
+
|
|
828
|
+
@hm_property(log_context=True)
|
|
829
|
+
def device(self) -> Device:
|
|
830
|
+
"""Return the device of the channel."""
|
|
831
|
+
return self._device
|
|
832
|
+
|
|
833
|
+
@property
|
|
834
|
+
def function(self) -> str | None:
|
|
835
|
+
"""Return the function of the channel."""
|
|
836
|
+
return self._function
|
|
837
|
+
|
|
838
|
+
@property
|
|
839
|
+
def full_name(self) -> str:
|
|
840
|
+
"""Return the full name of the channel."""
|
|
841
|
+
return self._name_data.full_name
|
|
842
|
+
|
|
843
|
+
@property
|
|
844
|
+
def generic_data_points(self) -> tuple[GenericDataPoint, ...]:
|
|
845
|
+
"""Return the generic data points."""
|
|
846
|
+
return tuple(self._generic_data_points.values())
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def generic_events(self) -> tuple[GenericEvent, ...]:
|
|
850
|
+
"""Return the generic events."""
|
|
851
|
+
return tuple(self._generic_events.values())
|
|
852
|
+
|
|
853
|
+
@property
|
|
854
|
+
def group_master(self) -> Channel | None:
|
|
855
|
+
"""Return the master channel of the group."""
|
|
856
|
+
if self.group_no is None:
|
|
857
|
+
return None
|
|
858
|
+
if self._group_master is None:
|
|
859
|
+
self._group_master = (
|
|
860
|
+
self
|
|
861
|
+
if self.is_group_master
|
|
862
|
+
else self._device.get_channel(channel_address=f"{self._device.address}:{self.group_no}")
|
|
863
|
+
)
|
|
864
|
+
return self._group_master
|
|
865
|
+
|
|
866
|
+
@property
|
|
867
|
+
def group_no(self) -> int | None:
|
|
868
|
+
"""Return the no of the channel group."""
|
|
869
|
+
if self._group_no is None:
|
|
870
|
+
self._group_no = self._device.get_channel_group_no(channel_no=self._no)
|
|
871
|
+
return self._group_no
|
|
872
|
+
|
|
873
|
+
@property
|
|
874
|
+
def id(self) -> str:
|
|
875
|
+
"""Return the id of the channel."""
|
|
876
|
+
return self._id
|
|
877
|
+
|
|
878
|
+
@property
|
|
879
|
+
def is_in_multi_group(self) -> bool:
|
|
880
|
+
"""Return if multiple channels are in the group."""
|
|
881
|
+
if self._is_in_multi_group is None:
|
|
882
|
+
self._is_in_multi_group = self._device.is_in_multi_channel_group(channel_no=self._no)
|
|
883
|
+
return self._is_in_multi_group
|
|
884
|
+
|
|
885
|
+
@property
|
|
886
|
+
def is_group_master(self) -> bool:
|
|
887
|
+
"""Return if group master of channel."""
|
|
888
|
+
return self.group_no == self._no
|
|
889
|
+
|
|
890
|
+
@property
|
|
891
|
+
def link_peer_channels(self) -> tuple[Channel, ...]:
|
|
892
|
+
"""Return the link peer channel."""
|
|
893
|
+
return tuple(
|
|
894
|
+
channel
|
|
895
|
+
for address in self._link_peer_addresses
|
|
896
|
+
if self._link_peer_addresses and (channel := self._central.get_channel(channel_address=address)) is not None
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
@property
|
|
900
|
+
def link_peer_addresses(self) -> tuple[str, ...]:
|
|
901
|
+
"""Return the link peer addresses."""
|
|
902
|
+
return self._link_peer_addresses
|
|
903
|
+
|
|
904
|
+
@property
|
|
905
|
+
def link_peer_source_categories(self) -> tuple[str, ...]:
|
|
906
|
+
"""Return the link peer source categories."""
|
|
907
|
+
return self._link_source_categories
|
|
908
|
+
|
|
909
|
+
@property
|
|
910
|
+
def link_peer_target_categories(self) -> tuple[str, ...]:
|
|
911
|
+
"""Return the link peer target categories."""
|
|
912
|
+
return self._link_target_categories
|
|
913
|
+
|
|
914
|
+
@property
|
|
915
|
+
def name(self) -> str:
|
|
916
|
+
"""Return the name of the channel."""
|
|
917
|
+
return self._name_data.channel_name
|
|
918
|
+
|
|
919
|
+
@property
|
|
920
|
+
def name_data(self) -> ChannelNameData:
|
|
921
|
+
"""Return the name data of the channel."""
|
|
922
|
+
return self._name_data
|
|
923
|
+
|
|
924
|
+
@hm_property(log_context=True)
|
|
925
|
+
def no(self) -> int | None:
|
|
926
|
+
"""Return the channel_no of the channel."""
|
|
927
|
+
return self._no
|
|
928
|
+
|
|
929
|
+
@property
|
|
930
|
+
def operation_mode(self) -> str | None:
|
|
931
|
+
"""Return the channel operation mode if available."""
|
|
932
|
+
if (
|
|
933
|
+
cop := self.get_generic_data_point(parameter=Parameter.CHANNEL_OPERATION_MODE)
|
|
934
|
+
) is not None and cop.value is not None:
|
|
935
|
+
return str(cop.value)
|
|
936
|
+
return None
|
|
937
|
+
|
|
938
|
+
@property
|
|
939
|
+
def paramset_keys(self) -> tuple[ParamsetKey, ...]:
|
|
940
|
+
"""Return the paramset_keys of the channel."""
|
|
941
|
+
return self._paramset_keys
|
|
942
|
+
|
|
943
|
+
@property
|
|
944
|
+
def paramset_descriptions(self) -> Mapping[ParamsetKey, Mapping[str, ParameterData]]:
|
|
945
|
+
"""Return the paramset descriptions of the channel."""
|
|
946
|
+
return self._central.paramset_descriptions.get_channel_paramset_descriptions(
|
|
947
|
+
interface_id=self._device.interface_id, channel_address=self._address
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
@info_property
|
|
951
|
+
def room(self) -> str | None:
|
|
952
|
+
"""Return the room of the device, if only one assigned in the backend."""
|
|
953
|
+
if self._rooms and len(self._rooms) == 1:
|
|
954
|
+
return list(self._rooms)[0]
|
|
955
|
+
if self.is_group_master:
|
|
956
|
+
return None
|
|
957
|
+
if (master_channel := self.group_master) is not None:
|
|
958
|
+
return master_channel.room
|
|
959
|
+
return None
|
|
960
|
+
|
|
961
|
+
@property
|
|
962
|
+
def rooms(self) -> set[str]:
|
|
963
|
+
"""Return all rooms of the channel."""
|
|
964
|
+
return self._rooms
|
|
965
|
+
|
|
966
|
+
@property
|
|
967
|
+
def type_name(self) -> str:
|
|
968
|
+
"""Return the type name of the channel."""
|
|
969
|
+
return self._type_name
|
|
970
|
+
|
|
971
|
+
@property
|
|
972
|
+
def unique_id(self) -> str:
|
|
973
|
+
"""Return the unique_id of the channel."""
|
|
974
|
+
return self._unique_id
|
|
975
|
+
|
|
976
|
+
@inspector
|
|
977
|
+
async def create_central_link(self) -> None:
|
|
978
|
+
"""Create a central link to support press events."""
|
|
979
|
+
if self._has_key_press_events and not await self._has_central_link():
|
|
980
|
+
await self._device.client.report_value_usage(
|
|
981
|
+
address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=1
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
@inspector
|
|
985
|
+
async def remove_central_link(self) -> None:
|
|
986
|
+
"""Remove a central link."""
|
|
987
|
+
if self._has_key_press_events and await self._has_central_link() and not await self._has_program_ids():
|
|
988
|
+
await self._device.client.report_value_usage(
|
|
989
|
+
address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=0
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
@inspector
|
|
993
|
+
async def cleanup_central_link_metadata(self) -> None:
|
|
994
|
+
"""Cleanup the metadata for central links."""
|
|
995
|
+
if metadata := await self._device.client.get_metadata(address=self._address, data_id=REPORT_VALUE_USAGE_DATA):
|
|
996
|
+
await self._device.client.set_metadata(
|
|
997
|
+
address=self._address,
|
|
998
|
+
data_id=REPORT_VALUE_USAGE_DATA,
|
|
999
|
+
value={key: value for key, value in metadata.items() if key in CLICK_EVENTS},
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
async def _has_central_link(self) -> bool:
|
|
1003
|
+
"""Check if central link exists."""
|
|
1004
|
+
try:
|
|
1005
|
+
if metadata := await self._device.client.get_metadata(
|
|
1006
|
+
address=self._address, data_id=REPORT_VALUE_USAGE_DATA
|
|
1007
|
+
):
|
|
1008
|
+
return any(
|
|
1009
|
+
key
|
|
1010
|
+
for key, value in metadata.items()
|
|
1011
|
+
if isinstance(key, str)
|
|
1012
|
+
and isinstance(value, int)
|
|
1013
|
+
and key == REPORT_VALUE_USAGE_VALUE_ID
|
|
1014
|
+
and value > 0
|
|
1015
|
+
)
|
|
1016
|
+
except BaseHomematicException as bhexc:
|
|
1017
|
+
_LOGGER.debug("HAS_CENTRAL_LINK failed: %s", extract_exc_args(exc=bhexc))
|
|
1018
|
+
return False
|
|
1019
|
+
|
|
1020
|
+
async def _has_program_ids(self) -> bool:
|
|
1021
|
+
"""Return if a channel has program ids."""
|
|
1022
|
+
return bool(await self._device.client.has_program_ids(channel_hmid=self._id))
|
|
1023
|
+
|
|
1024
|
+
@property
|
|
1025
|
+
def _has_key_press_events(self) -> bool:
|
|
1026
|
+
"""Return if channel has KEYPRESS events."""
|
|
1027
|
+
return any(event for event in self.generic_events if event.event_type is EventType.KEYPRESS)
|
|
1028
|
+
|
|
1029
|
+
def add_data_point(self, *, data_point: CallbackDataPoint) -> None:
|
|
1030
|
+
"""Add a data_point to a channel."""
|
|
1031
|
+
if isinstance(data_point, BaseParameterDataPoint):
|
|
1032
|
+
self._central.add_event_subscription(data_point=data_point)
|
|
1033
|
+
if isinstance(data_point, CalculatedDataPoint):
|
|
1034
|
+
self._calculated_data_points[data_point.dpk] = data_point
|
|
1035
|
+
if isinstance(data_point, GenericDataPoint):
|
|
1036
|
+
self._generic_data_points[data_point.dpk] = data_point
|
|
1037
|
+
self._device.register_device_updated_callback(cb=data_point.emit_data_point_updated_event)
|
|
1038
|
+
if isinstance(data_point, hmce.CustomDataPoint):
|
|
1039
|
+
self._custom_data_point = data_point
|
|
1040
|
+
if isinstance(data_point, GenericEvent):
|
|
1041
|
+
self._generic_events[data_point.dpk] = data_point
|
|
1042
|
+
|
|
1043
|
+
def _remove_data_point(self, *, data_point: CallbackDataPoint) -> None:
|
|
1044
|
+
"""Remove a data_point from a channel."""
|
|
1045
|
+
if isinstance(data_point, BaseParameterDataPoint):
|
|
1046
|
+
self._central.remove_event_subscription(data_point=data_point)
|
|
1047
|
+
if isinstance(data_point, CalculatedDataPoint):
|
|
1048
|
+
del self._calculated_data_points[data_point.dpk]
|
|
1049
|
+
if isinstance(data_point, GenericDataPoint):
|
|
1050
|
+
del self._generic_data_points[data_point.dpk]
|
|
1051
|
+
self._device.unregister_device_updated_callback(cb=data_point.emit_data_point_updated_event)
|
|
1052
|
+
if isinstance(data_point, hmce.CustomDataPoint):
|
|
1053
|
+
self._custom_data_point = None
|
|
1054
|
+
if isinstance(data_point, GenericEvent):
|
|
1055
|
+
del self._generic_events[data_point.dpk]
|
|
1056
|
+
data_point.emit_device_removed_event()
|
|
1057
|
+
|
|
1058
|
+
def remove(self) -> None:
|
|
1059
|
+
"""Remove data points from collections and central."""
|
|
1060
|
+
for event in self.generic_events:
|
|
1061
|
+
self._remove_data_point(data_point=event)
|
|
1062
|
+
self._generic_events.clear()
|
|
1063
|
+
|
|
1064
|
+
for ccdp in self.calculated_data_points:
|
|
1065
|
+
self._remove_data_point(data_point=ccdp)
|
|
1066
|
+
self._calculated_data_points.clear()
|
|
1067
|
+
|
|
1068
|
+
for gdp in self.generic_data_points:
|
|
1069
|
+
self._remove_data_point(data_point=gdp)
|
|
1070
|
+
self._generic_data_points.clear()
|
|
1071
|
+
|
|
1072
|
+
if self._custom_data_point:
|
|
1073
|
+
self._remove_data_point(data_point=self._custom_data_point)
|
|
1074
|
+
|
|
1075
|
+
def _set_modified_at(self) -> None:
|
|
1076
|
+
self._modified_at = datetime.now()
|
|
1077
|
+
|
|
1078
|
+
def register_link_peer_changed_callback(self, *, cb: Callable) -> CALLBACK_TYPE:
|
|
1079
|
+
"""Register the link peer changed callback."""
|
|
1080
|
+
if callable(cb) and cb not in self._link_peer_changed_callbacks:
|
|
1081
|
+
self._link_peer_changed_callbacks.append(cb)
|
|
1082
|
+
return partial(self._unregister_link_peer_changed_callback, cb=cb)
|
|
1083
|
+
return None
|
|
1084
|
+
|
|
1085
|
+
def _unregister_link_peer_changed_callback(self, *, cb: Callable) -> None:
|
|
1086
|
+
"""Unregister the link peer changed callback."""
|
|
1087
|
+
if cb in self._link_peer_changed_callbacks:
|
|
1088
|
+
self._link_peer_changed_callbacks.remove(cb)
|
|
1089
|
+
|
|
1090
|
+
@loop_check
|
|
1091
|
+
def emit_link_peer_changed_event(self) -> None:
|
|
1092
|
+
"""Do what is needed when the link peer has been changed for the device."""
|
|
1093
|
+
for callback_handler in self._link_peer_changed_callbacks:
|
|
1094
|
+
try:
|
|
1095
|
+
callback_handler()
|
|
1096
|
+
except Exception as exc:
|
|
1097
|
+
_LOGGER.warning("EMIT_LINK_PEER_CHANGED_EVENT failed: %s", extract_exc_args(exc=exc))
|
|
1098
|
+
|
|
1099
|
+
def get_data_points(
|
|
1100
|
+
self,
|
|
1101
|
+
*,
|
|
1102
|
+
category: DataPointCategory | None = None,
|
|
1103
|
+
exclude_no_create: bool = True,
|
|
1104
|
+
registered: bool | None = None,
|
|
1105
|
+
) -> tuple[CallbackDataPoint, ...]:
|
|
1106
|
+
"""Get all data points of the device."""
|
|
1107
|
+
all_data_points: list[CallbackDataPoint] = list(self._generic_data_points.values()) + list(
|
|
1108
|
+
self._calculated_data_points.values()
|
|
1109
|
+
)
|
|
1110
|
+
if self._custom_data_point:
|
|
1111
|
+
all_data_points.append(self._custom_data_point)
|
|
1112
|
+
|
|
1113
|
+
return tuple(
|
|
1114
|
+
dp
|
|
1115
|
+
for dp in all_data_points
|
|
1116
|
+
if dp is not None
|
|
1117
|
+
and (category is None or dp.category == category)
|
|
1118
|
+
and ((exclude_no_create and dp.usage != DataPointUsage.NO_CREATE) or exclude_no_create is False)
|
|
1119
|
+
and (registered is None or dp.is_registered == registered)
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
def get_events(self, *, event_type: EventType, registered: bool | None = None) -> tuple[GenericEvent, ...]:
|
|
1123
|
+
"""Return a list of specific events of a channel."""
|
|
1124
|
+
return tuple(
|
|
1125
|
+
event
|
|
1126
|
+
for event in self._generic_events.values()
|
|
1127
|
+
if (event.event_type == event_type and (registered is None or event.is_registered == registered))
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
def get_calculated_data_point(self, *, parameter: str) -> CalculatedDataPoint | None:
|
|
1131
|
+
"""Return a calculated data_point from device."""
|
|
1132
|
+
return self._calculated_data_points.get(
|
|
1133
|
+
DataPointKey(
|
|
1134
|
+
interface_id=self._device.interface_id,
|
|
1135
|
+
channel_address=self._address,
|
|
1136
|
+
paramset_key=ParamsetKey.CALCULATED,
|
|
1137
|
+
parameter=parameter,
|
|
1138
|
+
)
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
def get_generic_data_point(
|
|
1142
|
+
self, *, parameter: str, paramset_key: ParamsetKey | None = None
|
|
1143
|
+
) -> GenericDataPoint | None:
|
|
1144
|
+
"""Return a generic data_point from device."""
|
|
1145
|
+
if paramset_key:
|
|
1146
|
+
return self._generic_data_points.get(
|
|
1147
|
+
DataPointKey(
|
|
1148
|
+
interface_id=self._device.interface_id,
|
|
1149
|
+
channel_address=self._address,
|
|
1150
|
+
paramset_key=paramset_key,
|
|
1151
|
+
parameter=parameter,
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
if dp := self._generic_data_points.get(
|
|
1156
|
+
DataPointKey(
|
|
1157
|
+
interface_id=self._device.interface_id,
|
|
1158
|
+
channel_address=self._address,
|
|
1159
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1160
|
+
parameter=parameter,
|
|
1161
|
+
)
|
|
1162
|
+
):
|
|
1163
|
+
return dp
|
|
1164
|
+
return self._generic_data_points.get(
|
|
1165
|
+
DataPointKey(
|
|
1166
|
+
interface_id=self._device.interface_id,
|
|
1167
|
+
channel_address=self._address,
|
|
1168
|
+
paramset_key=ParamsetKey.MASTER,
|
|
1169
|
+
parameter=parameter,
|
|
1170
|
+
)
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
def get_generic_event(self, *, parameter: str) -> GenericEvent | None:
|
|
1174
|
+
"""Return a generic event from device."""
|
|
1175
|
+
return self._generic_events.get(
|
|
1176
|
+
DataPointKey(
|
|
1177
|
+
interface_id=self._device.interface_id,
|
|
1178
|
+
channel_address=self._address,
|
|
1179
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1180
|
+
parameter=parameter,
|
|
1181
|
+
)
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPoint, ...]:
|
|
1185
|
+
"""Return the list of readable master data points."""
|
|
1186
|
+
return tuple(
|
|
1187
|
+
ge for ge in self._generic_data_points.values() if ge.is_readable and ge.paramset_key == paramset_key
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
def has_link_source_category(self, *, category: DataPointCategory) -> bool:
|
|
1191
|
+
"""Return if channel is receiver."""
|
|
1192
|
+
return category in self._link_source_categories
|
|
1193
|
+
|
|
1194
|
+
def has_link_target_category(self, *, category: DataPointCategory) -> bool:
|
|
1195
|
+
"""Return if channel is transmitter."""
|
|
1196
|
+
return category in self._link_target_categories
|
|
1197
|
+
|
|
1198
|
+
def __str__(self) -> str:
|
|
1199
|
+
"""Provide some useful information."""
|
|
1200
|
+
return (
|
|
1201
|
+
f"address: {self._address}, "
|
|
1202
|
+
f"type: {self._type_name}, "
|
|
1203
|
+
f"generic dps: {len(self._generic_data_points)}, "
|
|
1204
|
+
f"calculated dps: {len(self._calculated_data_points)}, "
|
|
1205
|
+
f"custom dp: {self._custom_data_point is not None}, "
|
|
1206
|
+
f"events: {len(self._generic_events)}"
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
class _ValueCache:
|
|
1211
|
+
"""A Cache to temporarily stored values."""
|
|
1212
|
+
|
|
1213
|
+
__slots__ = (
|
|
1214
|
+
"_device",
|
|
1215
|
+
"_device_cache",
|
|
1216
|
+
"_sema_get_or_load_value",
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
_NO_VALUE_CACHE_ENTRY: Final = "NO_VALUE_CACHE_ENTRY"
|
|
1220
|
+
|
|
1221
|
+
def __init__(self, *, device: Device) -> None:
|
|
1222
|
+
"""Init the value cache."""
|
|
1223
|
+
self._sema_get_or_load_value: Final = asyncio.Semaphore()
|
|
1224
|
+
self._device: Final = device
|
|
1225
|
+
# {key, CacheEntry}
|
|
1226
|
+
self._device_cache: Final[dict[DataPointKey, CacheEntry]] = {}
|
|
1227
|
+
|
|
1228
|
+
async def init_base_data_points(self) -> None:
|
|
1229
|
+
"""Load data by get_value."""
|
|
1230
|
+
try:
|
|
1231
|
+
for dp in self._get_base_data_points():
|
|
1232
|
+
await dp.load_data_point_value(call_source=CallSource.HM_INIT)
|
|
1233
|
+
except BaseHomematicException as bhexc:
|
|
1234
|
+
_LOGGER.debug(
|
|
1235
|
+
"init_base_data_points: Failed to init cache for channel0 %s, %s [%s]",
|
|
1236
|
+
self._device.model,
|
|
1237
|
+
self._device.address,
|
|
1238
|
+
extract_exc_args(exc=bhexc),
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
def _get_base_data_points(self) -> set[GenericDataPoint]:
|
|
1242
|
+
"""Get data points of channel 0 and master."""
|
|
1243
|
+
return {
|
|
1244
|
+
dp
|
|
1245
|
+
for dp in self._device.generic_data_points
|
|
1246
|
+
if (
|
|
1247
|
+
dp.channel.no == 0
|
|
1248
|
+
and dp.paramset_key == ParamsetKey.VALUES
|
|
1249
|
+
and dp.parameter in RELEVANT_INIT_PARAMETERS
|
|
1250
|
+
)
|
|
1251
|
+
or dp.paramset_key == ParamsetKey.MASTER
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async def init_readable_events(self) -> None:
|
|
1255
|
+
"""Load data by get_value."""
|
|
1256
|
+
try:
|
|
1257
|
+
for event in self._get_readable_events():
|
|
1258
|
+
await event.load_data_point_value(call_source=CallSource.HM_INIT)
|
|
1259
|
+
except BaseHomematicException as bhexc:
|
|
1260
|
+
_LOGGER.debug(
|
|
1261
|
+
"init_base_events: Failed to init cache for channel0 %s, %s [%s]",
|
|
1262
|
+
self._device.model,
|
|
1263
|
+
self._device.address,
|
|
1264
|
+
extract_exc_args(exc=bhexc),
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
def _get_readable_events(self) -> set[GenericEvent]:
|
|
1268
|
+
"""Get readable events."""
|
|
1269
|
+
return {event for event in self._device.generic_events if event.is_readable}
|
|
1270
|
+
|
|
1271
|
+
async def get_value(
|
|
1272
|
+
self,
|
|
1273
|
+
*,
|
|
1274
|
+
dpk: DataPointKey,
|
|
1275
|
+
call_source: CallSource,
|
|
1276
|
+
direct_call: bool = False,
|
|
1277
|
+
) -> Any:
|
|
1278
|
+
"""Load data."""
|
|
1279
|
+
|
|
1280
|
+
async with self._sema_get_or_load_value:
|
|
1281
|
+
if direct_call is False and (cached_value := self._get_value_from_cache(dpk=dpk)) != NO_CACHE_ENTRY:
|
|
1282
|
+
return NO_CACHE_ENTRY if cached_value == self._NO_VALUE_CACHE_ENTRY else cached_value
|
|
1283
|
+
|
|
1284
|
+
value_dict: dict[str, Any] = {dpk.parameter: self._NO_VALUE_CACHE_ENTRY}
|
|
1285
|
+
try:
|
|
1286
|
+
value_dict = await self._get_values_for_cache(dpk=dpk)
|
|
1287
|
+
except BaseHomematicException as bhexc:
|
|
1288
|
+
_LOGGER.debug(
|
|
1289
|
+
"GET_OR_LOAD_VALUE: Failed to get data for %s, %s, %s, %s: %s",
|
|
1290
|
+
self._device.model,
|
|
1291
|
+
dpk.channel_address,
|
|
1292
|
+
dpk.parameter,
|
|
1293
|
+
call_source,
|
|
1294
|
+
extract_exc_args(exc=bhexc),
|
|
1295
|
+
)
|
|
1296
|
+
for d_parameter, d_value in value_dict.items():
|
|
1297
|
+
self._add_entry_to_device_cache(
|
|
1298
|
+
dpk=DataPointKey(
|
|
1299
|
+
interface_id=dpk.interface_id,
|
|
1300
|
+
channel_address=dpk.channel_address,
|
|
1301
|
+
paramset_key=dpk.paramset_key,
|
|
1302
|
+
parameter=d_parameter,
|
|
1303
|
+
),
|
|
1304
|
+
value=d_value,
|
|
1305
|
+
)
|
|
1306
|
+
return (
|
|
1307
|
+
NO_CACHE_ENTRY
|
|
1308
|
+
if (value := value_dict.get(dpk.parameter)) and value == self._NO_VALUE_CACHE_ENTRY
|
|
1309
|
+
else value
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
async def _get_values_for_cache(self, *, dpk: DataPointKey) -> dict[str, Any]:
|
|
1313
|
+
"""Return a value from the backend to store in cache."""
|
|
1314
|
+
if not self._device.available:
|
|
1315
|
+
_LOGGER.debug(
|
|
1316
|
+
"GET_VALUES_FOR_CACHE failed: device %s (%s) is not available", self._device.name, self._device.address
|
|
1317
|
+
)
|
|
1318
|
+
return {}
|
|
1319
|
+
if dpk.paramset_key == ParamsetKey.VALUES:
|
|
1320
|
+
return {
|
|
1321
|
+
dpk.parameter: await self._device.client.get_value(
|
|
1322
|
+
channel_address=dpk.channel_address,
|
|
1323
|
+
paramset_key=dpk.paramset_key,
|
|
1324
|
+
parameter=dpk.parameter,
|
|
1325
|
+
call_source=CallSource.HM_INIT,
|
|
1326
|
+
)
|
|
1327
|
+
}
|
|
1328
|
+
return await self._device.client.get_paramset(
|
|
1329
|
+
address=dpk.channel_address, paramset_key=dpk.paramset_key, call_source=CallSource.HM_INIT
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
def _add_entry_to_device_cache(self, *, dpk: DataPointKey, value: Any) -> None:
|
|
1333
|
+
"""Add value to cache."""
|
|
1334
|
+
# write value to cache even if an exception has occurred
|
|
1335
|
+
# to avoid repetitive calls to the backend within max_age
|
|
1336
|
+
self._device_cache[dpk] = CacheEntry(value=value, refresh_at=datetime.now())
|
|
1337
|
+
|
|
1338
|
+
def _get_value_from_cache(
|
|
1339
|
+
self,
|
|
1340
|
+
*,
|
|
1341
|
+
dpk: DataPointKey,
|
|
1342
|
+
) -> Any:
|
|
1343
|
+
"""Load data from store."""
|
|
1344
|
+
# Try to get data from central cache
|
|
1345
|
+
if (
|
|
1346
|
+
dpk.paramset_key == ParamsetKey.VALUES
|
|
1347
|
+
and (
|
|
1348
|
+
global_value := self._device.central.data_cache.get_data(
|
|
1349
|
+
interface=self._device.interface,
|
|
1350
|
+
channel_address=dpk.channel_address,
|
|
1351
|
+
parameter=dpk.parameter,
|
|
1352
|
+
)
|
|
1353
|
+
)
|
|
1354
|
+
!= NO_CACHE_ENTRY
|
|
1355
|
+
):
|
|
1356
|
+
return global_value
|
|
1357
|
+
|
|
1358
|
+
if (cache_entry := self._device_cache.get(dpk, CacheEntry.empty())) and cache_entry.is_valid:
|
|
1359
|
+
return cache_entry.value
|
|
1360
|
+
return NO_CACHE_ENTRY
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
class _DefinitionExporter:
|
|
1364
|
+
"""Export device definitions from cache."""
|
|
1365
|
+
|
|
1366
|
+
__slots__ = (
|
|
1367
|
+
"_central",
|
|
1368
|
+
"_client",
|
|
1369
|
+
"_device_address",
|
|
1370
|
+
"_interface_id",
|
|
1371
|
+
"_random_id",
|
|
1372
|
+
"_storage_directory",
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
def __init__(self, *, device: Device) -> None:
|
|
1376
|
+
"""Init the device exporter."""
|
|
1377
|
+
self._client: Final = device.client
|
|
1378
|
+
self._central: Final = device.client.central
|
|
1379
|
+
self._storage_directory: Final = self._central.config.storage_directory
|
|
1380
|
+
self._interface_id: Final = device.interface_id
|
|
1381
|
+
self._device_address: Final = device.address
|
|
1382
|
+
self._random_id: Final[str] = f"VCU{int(random.randint(1000000, 9999999))}"
|
|
1383
|
+
|
|
1384
|
+
@inspector
|
|
1385
|
+
async def export_data(self) -> None:
|
|
1386
|
+
"""Export data."""
|
|
1387
|
+
device_descriptions: Mapping[str, DeviceDescription] = (
|
|
1388
|
+
self._central.device_descriptions.get_device_with_channels(
|
|
1389
|
+
interface_id=self._interface_id, device_address=self._device_address
|
|
1390
|
+
)
|
|
1391
|
+
)
|
|
1392
|
+
paramset_descriptions: dict[
|
|
1393
|
+
str, dict[ParamsetKey, dict[str, ParameterData]]
|
|
1394
|
+
] = await self._client.get_all_paramset_descriptions(device_descriptions=tuple(device_descriptions.values()))
|
|
1395
|
+
model = device_descriptions[self._device_address]["TYPE"]
|
|
1396
|
+
file_name = f"{model}.json"
|
|
1397
|
+
|
|
1398
|
+
# anonymize device_descriptions
|
|
1399
|
+
anonymize_device_descriptions: list[DeviceDescription] = []
|
|
1400
|
+
for device_description in device_descriptions.values():
|
|
1401
|
+
new_device_description: DeviceDescription = device_description.copy()
|
|
1402
|
+
new_device_description["ADDRESS"] = self._anonymize_address(address=new_device_description["ADDRESS"])
|
|
1403
|
+
if new_device_description.get("PARENT"):
|
|
1404
|
+
new_device_description["PARENT"] = new_device_description["ADDRESS"].split(ADDRESS_SEPARATOR)[0]
|
|
1405
|
+
elif new_device_description.get("CHILDREN"):
|
|
1406
|
+
new_device_description["CHILDREN"] = [
|
|
1407
|
+
self._anonymize_address(address=a) for a in new_device_description["CHILDREN"]
|
|
1408
|
+
]
|
|
1409
|
+
anonymize_device_descriptions.append(new_device_description)
|
|
1410
|
+
|
|
1411
|
+
# anonymize paramset_descriptions
|
|
1412
|
+
anonymize_paramset_descriptions: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
1413
|
+
for address, paramset_description in paramset_descriptions.items():
|
|
1414
|
+
anonymize_paramset_descriptions[self._anonymize_address(address=address)] = paramset_description
|
|
1415
|
+
|
|
1416
|
+
# Save device_descriptions for device to file.
|
|
1417
|
+
await self._save(
|
|
1418
|
+
directory=f"{self._storage_directory}/{DEVICE_DESCRIPTIONS_DIR}",
|
|
1419
|
+
file_name=file_name,
|
|
1420
|
+
data=anonymize_device_descriptions,
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
# Save device_descriptions for device to file.
|
|
1424
|
+
await self._save(
|
|
1425
|
+
directory=f"{self._storage_directory}/{PARAMSET_DESCRIPTIONS_DIR}",
|
|
1426
|
+
file_name=file_name,
|
|
1427
|
+
data=anonymize_paramset_descriptions,
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
def _anonymize_address(self, *, address: str) -> str:
|
|
1431
|
+
address_parts = address.split(ADDRESS_SEPARATOR)
|
|
1432
|
+
address_parts[0] = self._random_id
|
|
1433
|
+
return ADDRESS_SEPARATOR.join(address_parts)
|
|
1434
|
+
|
|
1435
|
+
async def _save(self, *, directory: str, file_name: str, data: Any) -> DataOperationResult:
|
|
1436
|
+
"""Save file to disk."""
|
|
1437
|
+
|
|
1438
|
+
def perform_save() -> DataOperationResult:
|
|
1439
|
+
if not check_or_create_directory(directory=directory):
|
|
1440
|
+
return DataOperationResult.NO_SAVE # pragma: no cover
|
|
1441
|
+
with open(file=os.path.join(directory, file_name), mode="wb") as fptr:
|
|
1442
|
+
fptr.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS))
|
|
1443
|
+
return DataOperationResult.SAVE_SUCCESS
|
|
1444
|
+
|
|
1445
|
+
return await self._central.looper.async_add_executor_job(perform_save, name="save-device-description")
|