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,1840 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
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
|
+
-----------
|
|
13
|
+
- Device: Encapsulates metadata, channels, and operations for a single device.
|
|
14
|
+
- Channel: Represents a functional channel with its data points and events.
|
|
15
|
+
|
|
16
|
+
Other components
|
|
17
|
+
----------------
|
|
18
|
+
- _ValueCache: Lazy loading and caching of parameter values to minimize RPCs.
|
|
19
|
+
Accessed externally via ``device.value_cache``.
|
|
20
|
+
- _DefinitionExporter: Internal utility to export device and paramset descriptions.
|
|
21
|
+
|
|
22
|
+
Architecture notes
|
|
23
|
+
------------------
|
|
24
|
+
Device acts as a **Facade** aggregating 15+ protocol interfaces injected via
|
|
25
|
+
constructor. This design enables:
|
|
26
|
+
- Centralized access to all protocol interfaces for DataPoints
|
|
27
|
+
- Single instantiation point in DeviceCoordinator.create_devices()
|
|
28
|
+
- Full Protocol-based dependency injection (no direct CentralUnit reference)
|
|
29
|
+
|
|
30
|
+
Device responsibilities are organized into 7 areas:
|
|
31
|
+
1. **Metadata & Identity**: Address, model, name, manufacturer, firmware version
|
|
32
|
+
2. **Channel Hierarchy**: Channel management, grouping, data point access
|
|
33
|
+
3. **Value Caching**: Lazy loading and caching of parameter values
|
|
34
|
+
4. **Availability & State**: Device availability, config pending status
|
|
35
|
+
5. **Firmware Management**: Firmware updates, available updates, update state
|
|
36
|
+
6. **Links & Export**: Central link management, device definition export
|
|
37
|
+
7. **Week Profile**: Schedule/time program support
|
|
38
|
+
|
|
39
|
+
The Device/Channel classes are the anchor used by generic, custom, calculated,
|
|
40
|
+
and hub model code to attach data points and events.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import asyncio
|
|
46
|
+
from collections.abc import Mapping
|
|
47
|
+
from datetime import datetime
|
|
48
|
+
from functools import partial
|
|
49
|
+
import logging
|
|
50
|
+
import os
|
|
51
|
+
import random
|
|
52
|
+
from typing import Any, Final, cast
|
|
53
|
+
import zipfile
|
|
54
|
+
|
|
55
|
+
import orjson
|
|
56
|
+
|
|
57
|
+
from aiohomematic import i18n
|
|
58
|
+
from aiohomematic.async_support import loop_check
|
|
59
|
+
from aiohomematic.central.events import DeviceStateChangedEvent, FirmwareStateChangedEvent, LinkPeerChangedEvent
|
|
60
|
+
from aiohomematic.const import (
|
|
61
|
+
ADDRESS_SEPARATOR,
|
|
62
|
+
CLICK_EVENTS,
|
|
63
|
+
DEVICE_DESCRIPTIONS_ZIP_DIR,
|
|
64
|
+
IDENTIFIER_SEPARATOR,
|
|
65
|
+
INIT_DATETIME,
|
|
66
|
+
NO_CACHE_ENTRY,
|
|
67
|
+
PARAMSET_DESCRIPTIONS_ZIP_DIR,
|
|
68
|
+
RELEVANT_INIT_PARAMETERS,
|
|
69
|
+
REPORT_VALUE_USAGE_DATA,
|
|
70
|
+
REPORT_VALUE_USAGE_VALUE_ID,
|
|
71
|
+
VIRTUAL_REMOTE_MODELS,
|
|
72
|
+
WEEK_PROFILE_PATTERN,
|
|
73
|
+
CalculatedParameter,
|
|
74
|
+
CallSource,
|
|
75
|
+
DataPointCategory,
|
|
76
|
+
DataPointKey,
|
|
77
|
+
DataPointUsage,
|
|
78
|
+
DeviceDescription,
|
|
79
|
+
DeviceFirmwareState,
|
|
80
|
+
DeviceTriggerEventType,
|
|
81
|
+
ForcedDeviceAvailability,
|
|
82
|
+
Interface,
|
|
83
|
+
Manufacturer,
|
|
84
|
+
Parameter,
|
|
85
|
+
ParameterData,
|
|
86
|
+
ParamsetKey,
|
|
87
|
+
ProductGroup,
|
|
88
|
+
RxMode,
|
|
89
|
+
ServiceScope,
|
|
90
|
+
check_ignore_model_on_initial_load,
|
|
91
|
+
get_link_source_categories,
|
|
92
|
+
get_link_target_categories,
|
|
93
|
+
)
|
|
94
|
+
from aiohomematic.decorators import inspector
|
|
95
|
+
from aiohomematic.exceptions import (
|
|
96
|
+
AioHomematicException,
|
|
97
|
+
BaseHomematicException,
|
|
98
|
+
ClientException,
|
|
99
|
+
DescriptionNotFoundException,
|
|
100
|
+
)
|
|
101
|
+
from aiohomematic.interfaces import (
|
|
102
|
+
BaseParameterDataPointProtocol,
|
|
103
|
+
CalculatedDataPointProtocol,
|
|
104
|
+
CallbackDataPointProtocol,
|
|
105
|
+
CentralInfoProtocol,
|
|
106
|
+
ChannelLookupProtocol,
|
|
107
|
+
ChannelProtocol,
|
|
108
|
+
ClientProtocol,
|
|
109
|
+
ClientProviderProtocol,
|
|
110
|
+
ConfigProviderProtocol,
|
|
111
|
+
CustomDataPointProtocol,
|
|
112
|
+
DataCacheProviderProtocol,
|
|
113
|
+
DataPointProviderProtocol,
|
|
114
|
+
DeviceDescriptionProviderProtocol,
|
|
115
|
+
DeviceDetailsProviderProtocol,
|
|
116
|
+
DeviceProtocol,
|
|
117
|
+
EventBusProviderProtocol,
|
|
118
|
+
EventPublisherProtocol,
|
|
119
|
+
EventSubscriptionManagerProtocol,
|
|
120
|
+
FileOperationsProtocol,
|
|
121
|
+
GenericDataPointProtocol,
|
|
122
|
+
GenericDataPointProtocolAny,
|
|
123
|
+
GenericEventProtocol,
|
|
124
|
+
GenericEventProtocolAny,
|
|
125
|
+
ParameterVisibilityProviderProtocol,
|
|
126
|
+
ParamsetDescriptionProviderProtocol,
|
|
127
|
+
TaskSchedulerProtocol,
|
|
128
|
+
)
|
|
129
|
+
from aiohomematic.interfaces.central import FirmwareDataRefresherProtocol
|
|
130
|
+
from aiohomematic.model import week_profile as wp
|
|
131
|
+
from aiohomematic.model.availability import AvailabilityInfo
|
|
132
|
+
from aiohomematic.model.custom import data_point as hmce, definition as hmed
|
|
133
|
+
from aiohomematic.model.generic import DpBinarySensor
|
|
134
|
+
from aiohomematic.model.support import (
|
|
135
|
+
ChannelNameData,
|
|
136
|
+
generate_channel_unique_id,
|
|
137
|
+
get_channel_name_data,
|
|
138
|
+
get_device_name,
|
|
139
|
+
)
|
|
140
|
+
from aiohomematic.model.update import DpUpdate
|
|
141
|
+
from aiohomematic.property_decorators import DelegatedProperty, Kind, hm_property, info_property, state_property
|
|
142
|
+
from aiohomematic.support import (
|
|
143
|
+
CacheEntry,
|
|
144
|
+
LogContextMixin,
|
|
145
|
+
PayloadMixin,
|
|
146
|
+
extract_exc_args,
|
|
147
|
+
get_channel_address,
|
|
148
|
+
get_channel_no,
|
|
149
|
+
get_rx_modes,
|
|
150
|
+
)
|
|
151
|
+
from aiohomematic.type_aliases import (
|
|
152
|
+
DeviceUpdatedHandler,
|
|
153
|
+
FirmwareUpdateHandler,
|
|
154
|
+
LinkPeerChangedHandler,
|
|
155
|
+
UnsubscribeCallback,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
__all__ = ["Channel", "Device"]
|
|
159
|
+
|
|
160
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Device(DeviceProtocol, LogContextMixin, PayloadMixin):
|
|
164
|
+
"""
|
|
165
|
+
Represent a Homematic device with channels and data points.
|
|
166
|
+
|
|
167
|
+
Device is the central runtime model for a physical Homematic device. It acts
|
|
168
|
+
as a **Facade** that aggregates 15+ protocol interfaces (injected via constructor)
|
|
169
|
+
and provides unified access for all DataPoint classes.
|
|
170
|
+
|
|
171
|
+
Responsibilities
|
|
172
|
+
----------------
|
|
173
|
+
1. **Metadata & Identity**: Device address, model, name, manufacturer, interface.
|
|
174
|
+
2. **Channel Hierarchy**: Channel creation, grouping, data point lookup.
|
|
175
|
+
3. **Value Caching**: Lazy loading via ``_ValueCache`` (accessed via ``value_cache``).
|
|
176
|
+
4. **Availability & State**: UN_REACH/STICKY_UN_REACH handling, forced availability.
|
|
177
|
+
5. **Firmware Management**: Firmware version, available updates, update operations.
|
|
178
|
+
6. **Links & Export**: Central link management for press events, definition export.
|
|
179
|
+
7. **Week Profile**: Schedule support for climate devices.
|
|
180
|
+
|
|
181
|
+
Instantiation
|
|
182
|
+
-------------
|
|
183
|
+
Devices are created exclusively by ``DeviceCoordinator.create_devices()``. All
|
|
184
|
+
dependencies are injected as protocol interfaces, enabling full dependency
|
|
185
|
+
injection without direct CentralUnit references.
|
|
186
|
+
|
|
187
|
+
Protocol compliance
|
|
188
|
+
-------------------
|
|
189
|
+
Implements ``DeviceProtocol`` which is a composite of sub-protocols:
|
|
190
|
+
|
|
191
|
+
- ``DeviceIdentityProtocol``: Basic identification (address, name, model, manufacturer)
|
|
192
|
+
- ``DeviceChannelAccessProtocol``: Channel and DataPoint access methods
|
|
193
|
+
- ``DeviceAvailabilityProtocol``: Availability state management
|
|
194
|
+
- ``DeviceFirmwareProtocol``: Firmware information and update operations
|
|
195
|
+
- ``DeviceLinkManagementProtocol``: Central link operations
|
|
196
|
+
- ``DeviceGroupManagementProtocol``: Channel group management
|
|
197
|
+
- ``DeviceConfigurationProtocol``: Device configuration and metadata
|
|
198
|
+
- ``DeviceWeekProfileProtocol``: Week profile support
|
|
199
|
+
- ``DeviceProvidersProtocol``: Protocol interface providers
|
|
200
|
+
- ``DeviceLifecycleProtocol``: Lifecycle methods
|
|
201
|
+
|
|
202
|
+
Consumers can depend on specific sub-protocols for narrower contracts.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
__slots__ = (
|
|
206
|
+
"_address",
|
|
207
|
+
"_cached_allow_undefined_generic_data_points",
|
|
208
|
+
"_cached_has_sub_devices",
|
|
209
|
+
"_cached_relevant_for_central_link_management",
|
|
210
|
+
"_central_info",
|
|
211
|
+
"_channel_to_group",
|
|
212
|
+
"_channel_lookup",
|
|
213
|
+
"_channels",
|
|
214
|
+
"_client",
|
|
215
|
+
"_client_provider",
|
|
216
|
+
"_config_provider",
|
|
217
|
+
"_data_cache_provider",
|
|
218
|
+
"_data_point_provider",
|
|
219
|
+
"_device_description",
|
|
220
|
+
"_device_data_refresher",
|
|
221
|
+
"_device_description_provider",
|
|
222
|
+
"_device_details_provider",
|
|
223
|
+
"_event_bus_provider",
|
|
224
|
+
"_event_publisher",
|
|
225
|
+
"_event_subscription_manager",
|
|
226
|
+
"_file_operations",
|
|
227
|
+
"_forced_availability",
|
|
228
|
+
"_group_channels",
|
|
229
|
+
"_has_custom_data_point_definition",
|
|
230
|
+
"_rega_id",
|
|
231
|
+
"_ignore_for_custom_data_point",
|
|
232
|
+
"_ignore_on_initial_load",
|
|
233
|
+
"_interface",
|
|
234
|
+
"_interface_id",
|
|
235
|
+
"_is_updatable",
|
|
236
|
+
"_manufacturer",
|
|
237
|
+
"_model",
|
|
238
|
+
"_modified_at",
|
|
239
|
+
"_name",
|
|
240
|
+
"_parameter_visibility_provider",
|
|
241
|
+
"_paramset_description_provider",
|
|
242
|
+
"_product_group",
|
|
243
|
+
"_rooms",
|
|
244
|
+
"_rx_modes",
|
|
245
|
+
"_sub_model",
|
|
246
|
+
"_task_scheduler",
|
|
247
|
+
"_update_data_point",
|
|
248
|
+
"_value_cache",
|
|
249
|
+
"_week_profile",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
interface_id: str,
|
|
256
|
+
device_address: str,
|
|
257
|
+
central_info: CentralInfoProtocol,
|
|
258
|
+
channel_lookup: ChannelLookupProtocol,
|
|
259
|
+
client_provider: ClientProviderProtocol,
|
|
260
|
+
config_provider: ConfigProviderProtocol,
|
|
261
|
+
data_cache_provider: DataCacheProviderProtocol,
|
|
262
|
+
data_point_provider: DataPointProviderProtocol,
|
|
263
|
+
device_data_refresher: FirmwareDataRefresherProtocol,
|
|
264
|
+
device_description_provider: DeviceDescriptionProviderProtocol,
|
|
265
|
+
device_details_provider: DeviceDetailsProviderProtocol,
|
|
266
|
+
event_bus_provider: EventBusProviderProtocol,
|
|
267
|
+
event_publisher: EventPublisherProtocol,
|
|
268
|
+
event_subscription_manager: EventSubscriptionManagerProtocol,
|
|
269
|
+
file_operations: FileOperationsProtocol,
|
|
270
|
+
parameter_visibility_provider: ParameterVisibilityProviderProtocol,
|
|
271
|
+
paramset_description_provider: ParamsetDescriptionProviderProtocol,
|
|
272
|
+
task_scheduler: TaskSchedulerProtocol,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Initialize the device object."""
|
|
275
|
+
PayloadMixin.__init__(self)
|
|
276
|
+
self._interface_id: Final = interface_id
|
|
277
|
+
self._address: Final = device_address
|
|
278
|
+
self._device_details_provider: Final = device_details_provider
|
|
279
|
+
self._device_description_provider: Final = device_description_provider
|
|
280
|
+
self._paramset_description_provider: Final = paramset_description_provider
|
|
281
|
+
self._parameter_visibility_provider: Final = parameter_visibility_provider
|
|
282
|
+
self._client_provider: Final = client_provider
|
|
283
|
+
self._config_provider: Final = config_provider
|
|
284
|
+
self._central_info: Final = central_info
|
|
285
|
+
self._event_bus_provider: Final = event_bus_provider
|
|
286
|
+
self._event_publisher: Final = event_publisher
|
|
287
|
+
self._task_scheduler: Final = task_scheduler
|
|
288
|
+
self._file_operations: Final = file_operations
|
|
289
|
+
self._device_data_refresher: Final = device_data_refresher
|
|
290
|
+
self._data_cache_provider: Final = data_cache_provider
|
|
291
|
+
self._data_point_provider: Final = data_point_provider
|
|
292
|
+
self._channel_lookup: Final = channel_lookup
|
|
293
|
+
self._event_subscription_manager: Final = event_subscription_manager
|
|
294
|
+
self._channel_to_group: Final[dict[int | None, int]] = {}
|
|
295
|
+
self._group_channels: Final[dict[int, set[int | None]]] = {}
|
|
296
|
+
self._rega_id: Final = device_details_provider.get_address_id(address=device_address)
|
|
297
|
+
self._interface: Final = device_details_provider.get_interface(address=device_address)
|
|
298
|
+
self._client: Final = client_provider.get_client(interface_id=interface_id)
|
|
299
|
+
self._device_description = self._device_description_provider.get_device_description(
|
|
300
|
+
interface_id=interface_id, address=device_address
|
|
301
|
+
)
|
|
302
|
+
_LOGGER.debug(
|
|
303
|
+
"__INIT__: Initializing device: %s, %s",
|
|
304
|
+
interface_id,
|
|
305
|
+
device_address,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self._modified_at: datetime = INIT_DATETIME
|
|
309
|
+
self._forced_availability: ForcedDeviceAvailability = ForcedDeviceAvailability.NOT_SET
|
|
310
|
+
self._model: Final[str] = self._device_description["TYPE"]
|
|
311
|
+
self._ignore_on_initial_load: Final[bool] = check_ignore_model_on_initial_load(model=self._model)
|
|
312
|
+
self._is_updatable: Final = self._device_description.get("UPDATABLE") or False
|
|
313
|
+
self._rx_modes: Final = get_rx_modes(mode=self._device_description.get("RX_MODE", 0))
|
|
314
|
+
self._sub_model: Final[str | None] = self._device_description.get("SUBTYPE")
|
|
315
|
+
self._ignore_for_custom_data_point: Final[bool] = parameter_visibility_provider.model_is_ignored(
|
|
316
|
+
model=self._model
|
|
317
|
+
)
|
|
318
|
+
self._manufacturer = self._identify_manufacturer()
|
|
319
|
+
self._product_group: Final[ProductGroup] = self._client.get_product_group(model=self._model)
|
|
320
|
+
# marker if device will be created as custom data_point
|
|
321
|
+
self._has_custom_data_point_definition: Final = (
|
|
322
|
+
hmed.data_point_definition_exists(model=self._model) and not self._ignore_for_custom_data_point
|
|
323
|
+
)
|
|
324
|
+
self._name: Final = get_device_name(
|
|
325
|
+
device_details_provider=device_details_provider,
|
|
326
|
+
device_address=device_address,
|
|
327
|
+
model=self._model,
|
|
328
|
+
)
|
|
329
|
+
channel_addresses = tuple(
|
|
330
|
+
[device_address] + [address for address in self._device_description.get("CHILDREN", []) if address != ""]
|
|
331
|
+
)
|
|
332
|
+
self._channels: Final[dict[str, ChannelProtocol]] = {}
|
|
333
|
+
for address in channel_addresses:
|
|
334
|
+
try:
|
|
335
|
+
self._channels[address] = Channel(device=self, channel_address=address)
|
|
336
|
+
except DescriptionNotFoundException:
|
|
337
|
+
_LOGGER.warning(i18n.tr(key="log.model.device.channel_description_not_found", address=address))
|
|
338
|
+
self._value_cache: Final[_ValueCache] = _ValueCache(device=self)
|
|
339
|
+
self._rooms: Final = device_details_provider.get_device_rooms(device_address=device_address)
|
|
340
|
+
self._update_data_point: Final = DpUpdate(device=self) if self.is_updatable else None
|
|
341
|
+
self._week_profile: wp.WeekProfile[dict[Any, Any]] | None = None
|
|
342
|
+
_LOGGER.debug(
|
|
343
|
+
"__INIT__: Initialized device: %s, %s, %s, %s",
|
|
344
|
+
self._interface_id,
|
|
345
|
+
self._address,
|
|
346
|
+
self._model,
|
|
347
|
+
self._name,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def __str__(self) -> str:
|
|
351
|
+
"""Provide some useful information."""
|
|
352
|
+
return (
|
|
353
|
+
f"address: {self._address}, "
|
|
354
|
+
f"model: {self._model}, "
|
|
355
|
+
f"name: {self._name}, "
|
|
356
|
+
f"generic dps: {len(self.generic_data_points)}, "
|
|
357
|
+
f"calculated dps: {len(self.calculated_data_points)}, "
|
|
358
|
+
f"custom dps: {len(self.custom_data_points)}, "
|
|
359
|
+
f"events: {len(self.generic_events)}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
address: Final = DelegatedProperty[str](path="_address", kind=Kind.INFO, log_context=True)
|
|
363
|
+
central_info: Final = DelegatedProperty[CentralInfoProtocol](path="_central_info")
|
|
364
|
+
channel_lookup: Final = DelegatedProperty[ChannelLookupProtocol](path="_channel_lookup")
|
|
365
|
+
channels: Final = DelegatedProperty[Mapping[str, ChannelProtocol]](path="_channels")
|
|
366
|
+
client: Final = DelegatedProperty[ClientProtocol](path="_client")
|
|
367
|
+
config_provider: Final = DelegatedProperty[ConfigProviderProtocol](path="_config_provider")
|
|
368
|
+
data_cache_provider: Final = DelegatedProperty[DataCacheProviderProtocol](path="_data_cache_provider")
|
|
369
|
+
data_point_provider: Final = DelegatedProperty[DataPointProviderProtocol](path="_data_point_provider")
|
|
370
|
+
device_data_refresher: Final = DelegatedProperty[FirmwareDataRefresherProtocol](path="_device_data_refresher")
|
|
371
|
+
device_description_provider: Final = DelegatedProperty[DeviceDescriptionProviderProtocol](
|
|
372
|
+
path="_device_description_provider"
|
|
373
|
+
)
|
|
374
|
+
device_details_provider: Final = DelegatedProperty[DeviceDetailsProviderProtocol](path="_device_details_provider")
|
|
375
|
+
event_bus_provider: Final = DelegatedProperty[EventBusProviderProtocol](path="_event_bus_provider")
|
|
376
|
+
event_publisher: Final = DelegatedProperty[EventPublisherProtocol](path="_event_publisher")
|
|
377
|
+
event_subscription_manager: Final = DelegatedProperty[EventSubscriptionManagerProtocol](
|
|
378
|
+
path="_event_subscription_manager"
|
|
379
|
+
)
|
|
380
|
+
has_custom_data_point_definition: Final = DelegatedProperty[bool](path="_has_custom_data_point_definition")
|
|
381
|
+
ignore_for_custom_data_point: Final = DelegatedProperty[bool](path="_ignore_for_custom_data_point")
|
|
382
|
+
ignore_on_initial_load: Final = DelegatedProperty[bool](path="_ignore_on_initial_load")
|
|
383
|
+
interface: Final = DelegatedProperty[Interface](path="_interface")
|
|
384
|
+
interface_id: Final = DelegatedProperty[str](path="_interface_id", log_context=True)
|
|
385
|
+
is_updatable: Final = DelegatedProperty[bool](path="_is_updatable")
|
|
386
|
+
manufacturer: Final = DelegatedProperty[str](path="_manufacturer", kind=Kind.INFO)
|
|
387
|
+
model: Final = DelegatedProperty[str](path="_model", kind=Kind.INFO, log_context=True)
|
|
388
|
+
name: Final = DelegatedProperty[str](path="_name", kind=Kind.INFO)
|
|
389
|
+
parameter_visibility_provider: Final = DelegatedProperty[ParameterVisibilityProviderProtocol](
|
|
390
|
+
path="_parameter_visibility_provider"
|
|
391
|
+
)
|
|
392
|
+
paramset_description_provider: Final = DelegatedProperty[ParamsetDescriptionProviderProtocol](
|
|
393
|
+
path="_paramset_description_provider"
|
|
394
|
+
)
|
|
395
|
+
product_group: Final = DelegatedProperty[ProductGroup](path="_product_group")
|
|
396
|
+
rega_id: Final = DelegatedProperty[int](path="_rega_id")
|
|
397
|
+
rooms: Final = DelegatedProperty[set[str]](path="_rooms")
|
|
398
|
+
rx_modes: Final = DelegatedProperty[tuple[RxMode, ...]](path="_rx_modes")
|
|
399
|
+
sub_model: Final = DelegatedProperty[str | None](path="_sub_model")
|
|
400
|
+
task_scheduler: Final = DelegatedProperty[TaskSchedulerProtocol](path="_task_scheduler")
|
|
401
|
+
update_data_point: Final = DelegatedProperty[DpUpdate | None](path="_update_data_point")
|
|
402
|
+
value_cache: Final = DelegatedProperty["_ValueCache"](path="_value_cache")
|
|
403
|
+
week_profile: Final = DelegatedProperty[wp.WeekProfile[dict[Any, Any]] | None](path="_week_profile")
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def _dp_config_pending(self) -> DpBinarySensor | None:
|
|
407
|
+
"""Return the CONFIG_PENDING data point."""
|
|
408
|
+
return cast(
|
|
409
|
+
DpBinarySensor | None,
|
|
410
|
+
self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.CONFIG_PENDING),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def _dp_sticky_un_reach(self) -> DpBinarySensor | None:
|
|
415
|
+
"""Return the STICKY_UN_REACH data point."""
|
|
416
|
+
return cast(
|
|
417
|
+
DpBinarySensor | None,
|
|
418
|
+
self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.STICKY_UN_REACH),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def _dp_un_reach(self) -> DpBinarySensor | None:
|
|
423
|
+
"""Return the UN_REACH data point."""
|
|
424
|
+
return cast(
|
|
425
|
+
DpBinarySensor | None,
|
|
426
|
+
self.get_generic_data_point(channel_address=f"{self._address}:0", parameter=Parameter.UN_REACH),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
@property
|
|
430
|
+
def availability(self) -> AvailabilityInfo:
|
|
431
|
+
"""
|
|
432
|
+
Return bundled availability information for the device.
|
|
433
|
+
|
|
434
|
+
Provides a unified view of:
|
|
435
|
+
- Reachability (from UNREACH/STICKY_UNREACH)
|
|
436
|
+
- Last updated timestamp (from most recent data point)
|
|
437
|
+
- Battery level (from OperatingVoltageLevel or BATTERY_STATE)
|
|
438
|
+
- Low battery indicator (from LOW_BAT)
|
|
439
|
+
- Signal strength (from RSSI_DEVICE)
|
|
440
|
+
"""
|
|
441
|
+
return AvailabilityInfo(
|
|
442
|
+
is_reachable=self._get_is_reachable(),
|
|
443
|
+
last_updated=self._get_last_updated(),
|
|
444
|
+
battery_level=self._get_battery_level(),
|
|
445
|
+
low_battery=self._get_low_battery(),
|
|
446
|
+
signal_strength=self._get_signal_strength(),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def available_firmware(self) -> str | None:
|
|
451
|
+
"""Return the available firmware of the device."""
|
|
452
|
+
return str(self._device_description.get("AVAILABLE_FIRMWARE", ""))
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def calculated_data_points(self) -> tuple[CalculatedDataPointProtocol, ...]:
|
|
456
|
+
"""Return the generic data points."""
|
|
457
|
+
data_points: list[CalculatedDataPointProtocol] = []
|
|
458
|
+
for channel in self._channels.values():
|
|
459
|
+
data_points.extend(channel.calculated_data_points)
|
|
460
|
+
return tuple(data_points)
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def config_pending(self) -> bool:
|
|
464
|
+
"""Return if a config change of the device is pending."""
|
|
465
|
+
if self._dp_config_pending is not None and self._dp_config_pending.value is not None:
|
|
466
|
+
return self._dp_config_pending.value is True
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def custom_data_points(self) -> tuple[hmce.CustomDataPoint, ...]:
|
|
471
|
+
"""Return the custom data points."""
|
|
472
|
+
return tuple(
|
|
473
|
+
cast(hmce.CustomDataPoint, channel.custom_data_point)
|
|
474
|
+
for channel in self._channels.values()
|
|
475
|
+
if channel.custom_data_point is not None
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def data_point_paths(self) -> tuple[str, ...]:
|
|
480
|
+
"""Return the data point paths."""
|
|
481
|
+
data_point_paths: list[str] = []
|
|
482
|
+
for channel in self._channels.values():
|
|
483
|
+
data_point_paths.extend(channel.data_point_paths)
|
|
484
|
+
return tuple(data_point_paths)
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def default_schedule_channel(self) -> ChannelProtocol | None:
|
|
488
|
+
"""Return the schedule channel address."""
|
|
489
|
+
for channel in self._channels.values():
|
|
490
|
+
if channel.is_schedule_channel:
|
|
491
|
+
return channel
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def firmware_updatable(self) -> bool:
|
|
496
|
+
"""Return the firmware update state of the device."""
|
|
497
|
+
return self._device_description.get("FIRMWARE_UPDATABLE") or False
|
|
498
|
+
|
|
499
|
+
@property
|
|
500
|
+
def firmware_update_state(self) -> DeviceFirmwareState:
|
|
501
|
+
"""Return the firmware update state of the device."""
|
|
502
|
+
return DeviceFirmwareState(self._device_description.get("FIRMWARE_UPDATE_STATE") or DeviceFirmwareState.UNKNOWN)
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def generic_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
|
|
506
|
+
"""Return the generic data points."""
|
|
507
|
+
data_points: list[GenericDataPointProtocolAny] = []
|
|
508
|
+
for channel in self._channels.values():
|
|
509
|
+
data_points.extend(channel.generic_data_points)
|
|
510
|
+
return tuple(data_points)
|
|
511
|
+
|
|
512
|
+
@property
|
|
513
|
+
def generic_events(self) -> tuple[GenericEventProtocolAny, ...]:
|
|
514
|
+
"""Return the generic events."""
|
|
515
|
+
events: list[GenericEventProtocolAny] = []
|
|
516
|
+
for channel in self._channels.values():
|
|
517
|
+
events.extend(channel.generic_events)
|
|
518
|
+
return tuple(events)
|
|
519
|
+
|
|
520
|
+
@property
|
|
521
|
+
def has_week_profile(self) -> bool:
|
|
522
|
+
"""Return if the device supports week profiles."""
|
|
523
|
+
if self._week_profile is None:
|
|
524
|
+
return False
|
|
525
|
+
return self._week_profile.has_schedule
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def info(self) -> Mapping[str, Any]:
|
|
529
|
+
"""Return the device info."""
|
|
530
|
+
device_info = dict(self.info_payload)
|
|
531
|
+
device_info["central"] = self._central_info.info_payload
|
|
532
|
+
return device_info
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def link_peer_channels(self) -> Mapping[ChannelProtocol, tuple[ChannelProtocol, ...]]:
|
|
536
|
+
"""Return the link peer channels."""
|
|
537
|
+
return {
|
|
538
|
+
channel: channel.link_peer_channels for channel in self._channels.values() if channel.link_peer_channels
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
@state_property
|
|
542
|
+
def available(self) -> bool:
|
|
543
|
+
"""Return the availability of the device."""
|
|
544
|
+
return self._get_is_reachable()
|
|
545
|
+
|
|
546
|
+
@info_property
|
|
547
|
+
def firmware(self) -> str:
|
|
548
|
+
"""Return the firmware of the device."""
|
|
549
|
+
return self._device_description.get("FIRMWARE") or "0.0"
|
|
550
|
+
|
|
551
|
+
@info_property
|
|
552
|
+
def identifier(self) -> str:
|
|
553
|
+
"""Return the identifier of the device."""
|
|
554
|
+
return f"{self._address}{IDENTIFIER_SEPARATOR}{self._interface_id}"
|
|
555
|
+
|
|
556
|
+
@info_property
|
|
557
|
+
def room(self) -> str | None:
|
|
558
|
+
"""Return the room of the device, if only one assigned in the backend."""
|
|
559
|
+
if self._rooms and len(self._rooms) == 1:
|
|
560
|
+
return list(self._rooms)[0]
|
|
561
|
+
if (maintenance_channel := self.get_channel(channel_address=f"{self._address}:0")) is not None:
|
|
562
|
+
return maintenance_channel.room
|
|
563
|
+
return None
|
|
564
|
+
|
|
565
|
+
@hm_property(cached=True)
|
|
566
|
+
def allow_undefined_generic_data_points(self) -> bool:
|
|
567
|
+
"""Return if undefined generic data points of this device are allowed."""
|
|
568
|
+
return bool(
|
|
569
|
+
all(
|
|
570
|
+
channel.custom_data_point.allow_undefined_generic_data_points
|
|
571
|
+
for channel in self._channels.values()
|
|
572
|
+
if channel.custom_data_point is not None
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
@hm_property(cached=True)
|
|
577
|
+
def has_sub_devices(self) -> bool:
|
|
578
|
+
"""Return if device has multiple sub device channels."""
|
|
579
|
+
# If there is only one channel group, no sub devices are needed
|
|
580
|
+
if len(self._group_channels) <= 1:
|
|
581
|
+
return False
|
|
582
|
+
count = 0
|
|
583
|
+
# If there are multiple channel groups with more than one channel, there are sub devices
|
|
584
|
+
for gcs in self._group_channels.values():
|
|
585
|
+
if len(gcs) > 1:
|
|
586
|
+
count += 1
|
|
587
|
+
if count > 1:
|
|
588
|
+
return True
|
|
589
|
+
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
@hm_property(cached=True)
|
|
593
|
+
def relevant_for_central_link_management(self) -> bool:
|
|
594
|
+
"""Return if channel is relevant for central link management."""
|
|
595
|
+
return (
|
|
596
|
+
self._interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED, Interface.HMIP_RF)
|
|
597
|
+
and self._model not in VIRTUAL_REMOTE_MODELS
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
def add_channel_to_group(self, *, group_no: int, channel_no: int | None) -> None:
|
|
601
|
+
"""Add channel to group."""
|
|
602
|
+
if group_no not in self._group_channels:
|
|
603
|
+
self._group_channels[group_no] = set()
|
|
604
|
+
self._group_channels[group_no].add(channel_no)
|
|
605
|
+
|
|
606
|
+
if group_no not in self._channel_to_group:
|
|
607
|
+
self._channel_to_group[group_no] = group_no
|
|
608
|
+
if channel_no not in self._channel_to_group:
|
|
609
|
+
self._channel_to_group[channel_no] = group_no
|
|
610
|
+
|
|
611
|
+
@inspector
|
|
612
|
+
async def create_central_links(self) -> None:
|
|
613
|
+
"""Create a central links to support press events on all channels with click events."""
|
|
614
|
+
if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
|
|
615
|
+
for channel in self._channels.values():
|
|
616
|
+
await channel.create_central_link()
|
|
617
|
+
|
|
618
|
+
@inspector
|
|
619
|
+
async def export_device_definition(self) -> None:
|
|
620
|
+
"""Export the device definition for current device."""
|
|
621
|
+
try:
|
|
622
|
+
device_exporter = _DefinitionExporter(device=self)
|
|
623
|
+
await device_exporter.export_data()
|
|
624
|
+
except Exception as exc:
|
|
625
|
+
raise AioHomematicException(
|
|
626
|
+
i18n.tr(
|
|
627
|
+
key="exception.model.device.export_device_definition.failed",
|
|
628
|
+
reason=extract_exc_args(exc=exc),
|
|
629
|
+
)
|
|
630
|
+
) from exc
|
|
631
|
+
|
|
632
|
+
async def finalize_init(self) -> None:
|
|
633
|
+
"""Finalize the device init action after model setup."""
|
|
634
|
+
await self.load_value_cache()
|
|
635
|
+
for channel in self._channels.values():
|
|
636
|
+
await channel.finalize_init()
|
|
637
|
+
|
|
638
|
+
def get_calculated_data_point(self, *, channel_address: str, parameter: str) -> CalculatedDataPointProtocol | None:
|
|
639
|
+
"""Return a calculated data_point from device."""
|
|
640
|
+
if channel := self.get_channel(channel_address=channel_address):
|
|
641
|
+
return channel.get_calculated_data_point(parameter=parameter)
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
def get_channel(self, *, channel_address: str) -> ChannelProtocol | None:
|
|
645
|
+
"""Get channel of device."""
|
|
646
|
+
return self._channels.get(channel_address)
|
|
647
|
+
|
|
648
|
+
def get_channel_group_no(self, *, channel_no: int | None) -> int | None:
|
|
649
|
+
"""Return the group no of the channel."""
|
|
650
|
+
return self._channel_to_group.get(channel_no)
|
|
651
|
+
|
|
652
|
+
def get_custom_data_point(self, *, channel_no: int) -> hmce.CustomDataPoint | None:
|
|
653
|
+
"""Return a custom data_point from device."""
|
|
654
|
+
if channel := self.get_channel(
|
|
655
|
+
channel_address=get_channel_address(device_address=self._address, channel_no=channel_no)
|
|
656
|
+
):
|
|
657
|
+
return cast(hmce.CustomDataPoint | None, channel.custom_data_point)
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
def get_data_points(
|
|
661
|
+
self,
|
|
662
|
+
*,
|
|
663
|
+
category: DataPointCategory | None = None,
|
|
664
|
+
exclude_no_create: bool = True,
|
|
665
|
+
registered: bool | None = None,
|
|
666
|
+
) -> tuple[CallbackDataPointProtocol, ...]:
|
|
667
|
+
"""Get all data points of the device."""
|
|
668
|
+
all_data_points: list[CallbackDataPointProtocol] = []
|
|
669
|
+
if (
|
|
670
|
+
self._update_data_point
|
|
671
|
+
and (category is None or self._update_data_point.category == category)
|
|
672
|
+
and (
|
|
673
|
+
(exclude_no_create and self._update_data_point.usage != DataPointUsage.NO_CREATE)
|
|
674
|
+
or exclude_no_create is False
|
|
675
|
+
)
|
|
676
|
+
and (registered is None or self._update_data_point.is_registered == registered)
|
|
677
|
+
):
|
|
678
|
+
all_data_points.append(self._update_data_point)
|
|
679
|
+
for channel in self._channels.values():
|
|
680
|
+
all_data_points.extend(
|
|
681
|
+
channel.get_data_points(category=category, exclude_no_create=exclude_no_create, registered=registered)
|
|
682
|
+
)
|
|
683
|
+
return tuple(all_data_points)
|
|
684
|
+
|
|
685
|
+
def get_events(
|
|
686
|
+
self, *, event_type: DeviceTriggerEventType, registered: bool | None = None
|
|
687
|
+
) -> Mapping[int | None, tuple[GenericEventProtocolAny, ...]]:
|
|
688
|
+
"""Return a list of specific events of a channel."""
|
|
689
|
+
events: dict[int | None, tuple[GenericEventProtocolAny, ...]] = {}
|
|
690
|
+
for channel in self._channels.values():
|
|
691
|
+
if (values := channel.get_events(event_type=event_type, registered=registered)) and len(values) > 0:
|
|
692
|
+
events[channel.no] = values
|
|
693
|
+
return events
|
|
694
|
+
|
|
695
|
+
def get_generic_data_point(
|
|
696
|
+
self,
|
|
697
|
+
*,
|
|
698
|
+
channel_address: str | None = None,
|
|
699
|
+
parameter: str | None = None,
|
|
700
|
+
paramset_key: ParamsetKey | None = None,
|
|
701
|
+
state_path: str | None = None,
|
|
702
|
+
) -> GenericDataPointProtocolAny | None:
|
|
703
|
+
"""Return a generic data_point from device."""
|
|
704
|
+
if channel_address is None:
|
|
705
|
+
for ch in self._channels.values():
|
|
706
|
+
if dp := ch.get_generic_data_point(
|
|
707
|
+
parameter=parameter, paramset_key=paramset_key, state_path=state_path
|
|
708
|
+
):
|
|
709
|
+
return dp
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
if channel := self.get_channel(channel_address=channel_address):
|
|
713
|
+
return channel.get_generic_data_point(parameter=parameter, paramset_key=paramset_key, state_path=state_path)
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
def get_generic_event(
|
|
717
|
+
self, *, channel_address: str | None = None, parameter: str | None = None, state_path: str | None = None
|
|
718
|
+
) -> GenericEventProtocolAny | None:
|
|
719
|
+
"""Return a generic event from device."""
|
|
720
|
+
if channel_address is None:
|
|
721
|
+
for ch in self._channels.values():
|
|
722
|
+
if event := ch.get_generic_event(parameter=parameter, state_path=state_path):
|
|
723
|
+
return event
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
if channel := self.get_channel(channel_address=channel_address):
|
|
727
|
+
return channel.get_generic_event(parameter=parameter, state_path=state_path)
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPointProtocolAny, ...]:
|
|
731
|
+
"""Return the list of readable master data points."""
|
|
732
|
+
data_points: list[GenericDataPointProtocolAny] = []
|
|
733
|
+
for channel in self._channels.values():
|
|
734
|
+
data_points.extend(channel.get_readable_data_points(paramset_key=paramset_key))
|
|
735
|
+
return tuple(data_points)
|
|
736
|
+
|
|
737
|
+
def identify_channel(self, *, text: str) -> ChannelProtocol | None:
|
|
738
|
+
"""Identify channel within a text."""
|
|
739
|
+
for channel_address, channel in self._channels.items():
|
|
740
|
+
if text.endswith(channel_address):
|
|
741
|
+
return channel
|
|
742
|
+
if str(channel.rega_id) in text:
|
|
743
|
+
return channel
|
|
744
|
+
if str(channel.device.rega_id) in text:
|
|
745
|
+
return channel
|
|
746
|
+
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
def init_week_profile(self, *, data_point: CustomDataPointProtocol) -> None:
|
|
750
|
+
"""Initialize the device schedule."""
|
|
751
|
+
# Only initialize if week_profile supports schedule
|
|
752
|
+
if (
|
|
753
|
+
self._week_profile is None
|
|
754
|
+
and (week_profile := wp.create_week_profile(data_point=data_point)) is not None
|
|
755
|
+
and week_profile.has_schedule
|
|
756
|
+
):
|
|
757
|
+
self._week_profile = week_profile
|
|
758
|
+
|
|
759
|
+
def is_in_multi_channel_group(self, *, channel_no: int | None) -> bool:
|
|
760
|
+
"""Return if multiple channels are in the group."""
|
|
761
|
+
if channel_no is None:
|
|
762
|
+
return False
|
|
763
|
+
|
|
764
|
+
return len([s for s, m in self._channel_to_group.items() if m == self._channel_to_group.get(channel_no)]) > 1
|
|
765
|
+
|
|
766
|
+
@inspector(scope=ServiceScope.INTERNAL)
|
|
767
|
+
async def load_value_cache(self) -> None:
|
|
768
|
+
"""Initialize the parameter cache."""
|
|
769
|
+
if len(self.generic_data_points) > 0:
|
|
770
|
+
await self._value_cache.init_base_data_points()
|
|
771
|
+
if len(self.generic_events) > 0:
|
|
772
|
+
await self._value_cache.init_readable_events()
|
|
773
|
+
|
|
774
|
+
async def on_config_changed(self) -> None:
|
|
775
|
+
"""Do what is needed on device config change."""
|
|
776
|
+
for channel in self._channels.values():
|
|
777
|
+
await channel.on_config_changed()
|
|
778
|
+
if self._update_data_point:
|
|
779
|
+
await self._update_data_point.on_config_changed()
|
|
780
|
+
|
|
781
|
+
if self._week_profile:
|
|
782
|
+
await self._week_profile.reload_and_cache_schedule()
|
|
783
|
+
|
|
784
|
+
await self._file_operations.save_files(save_paramset_descriptions=True)
|
|
785
|
+
self.publish_device_updated_event()
|
|
786
|
+
|
|
787
|
+
@loop_check
|
|
788
|
+
def publish_device_updated_event(self, *, notify_data_points: bool = False) -> None:
|
|
789
|
+
"""
|
|
790
|
+
Do what is needed when the state of the device has been updated.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
notify_data_points: If True, also notify all data points so entities
|
|
794
|
+
refresh their state. This is needed for availability changes where
|
|
795
|
+
UN_REACH on channel :0 affects entities on other channels.
|
|
796
|
+
|
|
797
|
+
"""
|
|
798
|
+
self._set_modified_at()
|
|
799
|
+
|
|
800
|
+
# Notify all data points so entities refresh their availability state.
|
|
801
|
+
# Entities subscribe to their own data point's updated event, not to device events.
|
|
802
|
+
if notify_data_points:
|
|
803
|
+
for dp in self.generic_data_points:
|
|
804
|
+
dp.publish_data_point_updated_event()
|
|
805
|
+
|
|
806
|
+
# Publish to EventBus asynchronously
|
|
807
|
+
async def _publish_device_updated() -> None:
|
|
808
|
+
await self._event_bus_provider.event_bus.publish(
|
|
809
|
+
event=DeviceStateChangedEvent(
|
|
810
|
+
timestamp=datetime.now(),
|
|
811
|
+
device_address=self._address,
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
self._task_scheduler.create_task(
|
|
816
|
+
target=_publish_device_updated,
|
|
817
|
+
name=f"device-updated-{self._address}",
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def refresh_firmware_data(self) -> None:
|
|
821
|
+
"""Refresh firmware data of the device."""
|
|
822
|
+
old_available_firmware = self.available_firmware
|
|
823
|
+
old_firmware = self.firmware
|
|
824
|
+
old_firmware_update_state = self.firmware_update_state
|
|
825
|
+
old_firmware_updatable = self.firmware_updatable
|
|
826
|
+
|
|
827
|
+
self._device_description = self._device_description_provider.get_device_description(
|
|
828
|
+
interface_id=self._interface_id, address=self._address
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
if (
|
|
832
|
+
old_available_firmware != self.available_firmware
|
|
833
|
+
or old_firmware != self.firmware
|
|
834
|
+
or old_firmware_update_state != self.firmware_update_state
|
|
835
|
+
or old_firmware_updatable != self.firmware_updatable
|
|
836
|
+
):
|
|
837
|
+
# Publish to EventBus asynchronously
|
|
838
|
+
async def _publish_firmware_updated() -> None:
|
|
839
|
+
await self._event_bus_provider.event_bus.publish(
|
|
840
|
+
event=FirmwareStateChangedEvent(
|
|
841
|
+
timestamp=datetime.now(),
|
|
842
|
+
device_address=self._address,
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
self._task_scheduler.create_task(
|
|
847
|
+
target=_publish_firmware_updated,
|
|
848
|
+
name=f"firmware-updated-{self._address}",
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
def remove(self) -> None:
|
|
852
|
+
"""Remove data points from collections and central."""
|
|
853
|
+
for channel in self._channels.values():
|
|
854
|
+
channel.remove()
|
|
855
|
+
|
|
856
|
+
@inspector
|
|
857
|
+
async def remove_central_links(self) -> None:
|
|
858
|
+
"""Remove central links."""
|
|
859
|
+
if self.relevant_for_central_link_management: # pylint: disable=using-constant-test
|
|
860
|
+
for channel in self._channels.values():
|
|
861
|
+
await channel.remove_central_link()
|
|
862
|
+
|
|
863
|
+
@inspector
|
|
864
|
+
async def rename(self, *, new_name: str) -> bool:
|
|
865
|
+
"""Rename the device on the CCU."""
|
|
866
|
+
return await self._client.rename_device(rega_id=self._rega_id, new_name=new_name)
|
|
867
|
+
|
|
868
|
+
def set_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
|
|
869
|
+
"""Set the availability of the device."""
|
|
870
|
+
if self._forced_availability != forced_availability:
|
|
871
|
+
self._forced_availability = forced_availability
|
|
872
|
+
for dp in self.generic_data_points:
|
|
873
|
+
dp.publish_data_point_updated_event()
|
|
874
|
+
|
|
875
|
+
def subscribe_to_device_updated(self, *, handler: DeviceUpdatedHandler) -> UnsubscribeCallback:
|
|
876
|
+
"""Subscribe update handler."""
|
|
877
|
+
|
|
878
|
+
# Create adapter that filters for this device's events
|
|
879
|
+
def event_handler(*, event: DeviceStateChangedEvent) -> None:
|
|
880
|
+
if event.device_address == self._address:
|
|
881
|
+
handler()
|
|
882
|
+
|
|
883
|
+
return self._event_bus_provider.event_bus.subscribe(
|
|
884
|
+
event_type=DeviceStateChangedEvent,
|
|
885
|
+
event_key=self._address,
|
|
886
|
+
handler=event_handler,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
def subscribe_to_firmware_updated(self, *, handler: FirmwareUpdateHandler) -> UnsubscribeCallback:
|
|
890
|
+
"""Subscribe firmware update handler."""
|
|
891
|
+
|
|
892
|
+
# Create adapter that filters for this device's events
|
|
893
|
+
def event_handler(*, event: FirmwareStateChangedEvent) -> None:
|
|
894
|
+
if event.device_address == self._address:
|
|
895
|
+
handler()
|
|
896
|
+
|
|
897
|
+
return self._event_bus_provider.event_bus.subscribe(
|
|
898
|
+
event_type=FirmwareStateChangedEvent,
|
|
899
|
+
event_key=self._address,
|
|
900
|
+
handler=event_handler,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
@inspector
|
|
904
|
+
async def update_firmware(self, *, refresh_after_update_intervals: tuple[int, ...]) -> bool:
|
|
905
|
+
"""Update the firmware of the Homematic device."""
|
|
906
|
+
update_result = await self._client.update_device_firmware(device_address=self._address)
|
|
907
|
+
|
|
908
|
+
async def refresh_data() -> None:
|
|
909
|
+
for refresh_interval in refresh_after_update_intervals:
|
|
910
|
+
await asyncio.sleep(refresh_interval)
|
|
911
|
+
await self._device_data_refresher.refresh_firmware_data(device_address=self._address)
|
|
912
|
+
|
|
913
|
+
if refresh_after_update_intervals:
|
|
914
|
+
self._task_scheduler.create_task(target=refresh_data, name="refresh_firmware_data")
|
|
915
|
+
|
|
916
|
+
return update_result
|
|
917
|
+
|
|
918
|
+
def _get_battery_level(self) -> int | None:
|
|
919
|
+
"""Return battery level percentage (0-100)."""
|
|
920
|
+
# First try OperatingVoltageLevel calculated data point on channel 0
|
|
921
|
+
if (
|
|
922
|
+
ovl := self.get_calculated_data_point(
|
|
923
|
+
channel_address=f"{self._address}:0",
|
|
924
|
+
parameter=CalculatedParameter.OPERATING_VOLTAGE_LEVEL,
|
|
925
|
+
)
|
|
926
|
+
) is not None and ovl.value is not None:
|
|
927
|
+
return int(ovl.value)
|
|
928
|
+
|
|
929
|
+
# Fallback to BATTERY_STATE if available (channel 0)
|
|
930
|
+
# BATTERY_STATE is typically 0-100 percentage or voltage
|
|
931
|
+
# If value > 10, assume it's already a percentage
|
|
932
|
+
# If value <= 10, assume it's voltage and can't be converted without battery info
|
|
933
|
+
if (
|
|
934
|
+
(
|
|
935
|
+
dp := self.get_generic_data_point(
|
|
936
|
+
channel_address=f"{self._address}:0",
|
|
937
|
+
parameter=Parameter.BATTERY_STATE,
|
|
938
|
+
)
|
|
939
|
+
)
|
|
940
|
+
is not None
|
|
941
|
+
and dp.value is not None
|
|
942
|
+
and (value := float(dp.value)) > 10
|
|
943
|
+
):
|
|
944
|
+
return int(value)
|
|
945
|
+
return None
|
|
946
|
+
|
|
947
|
+
def _get_is_reachable(self) -> bool:
|
|
948
|
+
"""Return if device is reachable."""
|
|
949
|
+
if self._forced_availability != ForcedDeviceAvailability.NOT_SET:
|
|
950
|
+
return self._forced_availability == ForcedDeviceAvailability.FORCE_TRUE
|
|
951
|
+
if (un_reach := self._dp_un_reach) is None:
|
|
952
|
+
un_reach = self._dp_sticky_un_reach
|
|
953
|
+
if un_reach is not None and un_reach.value is not None:
|
|
954
|
+
return not un_reach.value
|
|
955
|
+
return True
|
|
956
|
+
|
|
957
|
+
def _get_last_updated(self) -> datetime | None:
|
|
958
|
+
"""Return the most recent data point modification time."""
|
|
959
|
+
latest = INIT_DATETIME
|
|
960
|
+
for channel in self._channels.values():
|
|
961
|
+
for dp in channel.get_data_points(exclude_no_create=False):
|
|
962
|
+
latest = max(latest, dp.modified_at)
|
|
963
|
+
return latest if latest > INIT_DATETIME else None
|
|
964
|
+
|
|
965
|
+
def _get_low_battery(self) -> bool | None:
|
|
966
|
+
"""Return low battery indicator from LOW_BAT parameter."""
|
|
967
|
+
# Check channels 0, 1, 2 for LOW_BAT (different devices use different channels)
|
|
968
|
+
for channel_no in (0, 1, 2):
|
|
969
|
+
if (
|
|
970
|
+
dp := self.get_generic_data_point(
|
|
971
|
+
channel_address=f"{self._address}:{channel_no}",
|
|
972
|
+
parameter=Parameter.LOW_BAT,
|
|
973
|
+
)
|
|
974
|
+
) is not None and dp.value is not None:
|
|
975
|
+
return dp.value is True
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
def _get_signal_strength(self) -> int | None:
|
|
979
|
+
"""Return signal strength in dBm from RSSI_DEVICE."""
|
|
980
|
+
if (
|
|
981
|
+
dp := self.get_generic_data_point(
|
|
982
|
+
channel_address=f"{self._address}:0",
|
|
983
|
+
parameter=Parameter.RSSI_DEVICE,
|
|
984
|
+
)
|
|
985
|
+
) is not None and dp.value is not None:
|
|
986
|
+
return int(dp.value)
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
def _identify_manufacturer(self) -> Manufacturer:
|
|
990
|
+
"""Identify the manufacturer of a device."""
|
|
991
|
+
if self._model.lower().startswith("hb"):
|
|
992
|
+
return Manufacturer.HB
|
|
993
|
+
if self._model.lower().startswith("alpha"):
|
|
994
|
+
return Manufacturer.MOEHLENHOFF
|
|
995
|
+
return Manufacturer.EQ3
|
|
996
|
+
|
|
997
|
+
def _set_modified_at(self) -> None:
|
|
998
|
+
self._modified_at = datetime.now()
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
class Channel(ChannelProtocol, LogContextMixin, PayloadMixin):
|
|
1002
|
+
"""
|
|
1003
|
+
Represent a device channel containing data points and events.
|
|
1004
|
+
|
|
1005
|
+
Channels group related parameters and provide the organizational structure
|
|
1006
|
+
for data points within a device. Each channel has a unique address (e.g.,
|
|
1007
|
+
``VCU0000001:1``) and contains generic, custom, and calculated data points.
|
|
1008
|
+
|
|
1009
|
+
Responsibilities
|
|
1010
|
+
----------------
|
|
1011
|
+
1. **Data Point Storage**: Stores generic, custom, and calculated data points.
|
|
1012
|
+
2. **Event Storage**: Stores generic events (KEYPRESS, MOTION, etc.).
|
|
1013
|
+
3. **Grouping**: Multi-channel grouping support (e.g., for multi-gang switches).
|
|
1014
|
+
4. **Link Peers**: Link partner management for peer-to-peer communication.
|
|
1015
|
+
5. **Paramset Access**: Access to VALUES and MASTER paramset descriptions.
|
|
1016
|
+
|
|
1017
|
+
Access pattern
|
|
1018
|
+
--------------
|
|
1019
|
+
Channels access protocol interfaces through their parent device:
|
|
1020
|
+
``self._device.event_bus_provider``, ``self._device.client``, etc.
|
|
1021
|
+
|
|
1022
|
+
Protocol compliance
|
|
1023
|
+
-------------------
|
|
1024
|
+
Implements ``ChannelProtocol`` which is a composite of sub-protocols:
|
|
1025
|
+
|
|
1026
|
+
- ``ChannelIdentityProtocol``: Basic identification (address, name, no, type_name)
|
|
1027
|
+
- ``ChannelDataPointAccessProtocol``: DataPoint and event access methods
|
|
1028
|
+
- ``ChannelGroupingProtocol``: Channel group management (group_master, link_peer_channels)
|
|
1029
|
+
- ``ChannelMetadataProtocol``: Additional metadata (device, function, room, paramset_descriptions)
|
|
1030
|
+
- ``ChannelLinkManagementProtocol``: Central link operations
|
|
1031
|
+
- ``ChannelLifecycleProtocol``: Lifecycle methods (finalize_init, on_config_changed, remove)
|
|
1032
|
+
|
|
1033
|
+
Consumers can depend on specific sub-protocols for narrower contracts.
|
|
1034
|
+
"""
|
|
1035
|
+
|
|
1036
|
+
__slots__ = (
|
|
1037
|
+
"_address",
|
|
1038
|
+
"_cached_group_master",
|
|
1039
|
+
"_cached_group_no",
|
|
1040
|
+
"_cached_is_in_multi_group",
|
|
1041
|
+
"_calculated_data_points",
|
|
1042
|
+
"_custom_data_point",
|
|
1043
|
+
"_channel_description",
|
|
1044
|
+
"_device",
|
|
1045
|
+
"_function",
|
|
1046
|
+
"_generic_data_points",
|
|
1047
|
+
"_generic_events",
|
|
1048
|
+
"_rega_id",
|
|
1049
|
+
"_is_schedule_channel",
|
|
1050
|
+
"_link_peer_addresses",
|
|
1051
|
+
"_link_source_categories",
|
|
1052
|
+
"_link_source_roles",
|
|
1053
|
+
"_link_target_categories",
|
|
1054
|
+
"_link_target_roles",
|
|
1055
|
+
"_modified_at",
|
|
1056
|
+
"_name_data",
|
|
1057
|
+
"_no",
|
|
1058
|
+
"_paramset_keys",
|
|
1059
|
+
"_rooms",
|
|
1060
|
+
"_state_path_to_dpk",
|
|
1061
|
+
"_type_name",
|
|
1062
|
+
"_unique_id",
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
def __init__(self, *, device: DeviceProtocol, channel_address: str) -> None:
|
|
1066
|
+
"""Initialize the channel object."""
|
|
1067
|
+
PayloadMixin.__init__(self)
|
|
1068
|
+
|
|
1069
|
+
self._device: Final = device
|
|
1070
|
+
self._address: Final = channel_address
|
|
1071
|
+
self._rega_id: Final = self._device.device_details_provider.get_address_id(address=channel_address)
|
|
1072
|
+
self._no: Final[int | None] = get_channel_no(address=channel_address)
|
|
1073
|
+
self._name_data: Final = get_channel_name_data(channel=self)
|
|
1074
|
+
self._channel_description: DeviceDescription = self._device.device_description_provider.get_device_description(
|
|
1075
|
+
interface_id=self._device.interface_id, address=channel_address
|
|
1076
|
+
)
|
|
1077
|
+
self._type_name: Final[str] = self._channel_description["TYPE"]
|
|
1078
|
+
self._is_schedule_channel: Final[bool] = WEEK_PROFILE_PATTERN.match(self._type_name) is not None
|
|
1079
|
+
self._paramset_keys: Final = tuple(
|
|
1080
|
+
ParamsetKey(paramset_key) for paramset_key in self._channel_description["PARAMSETS"]
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
self._unique_id: Final = generate_channel_unique_id(
|
|
1084
|
+
config_provider=self._device.config_provider, address=channel_address
|
|
1085
|
+
)
|
|
1086
|
+
self._calculated_data_points: Final[dict[DataPointKey, CalculatedDataPointProtocol]] = {}
|
|
1087
|
+
self._custom_data_point: hmce.CustomDataPoint | None = None
|
|
1088
|
+
self._generic_data_points: Final[dict[DataPointKey, GenericDataPointProtocolAny]] = {}
|
|
1089
|
+
self._generic_events: Final[dict[DataPointKey, GenericEventProtocolAny]] = {}
|
|
1090
|
+
self._state_path_to_dpk: Final[dict[str, DataPointKey]] = {}
|
|
1091
|
+
self._link_peer_addresses: tuple[str, ...] = ()
|
|
1092
|
+
self._link_source_roles: tuple[str, ...] = (
|
|
1093
|
+
tuple(source_roles.split(" "))
|
|
1094
|
+
if (source_roles := self._channel_description.get("LINK_SOURCE_ROLES"))
|
|
1095
|
+
else ()
|
|
1096
|
+
)
|
|
1097
|
+
self._link_source_categories: Final = get_link_source_categories(
|
|
1098
|
+
source_roles=self._link_source_roles, channel_type_name=self._type_name
|
|
1099
|
+
)
|
|
1100
|
+
self._link_target_roles: tuple[str, ...] = (
|
|
1101
|
+
tuple(target_roles.split(" "))
|
|
1102
|
+
if (target_roles := self._channel_description.get("LINK_TARGET_ROLES"))
|
|
1103
|
+
else ()
|
|
1104
|
+
)
|
|
1105
|
+
self._link_target_categories: Final = get_link_target_categories(
|
|
1106
|
+
target_roles=self._link_target_roles, channel_type_name=self._type_name
|
|
1107
|
+
)
|
|
1108
|
+
self._modified_at: datetime = INIT_DATETIME
|
|
1109
|
+
self._rooms: Final = self._device.device_details_provider.get_channel_rooms(channel_address=channel_address)
|
|
1110
|
+
self._function: Final = self._device.device_details_provider.get_function_text(address=self._address)
|
|
1111
|
+
self.init_channel()
|
|
1112
|
+
|
|
1113
|
+
def __str__(self) -> str:
|
|
1114
|
+
"""Provide some useful information."""
|
|
1115
|
+
return (
|
|
1116
|
+
f"address: {self._address}, "
|
|
1117
|
+
f"type: {self._type_name}, "
|
|
1118
|
+
f"generic dps: {len(self._generic_data_points)}, "
|
|
1119
|
+
f"calculated dps: {len(self._calculated_data_points)}, "
|
|
1120
|
+
f"custom dp: {self._custom_data_point is not None}, "
|
|
1121
|
+
f"events: {len(self._generic_events)}"
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
address: Final = DelegatedProperty[str](path="_address", kind=Kind.INFO)
|
|
1125
|
+
custom_data_point: Final = DelegatedProperty[hmce.CustomDataPoint | None](path="_custom_data_point")
|
|
1126
|
+
description: Final = DelegatedProperty[DeviceDescription](path="_channel_description")
|
|
1127
|
+
device: Final = DelegatedProperty[DeviceProtocol](path="_device", log_context=True)
|
|
1128
|
+
full_name: Final = DelegatedProperty[str](path="_name_data.full_name")
|
|
1129
|
+
function: Final = DelegatedProperty[str | None](path="_function")
|
|
1130
|
+
is_schedule_channel: Final = DelegatedProperty[bool](path="_is_schedule_channel")
|
|
1131
|
+
link_peer_addresses: Final = DelegatedProperty[tuple[str, ...]](path="_link_peer_addresses")
|
|
1132
|
+
link_peer_source_categories: Final = DelegatedProperty[tuple[str, ...]](path="_link_source_categories")
|
|
1133
|
+
link_peer_target_categories: Final = DelegatedProperty[tuple[str, ...]](path="_link_target_categories")
|
|
1134
|
+
name: Final = DelegatedProperty[str](path="_name_data.channel_name")
|
|
1135
|
+
name_data: Final = DelegatedProperty[ChannelNameData](path="_name_data")
|
|
1136
|
+
no: Final = DelegatedProperty[int | None](path="_no", log_context=True)
|
|
1137
|
+
paramset_keys: Final = DelegatedProperty[tuple[ParamsetKey, ...]](path="_paramset_keys")
|
|
1138
|
+
rega_id: Final = DelegatedProperty[int](path="_rega_id")
|
|
1139
|
+
rooms: Final = DelegatedProperty[set[str]](path="_rooms")
|
|
1140
|
+
type_name: Final = DelegatedProperty[str](path="_type_name")
|
|
1141
|
+
unique_id: Final = DelegatedProperty[str](path="_unique_id")
|
|
1142
|
+
|
|
1143
|
+
@property
|
|
1144
|
+
def _has_key_press_events(self) -> bool:
|
|
1145
|
+
"""Return if channel has KEYPRESS events."""
|
|
1146
|
+
return any(event for event in self.generic_events if event.event_type is DeviceTriggerEventType.KEYPRESS)
|
|
1147
|
+
|
|
1148
|
+
@property
|
|
1149
|
+
def calculated_data_points(self) -> tuple[CalculatedDataPointProtocol, ...]:
|
|
1150
|
+
"""Return the generic data points."""
|
|
1151
|
+
return tuple(self._calculated_data_points.values())
|
|
1152
|
+
|
|
1153
|
+
@property
|
|
1154
|
+
def data_point_paths(self) -> tuple[str, ...]:
|
|
1155
|
+
"""Return the data point paths."""
|
|
1156
|
+
return tuple(self._state_path_to_dpk.keys())
|
|
1157
|
+
|
|
1158
|
+
@property
|
|
1159
|
+
def generic_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
|
|
1160
|
+
"""Return the generic data points."""
|
|
1161
|
+
return tuple(self._generic_data_points.values())
|
|
1162
|
+
|
|
1163
|
+
@property
|
|
1164
|
+
def generic_events(self) -> tuple[GenericEventProtocolAny, ...]:
|
|
1165
|
+
"""Return the generic events."""
|
|
1166
|
+
return tuple(self._generic_events.values())
|
|
1167
|
+
|
|
1168
|
+
@property
|
|
1169
|
+
def is_group_master(self) -> bool:
|
|
1170
|
+
"""Return if group master of channel."""
|
|
1171
|
+
return self.group_no == self._no
|
|
1172
|
+
|
|
1173
|
+
@property
|
|
1174
|
+
def link_peer_channels(self) -> tuple[ChannelProtocol, ...]:
|
|
1175
|
+
"""Return the link peer channel."""
|
|
1176
|
+
return tuple(
|
|
1177
|
+
channel
|
|
1178
|
+
for address in self._link_peer_addresses
|
|
1179
|
+
if self._link_peer_addresses
|
|
1180
|
+
and (channel := self._device.channel_lookup.get_channel(channel_address=address)) is not None
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
@property
|
|
1184
|
+
def operation_mode(self) -> str | None:
|
|
1185
|
+
"""Return the channel operation mode if available."""
|
|
1186
|
+
if (
|
|
1187
|
+
cop := self.get_generic_data_point(parameter=Parameter.CHANNEL_OPERATION_MODE)
|
|
1188
|
+
) is not None and cop.value is not None:
|
|
1189
|
+
return str(cop.value)
|
|
1190
|
+
return None
|
|
1191
|
+
|
|
1192
|
+
@property
|
|
1193
|
+
def paramset_descriptions(self) -> Mapping[ParamsetKey, Mapping[str, ParameterData]]:
|
|
1194
|
+
"""Return the paramset descriptions of the channel."""
|
|
1195
|
+
return self._device.paramset_description_provider.get_channel_paramset_descriptions(
|
|
1196
|
+
interface_id=self._device.interface_id, channel_address=self._address
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
@info_property
|
|
1200
|
+
def room(self) -> str | None:
|
|
1201
|
+
"""Return the room of the device, if only one assigned in the backend."""
|
|
1202
|
+
if self._rooms and len(self._rooms) == 1:
|
|
1203
|
+
return list(self._rooms)[0]
|
|
1204
|
+
if self.is_group_master:
|
|
1205
|
+
return None
|
|
1206
|
+
if (master_channel := self.group_master) is not None:
|
|
1207
|
+
return master_channel.room
|
|
1208
|
+
return None
|
|
1209
|
+
|
|
1210
|
+
@hm_property(cached=True)
|
|
1211
|
+
def group_master(self) -> ChannelProtocol | None:
|
|
1212
|
+
"""Return the master channel of the group."""
|
|
1213
|
+
if self.group_no is None:
|
|
1214
|
+
return None
|
|
1215
|
+
return (
|
|
1216
|
+
self
|
|
1217
|
+
if self.is_group_master
|
|
1218
|
+
else self._device.get_channel(channel_address=f"{self._device.address}:{self.group_no}")
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
@hm_property(cached=True)
|
|
1222
|
+
def group_no(self) -> int | None:
|
|
1223
|
+
"""Return the no of the channel group."""
|
|
1224
|
+
return self._device.get_channel_group_no(channel_no=self._no)
|
|
1225
|
+
|
|
1226
|
+
@hm_property(cached=True)
|
|
1227
|
+
def is_in_multi_group(self) -> bool:
|
|
1228
|
+
"""Return if multiple channels are in the group."""
|
|
1229
|
+
return self._device.is_in_multi_channel_group(channel_no=self._no)
|
|
1230
|
+
|
|
1231
|
+
def add_data_point(self, *, data_point: CallbackDataPointProtocol) -> None:
|
|
1232
|
+
"""Add a data_point to a channel."""
|
|
1233
|
+
if isinstance(data_point, BaseParameterDataPointProtocol):
|
|
1234
|
+
self._device.event_subscription_manager.add_data_point_subscription(data_point=data_point)
|
|
1235
|
+
self._state_path_to_dpk[data_point.state_path] = data_point.dpk
|
|
1236
|
+
if isinstance(data_point, CalculatedDataPointProtocol):
|
|
1237
|
+
self._calculated_data_points[data_point.dpk] = data_point
|
|
1238
|
+
if isinstance(data_point, GenericDataPointProtocol):
|
|
1239
|
+
self._generic_data_points[data_point.dpk] = data_point
|
|
1240
|
+
if isinstance(data_point, hmce.CustomDataPoint):
|
|
1241
|
+
self._custom_data_point = data_point
|
|
1242
|
+
if isinstance(data_point, GenericEventProtocol):
|
|
1243
|
+
self._generic_events[data_point.dpk] = data_point
|
|
1244
|
+
|
|
1245
|
+
@inspector(scope=ServiceScope.INTERNAL)
|
|
1246
|
+
async def cleanup_central_link_metadata(self) -> None:
|
|
1247
|
+
"""Cleanup the metadata for central links."""
|
|
1248
|
+
if metadata := await self._device.client.get_metadata(address=self._address, data_id=REPORT_VALUE_USAGE_DATA):
|
|
1249
|
+
await self._device.client.set_metadata(
|
|
1250
|
+
address=self._address,
|
|
1251
|
+
data_id=REPORT_VALUE_USAGE_DATA,
|
|
1252
|
+
value={key: value for key, value in metadata.items() if key in CLICK_EVENTS},
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
@inspector(scope=ServiceScope.INTERNAL)
|
|
1256
|
+
async def create_central_link(self) -> None:
|
|
1257
|
+
"""Create a central link to support press events."""
|
|
1258
|
+
if self._has_key_press_events and not await self._has_central_link():
|
|
1259
|
+
await self._device.client.report_value_usage(
|
|
1260
|
+
address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=1
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
async def finalize_init(self) -> None:
|
|
1264
|
+
"""Finalize the channel init action after model setup."""
|
|
1265
|
+
for ge in self._generic_data_points.values():
|
|
1266
|
+
await ge.finalize_init()
|
|
1267
|
+
for gev in self._generic_events.values():
|
|
1268
|
+
await gev.finalize_init()
|
|
1269
|
+
for cdp in self._calculated_data_points.values():
|
|
1270
|
+
await cdp.finalize_init()
|
|
1271
|
+
if self._custom_data_point:
|
|
1272
|
+
await self._custom_data_point.finalize_init()
|
|
1273
|
+
|
|
1274
|
+
def get_calculated_data_point(self, *, parameter: str) -> CalculatedDataPointProtocol | None:
|
|
1275
|
+
"""Return a calculated data_point from device."""
|
|
1276
|
+
return self._calculated_data_points.get(
|
|
1277
|
+
DataPointKey(
|
|
1278
|
+
interface_id=self._device.interface_id,
|
|
1279
|
+
channel_address=self._address,
|
|
1280
|
+
paramset_key=ParamsetKey.CALCULATED,
|
|
1281
|
+
parameter=parameter,
|
|
1282
|
+
)
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
def get_data_points(
|
|
1286
|
+
self,
|
|
1287
|
+
*,
|
|
1288
|
+
category: DataPointCategory | None = None,
|
|
1289
|
+
exclude_no_create: bool = True,
|
|
1290
|
+
registered: bool | None = None,
|
|
1291
|
+
) -> tuple[CallbackDataPointProtocol, ...]:
|
|
1292
|
+
"""Get all data points of the device."""
|
|
1293
|
+
all_data_points: list[CallbackDataPointProtocol] = list(self._generic_data_points.values()) + list(
|
|
1294
|
+
self._calculated_data_points.values()
|
|
1295
|
+
)
|
|
1296
|
+
if self._custom_data_point:
|
|
1297
|
+
all_data_points.append(self._custom_data_point)
|
|
1298
|
+
|
|
1299
|
+
return tuple(
|
|
1300
|
+
dp
|
|
1301
|
+
for dp in all_data_points
|
|
1302
|
+
if dp is not None
|
|
1303
|
+
and (category is None or dp.category == category)
|
|
1304
|
+
and ((exclude_no_create and dp.usage != DataPointUsage.NO_CREATE) or exclude_no_create is False)
|
|
1305
|
+
and (registered is None or dp.is_registered == registered)
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
def get_events(
|
|
1309
|
+
self, *, event_type: DeviceTriggerEventType, registered: bool | None = None
|
|
1310
|
+
) -> tuple[GenericEventProtocolAny, ...]:
|
|
1311
|
+
"""Return a list of specific events of a channel."""
|
|
1312
|
+
return tuple(
|
|
1313
|
+
event
|
|
1314
|
+
for event in self._generic_events.values()
|
|
1315
|
+
if (event.event_type == event_type and (registered is None or event.is_registered == registered))
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
def get_generic_data_point(
|
|
1319
|
+
self, *, parameter: str | None = None, paramset_key: ParamsetKey | None = None, state_path: str | None = None
|
|
1320
|
+
) -> GenericDataPointProtocolAny | None:
|
|
1321
|
+
"""Return a generic data_point from device."""
|
|
1322
|
+
if state_path is not None and (dpk := self._state_path_to_dpk.get(state_path)) is not None:
|
|
1323
|
+
return self._generic_data_points.get(dpk)
|
|
1324
|
+
if parameter is None:
|
|
1325
|
+
return None
|
|
1326
|
+
if paramset_key:
|
|
1327
|
+
return self._generic_data_points.get(
|
|
1328
|
+
DataPointKey(
|
|
1329
|
+
interface_id=self._device.interface_id,
|
|
1330
|
+
channel_address=self._address,
|
|
1331
|
+
paramset_key=paramset_key,
|
|
1332
|
+
parameter=parameter,
|
|
1333
|
+
)
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
if dp := self._generic_data_points.get(
|
|
1337
|
+
DataPointKey(
|
|
1338
|
+
interface_id=self._device.interface_id,
|
|
1339
|
+
channel_address=self._address,
|
|
1340
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1341
|
+
parameter=parameter,
|
|
1342
|
+
)
|
|
1343
|
+
):
|
|
1344
|
+
return dp
|
|
1345
|
+
return self._generic_data_points.get(
|
|
1346
|
+
DataPointKey(
|
|
1347
|
+
interface_id=self._device.interface_id,
|
|
1348
|
+
channel_address=self._address,
|
|
1349
|
+
paramset_key=ParamsetKey.MASTER,
|
|
1350
|
+
parameter=parameter,
|
|
1351
|
+
)
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
def get_generic_event(
|
|
1355
|
+
self, *, parameter: str | None = None, state_path: str | None = None
|
|
1356
|
+
) -> GenericEventProtocolAny | None:
|
|
1357
|
+
"""Return a generic event from device."""
|
|
1358
|
+
if state_path is not None and (dpk := self._state_path_to_dpk.get(state_path)) is not None:
|
|
1359
|
+
return self._generic_events.get(dpk)
|
|
1360
|
+
if parameter is None:
|
|
1361
|
+
return None
|
|
1362
|
+
|
|
1363
|
+
return self._generic_events.get(
|
|
1364
|
+
DataPointKey(
|
|
1365
|
+
interface_id=self._device.interface_id,
|
|
1366
|
+
channel_address=self._address,
|
|
1367
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1368
|
+
parameter=parameter,
|
|
1369
|
+
)
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
def get_readable_data_points(self, *, paramset_key: ParamsetKey) -> tuple[GenericDataPointProtocolAny, ...]:
|
|
1373
|
+
"""Return the list of readable master data points."""
|
|
1374
|
+
return tuple(
|
|
1375
|
+
ge for ge in self._generic_data_points.values() if ge.is_readable and ge.paramset_key == paramset_key
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
def has_link_source_category(self, *, category: DataPointCategory) -> bool:
|
|
1379
|
+
"""Return if channel is receiver."""
|
|
1380
|
+
return category in self._link_source_categories
|
|
1381
|
+
|
|
1382
|
+
def has_link_target_category(self, *, category: DataPointCategory) -> bool:
|
|
1383
|
+
"""Return if channel is transmitter."""
|
|
1384
|
+
return category in self._link_target_categories
|
|
1385
|
+
|
|
1386
|
+
def init_channel(self) -> None:
|
|
1387
|
+
"""Initialize the channel."""
|
|
1388
|
+
self._device.task_scheduler.create_task(target=self.init_link_peer(), name=f"init_channel_{self._address}")
|
|
1389
|
+
|
|
1390
|
+
async def init_link_peer(self) -> None:
|
|
1391
|
+
"""Initialize the link partners."""
|
|
1392
|
+
if self._link_source_categories and self._device.model not in VIRTUAL_REMOTE_MODELS:
|
|
1393
|
+
try:
|
|
1394
|
+
link_peer_addresses = await self._device.client.get_link_peers(address=self._address)
|
|
1395
|
+
except ClientException:
|
|
1396
|
+
# Device may have been deleted or is temporarily unavailable
|
|
1397
|
+
_LOGGER.debug("INIT_LINK_PEER: Failed to get link peers for %s", self._address)
|
|
1398
|
+
return
|
|
1399
|
+
if self._link_peer_addresses != link_peer_addresses:
|
|
1400
|
+
self._link_peer_addresses = link_peer_addresses
|
|
1401
|
+
self.publish_link_peer_changed_event()
|
|
1402
|
+
|
|
1403
|
+
async def load_values(self, *, call_source: CallSource, direct_call: bool = False) -> None:
|
|
1404
|
+
"""Load data for the channel."""
|
|
1405
|
+
for ge in self._generic_data_points.values():
|
|
1406
|
+
await ge.load_data_point_value(call_source=call_source, direct_call=direct_call)
|
|
1407
|
+
for gev in self._generic_events.values():
|
|
1408
|
+
await gev.load_data_point_value(call_source=call_source, direct_call=direct_call)
|
|
1409
|
+
for cdp in self._calculated_data_points.values():
|
|
1410
|
+
await cdp.load_data_point_value(call_source=call_source, direct_call=direct_call)
|
|
1411
|
+
if self._custom_data_point:
|
|
1412
|
+
await self._custom_data_point.load_data_point_value(call_source=call_source, direct_call=direct_call)
|
|
1413
|
+
|
|
1414
|
+
async def on_config_changed(self) -> None:
|
|
1415
|
+
"""Do what is needed on device config change."""
|
|
1416
|
+
# reload paramset_descriptions
|
|
1417
|
+
await self._reload_paramset_descriptions()
|
|
1418
|
+
|
|
1419
|
+
# re init link peers
|
|
1420
|
+
await self.init_link_peer()
|
|
1421
|
+
|
|
1422
|
+
for ge in self._generic_data_points.values():
|
|
1423
|
+
await ge.on_config_changed()
|
|
1424
|
+
for gev in self._generic_events.values():
|
|
1425
|
+
await gev.on_config_changed()
|
|
1426
|
+
for cdp in self._calculated_data_points.values():
|
|
1427
|
+
await cdp.on_config_changed()
|
|
1428
|
+
if self._custom_data_point:
|
|
1429
|
+
await self._custom_data_point.on_config_changed()
|
|
1430
|
+
|
|
1431
|
+
@loop_check
|
|
1432
|
+
def publish_link_peer_changed_event(self) -> None:
|
|
1433
|
+
"""Do what is needed when the link peer has been changed for the device."""
|
|
1434
|
+
|
|
1435
|
+
# Publish to EventBus asynchronously
|
|
1436
|
+
async def _publish_link_peer_changed() -> None:
|
|
1437
|
+
await self._device.event_bus_provider.event_bus.publish(
|
|
1438
|
+
event=LinkPeerChangedEvent(
|
|
1439
|
+
timestamp=datetime.now(),
|
|
1440
|
+
channel_address=self._address,
|
|
1441
|
+
)
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
self._device.task_scheduler.create_task(
|
|
1445
|
+
target=_publish_link_peer_changed,
|
|
1446
|
+
name=f"link-peer-changed-{self._address}",
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
def remove(self) -> None:
|
|
1450
|
+
"""Remove data points from collections and central."""
|
|
1451
|
+
for event in self.generic_events:
|
|
1452
|
+
self._remove_data_point(data_point=event)
|
|
1453
|
+
self._generic_events.clear()
|
|
1454
|
+
|
|
1455
|
+
for ccdp in self.calculated_data_points:
|
|
1456
|
+
self._remove_data_point(data_point=ccdp)
|
|
1457
|
+
self._calculated_data_points.clear()
|
|
1458
|
+
|
|
1459
|
+
for gdp in self.generic_data_points:
|
|
1460
|
+
self._remove_data_point(data_point=gdp)
|
|
1461
|
+
self._generic_data_points.clear()
|
|
1462
|
+
|
|
1463
|
+
if self._custom_data_point:
|
|
1464
|
+
self._remove_data_point(data_point=self._custom_data_point)
|
|
1465
|
+
self._state_path_to_dpk.clear()
|
|
1466
|
+
|
|
1467
|
+
# Clean up channel-level EventBus subscriptions (e.g., LinkPeerChangedEvent)
|
|
1468
|
+
self._device.event_bus_provider.event_bus.clear_subscriptions_by_key(event_key=self._address)
|
|
1469
|
+
|
|
1470
|
+
@inspector(scope=ServiceScope.INTERNAL)
|
|
1471
|
+
async def remove_central_link(self) -> None:
|
|
1472
|
+
"""Remove a central link."""
|
|
1473
|
+
if self._has_key_press_events and await self._has_central_link() and not await self._has_program_ids():
|
|
1474
|
+
await self._device.client.report_value_usage(
|
|
1475
|
+
address=self._address, value_id=REPORT_VALUE_USAGE_VALUE_ID, ref_counter=0
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
@inspector
|
|
1479
|
+
async def rename(self, *, new_name: str) -> bool:
|
|
1480
|
+
"""Rename the channel on the CCU."""
|
|
1481
|
+
return await self._device.client.rename_channel(rega_id=self._rega_id, new_name=new_name)
|
|
1482
|
+
|
|
1483
|
+
def subscribe_to_link_peer_changed(self, *, handler: LinkPeerChangedHandler) -> UnsubscribeCallback:
|
|
1484
|
+
"""Subscribe to the link peer changed event."""
|
|
1485
|
+
|
|
1486
|
+
# Create adapter that filters for this channel's events
|
|
1487
|
+
def event_handler(*, event: LinkPeerChangedEvent) -> None:
|
|
1488
|
+
if event.channel_address == self._address:
|
|
1489
|
+
handler()
|
|
1490
|
+
|
|
1491
|
+
return self._device.event_bus_provider.event_bus.subscribe(
|
|
1492
|
+
event_type=LinkPeerChangedEvent,
|
|
1493
|
+
event_key=self._address,
|
|
1494
|
+
handler=event_handler,
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
async def _has_central_link(self) -> bool:
|
|
1498
|
+
"""Check if central link exists."""
|
|
1499
|
+
try:
|
|
1500
|
+
if metadata := await self._device.client.get_metadata(
|
|
1501
|
+
address=self._address, data_id=REPORT_VALUE_USAGE_DATA
|
|
1502
|
+
):
|
|
1503
|
+
return any(
|
|
1504
|
+
key
|
|
1505
|
+
for key, value in metadata.items()
|
|
1506
|
+
if isinstance(key, str)
|
|
1507
|
+
and isinstance(value, int)
|
|
1508
|
+
and key == REPORT_VALUE_USAGE_VALUE_ID
|
|
1509
|
+
and value > 0
|
|
1510
|
+
)
|
|
1511
|
+
except BaseHomematicException as bhexc:
|
|
1512
|
+
_LOGGER.debug("HAS_CENTRAL_LINK failed: %s", extract_exc_args(exc=bhexc))
|
|
1513
|
+
return False
|
|
1514
|
+
|
|
1515
|
+
async def _has_program_ids(self) -> bool:
|
|
1516
|
+
"""Return if a channel has program ids."""
|
|
1517
|
+
return bool(await self._device.client.has_program_ids(rega_id=self._rega_id))
|
|
1518
|
+
|
|
1519
|
+
@inspector(scope=ServiceScope.INTERNAL)
|
|
1520
|
+
async def _reload_paramset_descriptions(self) -> None:
|
|
1521
|
+
"""Reload paramset for channel."""
|
|
1522
|
+
for paramset_key in self._paramset_keys:
|
|
1523
|
+
await self._device.client.fetch_paramset_description(
|
|
1524
|
+
channel_address=self._address,
|
|
1525
|
+
paramset_key=paramset_key,
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
def _remove_data_point(self, *, data_point: CallbackDataPointProtocol) -> None:
|
|
1529
|
+
"""Remove a data_point from a channel."""
|
|
1530
|
+
# Clean up internal subscriptions for custom/calculated data points
|
|
1531
|
+
if isinstance(data_point, (hmce.CustomDataPoint, CalculatedDataPointProtocol)):
|
|
1532
|
+
data_point.unsubscribe_from_data_point_updated()
|
|
1533
|
+
|
|
1534
|
+
# Remove from collections
|
|
1535
|
+
if isinstance(data_point, BaseParameterDataPointProtocol):
|
|
1536
|
+
self._state_path_to_dpk.pop(data_point.state_path, None)
|
|
1537
|
+
if isinstance(data_point, CalculatedDataPointProtocol):
|
|
1538
|
+
self._calculated_data_points.pop(data_point.dpk, None)
|
|
1539
|
+
if isinstance(data_point, GenericDataPointProtocol):
|
|
1540
|
+
self._generic_data_points.pop(data_point.dpk, None)
|
|
1541
|
+
if isinstance(data_point, hmce.CustomDataPoint):
|
|
1542
|
+
self._custom_data_point = None
|
|
1543
|
+
if isinstance(data_point, GenericEventProtocol):
|
|
1544
|
+
self._generic_events.pop(data_point.dpk, None)
|
|
1545
|
+
|
|
1546
|
+
# Publish removed event and cleanup subscriptions (async, cleanup after event)
|
|
1547
|
+
data_point.publish_device_removed_event()
|
|
1548
|
+
|
|
1549
|
+
def _set_modified_at(self) -> None:
|
|
1550
|
+
self._modified_at = datetime.now()
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
class _ValueCache:
|
|
1554
|
+
"""
|
|
1555
|
+
Cache for lazy loading and temporary storage of parameter values.
|
|
1556
|
+
|
|
1557
|
+
This cache minimizes RPC calls by storing fetched parameter values and
|
|
1558
|
+
providing them on subsequent requests within a validity period.
|
|
1559
|
+
|
|
1560
|
+
External access
|
|
1561
|
+
---------------
|
|
1562
|
+
Accessed via ``device.value_cache`` property. Used by DataPoint classes in
|
|
1563
|
+
``model/data_point.py`` to load values via ``get_value()``.
|
|
1564
|
+
|
|
1565
|
+
Cache strategy
|
|
1566
|
+
--------------
|
|
1567
|
+
- First checks the central data cache for VALUES paramset
|
|
1568
|
+
- Falls back to device-local cache with timestamp-based validity
|
|
1569
|
+
- Uses semaphore for thread-safe concurrent access
|
|
1570
|
+
"""
|
|
1571
|
+
|
|
1572
|
+
__slots__ = (
|
|
1573
|
+
"_device",
|
|
1574
|
+
"_device_cache",
|
|
1575
|
+
"_sema_get_or_load_value",
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
_NO_VALUE_CACHE_ENTRY: Final = "NO_VALUE_CACHE_ENTRY"
|
|
1579
|
+
|
|
1580
|
+
def __init__(self, *, device: DeviceProtocol) -> None:
|
|
1581
|
+
"""Initialize the value cache."""
|
|
1582
|
+
self._sema_get_or_load_value: Final = asyncio.Semaphore()
|
|
1583
|
+
self._device: Final = device
|
|
1584
|
+
# {key, CacheEntry}
|
|
1585
|
+
self._device_cache: Final[dict[DataPointKey, CacheEntry]] = {}
|
|
1586
|
+
|
|
1587
|
+
async def get_value(
|
|
1588
|
+
self,
|
|
1589
|
+
*,
|
|
1590
|
+
dpk: DataPointKey,
|
|
1591
|
+
call_source: CallSource,
|
|
1592
|
+
direct_call: bool = False,
|
|
1593
|
+
) -> Any:
|
|
1594
|
+
"""Load data."""
|
|
1595
|
+
async with self._sema_get_or_load_value:
|
|
1596
|
+
if direct_call is False and (cached_value := self._get_value_from_cache(dpk=dpk)) != NO_CACHE_ENTRY:
|
|
1597
|
+
return NO_CACHE_ENTRY if cached_value == self._NO_VALUE_CACHE_ENTRY else cached_value
|
|
1598
|
+
|
|
1599
|
+
value_dict: dict[str, Any] = {dpk.parameter: self._NO_VALUE_CACHE_ENTRY}
|
|
1600
|
+
try:
|
|
1601
|
+
value_dict = await self._get_values_for_cache(dpk=dpk)
|
|
1602
|
+
except BaseHomematicException as bhexc:
|
|
1603
|
+
_LOGGER.debug(
|
|
1604
|
+
"GET_OR_LOAD_VALUE: Failed to get data for %s, %s, %s, %s: %s",
|
|
1605
|
+
self._device.model,
|
|
1606
|
+
dpk.channel_address,
|
|
1607
|
+
dpk.parameter,
|
|
1608
|
+
call_source,
|
|
1609
|
+
extract_exc_args(exc=bhexc),
|
|
1610
|
+
)
|
|
1611
|
+
for d_parameter, d_value in value_dict.items():
|
|
1612
|
+
self._add_entry_to_device_cache(
|
|
1613
|
+
dpk=DataPointKey(
|
|
1614
|
+
interface_id=dpk.interface_id,
|
|
1615
|
+
channel_address=dpk.channel_address,
|
|
1616
|
+
paramset_key=dpk.paramset_key,
|
|
1617
|
+
parameter=d_parameter,
|
|
1618
|
+
),
|
|
1619
|
+
value=d_value,
|
|
1620
|
+
)
|
|
1621
|
+
return (
|
|
1622
|
+
NO_CACHE_ENTRY
|
|
1623
|
+
if (value := value_dict.get(dpk.parameter)) and value == self._NO_VALUE_CACHE_ENTRY
|
|
1624
|
+
else value
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
async def init_base_data_points(self) -> None:
|
|
1628
|
+
"""Load data by get_value."""
|
|
1629
|
+
try:
|
|
1630
|
+
for dp in self._get_base_data_points():
|
|
1631
|
+
await dp.load_data_point_value(call_source=CallSource.HM_INIT)
|
|
1632
|
+
except BaseHomematicException as bhexc:
|
|
1633
|
+
_LOGGER.debug(
|
|
1634
|
+
"init_base_data_points: Failed to init cache for channel0 %s, %s [%s]",
|
|
1635
|
+
self._device.model,
|
|
1636
|
+
self._device.address,
|
|
1637
|
+
extract_exc_args(exc=bhexc),
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
async def init_readable_events(self) -> None:
|
|
1641
|
+
"""Load data by get_value."""
|
|
1642
|
+
try:
|
|
1643
|
+
for event in self._get_readable_events():
|
|
1644
|
+
await event.load_data_point_value(call_source=CallSource.HM_INIT)
|
|
1645
|
+
except BaseHomematicException as bhexc:
|
|
1646
|
+
_LOGGER.debug(
|
|
1647
|
+
"init_base_events: Failed to init cache for channel0 %s, %s [%s]",
|
|
1648
|
+
self._device.model,
|
|
1649
|
+
self._device.address,
|
|
1650
|
+
extract_exc_args(exc=bhexc),
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
def _add_entry_to_device_cache(self, *, dpk: DataPointKey, value: Any) -> None:
|
|
1654
|
+
"""Add value to cache."""
|
|
1655
|
+
# write value to cache even if an exception has occurred
|
|
1656
|
+
# to avoid repetitive calls to the backend within max_age
|
|
1657
|
+
self._device_cache[dpk] = CacheEntry(value=value, refresh_at=datetime.now())
|
|
1658
|
+
|
|
1659
|
+
def _get_base_data_points(self) -> set[GenericDataPointProtocolAny]:
|
|
1660
|
+
"""Get data points of channel 0 and master."""
|
|
1661
|
+
return {
|
|
1662
|
+
dp
|
|
1663
|
+
for dp in self._device.generic_data_points
|
|
1664
|
+
if (
|
|
1665
|
+
dp.channel.no == 0
|
|
1666
|
+
and dp.paramset_key == ParamsetKey.VALUES
|
|
1667
|
+
and dp.parameter in RELEVANT_INIT_PARAMETERS
|
|
1668
|
+
)
|
|
1669
|
+
or dp.paramset_key == ParamsetKey.MASTER
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
def _get_readable_events(self) -> set[GenericEventProtocolAny]:
|
|
1673
|
+
"""Get readable events."""
|
|
1674
|
+
return {event for event in self._device.generic_events if event.is_readable}
|
|
1675
|
+
|
|
1676
|
+
def _get_value_from_cache(
|
|
1677
|
+
self,
|
|
1678
|
+
*,
|
|
1679
|
+
dpk: DataPointKey,
|
|
1680
|
+
) -> Any:
|
|
1681
|
+
"""Load data from store."""
|
|
1682
|
+
# Try to get data from central cache
|
|
1683
|
+
if (
|
|
1684
|
+
dpk.paramset_key == ParamsetKey.VALUES
|
|
1685
|
+
and (
|
|
1686
|
+
global_value := self._device.data_cache_provider.get_data(
|
|
1687
|
+
interface=self._device.interface,
|
|
1688
|
+
channel_address=dpk.channel_address,
|
|
1689
|
+
parameter=dpk.parameter,
|
|
1690
|
+
)
|
|
1691
|
+
)
|
|
1692
|
+
!= NO_CACHE_ENTRY
|
|
1693
|
+
):
|
|
1694
|
+
return global_value
|
|
1695
|
+
|
|
1696
|
+
if (cache_entry := self._device_cache.get(dpk, CacheEntry.empty())) and cache_entry.is_valid:
|
|
1697
|
+
return cache_entry.value
|
|
1698
|
+
return NO_CACHE_ENTRY
|
|
1699
|
+
|
|
1700
|
+
async def _get_values_for_cache(self, *, dpk: DataPointKey) -> dict[str, Any]:
|
|
1701
|
+
"""Return a value from the backend to store in cache."""
|
|
1702
|
+
if not self._device.available:
|
|
1703
|
+
_LOGGER.debug(
|
|
1704
|
+
"GET_VALUES_FOR_CACHE failed: device %s (%s) is not available", self._device.name, self._device.address
|
|
1705
|
+
)
|
|
1706
|
+
return {}
|
|
1707
|
+
if dpk.paramset_key == ParamsetKey.VALUES:
|
|
1708
|
+
return {
|
|
1709
|
+
dpk.parameter: await self._device.client.get_value(
|
|
1710
|
+
channel_address=dpk.channel_address,
|
|
1711
|
+
paramset_key=dpk.paramset_key,
|
|
1712
|
+
parameter=dpk.parameter,
|
|
1713
|
+
call_source=CallSource.HM_INIT,
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1716
|
+
return await self._device.client.get_paramset(
|
|
1717
|
+
address=dpk.channel_address, paramset_key=dpk.paramset_key, call_source=CallSource.HM_INIT
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
class _DefinitionExporter:
|
|
1722
|
+
"""
|
|
1723
|
+
Export device and paramset descriptions for diagnostics.
|
|
1724
|
+
|
|
1725
|
+
This internal utility class exports device definitions to JSON files for
|
|
1726
|
+
debugging and issue reporting. Device addresses are anonymized before export.
|
|
1727
|
+
|
|
1728
|
+
Internal use only
|
|
1729
|
+
-----------------
|
|
1730
|
+
Used exclusively by ``Device.export_device_definition()``. Not accessible
|
|
1731
|
+
from external code.
|
|
1732
|
+
|
|
1733
|
+
Output files
|
|
1734
|
+
------------
|
|
1735
|
+
- ``{storage_dir}/device_descriptions/{model}.json`` - Device descriptions
|
|
1736
|
+
- ``{storage_dir}/paramset_descriptions/{model}.json`` - Paramset descriptions
|
|
1737
|
+
"""
|
|
1738
|
+
|
|
1739
|
+
__slots__ = (
|
|
1740
|
+
"_client",
|
|
1741
|
+
"_device_address",
|
|
1742
|
+
"_device_description_provider",
|
|
1743
|
+
"_interface_id",
|
|
1744
|
+
"_random_id",
|
|
1745
|
+
"_storage_directory",
|
|
1746
|
+
"_task_scheduler",
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
def __init__(self, *, device: DeviceProtocol) -> None:
|
|
1750
|
+
"""Initialize the device exporter."""
|
|
1751
|
+
self._client: Final = device.client
|
|
1752
|
+
self._device_description_provider: Final = device.device_description_provider
|
|
1753
|
+
self._task_scheduler: Final = device.task_scheduler
|
|
1754
|
+
self._storage_directory: Final = device.config_provider.config.storage_directory
|
|
1755
|
+
self._interface_id: Final = device.interface_id
|
|
1756
|
+
self._device_address: Final = device.address
|
|
1757
|
+
self._random_id: Final[str] = f"VCU{int(random.randint(1000000, 9999999))}"
|
|
1758
|
+
|
|
1759
|
+
@inspector(scope=ServiceScope.INTERNAL)
|
|
1760
|
+
async def export_data(self) -> None:
|
|
1761
|
+
"""Export device and paramset descriptions as a single ZIP file."""
|
|
1762
|
+
device_descriptions: Mapping[str, DeviceDescription] = (
|
|
1763
|
+
self._device_description_provider.get_device_with_channels(
|
|
1764
|
+
interface_id=self._interface_id, device_address=self._device_address
|
|
1765
|
+
)
|
|
1766
|
+
)
|
|
1767
|
+
paramset_descriptions: dict[
|
|
1768
|
+
str, dict[ParamsetKey, dict[str, ParameterData]]
|
|
1769
|
+
] = await self._client.get_all_paramset_descriptions(device_descriptions=tuple(device_descriptions.values()))
|
|
1770
|
+
model = device_descriptions[self._device_address]["TYPE"]
|
|
1771
|
+
|
|
1772
|
+
# anonymize device_descriptions (list format matching pydevccu)
|
|
1773
|
+
anonymize_device_descriptions: list[DeviceDescription] = []
|
|
1774
|
+
for device_description in device_descriptions.values():
|
|
1775
|
+
new_device_description: DeviceDescription = device_description.copy()
|
|
1776
|
+
new_address = self._anonymize_address(address=new_device_description["ADDRESS"])
|
|
1777
|
+
new_device_description["ADDRESS"] = new_address
|
|
1778
|
+
if new_device_description.get("PARENT"):
|
|
1779
|
+
new_device_description["PARENT"] = new_address.split(ADDRESS_SEPARATOR, maxsplit=1)[0]
|
|
1780
|
+
elif new_device_description.get("CHILDREN"):
|
|
1781
|
+
new_device_description["CHILDREN"] = [
|
|
1782
|
+
self._anonymize_address(address=a) for a in new_device_description["CHILDREN"]
|
|
1783
|
+
]
|
|
1784
|
+
anonymize_device_descriptions.append(new_device_description)
|
|
1785
|
+
|
|
1786
|
+
# anonymize paramset_descriptions
|
|
1787
|
+
anonymize_paramset_descriptions: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
1788
|
+
for address, paramset_description in paramset_descriptions.items():
|
|
1789
|
+
anonymize_paramset_descriptions[self._anonymize_address(address=address)] = paramset_description
|
|
1790
|
+
|
|
1791
|
+
# Write single ZIP file with subdirectories
|
|
1792
|
+
if self._task_scheduler:
|
|
1793
|
+
await self._task_scheduler.async_add_executor_job(
|
|
1794
|
+
partial(
|
|
1795
|
+
self._write_export_zip,
|
|
1796
|
+
model=model,
|
|
1797
|
+
device_descriptions=anonymize_device_descriptions,
|
|
1798
|
+
paramset_descriptions=anonymize_paramset_descriptions,
|
|
1799
|
+
),
|
|
1800
|
+
name="export-device-definition",
|
|
1801
|
+
)
|
|
1802
|
+
else:
|
|
1803
|
+
await asyncio.to_thread(
|
|
1804
|
+
self._write_export_zip,
|
|
1805
|
+
model=model,
|
|
1806
|
+
device_descriptions=anonymize_device_descriptions,
|
|
1807
|
+
paramset_descriptions=anonymize_paramset_descriptions,
|
|
1808
|
+
)
|
|
1809
|
+
|
|
1810
|
+
def _anonymize_address(self, *, address: str) -> str:
|
|
1811
|
+
"""Anonymize device address with random ID."""
|
|
1812
|
+
address_parts = address.split(ADDRESS_SEPARATOR)
|
|
1813
|
+
address_parts[0] = self._random_id
|
|
1814
|
+
return ADDRESS_SEPARATOR.join(address_parts)
|
|
1815
|
+
|
|
1816
|
+
def _write_export_zip(
|
|
1817
|
+
self,
|
|
1818
|
+
*,
|
|
1819
|
+
model: str,
|
|
1820
|
+
device_descriptions: list[DeviceDescription],
|
|
1821
|
+
paramset_descriptions: dict[str, dict[ParamsetKey, dict[str, ParameterData]]],
|
|
1822
|
+
) -> None:
|
|
1823
|
+
"""Write export data to a ZIP file with subdirectories."""
|
|
1824
|
+
# Ensure directory exists
|
|
1825
|
+
os.makedirs(self._storage_directory, exist_ok=True)
|
|
1826
|
+
|
|
1827
|
+
zip_path = os.path.join(self._storage_directory, f"{model}.zip")
|
|
1828
|
+
temp_path = f"{zip_path}.tmp"
|
|
1829
|
+
|
|
1830
|
+
# Serialize JSON with formatting
|
|
1831
|
+
opts = orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS
|
|
1832
|
+
device_json = orjson.dumps(device_descriptions, option=opts)
|
|
1833
|
+
paramset_json = orjson.dumps(paramset_descriptions, option=opts)
|
|
1834
|
+
|
|
1835
|
+
# Write ZIP with subdirectories
|
|
1836
|
+
with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
1837
|
+
zf.writestr(f"{DEVICE_DESCRIPTIONS_ZIP_DIR}/{model}.json", device_json)
|
|
1838
|
+
zf.writestr(f"{PARAMSET_DESCRIPTIONS_ZIP_DIR}/{model}.json", paramset_json)
|
|
1839
|
+
|
|
1840
|
+
os.replace(temp_path, zip_path)
|