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,1085 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Device operations handler.
|
|
5
|
+
|
|
6
|
+
Handles value read/write, paramset operations, and device description fetching.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
import logging
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Final, cast
|
|
15
|
+
|
|
16
|
+
from aiohomematic import i18n
|
|
17
|
+
from aiohomematic.central.events import IntegrationIssue, SystemStatusChangedEvent
|
|
18
|
+
from aiohomematic.client.handlers.base import BaseHandler
|
|
19
|
+
from aiohomematic.client.request_coalescer import RequestCoalescer, make_coalesce_key
|
|
20
|
+
from aiohomematic.const import (
|
|
21
|
+
DP_KEY_VALUE,
|
|
22
|
+
WAIT_FOR_CALLBACK,
|
|
23
|
+
CallSource,
|
|
24
|
+
CommandRxMode,
|
|
25
|
+
DeviceDescription,
|
|
26
|
+
IntegrationIssueSeverity,
|
|
27
|
+
IntegrationIssueType,
|
|
28
|
+
Interface,
|
|
29
|
+
InternalCustomID,
|
|
30
|
+
Operations,
|
|
31
|
+
ParameterData,
|
|
32
|
+
ParameterType,
|
|
33
|
+
ParamsetKey,
|
|
34
|
+
)
|
|
35
|
+
from aiohomematic.decorators import inspector, measure_execution_time
|
|
36
|
+
from aiohomematic.exceptions import BaseHomematicException, ClientException, ValidationException
|
|
37
|
+
from aiohomematic.interfaces import (
|
|
38
|
+
DeviceDiscoveryOperationsProtocol,
|
|
39
|
+
ParamsetOperationsProtocol,
|
|
40
|
+
ValueOperationsProtocol,
|
|
41
|
+
)
|
|
42
|
+
from aiohomematic.model.support import convert_value
|
|
43
|
+
from aiohomematic.schemas import normalize_device_description, normalize_paramset_description
|
|
44
|
+
from aiohomematic.support import (
|
|
45
|
+
extract_exc_args,
|
|
46
|
+
get_device_address,
|
|
47
|
+
is_channel_address,
|
|
48
|
+
is_paramset_key,
|
|
49
|
+
supports_rx_mode,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from aiohomematic.client import AioJsonRpcAioHttpClient, BaseRpcProxy
|
|
54
|
+
from aiohomematic.interfaces import ClientDependenciesProtocol, DeviceProtocol
|
|
55
|
+
from aiohomematic.store.dynamic import CommandTracker
|
|
56
|
+
|
|
57
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DeviceHandler(
|
|
61
|
+
BaseHandler,
|
|
62
|
+
DeviceDiscoveryOperationsProtocol,
|
|
63
|
+
ParamsetOperationsProtocol,
|
|
64
|
+
ValueOperationsProtocol,
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
Handler for device value and paramset operations.
|
|
68
|
+
|
|
69
|
+
Implements DeviceDiscoveryOperationsProtocol, ParamsetOperationsProtocol, and ValueOperationsProtocol
|
|
70
|
+
protocols for ISP-compliant client operations.
|
|
71
|
+
|
|
72
|
+
Handles:
|
|
73
|
+
- Reading and writing data point values
|
|
74
|
+
- Reading and writing paramsets
|
|
75
|
+
- Fetching device and paramset descriptions
|
|
76
|
+
- Value conversion and validation
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
__slots__ = ("_device_description_coalescer", "_last_value_send_tracker", "_paramset_description_coalescer")
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
client_deps: ClientDependenciesProtocol,
|
|
85
|
+
interface: Interface,
|
|
86
|
+
interface_id: str,
|
|
87
|
+
json_rpc_client: AioJsonRpcAioHttpClient,
|
|
88
|
+
proxy: BaseRpcProxy,
|
|
89
|
+
proxy_read: BaseRpcProxy,
|
|
90
|
+
last_value_send_tracker: CommandTracker,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Initialize the device operations handler."""
|
|
93
|
+
super().__init__(
|
|
94
|
+
client_deps=client_deps,
|
|
95
|
+
interface=interface,
|
|
96
|
+
interface_id=interface_id,
|
|
97
|
+
json_rpc_client=json_rpc_client,
|
|
98
|
+
proxy=proxy,
|
|
99
|
+
proxy_read=proxy_read,
|
|
100
|
+
)
|
|
101
|
+
self._last_value_send_tracker: Final = last_value_send_tracker
|
|
102
|
+
self._device_description_coalescer: Final = RequestCoalescer(
|
|
103
|
+
name=f"device_desc:{interface_id}",
|
|
104
|
+
event_bus=client_deps.event_bus,
|
|
105
|
+
interface_id=interface_id,
|
|
106
|
+
)
|
|
107
|
+
self._paramset_description_coalescer: Final = RequestCoalescer(
|
|
108
|
+
name=f"paramset:{interface_id}",
|
|
109
|
+
event_bus=client_deps.event_bus,
|
|
110
|
+
interface_id=interface_id,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def paramset_description_coalescer(self) -> RequestCoalescer:
|
|
115
|
+
"""Return the paramset description coalescer for metrics access."""
|
|
116
|
+
return self._paramset_description_coalescer
|
|
117
|
+
|
|
118
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
119
|
+
async def fetch_all_device_data(self) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Fetch all device data from the backend via JSON-RPC.
|
|
122
|
+
|
|
123
|
+
Retrieves current values for all data points on this interface in a single
|
|
124
|
+
bulk request. This is more efficient than fetching values individually.
|
|
125
|
+
|
|
126
|
+
The fetched data is stored in the central data cache for later use during
|
|
127
|
+
device initialization.
|
|
128
|
+
|
|
129
|
+
Raises
|
|
130
|
+
------
|
|
131
|
+
ClientException: If the JSON-RPC call fails. Also publishes a
|
|
132
|
+
SystemStatusChangedEvent with an IntegrationIssue.
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
if all_device_data := await self._json_rpc_client.get_all_device_data(interface=self._interface):
|
|
137
|
+
_LOGGER.debug(
|
|
138
|
+
"FETCH_ALL_DEVICE_DATA: Fetched all device data for interface %s",
|
|
139
|
+
self._interface,
|
|
140
|
+
)
|
|
141
|
+
self._client_deps.cache_coordinator.data_cache.add_data(
|
|
142
|
+
interface=self._interface, all_device_data=all_device_data
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
except ClientException:
|
|
146
|
+
issue = IntegrationIssue(
|
|
147
|
+
issue_type=IntegrationIssueType.FETCH_DATA_FAILED,
|
|
148
|
+
severity=IntegrationIssueSeverity.ERROR,
|
|
149
|
+
interface_id=self._interface_id,
|
|
150
|
+
)
|
|
151
|
+
await self._client_deps.event_bus.publish(
|
|
152
|
+
event=SystemStatusChangedEvent(
|
|
153
|
+
timestamp=datetime.now(),
|
|
154
|
+
issues=(issue,),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
_LOGGER.debug(
|
|
160
|
+
"FETCH_ALL_DEVICE_DATA: Unable to get all device data via JSON-RPC RegaScript for interface %s",
|
|
161
|
+
self._interface,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
165
|
+
async def fetch_device_details(self) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Fetch device details (names, interfaces, rega IDs) via JSON-RPC.
|
|
168
|
+
|
|
169
|
+
Retrieves metadata for all devices and channels from the CCU's ReGaHSS
|
|
170
|
+
scripting engine. The JSON response contains typed DeviceDetail objects
|
|
171
|
+
with address, name, id, interface, and nested channels.
|
|
172
|
+
|
|
173
|
+
Data is stored in the central's device_details cache for later use
|
|
174
|
+
during device/channel creation.
|
|
175
|
+
"""
|
|
176
|
+
if json_result := await self._json_rpc_client.get_device_details():
|
|
177
|
+
for device in json_result:
|
|
178
|
+
# ignore unknown interfaces
|
|
179
|
+
if (interface := device["interface"]) and interface not in Interface:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
device_address = device["address"]
|
|
183
|
+
self._client_deps.cache_coordinator.device_details.add_interface(
|
|
184
|
+
address=device_address, interface=Interface(interface)
|
|
185
|
+
)
|
|
186
|
+
self._client_deps.cache_coordinator.device_details.add_name(address=device_address, name=device["name"])
|
|
187
|
+
self._client_deps.cache_coordinator.device_details.add_address_rega_id(
|
|
188
|
+
address=device_address, rega_id=device["id"]
|
|
189
|
+
)
|
|
190
|
+
for channel in device["channels"]:
|
|
191
|
+
channel_address = channel["address"]
|
|
192
|
+
self._client_deps.cache_coordinator.device_details.add_name(
|
|
193
|
+
address=channel_address, name=channel["name"]
|
|
194
|
+
)
|
|
195
|
+
self._client_deps.cache_coordinator.device_details.add_address_rega_id(
|
|
196
|
+
address=channel_address, rega_id=channel["id"]
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
_LOGGER.debug("FETCH_DEVICE_DETAILS: Unable to fetch device details via JSON-RPC")
|
|
200
|
+
|
|
201
|
+
@inspector(re_raise=False)
|
|
202
|
+
async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Fetch a single paramset description and add it to the cache.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
channel_address: Channel address (e.g., "VCU0000001:1").
|
|
208
|
+
paramset_key: Type of paramset (VALUES, MASTER, or LINK).
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
_LOGGER.debug("FETCH_PARAMSET_DESCRIPTION: %s for %s", paramset_key, channel_address)
|
|
212
|
+
|
|
213
|
+
if paramset_description := await self._get_paramset_description(
|
|
214
|
+
address=channel_address, paramset_key=paramset_key
|
|
215
|
+
):
|
|
216
|
+
self._client_deps.cache_coordinator.paramset_descriptions.add(
|
|
217
|
+
interface_id=self._interface_id,
|
|
218
|
+
channel_address=channel_address,
|
|
219
|
+
paramset_key=paramset_key,
|
|
220
|
+
paramset_description=paramset_description,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@inspector(re_raise=False)
|
|
224
|
+
async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Fetch all paramset descriptions for a device and store in cache.
|
|
227
|
+
|
|
228
|
+
Iterates through all available paramsets (VALUES, MASTER, LINK) for the
|
|
229
|
+
device/channel specified in the device_description and adds each to the
|
|
230
|
+
central's paramset_descriptions cache.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
device_description: Device description from listDevices() containing
|
|
234
|
+
ADDRESS and PARAMSETS fields.
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
data = await self.get_paramset_descriptions(device_description=device_description)
|
|
238
|
+
for address, paramsets in data.items():
|
|
239
|
+
_LOGGER.debug("FETCH_PARAMSET_DESCRIPTIONS for %s", address)
|
|
240
|
+
for paramset_key, paramset_description in paramsets.items():
|
|
241
|
+
self._client_deps.cache_coordinator.paramset_descriptions.add(
|
|
242
|
+
interface_id=self._interface_id,
|
|
243
|
+
channel_address=address,
|
|
244
|
+
paramset_key=paramset_key,
|
|
245
|
+
paramset_description=paramset_description,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@inspector(re_raise=False)
|
|
249
|
+
async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
|
|
250
|
+
"""
|
|
251
|
+
Return device description and all child channel descriptions.
|
|
252
|
+
|
|
253
|
+
Fetches the main device description, then iterates through its CHILDREN
|
|
254
|
+
field to fetch each channel's description. Logs warnings for any
|
|
255
|
+
missing descriptions but continues processing.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
device_address: Device address without channel suffix (e.g., "VCU0000001").
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Tuple of DeviceDescription dicts, starting with the main device
|
|
262
|
+
followed by all its channels. Empty tuple if device not found.
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
all_device_description: list[DeviceDescription] = []
|
|
266
|
+
if main_dd := await self.get_device_description(address=device_address):
|
|
267
|
+
all_device_description.append(main_dd)
|
|
268
|
+
else:
|
|
269
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
270
|
+
"GET_ALL_DEVICE_DESCRIPTIONS: No device description for %s",
|
|
271
|
+
device_address,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if main_dd:
|
|
275
|
+
for channel_address in main_dd.get("CHILDREN", []):
|
|
276
|
+
if channel_dd := await self.get_device_description(address=channel_address):
|
|
277
|
+
all_device_description.append(channel_dd)
|
|
278
|
+
else:
|
|
279
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
280
|
+
"GET_ALL_DEVICE_DESCRIPTIONS: No channel description for %s",
|
|
281
|
+
channel_address,
|
|
282
|
+
)
|
|
283
|
+
return tuple(all_device_description)
|
|
284
|
+
|
|
285
|
+
@inspector
|
|
286
|
+
async def get_all_paramset_descriptions(
|
|
287
|
+
self, *, device_descriptions: tuple[DeviceDescription, ...]
|
|
288
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
289
|
+
"""
|
|
290
|
+
Return aggregated paramset descriptions for multiple devices.
|
|
291
|
+
|
|
292
|
+
Iterates through each device description, fetching its paramset
|
|
293
|
+
descriptions and merging them into a single dictionary.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
device_descriptions: Tuple of DeviceDescription dicts to process.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Nested dict mapping: address -> paramset_key -> parameter -> ParameterData.
|
|
300
|
+
|
|
301
|
+
"""
|
|
302
|
+
all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
303
|
+
for device_description in device_descriptions:
|
|
304
|
+
all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
|
|
305
|
+
return all_paramsets
|
|
306
|
+
|
|
307
|
+
@inspector(re_raise=False)
|
|
308
|
+
async def get_device_description(self, *, address: str) -> DeviceDescription | None:
|
|
309
|
+
"""
|
|
310
|
+
Return device description for a single address (normalized).
|
|
311
|
+
|
|
312
|
+
Uses request coalescing to deduplicate concurrent requests for the same
|
|
313
|
+
address. This is beneficial during device discovery when multiple callers
|
|
314
|
+
may request the same device description simultaneously.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
address: Device or channel address (e.g., "VCU0000001" or "VCU0000001:1").
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Normalized DeviceDescription dict with TYPE, ADDRESS, CHILDREN, PARAMSETS, etc.
|
|
321
|
+
None if the address is not found or the RPC call fails.
|
|
322
|
+
|
|
323
|
+
"""
|
|
324
|
+
key = make_coalesce_key(method="getDeviceDescription", args=(address,))
|
|
325
|
+
|
|
326
|
+
async def _fetch() -> DeviceDescription | None:
|
|
327
|
+
try:
|
|
328
|
+
if raw := await self._proxy_read.getDeviceDescription(address):
|
|
329
|
+
return normalize_device_description(device_description=raw)
|
|
330
|
+
except BaseHomematicException as bhexc:
|
|
331
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
332
|
+
"GET_DEVICE_DESCRIPTION failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc)
|
|
333
|
+
)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
return await self._device_description_coalescer.execute(key=key, executor=_fetch)
|
|
337
|
+
|
|
338
|
+
@inspector
|
|
339
|
+
async def get_paramset(
|
|
340
|
+
self,
|
|
341
|
+
*,
|
|
342
|
+
address: str,
|
|
343
|
+
paramset_key: ParamsetKey | str,
|
|
344
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
"""
|
|
347
|
+
Return a paramset from the backend.
|
|
348
|
+
|
|
349
|
+
Address is usually the channel_address, but for bidcos devices
|
|
350
|
+
there is a master paramset at the device.
|
|
351
|
+
"""
|
|
352
|
+
try:
|
|
353
|
+
_LOGGER.debug(
|
|
354
|
+
"GET_PARAMSET: address %s, paramset_key %s, source %s",
|
|
355
|
+
address,
|
|
356
|
+
paramset_key,
|
|
357
|
+
call_source,
|
|
358
|
+
)
|
|
359
|
+
return cast(dict[str, Any], await self._proxy_read.getParamset(address, paramset_key))
|
|
360
|
+
except BaseHomematicException as bhexc:
|
|
361
|
+
raise ClientException(
|
|
362
|
+
i18n.tr(
|
|
363
|
+
key="exception.client.get_paramset.failed",
|
|
364
|
+
address=address,
|
|
365
|
+
paramset_key=paramset_key,
|
|
366
|
+
reason=extract_exc_args(exc=bhexc),
|
|
367
|
+
)
|
|
368
|
+
) from bhexc
|
|
369
|
+
|
|
370
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
371
|
+
async def get_paramset_descriptions(
|
|
372
|
+
self, *, device_description: DeviceDescription
|
|
373
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
374
|
+
"""
|
|
375
|
+
Return paramset descriptions for a single device/channel.
|
|
376
|
+
|
|
377
|
+
Iterates through the PARAMSETS field of the device_description to fetch
|
|
378
|
+
each available paramset (VALUES, MASTER, LINK) from the backend.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
device_description: DeviceDescription dict containing ADDRESS and
|
|
382
|
+
PARAMSETS fields.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Dict mapping address -> paramset_key -> parameter_name -> ParameterData.
|
|
386
|
+
Empty dict if all paramset fetches fail.
|
|
387
|
+
|
|
388
|
+
"""
|
|
389
|
+
paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
390
|
+
address = device_description["ADDRESS"]
|
|
391
|
+
paramsets[address] = {}
|
|
392
|
+
_LOGGER.debug("GET_PARAMSET_DESCRIPTIONS for %s", address)
|
|
393
|
+
for p_key in device_description["PARAMSETS"]:
|
|
394
|
+
paramset_key = ParamsetKey(p_key)
|
|
395
|
+
if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
|
|
396
|
+
paramsets[address][paramset_key] = paramset_description
|
|
397
|
+
return paramsets
|
|
398
|
+
|
|
399
|
+
@inspector(log_level=logging.NOTSET)
|
|
400
|
+
async def get_value(
|
|
401
|
+
self,
|
|
402
|
+
*,
|
|
403
|
+
channel_address: str,
|
|
404
|
+
paramset_key: ParamsetKey,
|
|
405
|
+
parameter: str,
|
|
406
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
407
|
+
) -> Any:
|
|
408
|
+
"""
|
|
409
|
+
Return a single parameter value from the backend.
|
|
410
|
+
|
|
411
|
+
For VALUES paramset: Uses the optimized getValue() RPC call.
|
|
412
|
+
For MASTER paramset: Fetches entire paramset via getParamset() and
|
|
413
|
+
extracts the requested parameter, as there's no direct getValue for MASTER.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
channel_address: Channel address (e.g., "VCU0000001:1").
|
|
417
|
+
paramset_key: VALUES or MASTER paramset key.
|
|
418
|
+
parameter: Parameter name (e.g., "STATE", "LEVEL").
|
|
419
|
+
call_source: Origin of the call for logging/metrics.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Parameter value (type varies by parameter definition).
|
|
423
|
+
|
|
424
|
+
Raises:
|
|
425
|
+
ClientException: If the RPC call fails.
|
|
426
|
+
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
_LOGGER.debug(
|
|
430
|
+
"GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
|
|
431
|
+
channel_address,
|
|
432
|
+
parameter,
|
|
433
|
+
paramset_key,
|
|
434
|
+
call_source,
|
|
435
|
+
)
|
|
436
|
+
if paramset_key == ParamsetKey.VALUES:
|
|
437
|
+
return await self._proxy_read.getValue(channel_address, parameter)
|
|
438
|
+
paramset = await self._proxy_read.getParamset(channel_address, ParamsetKey.MASTER) or {}
|
|
439
|
+
return paramset.get(parameter)
|
|
440
|
+
except BaseHomematicException as bhexc:
|
|
441
|
+
raise ClientException(
|
|
442
|
+
i18n.tr(
|
|
443
|
+
key="exception.client.get_value.failed",
|
|
444
|
+
channel_address=channel_address,
|
|
445
|
+
parameter=parameter,
|
|
446
|
+
paramset_key=paramset_key,
|
|
447
|
+
reason=extract_exc_args(exc=bhexc),
|
|
448
|
+
)
|
|
449
|
+
) from bhexc
|
|
450
|
+
|
|
451
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
452
|
+
async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
|
|
453
|
+
"""
|
|
454
|
+
Return all device descriptions from the backend (normalized).
|
|
455
|
+
|
|
456
|
+
Calls the XML-RPC listDevices() method to retrieve descriptions for all
|
|
457
|
+
devices and channels known to this interface.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Tuple of normalized DeviceDescription dicts for all devices/channels.
|
|
461
|
+
None if the RPC call fails (e.g., connection error).
|
|
462
|
+
|
|
463
|
+
"""
|
|
464
|
+
try:
|
|
465
|
+
raw_descriptions = await self._proxy_read.listDevices()
|
|
466
|
+
return tuple(normalize_device_description(device_description=desc) for desc in raw_descriptions)
|
|
467
|
+
except BaseHomematicException as bhexc:
|
|
468
|
+
_LOGGER.debug(
|
|
469
|
+
"LIST_DEVICES failed: %s [%s]",
|
|
470
|
+
bhexc.name,
|
|
471
|
+
extract_exc_args(exc=bhexc),
|
|
472
|
+
)
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
@inspector(measure_performance=True)
|
|
476
|
+
async def put_paramset(
|
|
477
|
+
self,
|
|
478
|
+
*,
|
|
479
|
+
channel_address: str,
|
|
480
|
+
paramset_key_or_link_address: ParamsetKey | str,
|
|
481
|
+
values: dict[str, Any],
|
|
482
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
483
|
+
rx_mode: CommandRxMode | None = None,
|
|
484
|
+
check_against_pd: bool = False,
|
|
485
|
+
) -> set[DP_KEY_VALUE]:
|
|
486
|
+
"""
|
|
487
|
+
Set paramsets manually.
|
|
488
|
+
|
|
489
|
+
Address is usually the channel_address, but for bidcos devices there is
|
|
490
|
+
a master paramset at the device. Paramset_key can be a str with a channel
|
|
491
|
+
address in case of manipulating a direct link.
|
|
492
|
+
"""
|
|
493
|
+
is_link_call: bool = False
|
|
494
|
+
checked_values = values
|
|
495
|
+
try:
|
|
496
|
+
if check_against_pd:
|
|
497
|
+
check_paramset_key = (
|
|
498
|
+
ParamsetKey(paramset_key_or_link_address)
|
|
499
|
+
if is_paramset_key(paramset_key=paramset_key_or_link_address)
|
|
500
|
+
else ParamsetKey.LINK
|
|
501
|
+
if (is_link_call := is_channel_address(address=paramset_key_or_link_address))
|
|
502
|
+
else None
|
|
503
|
+
)
|
|
504
|
+
if check_paramset_key:
|
|
505
|
+
checked_values = self._check_put_paramset(
|
|
506
|
+
channel_address=channel_address,
|
|
507
|
+
paramset_key=check_paramset_key,
|
|
508
|
+
values=values,
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
raise ClientException(i18n.tr(key="exception.client.paramset_key.invalid"))
|
|
512
|
+
|
|
513
|
+
_LOGGER.debug("PUT_PARAMSET: %s, %s, %s", channel_address, paramset_key_or_link_address, checked_values)
|
|
514
|
+
if rx_mode and (device := self._client_deps.device_coordinator.get_device(address=channel_address)):
|
|
515
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
516
|
+
await self._exec_put_paramset(
|
|
517
|
+
channel_address=channel_address,
|
|
518
|
+
paramset_key=paramset_key_or_link_address,
|
|
519
|
+
values=checked_values,
|
|
520
|
+
rx_mode=rx_mode,
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
|
|
524
|
+
else:
|
|
525
|
+
await self._exec_put_paramset(
|
|
526
|
+
channel_address=channel_address,
|
|
527
|
+
paramset_key=paramset_key_or_link_address,
|
|
528
|
+
values=checked_values,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# if a call is related to a link then no further action is needed
|
|
532
|
+
if is_link_call:
|
|
533
|
+
return set()
|
|
534
|
+
|
|
535
|
+
# store the send value in the last_value_send_tracker
|
|
536
|
+
dpk_values = self._last_value_send_tracker.add_put_paramset(
|
|
537
|
+
channel_address=channel_address,
|
|
538
|
+
paramset_key=ParamsetKey(paramset_key_or_link_address),
|
|
539
|
+
values=checked_values,
|
|
540
|
+
)
|
|
541
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
542
|
+
|
|
543
|
+
if (
|
|
544
|
+
self._interface in ("BidCos-RF", "BidCos-Wired")
|
|
545
|
+
and paramset_key_or_link_address == ParamsetKey.MASTER
|
|
546
|
+
and (channel := self._client_deps.device_coordinator.get_channel(channel_address=channel_address))
|
|
547
|
+
is not None
|
|
548
|
+
):
|
|
549
|
+
|
|
550
|
+
async def poll_master_dp_values() -> None:
|
|
551
|
+
"""Load master paramset values."""
|
|
552
|
+
if not channel:
|
|
553
|
+
return
|
|
554
|
+
for interval in self._client_deps.config.schedule_timer_config.master_poll_after_send_intervals:
|
|
555
|
+
await asyncio.sleep(interval)
|
|
556
|
+
for dp in channel.get_readable_data_points(
|
|
557
|
+
paramset_key=ParamsetKey(paramset_key_or_link_address)
|
|
558
|
+
):
|
|
559
|
+
await dp.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True)
|
|
560
|
+
|
|
561
|
+
self._client_deps.looper.create_task(target=poll_master_dp_values(), name="poll_master_dp_values")
|
|
562
|
+
|
|
563
|
+
if wait_for_callback is not None and (
|
|
564
|
+
device := self._client_deps.device_coordinator.get_device(
|
|
565
|
+
address=get_device_address(address=channel_address)
|
|
566
|
+
)
|
|
567
|
+
):
|
|
568
|
+
await _wait_for_state_change_or_timeout(
|
|
569
|
+
device=device,
|
|
570
|
+
dpk_values=dpk_values,
|
|
571
|
+
wait_for_callback=wait_for_callback,
|
|
572
|
+
)
|
|
573
|
+
except BaseHomematicException as bhexc:
|
|
574
|
+
raise ClientException(
|
|
575
|
+
i18n.tr(
|
|
576
|
+
key="exception.client.put_paramset.failed",
|
|
577
|
+
channel_address=channel_address,
|
|
578
|
+
paramset_key=paramset_key_or_link_address,
|
|
579
|
+
values=values,
|
|
580
|
+
reason=extract_exc_args(exc=bhexc),
|
|
581
|
+
)
|
|
582
|
+
) from bhexc
|
|
583
|
+
else:
|
|
584
|
+
return dpk_values
|
|
585
|
+
|
|
586
|
+
@inspector
|
|
587
|
+
async def report_value_usage(
|
|
588
|
+
self,
|
|
589
|
+
*,
|
|
590
|
+
address: str,
|
|
591
|
+
value_id: str,
|
|
592
|
+
ref_counter: int,
|
|
593
|
+
supports: bool = True,
|
|
594
|
+
) -> bool:
|
|
595
|
+
"""
|
|
596
|
+
Report value usage to the backend for subscription management.
|
|
597
|
+
|
|
598
|
+
Used by the Homematic backend to track which parameters are actively
|
|
599
|
+
being used. This helps optimize event delivery by only sending events
|
|
600
|
+
for subscribed parameters.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
address: Channel address (e.g., "VCU0000001:1").
|
|
604
|
+
value_id: Parameter identifier.
|
|
605
|
+
ref_counter: Reference count (positive = subscribe, 0 = unsubscribe).
|
|
606
|
+
supports: Whether this client type supports value usage reporting.
|
|
607
|
+
Defaults to True; ClientCCU passes actual capability.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
True if the report was successful, False if not supported or failed.
|
|
611
|
+
|
|
612
|
+
Raises:
|
|
613
|
+
ClientException: If the RPC call fails (when supports=True).
|
|
614
|
+
|
|
615
|
+
"""
|
|
616
|
+
if not supports:
|
|
617
|
+
_LOGGER.debug("REPORT_VALUE_USAGE: Not supported by client for %s", self._interface_id)
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
return bool(await self._proxy.reportValueUsage(address, value_id, ref_counter))
|
|
622
|
+
except BaseHomematicException as bhexc:
|
|
623
|
+
raise ClientException(
|
|
624
|
+
i18n.tr(
|
|
625
|
+
key="exception.client.report_value_usage.failed",
|
|
626
|
+
address=address,
|
|
627
|
+
value_id=value_id,
|
|
628
|
+
ref_counter=ref_counter,
|
|
629
|
+
reason=extract_exc_args(exc=bhexc),
|
|
630
|
+
)
|
|
631
|
+
) from bhexc
|
|
632
|
+
|
|
633
|
+
@inspector(re_raise=False, no_raise_return=set())
|
|
634
|
+
async def set_value(
|
|
635
|
+
self,
|
|
636
|
+
*,
|
|
637
|
+
channel_address: str,
|
|
638
|
+
paramset_key: ParamsetKey,
|
|
639
|
+
parameter: str,
|
|
640
|
+
value: Any,
|
|
641
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
642
|
+
rx_mode: CommandRxMode | None = None,
|
|
643
|
+
check_against_pd: bool = False,
|
|
644
|
+
) -> set[DP_KEY_VALUE]:
|
|
645
|
+
"""
|
|
646
|
+
Set a single parameter value.
|
|
647
|
+
|
|
648
|
+
Routes to set_value_internal() for VALUES paramset or put_paramset()
|
|
649
|
+
for MASTER paramset.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
channel_address: Channel address (e.g., "VCU0000001:1").
|
|
653
|
+
paramset_key: VALUES or MASTER paramset key.
|
|
654
|
+
parameter: Parameter name (e.g., "STATE", "LEVEL").
|
|
655
|
+
value: New value to set.
|
|
656
|
+
wait_for_callback: Seconds to wait for confirmation event (None = don't wait).
|
|
657
|
+
rx_mode: Optional transmission mode (BURST, WAKEUP, etc.).
|
|
658
|
+
check_against_pd: Validate value against paramset description.
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Set of (DataPointKey, value) tuples for the affected data points.
|
|
662
|
+
|
|
663
|
+
"""
|
|
664
|
+
if paramset_key == ParamsetKey.VALUES:
|
|
665
|
+
return await self.set_value_internal(
|
|
666
|
+
channel_address=channel_address,
|
|
667
|
+
parameter=parameter,
|
|
668
|
+
value=value,
|
|
669
|
+
wait_for_callback=wait_for_callback,
|
|
670
|
+
rx_mode=rx_mode,
|
|
671
|
+
check_against_pd=check_against_pd,
|
|
672
|
+
)
|
|
673
|
+
return await self.put_paramset(
|
|
674
|
+
channel_address=channel_address,
|
|
675
|
+
paramset_key_or_link_address=paramset_key,
|
|
676
|
+
values={parameter: value},
|
|
677
|
+
wait_for_callback=wait_for_callback,
|
|
678
|
+
rx_mode=rx_mode,
|
|
679
|
+
check_against_pd=check_against_pd,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
@inspector(measure_performance=True)
|
|
683
|
+
async def set_value_internal(
|
|
684
|
+
self,
|
|
685
|
+
*,
|
|
686
|
+
channel_address: str,
|
|
687
|
+
parameter: str,
|
|
688
|
+
value: Any,
|
|
689
|
+
wait_for_callback: int | None,
|
|
690
|
+
rx_mode: CommandRxMode | None = None,
|
|
691
|
+
check_against_pd: bool = False,
|
|
692
|
+
) -> set[DP_KEY_VALUE]:
|
|
693
|
+
"""
|
|
694
|
+
Set a single value on the VALUES paramset via setValue() RPC.
|
|
695
|
+
|
|
696
|
+
This is the core implementation for sending values to devices. It:
|
|
697
|
+
1. Optionally validates the value against paramset description
|
|
698
|
+
2. Sends the value via XML-RPC setValue()
|
|
699
|
+
3. Caches the sent value for comparison with callback events
|
|
700
|
+
4. Writes a temporary value to the data point for immediate UI feedback
|
|
701
|
+
5. Optionally waits for the backend callback confirming the change
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
channel_address: Channel address (e.g., "VCU0000001:1").
|
|
705
|
+
parameter: Parameter name (e.g., "STATE", "LEVEL").
|
|
706
|
+
value: New value to set.
|
|
707
|
+
wait_for_callback: Seconds to wait for confirmation event (None = don't wait).
|
|
708
|
+
rx_mode: Optional transmission mode (BURST, WAKEUP, etc.).
|
|
709
|
+
check_against_pd: Validate value against paramset description.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
Set of (DataPointKey, value) tuples for the affected data points.
|
|
713
|
+
|
|
714
|
+
Raises:
|
|
715
|
+
ClientException: If the RPC call fails or rx_mode is unsupported.
|
|
716
|
+
|
|
717
|
+
"""
|
|
718
|
+
try:
|
|
719
|
+
checked_value = (
|
|
720
|
+
self._check_set_value(
|
|
721
|
+
channel_address=channel_address,
|
|
722
|
+
paramset_key=ParamsetKey.VALUES,
|
|
723
|
+
parameter=parameter,
|
|
724
|
+
value=value,
|
|
725
|
+
)
|
|
726
|
+
if check_against_pd
|
|
727
|
+
else value
|
|
728
|
+
)
|
|
729
|
+
_LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, checked_value)
|
|
730
|
+
if rx_mode and (device := self._client_deps.device_coordinator.get_device(address=channel_address)):
|
|
731
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
732
|
+
await self._exec_set_value(
|
|
733
|
+
channel_address=channel_address,
|
|
734
|
+
parameter=parameter,
|
|
735
|
+
value=value,
|
|
736
|
+
rx_mode=rx_mode,
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
|
|
740
|
+
else:
|
|
741
|
+
await self._exec_set_value(channel_address=channel_address, parameter=parameter, value=value)
|
|
742
|
+
# store the send value in the last_value_send_tracker
|
|
743
|
+
dpk_values = self._last_value_send_tracker.add_set_value(
|
|
744
|
+
channel_address=channel_address, parameter=parameter, value=checked_value
|
|
745
|
+
)
|
|
746
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
747
|
+
|
|
748
|
+
if wait_for_callback is not None and (
|
|
749
|
+
device := self._client_deps.device_coordinator.get_device(
|
|
750
|
+
address=get_device_address(address=channel_address)
|
|
751
|
+
)
|
|
752
|
+
):
|
|
753
|
+
await _wait_for_state_change_or_timeout(
|
|
754
|
+
device=device,
|
|
755
|
+
dpk_values=dpk_values,
|
|
756
|
+
wait_for_callback=wait_for_callback,
|
|
757
|
+
)
|
|
758
|
+
except BaseHomematicException as bhexc:
|
|
759
|
+
raise ClientException(
|
|
760
|
+
i18n.tr(
|
|
761
|
+
key="exception.client.set_value.failed",
|
|
762
|
+
channel_address=channel_address,
|
|
763
|
+
parameter=parameter,
|
|
764
|
+
value=value,
|
|
765
|
+
reason=extract_exc_args(exc=bhexc),
|
|
766
|
+
)
|
|
767
|
+
) from bhexc
|
|
768
|
+
else:
|
|
769
|
+
return dpk_values
|
|
770
|
+
|
|
771
|
+
@inspector(re_raise=False)
|
|
772
|
+
async def update_paramset_descriptions(self, *, device_address: str) -> None:
|
|
773
|
+
"""
|
|
774
|
+
Re-fetch and update paramset descriptions for a device.
|
|
775
|
+
|
|
776
|
+
Used when a device's firmware is updated or its configuration changes.
|
|
777
|
+
Fetches fresh paramset descriptions from the backend and saves them
|
|
778
|
+
to the persistent cache.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
device_address: Device address without channel suffix (e.g., "VCU0000001").
|
|
782
|
+
|
|
783
|
+
"""
|
|
784
|
+
if not self._client_deps.cache_coordinator.device_descriptions.get_device_descriptions(
|
|
785
|
+
interface_id=self._interface_id
|
|
786
|
+
):
|
|
787
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
788
|
+
"UPDATE_PARAMSET_DESCRIPTIONS failed: Interface missing in central cache. Not updating paramsets for %s",
|
|
789
|
+
device_address,
|
|
790
|
+
)
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
if device_description := self._client_deps.cache_coordinator.device_descriptions.find_device_description(
|
|
794
|
+
interface_id=self._interface_id, device_address=device_address
|
|
795
|
+
):
|
|
796
|
+
await self.fetch_paramset_descriptions(device_description=device_description)
|
|
797
|
+
else:
|
|
798
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
799
|
+
"UPDATE_PARAMSET_DESCRIPTIONS failed: Channel missing in central.cache. Not updating paramsets for %s",
|
|
800
|
+
device_address,
|
|
801
|
+
)
|
|
802
|
+
return
|
|
803
|
+
await self._client_deps.save_files(save_paramset_descriptions=True)
|
|
804
|
+
|
|
805
|
+
def _check_put_paramset(
|
|
806
|
+
self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
|
|
807
|
+
) -> dict[str, Any]:
|
|
808
|
+
"""
|
|
809
|
+
Validate and convert all values in a paramset against their descriptions.
|
|
810
|
+
|
|
811
|
+
Iterates through each parameter in the values dict, converting types
|
|
812
|
+
and validating against MIN/MAX constraints.
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
Dict with validated/converted values.
|
|
816
|
+
|
|
817
|
+
Raises:
|
|
818
|
+
ClientException: If any parameter validation fails.
|
|
819
|
+
|
|
820
|
+
"""
|
|
821
|
+
checked_values: dict[str, Any] = {}
|
|
822
|
+
for param, value in values.items():
|
|
823
|
+
checked_values[param] = self._convert_value(
|
|
824
|
+
channel_address=channel_address,
|
|
825
|
+
paramset_key=paramset_key,
|
|
826
|
+
parameter=param,
|
|
827
|
+
value=value,
|
|
828
|
+
operation=Operations.WRITE,
|
|
829
|
+
)
|
|
830
|
+
return checked_values
|
|
831
|
+
|
|
832
|
+
def _check_set_value(self, *, channel_address: str, paramset_key: ParamsetKey, parameter: str, value: Any) -> Any:
|
|
833
|
+
"""Validate and convert a single value against its parameter description."""
|
|
834
|
+
return self._convert_value(
|
|
835
|
+
channel_address=channel_address,
|
|
836
|
+
paramset_key=paramset_key,
|
|
837
|
+
parameter=parameter,
|
|
838
|
+
value=value,
|
|
839
|
+
operation=Operations.WRITE,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def _convert_value(
|
|
843
|
+
self,
|
|
844
|
+
*,
|
|
845
|
+
channel_address: str,
|
|
846
|
+
paramset_key: ParamsetKey,
|
|
847
|
+
parameter: str,
|
|
848
|
+
value: Any,
|
|
849
|
+
operation: Operations,
|
|
850
|
+
) -> Any:
|
|
851
|
+
"""
|
|
852
|
+
Validate and convert a parameter value against its description.
|
|
853
|
+
|
|
854
|
+
Performs the following checks:
|
|
855
|
+
1. Parameter exists in paramset description
|
|
856
|
+
2. Requested operation (READ/WRITE/EVENT) is supported
|
|
857
|
+
3. Value is converted to the correct type (INTEGER, FLOAT, BOOL, ENUM, STRING)
|
|
858
|
+
4. For numeric types, value is within MIN/MAX bounds
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
Converted value matching the parameter's type definition.
|
|
862
|
+
|
|
863
|
+
Raises:
|
|
864
|
+
ClientException: If parameter not found or operation not supported.
|
|
865
|
+
ValidationException: If value is outside MIN/MAX bounds.
|
|
866
|
+
|
|
867
|
+
"""
|
|
868
|
+
if parameter_data := self._client_deps.cache_coordinator.paramset_descriptions.get_parameter_data(
|
|
869
|
+
interface_id=self._interface_id,
|
|
870
|
+
channel_address=channel_address,
|
|
871
|
+
paramset_key=paramset_key,
|
|
872
|
+
parameter=parameter,
|
|
873
|
+
):
|
|
874
|
+
pd_type = parameter_data["TYPE"]
|
|
875
|
+
op_mask = int(operation)
|
|
876
|
+
if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
|
|
877
|
+
raise ClientException(
|
|
878
|
+
i18n.tr(
|
|
879
|
+
key="exception.client.parameter.operation_unsupported",
|
|
880
|
+
parameter=parameter,
|
|
881
|
+
operation=operation.value,
|
|
882
|
+
)
|
|
883
|
+
)
|
|
884
|
+
# Only build a tuple if a value list exists
|
|
885
|
+
pd_value_list = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
|
|
886
|
+
converted_value = convert_value(value=value, target_type=pd_type, value_list=pd_value_list)
|
|
887
|
+
|
|
888
|
+
# Validate MIN/MAX constraints for numeric types
|
|
889
|
+
if pd_type in (ParameterType.INTEGER, ParameterType.FLOAT) and converted_value is not None:
|
|
890
|
+
pd_min = parameter_data.get("MIN")
|
|
891
|
+
pd_max = parameter_data.get("MAX")
|
|
892
|
+
if pd_min is not None and converted_value < pd_min:
|
|
893
|
+
raise ValidationException(
|
|
894
|
+
i18n.tr(
|
|
895
|
+
key="exception.client.parameter.value_below_min",
|
|
896
|
+
parameter=parameter,
|
|
897
|
+
value=converted_value,
|
|
898
|
+
min_value=pd_min,
|
|
899
|
+
)
|
|
900
|
+
)
|
|
901
|
+
if pd_max is not None and converted_value > pd_max:
|
|
902
|
+
raise ValidationException(
|
|
903
|
+
i18n.tr(
|
|
904
|
+
key="exception.client.parameter.value_above_max",
|
|
905
|
+
parameter=parameter,
|
|
906
|
+
value=converted_value,
|
|
907
|
+
max_value=pd_max,
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
return converted_value
|
|
912
|
+
raise ClientException(
|
|
913
|
+
i18n.tr(
|
|
914
|
+
key="exception.client.parameter.not_found",
|
|
915
|
+
parameter=parameter,
|
|
916
|
+
interface_id=self._interface_id,
|
|
917
|
+
channel_address=channel_address,
|
|
918
|
+
paramset_key=paramset_key,
|
|
919
|
+
)
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
async def _exec_put_paramset(
|
|
923
|
+
self,
|
|
924
|
+
*,
|
|
925
|
+
channel_address: str,
|
|
926
|
+
paramset_key: ParamsetKey | str,
|
|
927
|
+
values: dict[str, Any],
|
|
928
|
+
rx_mode: CommandRxMode | None = None,
|
|
929
|
+
) -> None:
|
|
930
|
+
"""Execute the XML-RPC putParamset call with optional rx_mode."""
|
|
931
|
+
if rx_mode:
|
|
932
|
+
await self._proxy.putParamset(channel_address, paramset_key, values, rx_mode)
|
|
933
|
+
else:
|
|
934
|
+
await self._proxy.putParamset(channel_address, paramset_key, values)
|
|
935
|
+
|
|
936
|
+
async def _exec_set_value(
|
|
937
|
+
self,
|
|
938
|
+
*,
|
|
939
|
+
channel_address: str,
|
|
940
|
+
parameter: str,
|
|
941
|
+
value: Any,
|
|
942
|
+
rx_mode: CommandRxMode | None = None,
|
|
943
|
+
) -> None:
|
|
944
|
+
"""Execute the XML-RPC setValue call with optional rx_mode."""
|
|
945
|
+
if rx_mode:
|
|
946
|
+
await self._proxy.setValue(channel_address, parameter, value, rx_mode)
|
|
947
|
+
else:
|
|
948
|
+
await self._proxy.setValue(channel_address, parameter, value)
|
|
949
|
+
|
|
950
|
+
def _get_parameter_type(
|
|
951
|
+
self,
|
|
952
|
+
*,
|
|
953
|
+
channel_address: str,
|
|
954
|
+
paramset_key: ParamsetKey,
|
|
955
|
+
parameter: str,
|
|
956
|
+
) -> ParameterType | None:
|
|
957
|
+
"""Return the parameter's TYPE field from its description, or None if not found."""
|
|
958
|
+
if parameter_data := self._client_deps.cache_coordinator.paramset_descriptions.get_parameter_data(
|
|
959
|
+
interface_id=self._interface_id,
|
|
960
|
+
channel_address=channel_address,
|
|
961
|
+
paramset_key=paramset_key,
|
|
962
|
+
parameter=parameter,
|
|
963
|
+
):
|
|
964
|
+
return parameter_data["TYPE"]
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
async def _get_paramset_description(
|
|
968
|
+
self, *, address: str, paramset_key: ParamsetKey
|
|
969
|
+
) -> dict[str, ParameterData] | None:
|
|
970
|
+
"""
|
|
971
|
+
Fetch and normalize paramset description via XML-RPC.
|
|
972
|
+
|
|
973
|
+
Uses request coalescing to deduplicate concurrent requests for the same
|
|
974
|
+
address and paramset_key combination. This is particularly beneficial
|
|
975
|
+
during device discovery when multiple channels request the same descriptions.
|
|
976
|
+
"""
|
|
977
|
+
key = make_coalesce_key(method="getParamsetDescription", args=(address, paramset_key))
|
|
978
|
+
|
|
979
|
+
async def _fetch() -> dict[str, ParameterData] | None:
|
|
980
|
+
try:
|
|
981
|
+
raw = await self._proxy_read.getParamsetDescription(address, paramset_key)
|
|
982
|
+
return normalize_paramset_description(paramset=raw)
|
|
983
|
+
except BaseHomematicException as bhexc:
|
|
984
|
+
_LOGGER.debug(
|
|
985
|
+
"GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
|
|
986
|
+
bhexc.name,
|
|
987
|
+
extract_exc_args(exc=bhexc),
|
|
988
|
+
paramset_key,
|
|
989
|
+
address,
|
|
990
|
+
)
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
return await self._paramset_description_coalescer.execute(key=key, executor=_fetch)
|
|
994
|
+
|
|
995
|
+
def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
|
|
996
|
+
"""Write temporary values to polling data points for immediate UI feedback."""
|
|
997
|
+
for dpk, value in dpk_values:
|
|
998
|
+
if (
|
|
999
|
+
data_point := self._client_deps.get_generic_data_point(
|
|
1000
|
+
channel_address=dpk.channel_address,
|
|
1001
|
+
parameter=dpk.parameter,
|
|
1002
|
+
paramset_key=dpk.paramset_key,
|
|
1003
|
+
)
|
|
1004
|
+
) and data_point.requires_polling:
|
|
1005
|
+
data_point.write_temporary_value(value=value, write_at=datetime.now())
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@measure_execution_time
|
|
1009
|
+
async def _wait_for_state_change_or_timeout(
|
|
1010
|
+
*,
|
|
1011
|
+
device: DeviceProtocol,
|
|
1012
|
+
dpk_values: set[DP_KEY_VALUE],
|
|
1013
|
+
wait_for_callback: int,
|
|
1014
|
+
) -> None:
|
|
1015
|
+
"""Wait for all affected data points to receive confirmation callbacks in parallel."""
|
|
1016
|
+
waits = [
|
|
1017
|
+
_track_single_data_point_state_change_or_timeout(
|
|
1018
|
+
device=device,
|
|
1019
|
+
dpk_value=dpk_value,
|
|
1020
|
+
wait_for_callback=wait_for_callback,
|
|
1021
|
+
)
|
|
1022
|
+
for dpk_value in dpk_values
|
|
1023
|
+
]
|
|
1024
|
+
await asyncio.gather(*waits)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@measure_execution_time
|
|
1028
|
+
async def _track_single_data_point_state_change_or_timeout(
|
|
1029
|
+
*, device: DeviceProtocol, dpk_value: DP_KEY_VALUE, wait_for_callback: int
|
|
1030
|
+
) -> None:
|
|
1031
|
+
"""
|
|
1032
|
+
Wait for a single data point to receive its confirmation callback.
|
|
1033
|
+
|
|
1034
|
+
Subscribes to the data point's update events and waits until the received
|
|
1035
|
+
value matches the sent value (using fuzzy float comparison) or times out.
|
|
1036
|
+
"""
|
|
1037
|
+
ev = asyncio.Event()
|
|
1038
|
+
dpk, value = dpk_value
|
|
1039
|
+
|
|
1040
|
+
def _async_event_changed(*args: Any, **kwargs: Any) -> None:
|
|
1041
|
+
if dp:
|
|
1042
|
+
_LOGGER.debug(
|
|
1043
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Received event %s with value %s",
|
|
1044
|
+
dpk,
|
|
1045
|
+
dp.value,
|
|
1046
|
+
)
|
|
1047
|
+
if _isclose(value1=value, value2=dp.value):
|
|
1048
|
+
_LOGGER.debug(
|
|
1049
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Finished event %s with value %s",
|
|
1050
|
+
dpk,
|
|
1051
|
+
dp.value,
|
|
1052
|
+
)
|
|
1053
|
+
ev.set()
|
|
1054
|
+
|
|
1055
|
+
if dp := device.get_generic_data_point(
|
|
1056
|
+
channel_address=dpk.channel_address,
|
|
1057
|
+
parameter=dpk.parameter,
|
|
1058
|
+
paramset_key=ParamsetKey(dpk.paramset_key),
|
|
1059
|
+
):
|
|
1060
|
+
if not dp.has_events:
|
|
1061
|
+
_LOGGER.debug(
|
|
1062
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: DataPoint supports no events %s",
|
|
1063
|
+
dpk,
|
|
1064
|
+
)
|
|
1065
|
+
return
|
|
1066
|
+
unreg = dp.subscribe_to_data_point_updated(handler=_async_event_changed, custom_id=InternalCustomID.DEFAULT)
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
async with asyncio.timeout(wait_for_callback):
|
|
1070
|
+
await ev.wait()
|
|
1071
|
+
except TimeoutError:
|
|
1072
|
+
_LOGGER.debug(
|
|
1073
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Timeout waiting for event %s with value %s",
|
|
1074
|
+
dpk,
|
|
1075
|
+
dp.value,
|
|
1076
|
+
)
|
|
1077
|
+
finally:
|
|
1078
|
+
unreg()
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _isclose(*, value1: Any, value2: Any) -> bool:
|
|
1082
|
+
"""Compare values with fuzzy float matching (2 decimal places) for confirmation."""
|
|
1083
|
+
if isinstance(value1, float):
|
|
1084
|
+
return bool(round(value1, 2) == round(value2, 2))
|
|
1085
|
+
return bool(value1 == value2)
|