aiohomematic 2026.1.29__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.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Base implementation for custom device-specific data points.
|
|
5
|
+
|
|
6
|
+
Public API of this module is defined by __all__.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
import contextlib
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Final, Unpack
|
|
16
|
+
|
|
17
|
+
from aiohomematic.const import INIT_DATETIME, CallSource, DataPointKey, DataPointUsage, DeviceProfile, Field, Parameter
|
|
18
|
+
from aiohomematic.decorators import inspector
|
|
19
|
+
from aiohomematic.interfaces import ChannelProtocol, CustomDataPointProtocol, GenericDataPointProtocolAny
|
|
20
|
+
from aiohomematic.model.custom import definition as hmed
|
|
21
|
+
from aiohomematic.model.custom.mixins import StateChangeArgs
|
|
22
|
+
from aiohomematic.model.custom.profile import RebasedChannelGroupConfig
|
|
23
|
+
from aiohomematic.model.custom.registry import DeviceConfig
|
|
24
|
+
from aiohomematic.model.data_point import BaseDataPoint
|
|
25
|
+
from aiohomematic.model.support import (
|
|
26
|
+
DataPointNameData,
|
|
27
|
+
DataPointPathData,
|
|
28
|
+
PathData,
|
|
29
|
+
check_channel_is_the_only_primary_channel,
|
|
30
|
+
get_custom_data_point_name,
|
|
31
|
+
)
|
|
32
|
+
from aiohomematic.property_decorators import DelegatedProperty, state_property
|
|
33
|
+
from aiohomematic.support import get_channel_address
|
|
34
|
+
from aiohomematic.type_aliases import UnsubscribeCallback
|
|
35
|
+
|
|
36
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CustomDataPoint(BaseDataPoint, CustomDataPointProtocol):
|
|
40
|
+
"""Base class for custom data point."""
|
|
41
|
+
|
|
42
|
+
__slots__ = (
|
|
43
|
+
"_allow_undefined_generic_data_points",
|
|
44
|
+
"_channel_group",
|
|
45
|
+
"_custom_data_point_def",
|
|
46
|
+
"_data_points",
|
|
47
|
+
"_device_config",
|
|
48
|
+
"_device_profile",
|
|
49
|
+
"_extended",
|
|
50
|
+
"_group_no",
|
|
51
|
+
"_schedule_channel_no",
|
|
52
|
+
"_unsubscribe_callbacks",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
channel: ChannelProtocol,
|
|
59
|
+
unique_id: str,
|
|
60
|
+
device_profile: DeviceProfile,
|
|
61
|
+
channel_group: RebasedChannelGroupConfig,
|
|
62
|
+
custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]],
|
|
63
|
+
group_no: int | None,
|
|
64
|
+
device_config: DeviceConfig,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialize the data point."""
|
|
67
|
+
self._unsubscribe_callbacks: list[UnsubscribeCallback] = []
|
|
68
|
+
self._device_profile: Final = device_profile
|
|
69
|
+
self._channel_group: Final = channel_group
|
|
70
|
+
self._custom_data_point_def: Final = custom_data_point_def
|
|
71
|
+
self._group_no: int | None = group_no
|
|
72
|
+
self._device_config: Final = device_config
|
|
73
|
+
self._extended: Final = device_config.extended
|
|
74
|
+
super().__init__(
|
|
75
|
+
channel=channel,
|
|
76
|
+
unique_id=unique_id,
|
|
77
|
+
is_in_multiple_channels=hmed.is_multi_channel_device(model=channel.device.model, category=self.category),
|
|
78
|
+
)
|
|
79
|
+
self._allow_undefined_generic_data_points: Final[bool] = channel_group.allow_undefined_generic_data_points
|
|
80
|
+
self._data_points: Final[dict[Field, GenericDataPointProtocolAny]] = {}
|
|
81
|
+
self._init_data_points()
|
|
82
|
+
self._post_init()
|
|
83
|
+
if self.usage == DataPointUsage.CDP_PRIMARY:
|
|
84
|
+
self._device.init_week_profile(data_point=self)
|
|
85
|
+
|
|
86
|
+
def __del__(self) -> None:
|
|
87
|
+
"""Clean up subscriptions when the object is garbage collected."""
|
|
88
|
+
with contextlib.suppress(Exception):
|
|
89
|
+
self.unsubscribe_from_data_point_updated()
|
|
90
|
+
|
|
91
|
+
allow_undefined_generic_data_points: Final = DelegatedProperty[bool](path="_allow_undefined_generic_data_points")
|
|
92
|
+
device_config: Final = DelegatedProperty[DeviceConfig](path="_device_config")
|
|
93
|
+
group_no: Final = DelegatedProperty[int | None](path="_group_no")
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def _readable_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
|
|
97
|
+
"""Returns the list of readable data points."""
|
|
98
|
+
return tuple(dp for dp in self._data_points.values() if dp.is_readable)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def _relevant_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
|
|
102
|
+
"""Returns the list of relevant data points. To be overridden by subclasses."""
|
|
103
|
+
return self._readable_data_points
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def data_point_name_postfix(self) -> str:
|
|
107
|
+
"""Return the data point name postfix."""
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def has_data_points(self) -> bool:
|
|
112
|
+
"""Return if there are data points."""
|
|
113
|
+
return len(self._data_points) > 0
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def has_schedule(self) -> bool:
|
|
117
|
+
"""Flag if device supports schedule."""
|
|
118
|
+
if self._device.week_profile:
|
|
119
|
+
return self._device.week_profile.has_schedule
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_refreshed(self) -> bool:
|
|
124
|
+
"""Return if all relevant data_point have been refreshed (received a value)."""
|
|
125
|
+
return all(dp.is_refreshed for dp in self._relevant_data_points)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_status_valid(self) -> bool:
|
|
129
|
+
"""Return if all relevant data points have valid status."""
|
|
130
|
+
return all(dp.is_status_valid for dp in self._relevant_data_points)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def schedule(self) -> dict[Any, Any]:
|
|
134
|
+
"""Return cached schedule entries from device week profile."""
|
|
135
|
+
if self._device.week_profile:
|
|
136
|
+
return self._device.week_profile.schedule
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def state_uncertain(self) -> bool:
|
|
141
|
+
"""Return, if the state is uncertain."""
|
|
142
|
+
return any(dp.state_uncertain for dp in self._relevant_data_points)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def unconfirmed_last_values_send(self) -> Mapping[Field, Any]:
|
|
146
|
+
"""Return the unconfirmed values send for the data point."""
|
|
147
|
+
unconfirmed_values: dict[Field, Any] = {}
|
|
148
|
+
for field, dp in self._data_points.items():
|
|
149
|
+
if (unconfirmed_value := dp.unconfirmed_last_value_send) is not None:
|
|
150
|
+
unconfirmed_values[field] = unconfirmed_value
|
|
151
|
+
return unconfirmed_values
|
|
152
|
+
|
|
153
|
+
@state_property
|
|
154
|
+
def modified_at(self) -> datetime:
|
|
155
|
+
"""Return the latest last update timestamp."""
|
|
156
|
+
modified_at: datetime = INIT_DATETIME
|
|
157
|
+
for dp in self._readable_data_points:
|
|
158
|
+
if (data_point_modified_at := dp.modified_at) and data_point_modified_at > modified_at:
|
|
159
|
+
modified_at = data_point_modified_at
|
|
160
|
+
return modified_at
|
|
161
|
+
|
|
162
|
+
@state_property
|
|
163
|
+
def refreshed_at(self) -> datetime:
|
|
164
|
+
"""Return the latest last refresh timestamp."""
|
|
165
|
+
refreshed_at: datetime = INIT_DATETIME
|
|
166
|
+
for dp in self._readable_data_points:
|
|
167
|
+
if (data_point_refreshed_at := dp.refreshed_at) and data_point_refreshed_at > refreshed_at:
|
|
168
|
+
refreshed_at = data_point_refreshed_at
|
|
169
|
+
return refreshed_at
|
|
170
|
+
|
|
171
|
+
async def get_schedule(self, *, force_load: bool = False) -> dict[Any, Any]:
|
|
172
|
+
"""Get schedule from device week profile."""
|
|
173
|
+
if self._device.week_profile:
|
|
174
|
+
return await self._device.week_profile.get_schedule(force_load=force_load)
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
def has_data_point_key(self, *, data_point_keys: set[DataPointKey]) -> bool:
|
|
178
|
+
"""Return if a data_point with one of the data points is part of this data_point."""
|
|
179
|
+
result = [dp for dp in self._data_points.values() if dp.dpk in data_point_keys]
|
|
180
|
+
return len(result) > 0
|
|
181
|
+
|
|
182
|
+
def is_state_change(self, **kwargs: Unpack[StateChangeArgs]) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Check if the state changes due to kwargs.
|
|
185
|
+
|
|
186
|
+
If the state is uncertain, the state should also marked as changed.
|
|
187
|
+
"""
|
|
188
|
+
if self.state_uncertain:
|
|
189
|
+
return True
|
|
190
|
+
_LOGGER.debug("NO_STATE_CHANGE: %s", self.name)
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
@inspector(re_raise=False)
|
|
194
|
+
async def load_data_point_value(self, *, call_source: CallSource, direct_call: bool = False) -> None:
|
|
195
|
+
"""Initialize the data point values."""
|
|
196
|
+
for dp in self._readable_data_points:
|
|
197
|
+
await dp.load_data_point_value(call_source=call_source, direct_call=direct_call)
|
|
198
|
+
if self._device.week_profile and self.usage == DataPointUsage.CDP_PRIMARY:
|
|
199
|
+
await self._device.week_profile.reload_and_cache_schedule()
|
|
200
|
+
self.publish_data_point_updated_event()
|
|
201
|
+
|
|
202
|
+
async def set_schedule(self, *, schedule_data: dict[Any, Any]) -> None:
|
|
203
|
+
"""Set schedule on device week profile."""
|
|
204
|
+
if self._device.week_profile:
|
|
205
|
+
await self._device.week_profile.set_schedule(schedule_data=schedule_data)
|
|
206
|
+
|
|
207
|
+
def unsubscribe_from_data_point_updated(self) -> None:
|
|
208
|
+
"""Unregister all internal update handlers."""
|
|
209
|
+
for unreg in self._unsubscribe_callbacks:
|
|
210
|
+
if unreg is not None:
|
|
211
|
+
unreg()
|
|
212
|
+
self._unsubscribe_callbacks.clear()
|
|
213
|
+
|
|
214
|
+
def _add_channel_data_points(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
channel_fields: Mapping[int | None, Mapping[Field, Parameter]],
|
|
218
|
+
is_visible: bool | None = None,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Add channel-specific data points to custom data point."""
|
|
221
|
+
for channel_no, ch_fields in channel_fields.items():
|
|
222
|
+
for field_name, parameter in ch_fields.items():
|
|
223
|
+
channel_address = get_channel_address(device_address=self._device.address, channel_no=channel_no)
|
|
224
|
+
if dp := self._device.get_generic_data_point(channel_address=channel_address, parameter=parameter):
|
|
225
|
+
self._add_data_point(field=field_name, data_point=dp, is_visible=is_visible)
|
|
226
|
+
|
|
227
|
+
def _add_data_point(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
field: Field,
|
|
231
|
+
data_point: GenericDataPointProtocolAny | None,
|
|
232
|
+
is_visible: bool | None = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Add data point to collection and subscribed handler."""
|
|
235
|
+
if not data_point:
|
|
236
|
+
return
|
|
237
|
+
if is_visible is True and data_point.is_forced_sensor is False:
|
|
238
|
+
data_point.force_usage(forced_usage=DataPointUsage.CDP_VISIBLE)
|
|
239
|
+
elif is_visible is False and data_point.is_forced_sensor is False:
|
|
240
|
+
data_point.force_usage(forced_usage=DataPointUsage.NO_CREATE)
|
|
241
|
+
|
|
242
|
+
self._unsubscribe_callbacks.append(
|
|
243
|
+
data_point.subscribe_to_internal_data_point_updated(handler=self.publish_data_point_updated_event)
|
|
244
|
+
)
|
|
245
|
+
self._data_points[field] = data_point
|
|
246
|
+
|
|
247
|
+
def _add_fixed_channel_data_points(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
fixed_channel_fields: Mapping[int, Mapping[Field, Parameter]],
|
|
251
|
+
is_visible: bool | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Add fixed channel data points (absolute channel numbers) to custom data point."""
|
|
254
|
+
for channel_no, ch_fields in fixed_channel_fields.items():
|
|
255
|
+
channel_address = get_channel_address(device_address=self._device.address, channel_no=channel_no)
|
|
256
|
+
for field_name, parameter in ch_fields.items():
|
|
257
|
+
if dp := self._device.get_generic_data_point(channel_address=channel_address, parameter=parameter):
|
|
258
|
+
self._add_data_point(field=field_name, data_point=dp, is_visible=is_visible)
|
|
259
|
+
|
|
260
|
+
def _get_data_point_name(self) -> DataPointNameData:
|
|
261
|
+
"""Create the name for the data point."""
|
|
262
|
+
is_only_primary_channel = check_channel_is_the_only_primary_channel(
|
|
263
|
+
current_channel_no=self._channel.no,
|
|
264
|
+
primary_channel=self._channel_group.primary_channel,
|
|
265
|
+
device_has_multiple_channels=self.is_in_multiple_channels,
|
|
266
|
+
)
|
|
267
|
+
return get_custom_data_point_name(
|
|
268
|
+
channel=self._channel,
|
|
269
|
+
is_only_primary_channel=is_only_primary_channel,
|
|
270
|
+
ignore_multiple_channels_for_name=self._ignore_multiple_channels_for_name,
|
|
271
|
+
usage=self._get_data_point_usage(),
|
|
272
|
+
postfix=self.data_point_name_postfix.replace("_", " ").title(),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _get_data_point_usage(self) -> DataPointUsage:
|
|
276
|
+
"""Generate the usage for the data point."""
|
|
277
|
+
if self._forced_usage:
|
|
278
|
+
return self._forced_usage
|
|
279
|
+
if self._channel.no in self._device_config.channels:
|
|
280
|
+
return DataPointUsage.CDP_PRIMARY
|
|
281
|
+
return DataPointUsage.CDP_SECONDARY
|
|
282
|
+
|
|
283
|
+
def _get_path_data(self) -> PathData:
|
|
284
|
+
"""Return the path data of the data_point."""
|
|
285
|
+
return DataPointPathData(
|
|
286
|
+
interface=self._device.client.interface,
|
|
287
|
+
address=self._device.address,
|
|
288
|
+
channel_no=self._channel.no,
|
|
289
|
+
kind=self._category,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def _get_signature(self) -> str:
|
|
293
|
+
"""Return the signature of the data_point."""
|
|
294
|
+
return f"{self._category}/{self._channel.device.model}/{self.data_point_name_postfix}"
|
|
295
|
+
|
|
296
|
+
def _init_data_points(self) -> None:
|
|
297
|
+
"""Initialize data point collection."""
|
|
298
|
+
cg = self._channel_group
|
|
299
|
+
|
|
300
|
+
# Add primary channel fields (applied to the primary channel)
|
|
301
|
+
for field_name, parameter in cg.fields.items():
|
|
302
|
+
if dp := self._device.get_generic_data_point(channel_address=self._channel.address, parameter=parameter):
|
|
303
|
+
self._add_data_point(field=field_name, data_point=dp, is_visible=False)
|
|
304
|
+
|
|
305
|
+
# Add visible primary channel fields
|
|
306
|
+
for field_name, parameter in cg.visible_fields.items():
|
|
307
|
+
if dp := self._device.get_generic_data_point(channel_address=self._channel.address, parameter=parameter):
|
|
308
|
+
self._add_data_point(field=field_name, data_point=dp, is_visible=True)
|
|
309
|
+
|
|
310
|
+
# Add fixed channel fields (absolute channel numbers from profile config)
|
|
311
|
+
self._add_fixed_channel_data_points(fixed_channel_fields=cg.fixed_channel_fields)
|
|
312
|
+
self._add_fixed_channel_data_points(fixed_channel_fields=cg.visible_fixed_channel_fields, is_visible=True)
|
|
313
|
+
|
|
314
|
+
# Add fixed channel fields from extended config (legacy support)
|
|
315
|
+
if self._extended:
|
|
316
|
+
if fixed_channels := self._extended.fixed_channel_fields:
|
|
317
|
+
self._add_fixed_channel_data_points(fixed_channel_fields=fixed_channels)
|
|
318
|
+
if additional_dps := self._extended.additional_data_points:
|
|
319
|
+
self._mark_data_points(custom_data_point_def=additional_dps)
|
|
320
|
+
|
|
321
|
+
# Add channel-specific fields (relative channel numbers, rebased)
|
|
322
|
+
self._add_channel_data_points(channel_fields=cg.channel_fields)
|
|
323
|
+
|
|
324
|
+
# Add visible channel-specific fields
|
|
325
|
+
self._add_channel_data_points(channel_fields=cg.visible_channel_fields, is_visible=True)
|
|
326
|
+
|
|
327
|
+
# Add default device data points
|
|
328
|
+
self._mark_data_points(custom_data_point_def=self._custom_data_point_def)
|
|
329
|
+
|
|
330
|
+
# Add default data points
|
|
331
|
+
if hmed.get_include_default_data_points(device_profile=self._device_profile):
|
|
332
|
+
self._mark_data_points(custom_data_point_def=hmed.get_default_data_points())
|
|
333
|
+
|
|
334
|
+
def _mark_data_point(self, *, channel_no: int | None, parameters: tuple[Parameter, ...]) -> None:
|
|
335
|
+
"""Mark data point to be created, even though a custom data point is present."""
|
|
336
|
+
channel_address = get_channel_address(device_address=self._device.address, channel_no=channel_no)
|
|
337
|
+
|
|
338
|
+
for parameter in parameters:
|
|
339
|
+
if dp := self._device.get_generic_data_point(channel_address=channel_address, parameter=parameter):
|
|
340
|
+
dp.force_usage(forced_usage=DataPointUsage.DATA_POINT)
|
|
341
|
+
|
|
342
|
+
def _mark_data_points(
|
|
343
|
+
self, *, custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]]
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Mark data points to be created, even though a custom data point is present."""
|
|
346
|
+
if not custom_data_point_def:
|
|
347
|
+
return
|
|
348
|
+
for channel_nos, parameters in custom_data_point_def.items():
|
|
349
|
+
if isinstance(channel_nos, int):
|
|
350
|
+
self._mark_data_point(channel_no=channel_nos, parameters=parameters)
|
|
351
|
+
else:
|
|
352
|
+
for channel_no in channel_nos:
|
|
353
|
+
self._mark_data_point(channel_no=channel_no, parameters=parameters)
|
|
354
|
+
|
|
355
|
+
def _post_init(self) -> None:
|
|
356
|
+
"""Post action after initialisation of the data point fields."""
|
|
357
|
+
_LOGGER.debug(
|
|
358
|
+
"POST_INIT_DATA_POINT_FIELDS: Post action after initialisation of the data point fields for %s",
|
|
359
|
+
self.full_name,
|
|
360
|
+
)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Device profile definitions for custom data point implementations.
|
|
5
|
+
|
|
6
|
+
This module provides profile definitions and factory functions for creating
|
|
7
|
+
custom data points. Device-to-profile mappings are managed by DeviceProfileRegistry
|
|
8
|
+
in registry.py.
|
|
9
|
+
|
|
10
|
+
Public API of this module is defined by __all__.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Final, cast
|
|
18
|
+
|
|
19
|
+
from aiohomematic import i18n
|
|
20
|
+
from aiohomematic.const import DataPointCategory, DeviceProfile, Parameter
|
|
21
|
+
from aiohomematic.exceptions import AioHomematicException
|
|
22
|
+
from aiohomematic.interfaces import ChannelProtocol, DeviceProtocol
|
|
23
|
+
from aiohomematic.model.custom.profile import (
|
|
24
|
+
DEFAULT_DATA_POINTS,
|
|
25
|
+
PROFILE_CONFIGS,
|
|
26
|
+
ProfileConfig,
|
|
27
|
+
RebasedChannelGroupConfig,
|
|
28
|
+
get_profile_config,
|
|
29
|
+
rebase_channel_group,
|
|
30
|
+
)
|
|
31
|
+
from aiohomematic.model.custom.registry import DeviceConfig, DeviceProfileRegistry
|
|
32
|
+
from aiohomematic.model.support import generate_unique_id
|
|
33
|
+
from aiohomematic.support import extract_exc_args
|
|
34
|
+
|
|
35
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_custom_data_point(
|
|
39
|
+
*,
|
|
40
|
+
channel: ChannelProtocol,
|
|
41
|
+
device_config: DeviceConfig,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Create a custom data point for a channel.
|
|
45
|
+
|
|
46
|
+
This is the main entry point for creating custom data points. It handles
|
|
47
|
+
channel group setup, determines the relevant channels, and creates the
|
|
48
|
+
actual data point instance.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
channel: The channel to create the data point for.
|
|
52
|
+
device_config: The device configuration from DeviceProfileRegistry.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
device_profile = device_config.profile_type
|
|
56
|
+
profile_config = get_profile_config(profile_type=device_profile)
|
|
57
|
+
|
|
58
|
+
# Set up channel groups on the device
|
|
59
|
+
_add_channel_groups_to_device(
|
|
60
|
+
device=channel.device,
|
|
61
|
+
profile_config=profile_config,
|
|
62
|
+
device_config=device_config,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Get the group number for this channel
|
|
66
|
+
group_no = channel.device.get_channel_group_no(channel_no=channel.no)
|
|
67
|
+
|
|
68
|
+
# Determine which channels are relevant for this data point
|
|
69
|
+
relevant = _get_relevant_channels(
|
|
70
|
+
profile_config=profile_config,
|
|
71
|
+
device_config=device_config,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if channel.no not in relevant:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Create the rebased channel group
|
|
78
|
+
channel_group = rebase_channel_group(profile_config=profile_config, group_no=group_no)
|
|
79
|
+
|
|
80
|
+
# Get rebased additional data points
|
|
81
|
+
custom_data_point_def = _rebase_additional_data_points(
|
|
82
|
+
profile_config=profile_config,
|
|
83
|
+
group_no=group_no,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Rebase the device config channels
|
|
87
|
+
rebased_device_config = _rebase_device_config_channels(
|
|
88
|
+
profile_config=profile_config,
|
|
89
|
+
device_config=device_config,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Create the data point instance
|
|
93
|
+
_instantiate_custom_data_point(
|
|
94
|
+
channel=channel,
|
|
95
|
+
device_config=rebased_device_config,
|
|
96
|
+
device_profile=device_profile,
|
|
97
|
+
channel_group=channel_group,
|
|
98
|
+
custom_data_point_def=custom_data_point_def,
|
|
99
|
+
group_no=group_no,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _instantiate_custom_data_point(
|
|
104
|
+
*,
|
|
105
|
+
channel: ChannelProtocol,
|
|
106
|
+
device_config: DeviceConfig,
|
|
107
|
+
device_profile: DeviceProfile,
|
|
108
|
+
channel_group: RebasedChannelGroupConfig,
|
|
109
|
+
custom_data_point_def: Mapping[int | tuple[int, ...], tuple[Parameter, ...]],
|
|
110
|
+
group_no: int | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Instantiate and add a custom data point to the channel."""
|
|
113
|
+
|
|
114
|
+
unique_id = generate_unique_id(config_provider=channel.device.config_provider, address=channel.address)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
dp = device_config.data_point_class(
|
|
118
|
+
channel=channel,
|
|
119
|
+
unique_id=unique_id,
|
|
120
|
+
device_profile=device_profile,
|
|
121
|
+
channel_group=channel_group,
|
|
122
|
+
custom_data_point_def=custom_data_point_def,
|
|
123
|
+
group_no=group_no,
|
|
124
|
+
device_config=device_config,
|
|
125
|
+
)
|
|
126
|
+
if dp.has_data_points:
|
|
127
|
+
channel.add_data_point(data_point=dp)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
raise AioHomematicException(
|
|
130
|
+
i18n.tr(
|
|
131
|
+
key="exception.model.custom.definition.create_custom_data_point.failed",
|
|
132
|
+
reason=extract_exc_args(exc=exc),
|
|
133
|
+
)
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _add_channel_groups_to_device(
|
|
138
|
+
*,
|
|
139
|
+
device: DeviceProtocol,
|
|
140
|
+
profile_config: ProfileConfig,
|
|
141
|
+
device_config: DeviceConfig,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Add channel group mappings to the device."""
|
|
144
|
+
cg = profile_config.channel_group
|
|
145
|
+
|
|
146
|
+
if (primary_channel := cg.primary_channel) is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
for conf_channel in device_config.channels:
|
|
150
|
+
if conf_channel is None:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
group_no = conf_channel + primary_channel
|
|
154
|
+
device.add_channel_to_group(channel_no=group_no, group_no=group_no)
|
|
155
|
+
|
|
156
|
+
if cg.state_channel_offset is not None:
|
|
157
|
+
device.add_channel_to_group(channel_no=conf_channel + cg.state_channel_offset, group_no=group_no)
|
|
158
|
+
|
|
159
|
+
for sec_channel in cg.secondary_channels:
|
|
160
|
+
device.add_channel_to_group(channel_no=conf_channel + sec_channel, group_no=group_no)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _get_relevant_channels(
|
|
164
|
+
*,
|
|
165
|
+
profile_config: ProfileConfig,
|
|
166
|
+
device_config: DeviceConfig,
|
|
167
|
+
) -> set[int | None]:
|
|
168
|
+
"""Return the set of channels that are relevant for this data point."""
|
|
169
|
+
|
|
170
|
+
cg = profile_config.channel_group
|
|
171
|
+
primary_channel = cg.primary_channel
|
|
172
|
+
|
|
173
|
+
# Collect all definition channels (primary + secondary)
|
|
174
|
+
def_channels: list[int | None] = [primary_channel]
|
|
175
|
+
def_channels.extend(cg.secondary_channels)
|
|
176
|
+
|
|
177
|
+
# Calculate relevant channels by combining definition and config channels
|
|
178
|
+
relevant: set[int | None] = set()
|
|
179
|
+
for def_ch in def_channels:
|
|
180
|
+
for conf_ch in device_config.channels:
|
|
181
|
+
if def_ch is not None and conf_ch is not None:
|
|
182
|
+
relevant.add(def_ch + conf_ch)
|
|
183
|
+
else:
|
|
184
|
+
relevant.add(None)
|
|
185
|
+
|
|
186
|
+
return relevant
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rebase_device_config_channels(
|
|
190
|
+
*,
|
|
191
|
+
profile_config: ProfileConfig,
|
|
192
|
+
device_config: DeviceConfig,
|
|
193
|
+
) -> DeviceConfig:
|
|
194
|
+
"""Rebase device config channels with the primary channel offset."""
|
|
195
|
+
if (primary_channel := profile_config.channel_group.primary_channel) is None:
|
|
196
|
+
return device_config
|
|
197
|
+
|
|
198
|
+
rebased_channels = tuple(ch + primary_channel for ch in device_config.channels if ch is not None)
|
|
199
|
+
|
|
200
|
+
return DeviceConfig(
|
|
201
|
+
data_point_class=device_config.data_point_class,
|
|
202
|
+
profile_type=device_config.profile_type,
|
|
203
|
+
channels=rebased_channels if rebased_channels else device_config.channels,
|
|
204
|
+
extended=device_config.extended,
|
|
205
|
+
schedule_channel_no=device_config.schedule_channel_no,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _rebase_additional_data_points(
|
|
210
|
+
*,
|
|
211
|
+
profile_config: ProfileConfig,
|
|
212
|
+
group_no: int | None,
|
|
213
|
+
) -> Mapping[int | tuple[int, ...], tuple[Parameter, ...]]:
|
|
214
|
+
"""Rebase additional data points with the group offset."""
|
|
215
|
+
additional_dps = profile_config.additional_data_points
|
|
216
|
+
if not group_no:
|
|
217
|
+
# Cast is safe: Mapping[int, T] is a subtype of Mapping[int | tuple[int, ...], T]
|
|
218
|
+
return cast(Mapping[int | tuple[int, ...], tuple[Parameter, ...]], additional_dps)
|
|
219
|
+
|
|
220
|
+
new_dps: dict[int | tuple[int, ...], tuple[Parameter, ...]] = {}
|
|
221
|
+
for channel_no, params in additional_dps.items():
|
|
222
|
+
new_dps[channel_no + group_no] = params
|
|
223
|
+
|
|
224
|
+
return new_dps
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# =============================================================================
|
|
228
|
+
# Public API functions
|
|
229
|
+
# =============================================================================
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def create_custom_data_points(*, channel: ChannelProtocol) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Create custom data points for a channel.
|
|
235
|
+
|
|
236
|
+
Queries the DeviceProfileRegistry for configurations matching the device model
|
|
237
|
+
and creates custom data points for each configuration.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
channel: The channel to create data points for.
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
device_configs = DeviceProfileRegistry.get_configs(model=channel.device.model)
|
|
244
|
+
for device_config in device_configs:
|
|
245
|
+
create_custom_data_point(channel=channel, device_config=device_config)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def data_point_definition_exists(*, model: str) -> bool:
|
|
249
|
+
"""Check if a device definition exists for the model."""
|
|
250
|
+
return len(DeviceProfileRegistry.get_configs(model=model)) > 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_default_data_points() -> Mapping[int | tuple[int, ...], tuple[Parameter, ...]]:
|
|
254
|
+
"""Return the default data points configuration."""
|
|
255
|
+
return DEFAULT_DATA_POINTS
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_include_default_data_points(*, device_profile: DeviceProfile) -> bool:
|
|
259
|
+
"""Return if default data points should be included for this profile."""
|
|
260
|
+
return get_profile_config(profile_type=device_profile).include_default_data_points
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_required_parameters() -> tuple[Parameter, ...]:
|
|
264
|
+
"""Return all required parameters for custom data points."""
|
|
265
|
+
required_parameters: list[Parameter] = []
|
|
266
|
+
|
|
267
|
+
# Add default data points
|
|
268
|
+
for params in DEFAULT_DATA_POINTS.values():
|
|
269
|
+
required_parameters.extend(params)
|
|
270
|
+
|
|
271
|
+
# Add parameters from profile configurations
|
|
272
|
+
for profile_config in PROFILE_CONFIGS.values():
|
|
273
|
+
group = profile_config.channel_group
|
|
274
|
+
required_parameters.extend(group.fields.values())
|
|
275
|
+
required_parameters.extend(group.visible_fields.values())
|
|
276
|
+
for field_map in group.channel_fields.values():
|
|
277
|
+
required_parameters.extend(field_map.values())
|
|
278
|
+
for field_map in group.visible_channel_fields.values():
|
|
279
|
+
required_parameters.extend(field_map.values())
|
|
280
|
+
for field_map in group.fixed_channel_fields.values():
|
|
281
|
+
required_parameters.extend(field_map.values())
|
|
282
|
+
for field_map in group.visible_fixed_channel_fields.values():
|
|
283
|
+
required_parameters.extend(field_map.values())
|
|
284
|
+
for params in profile_config.additional_data_points.values():
|
|
285
|
+
required_parameters.extend(params)
|
|
286
|
+
|
|
287
|
+
# Add required parameters from DeviceProfileRegistry extended configs
|
|
288
|
+
for extended_config in DeviceProfileRegistry.get_all_extended_configs():
|
|
289
|
+
required_parameters.extend(extended_config.required_parameters)
|
|
290
|
+
|
|
291
|
+
return tuple(sorted(set(required_parameters)))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def is_multi_channel_device(*, model: str, category: DataPointCategory) -> bool:
|
|
295
|
+
"""Return true if device has multiple channels for the given category."""
|
|
296
|
+
device_configs = DeviceProfileRegistry.get_configs(model=model, category=category)
|
|
297
|
+
channels: list[int | None] = []
|
|
298
|
+
for config in device_configs:
|
|
299
|
+
channels.extend(config.channels)
|
|
300
|
+
return len(channels) > 1
|