aiohomematic 2025.11.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of aiohomematic might be problematic. Click here for more details.
- aiohomematic/__init__.py +61 -0
- aiohomematic/async_support.py +212 -0
- aiohomematic/central/__init__.py +2309 -0
- aiohomematic/central/decorators.py +155 -0
- aiohomematic/central/rpc_server.py +295 -0
- aiohomematic/client/__init__.py +1848 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +1326 -0
- aiohomematic/client/rpc_proxy.py +311 -0
- aiohomematic/const.py +1127 -0
- aiohomematic/context.py +18 -0
- aiohomematic/converter.py +108 -0
- aiohomematic/decorators.py +302 -0
- aiohomematic/exceptions.py +164 -0
- aiohomematic/hmcli.py +186 -0
- aiohomematic/model/__init__.py +140 -0
- aiohomematic/model/calculated/__init__.py +84 -0
- aiohomematic/model/calculated/climate.py +290 -0
- aiohomematic/model/calculated/data_point.py +327 -0
- aiohomematic/model/calculated/operating_voltage_level.py +299 -0
- aiohomematic/model/calculated/support.py +234 -0
- aiohomematic/model/custom/__init__.py +177 -0
- aiohomematic/model/custom/climate.py +1532 -0
- aiohomematic/model/custom/cover.py +792 -0
- aiohomematic/model/custom/data_point.py +334 -0
- aiohomematic/model/custom/definition.py +871 -0
- aiohomematic/model/custom/light.py +1128 -0
- aiohomematic/model/custom/lock.py +394 -0
- aiohomematic/model/custom/siren.py +275 -0
- aiohomematic/model/custom/support.py +41 -0
- aiohomematic/model/custom/switch.py +175 -0
- aiohomematic/model/custom/valve.py +114 -0
- aiohomematic/model/data_point.py +1123 -0
- aiohomematic/model/device.py +1445 -0
- aiohomematic/model/event.py +208 -0
- aiohomematic/model/generic/__init__.py +217 -0
- aiohomematic/model/generic/action.py +34 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +27 -0
- aiohomematic/model/generic/data_point.py +171 -0
- aiohomematic/model/generic/dummy.py +147 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +39 -0
- aiohomematic/model/generic/sensor.py +74 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +29 -0
- aiohomematic/model/hub/__init__.py +333 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/data_point.py +340 -0
- aiohomematic/model/hub/number.py +39 -0
- aiohomematic/model/hub/select.py +49 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +44 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/support.py +586 -0
- aiohomematic/model/update.py +143 -0
- aiohomematic/property_decorators.py +496 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
- aiohomematic/rega_scripts/set_program_state.fn +12 -0
- aiohomematic/rega_scripts/set_system_variable.fn +15 -0
- aiohomematic/store/__init__.py +34 -0
- aiohomematic/store/dynamic.py +551 -0
- aiohomematic/store/persistent.py +988 -0
- aiohomematic/store/visibility.py +812 -0
- aiohomematic/support.py +664 -0
- aiohomematic/validator.py +112 -0
- aiohomematic-2025.11.3.dist-info/METADATA +144 -0
- aiohomematic-2025.11.3.dist-info/RECORD +77 -0
- aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
- aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
- aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1848 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
|
+
"""
|
|
4
|
+
Client adapters for communicating with Homematic CCU and compatible backends.
|
|
5
|
+
|
|
6
|
+
Overview
|
|
7
|
+
--------
|
|
8
|
+
This package provides client implementations that abstract the transport details of
|
|
9
|
+
Homematic backends (e.g., CCU via JSON-RPC/XML-RPC or Homegear) and expose a
|
|
10
|
+
consistent API used by the central module.
|
|
11
|
+
|
|
12
|
+
Provided clients
|
|
13
|
+
----------------
|
|
14
|
+
- Client: Abstract base with common logic for parameter access, metadata retrieval,
|
|
15
|
+
connection checks, and firmware support detection.
|
|
16
|
+
- ClientCCU: Concrete client for CCU-compatible backends using XML-RPC for write/reads
|
|
17
|
+
and optional JSON-RPC for rich metadata and sysvar/program access.
|
|
18
|
+
- ClientJsonCCU: Specialization of ClientCCU that prefers JSON-RPC endpoints for
|
|
19
|
+
reads/writes and metadata.
|
|
20
|
+
- ClientHomegear: Client for Homegear using XML-RPC.
|
|
21
|
+
|
|
22
|
+
Key responsibilities
|
|
23
|
+
--------------------
|
|
24
|
+
- Initialize and manage transport proxies (XmlRpcProxy, JsonRpcAioHttpClient)
|
|
25
|
+
- Read/write data point values and paramsets
|
|
26
|
+
- Fetch device, channel, and parameter descriptions
|
|
27
|
+
- Track connection health and implement ping/pong where supported
|
|
28
|
+
- Provide program and system variable access (where supported)
|
|
29
|
+
|
|
30
|
+
Quick start
|
|
31
|
+
-----------
|
|
32
|
+
Create a client via create_client using an InterfaceConfig and a CentralUnit:
|
|
33
|
+
|
|
34
|
+
from aiohomematic import client as hmcl
|
|
35
|
+
|
|
36
|
+
iface_cfg = hmcl.InterfaceConfig(central_name="ccu-main", interface=hmcl.Interface.HMIP, port=2010)
|
|
37
|
+
client = hmcl.create_client(central, iface_cfg)
|
|
38
|
+
await client.init_client()
|
|
39
|
+
# ... use client.get_value(...), client.set_value(...), etc.
|
|
40
|
+
|
|
41
|
+
Notes
|
|
42
|
+
-----
|
|
43
|
+
- Most users interact with clients via the CentralUnit. Direct usage is possible for
|
|
44
|
+
advanced scenarios.
|
|
45
|
+
- XML-RPC support depends on the interface; JSON-RPC is only available on CCU backends.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from abc import ABC, abstractmethod
|
|
52
|
+
import asyncio
|
|
53
|
+
from datetime import datetime
|
|
54
|
+
import logging
|
|
55
|
+
from typing import Any, Final, cast
|
|
56
|
+
|
|
57
|
+
from aiohomematic import central as hmcu
|
|
58
|
+
from aiohomematic.client.json_rpc import AioJsonRpcAioHttpClient
|
|
59
|
+
from aiohomematic.client.rpc_proxy import AioXmlRpcProxy, BaseRpcProxy
|
|
60
|
+
from aiohomematic.const import (
|
|
61
|
+
CALLBACK_WARN_INTERVAL,
|
|
62
|
+
DATETIME_FORMAT_MILLIS,
|
|
63
|
+
DEFAULT_MAX_WORKERS,
|
|
64
|
+
DP_KEY_VALUE,
|
|
65
|
+
DUMMY_SERIAL,
|
|
66
|
+
INIT_DATETIME,
|
|
67
|
+
INTERFACE_RPC_SERVER_TYPE,
|
|
68
|
+
INTERFACES_REQUIRING_JSON_RPC_CLIENT,
|
|
69
|
+
INTERFACES_SUPPORTING_FIRMWARE_UPDATES,
|
|
70
|
+
INTERFACES_SUPPORTING_RPC_CALLBACK,
|
|
71
|
+
RECONNECT_WAIT,
|
|
72
|
+
VIRTUAL_REMOTE_MODELS,
|
|
73
|
+
WAIT_FOR_CALLBACK,
|
|
74
|
+
Backend,
|
|
75
|
+
CallSource,
|
|
76
|
+
CommandRxMode,
|
|
77
|
+
DescriptionMarker,
|
|
78
|
+
DeviceDescription,
|
|
79
|
+
EventKey,
|
|
80
|
+
ForcedDeviceAvailability,
|
|
81
|
+
Interface,
|
|
82
|
+
InterfaceEventType,
|
|
83
|
+
InternalCustomID,
|
|
84
|
+
Operations,
|
|
85
|
+
ParameterData,
|
|
86
|
+
ParameterType,
|
|
87
|
+
ParamsetKey,
|
|
88
|
+
ProductGroup,
|
|
89
|
+
ProgramData,
|
|
90
|
+
ProxyInitState,
|
|
91
|
+
RpcServerType,
|
|
92
|
+
SystemInformation,
|
|
93
|
+
SystemVariableData,
|
|
94
|
+
)
|
|
95
|
+
from aiohomematic.decorators import inspector, measure_execution_time
|
|
96
|
+
from aiohomematic.exceptions import BaseHomematicException, ClientException, NoConnectionException
|
|
97
|
+
from aiohomematic.model.device import Device
|
|
98
|
+
from aiohomematic.model.support import convert_value
|
|
99
|
+
from aiohomematic.property_decorators import hm_property
|
|
100
|
+
from aiohomematic.store import CommandCache, PingPongCache
|
|
101
|
+
from aiohomematic.support import (
|
|
102
|
+
LogContextMixin,
|
|
103
|
+
build_xml_rpc_headers,
|
|
104
|
+
build_xml_rpc_uri,
|
|
105
|
+
extract_exc_args,
|
|
106
|
+
get_device_address,
|
|
107
|
+
is_channel_address,
|
|
108
|
+
is_paramset_key,
|
|
109
|
+
supports_rx_mode,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"AioJsonRpcAioHttpClient",
|
|
114
|
+
"BaseRpcProxy",
|
|
115
|
+
"Client",
|
|
116
|
+
"InterfaceConfig",
|
|
117
|
+
"ClientConfig",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
121
|
+
|
|
122
|
+
_JSON_ADDRESS: Final = "address"
|
|
123
|
+
_JSON_CHANNELS: Final = "channels"
|
|
124
|
+
_JSON_ID: Final = "id"
|
|
125
|
+
_JSON_INTERFACE: Final = "interface"
|
|
126
|
+
_JSON_NAME: Final = "name"
|
|
127
|
+
_NAME: Final = "NAME"
|
|
128
|
+
|
|
129
|
+
_CCU_JSON_VALUE_TYPE: Final = {
|
|
130
|
+
"ACTION": "bool",
|
|
131
|
+
"BOOL": "bool",
|
|
132
|
+
"ENUM": "list",
|
|
133
|
+
"FLOAT": "double",
|
|
134
|
+
"INTEGER": "int",
|
|
135
|
+
"STRING": "string",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Client(ABC, LogContextMixin):
|
|
140
|
+
"""Client object to access the backends via XML-RPC or JSON-RPC."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
143
|
+
"""Initialize the Client."""
|
|
144
|
+
self._config: Final = client_config
|
|
145
|
+
self._last_value_send_cache = CommandCache(interface_id=client_config.interface_id)
|
|
146
|
+
self._available: bool = True
|
|
147
|
+
self._connection_error_count: int = 0
|
|
148
|
+
self._is_callback_alive: bool = True
|
|
149
|
+
self._is_initialized: bool = False
|
|
150
|
+
self._ping_pong_cache: Final = PingPongCache(
|
|
151
|
+
central=client_config.central, interface_id=client_config.interface_id
|
|
152
|
+
)
|
|
153
|
+
self._proxy: BaseRpcProxy
|
|
154
|
+
self._proxy_read: BaseRpcProxy
|
|
155
|
+
self._system_information: SystemInformation
|
|
156
|
+
self.modified_at: datetime = INIT_DATETIME
|
|
157
|
+
|
|
158
|
+
@inspector
|
|
159
|
+
async def init_client(self) -> None:
|
|
160
|
+
"""Init the client."""
|
|
161
|
+
self._system_information = await self._get_system_information()
|
|
162
|
+
if self.supports_rpc_callback:
|
|
163
|
+
self._proxy = await self._config.create_rpc_proxy(
|
|
164
|
+
interface=self.interface,
|
|
165
|
+
auth_enabled=self.system_information.auth_enabled,
|
|
166
|
+
)
|
|
167
|
+
self._proxy_read = await self._config.create_rpc_proxy(
|
|
168
|
+
interface=self.interface,
|
|
169
|
+
auth_enabled=self.system_information.auth_enabled,
|
|
170
|
+
max_workers=self._config.max_read_workers,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def available(self) -> bool:
|
|
175
|
+
"""Return the availability of the client."""
|
|
176
|
+
return self._available
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def central(self) -> hmcu.CentralUnit:
|
|
180
|
+
"""Return the central of the client."""
|
|
181
|
+
return self._config.central
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def interface(self) -> Interface:
|
|
185
|
+
"""Return the interface of the client."""
|
|
186
|
+
return self._config.interface
|
|
187
|
+
|
|
188
|
+
@hm_property(log_context=True)
|
|
189
|
+
def interface_id(self) -> str:
|
|
190
|
+
"""Return the interface id of the client."""
|
|
191
|
+
return self._config.interface_id
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def is_initialized(self) -> bool:
|
|
195
|
+
"""Return if interface is initialized."""
|
|
196
|
+
return self._is_initialized
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def last_value_send_cache(self) -> CommandCache:
|
|
200
|
+
"""Return the last value send cache."""
|
|
201
|
+
return self._last_value_send_cache
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
@abstractmethod
|
|
205
|
+
def model(self) -> str:
|
|
206
|
+
"""Return the model of the backend."""
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def ping_pong_cache(self) -> PingPongCache:
|
|
210
|
+
"""Return the ping pong cache."""
|
|
211
|
+
return self._ping_pong_cache
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def supports_rpc_callback(self) -> bool:
|
|
215
|
+
"""Return if interface support rpc callback."""
|
|
216
|
+
return self._config.supports_rpc_callback
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def system_information(self) -> SystemInformation:
|
|
220
|
+
"""Return the system_information of the client."""
|
|
221
|
+
return self._system_information
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def version(self) -> str:
|
|
225
|
+
"""Return the version id of the client."""
|
|
226
|
+
return self._config.version
|
|
227
|
+
|
|
228
|
+
def get_product_group(self, *, model: str) -> ProductGroup:
|
|
229
|
+
"""Return the product group."""
|
|
230
|
+
l_model = model.lower()
|
|
231
|
+
if l_model.startswith("hmipw-"):
|
|
232
|
+
return ProductGroup.HMIPW
|
|
233
|
+
if l_model.startswith("hmip-"):
|
|
234
|
+
return ProductGroup.HMIP
|
|
235
|
+
if l_model.startswith("hmw-"):
|
|
236
|
+
return ProductGroup.HMW
|
|
237
|
+
if l_model.startswith("hm-"):
|
|
238
|
+
return ProductGroup.HM
|
|
239
|
+
if self.interface == Interface.HMIP_RF:
|
|
240
|
+
return ProductGroup.HMIP
|
|
241
|
+
if self.interface == Interface.BIDCOS_WIRED:
|
|
242
|
+
return ProductGroup.HMW
|
|
243
|
+
if self.interface == Interface.BIDCOS_RF:
|
|
244
|
+
return ProductGroup.HM
|
|
245
|
+
if self.interface == Interface.VIRTUAL_DEVICES:
|
|
246
|
+
return ProductGroup.VIRTUAL
|
|
247
|
+
return ProductGroup.UNKNOWN
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def supports_ping_pong(self) -> bool:
|
|
251
|
+
"""Return the supports_ping_pong info of the backend."""
|
|
252
|
+
return self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def supports_push_updates(self) -> bool:
|
|
256
|
+
"""Return the client supports push update."""
|
|
257
|
+
return self._config.supports_push_updates
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def supports_firmware_updates(self) -> bool:
|
|
261
|
+
"""Return the supports_ping_pong info of the backend."""
|
|
262
|
+
return self._config.supports_firmware_updates
|
|
263
|
+
|
|
264
|
+
async def initialize_proxy(self) -> ProxyInitState:
|
|
265
|
+
"""Init the proxy has to tell the backend where to send the events."""
|
|
266
|
+
|
|
267
|
+
if not self.supports_rpc_callback:
|
|
268
|
+
if device_descriptions := await self.list_devices():
|
|
269
|
+
await self.central.add_new_devices(
|
|
270
|
+
interface_id=self.interface_id, device_descriptions=device_descriptions
|
|
271
|
+
)
|
|
272
|
+
return ProxyInitState.INIT_SUCCESS
|
|
273
|
+
return ProxyInitState.INIT_FAILED
|
|
274
|
+
try:
|
|
275
|
+
_LOGGER.debug("PROXY_INIT: init('%s', '%s')", self._config.init_url, self.interface_id)
|
|
276
|
+
self._ping_pong_cache.clear()
|
|
277
|
+
await self._proxy.init(self._config.init_url, self.interface_id)
|
|
278
|
+
self._is_initialized = True
|
|
279
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
|
|
280
|
+
_LOGGER.debug("PROXY_INIT: Proxy for %s initialized", self.interface_id)
|
|
281
|
+
except BaseHomematicException as bhexc:
|
|
282
|
+
_LOGGER.warning(
|
|
283
|
+
"PROXY_INIT failed: %s [%s] Unable to initialize proxy for %s",
|
|
284
|
+
bhexc.name,
|
|
285
|
+
extract_exc_args(exc=bhexc),
|
|
286
|
+
self.interface_id,
|
|
287
|
+
)
|
|
288
|
+
self.modified_at = INIT_DATETIME
|
|
289
|
+
return ProxyInitState.INIT_FAILED
|
|
290
|
+
self.modified_at = datetime.now()
|
|
291
|
+
return ProxyInitState.INIT_SUCCESS
|
|
292
|
+
|
|
293
|
+
async def deinitialize_proxy(self) -> ProxyInitState:
|
|
294
|
+
"""De-init to stop the backend from sending events for this remote."""
|
|
295
|
+
if not self.supports_rpc_callback:
|
|
296
|
+
return ProxyInitState.DE_INIT_SUCCESS
|
|
297
|
+
|
|
298
|
+
if self.modified_at == INIT_DATETIME:
|
|
299
|
+
_LOGGER.debug(
|
|
300
|
+
"PROXY_DE_INIT: Skipping de-init for %s (not initialized)",
|
|
301
|
+
self.interface_id,
|
|
302
|
+
)
|
|
303
|
+
return ProxyInitState.DE_INIT_SKIPPED
|
|
304
|
+
try:
|
|
305
|
+
_LOGGER.debug("PROXY_DE_INIT: init('%s')", self._config.init_url)
|
|
306
|
+
await self._proxy.init(self._config.init_url)
|
|
307
|
+
self._is_initialized = False
|
|
308
|
+
except BaseHomematicException as bhexc:
|
|
309
|
+
_LOGGER.warning(
|
|
310
|
+
"PROXY_DE_INIT failed: %s [%s] Unable to de-initialize proxy for %s",
|
|
311
|
+
bhexc.name,
|
|
312
|
+
extract_exc_args(exc=bhexc),
|
|
313
|
+
self.interface_id,
|
|
314
|
+
)
|
|
315
|
+
return ProxyInitState.DE_INIT_FAILED
|
|
316
|
+
|
|
317
|
+
self.modified_at = INIT_DATETIME
|
|
318
|
+
return ProxyInitState.DE_INIT_SUCCESS
|
|
319
|
+
|
|
320
|
+
async def reinitialize_proxy(self) -> ProxyInitState:
|
|
321
|
+
"""Reinit Proxy."""
|
|
322
|
+
if await self.deinitialize_proxy() != ProxyInitState.DE_INIT_FAILED:
|
|
323
|
+
return await self.initialize_proxy()
|
|
324
|
+
return ProxyInitState.DE_INIT_FAILED
|
|
325
|
+
|
|
326
|
+
def _mark_all_devices_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
|
|
327
|
+
"""Mark device's availability state for this interface."""
|
|
328
|
+
available = forced_availability != ForcedDeviceAvailability.FORCE_FALSE
|
|
329
|
+
if self._available != available:
|
|
330
|
+
for device in self.central.devices:
|
|
331
|
+
if device.interface_id == self.interface_id:
|
|
332
|
+
device.set_forced_availability(forced_availability=forced_availability)
|
|
333
|
+
self._available = available
|
|
334
|
+
_LOGGER.debug(
|
|
335
|
+
"MARK_ALL_DEVICES_FORCED_AVAILABILITY: marked all devices %s for %s",
|
|
336
|
+
"available" if available else "unavailable",
|
|
337
|
+
self.interface_id,
|
|
338
|
+
)
|
|
339
|
+
self.central.emit_interface_event(
|
|
340
|
+
interface_id=self.interface_id,
|
|
341
|
+
interface_event_type=InterfaceEventType.PROXY,
|
|
342
|
+
data={EventKey.AVAILABLE: available},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
async def reconnect(self) -> bool:
|
|
346
|
+
"""re-init all RPC clients."""
|
|
347
|
+
if await self.is_connected():
|
|
348
|
+
_LOGGER.debug(
|
|
349
|
+
"RECONNECT: waiting to re-connect client %s for %is",
|
|
350
|
+
self.interface_id,
|
|
351
|
+
int(RECONNECT_WAIT),
|
|
352
|
+
)
|
|
353
|
+
await asyncio.sleep(RECONNECT_WAIT)
|
|
354
|
+
|
|
355
|
+
await self.reinitialize_proxy()
|
|
356
|
+
_LOGGER.info(
|
|
357
|
+
"RECONNECT: re-connected client %s",
|
|
358
|
+
self.interface_id,
|
|
359
|
+
)
|
|
360
|
+
return True
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
async def stop(self) -> None:
|
|
364
|
+
"""Stop depending services."""
|
|
365
|
+
if not self.supports_rpc_callback:
|
|
366
|
+
return
|
|
367
|
+
await self._proxy.stop()
|
|
368
|
+
await self._proxy_read.stop()
|
|
369
|
+
|
|
370
|
+
@abstractmethod
|
|
371
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
372
|
+
async def fetch_all_device_data(self) -> None:
|
|
373
|
+
"""Fetch all device data from the backend."""
|
|
374
|
+
|
|
375
|
+
@abstractmethod
|
|
376
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
377
|
+
async def fetch_device_details(self) -> None:
|
|
378
|
+
"""Fetch names from the backend."""
|
|
379
|
+
|
|
380
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
381
|
+
async def is_connected(self) -> bool:
|
|
382
|
+
"""
|
|
383
|
+
Perform actions required for connectivity check.
|
|
384
|
+
|
|
385
|
+
Connection is not connected, if three consecutive checks fail.
|
|
386
|
+
Return connectivity state.
|
|
387
|
+
"""
|
|
388
|
+
if await self.check_connection_availability(handle_ping_pong=True) is True:
|
|
389
|
+
self._connection_error_count = 0
|
|
390
|
+
else:
|
|
391
|
+
self._connection_error_count += 1
|
|
392
|
+
|
|
393
|
+
if self._connection_error_count > 3:
|
|
394
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
395
|
+
return False
|
|
396
|
+
if not self.supports_push_updates:
|
|
397
|
+
return True
|
|
398
|
+
return (datetime.now() - self.modified_at).total_seconds() < CALLBACK_WARN_INTERVAL
|
|
399
|
+
|
|
400
|
+
def is_callback_alive(self) -> bool:
|
|
401
|
+
"""Return if XmlRPC-Server is alive based on received events for this client."""
|
|
402
|
+
if not self.supports_ping_pong:
|
|
403
|
+
return True
|
|
404
|
+
if (
|
|
405
|
+
last_events_dt := self.central.get_last_event_seen_for_interface(interface_id=self.interface_id)
|
|
406
|
+
) is not None:
|
|
407
|
+
if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > CALLBACK_WARN_INTERVAL:
|
|
408
|
+
if self._is_callback_alive:
|
|
409
|
+
self.central.emit_interface_event(
|
|
410
|
+
interface_id=self.interface_id,
|
|
411
|
+
interface_event_type=InterfaceEventType.CALLBACK,
|
|
412
|
+
data={
|
|
413
|
+
EventKey.AVAILABLE: False,
|
|
414
|
+
EventKey.SECONDS_SINCE_LAST_EVENT: int(seconds_since_last_event),
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
self._is_callback_alive = False
|
|
418
|
+
_LOGGER.warning(
|
|
419
|
+
"IS_CALLBACK_ALIVE: Callback for %s has not received events for %is",
|
|
420
|
+
self.interface_id,
|
|
421
|
+
seconds_since_last_event,
|
|
422
|
+
)
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
if not self._is_callback_alive:
|
|
426
|
+
self.central.emit_interface_event(
|
|
427
|
+
interface_id=self.interface_id,
|
|
428
|
+
interface_event_type=InterfaceEventType.CALLBACK,
|
|
429
|
+
data={EventKey.AVAILABLE: True},
|
|
430
|
+
)
|
|
431
|
+
self._is_callback_alive = True
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
@abstractmethod
|
|
435
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
436
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
437
|
+
"""Send ping to the backend to generate PONG event."""
|
|
438
|
+
|
|
439
|
+
@inspector
|
|
440
|
+
async def execute_program(self, *, pid: str) -> bool: # pragma: no cover
|
|
441
|
+
"""Execute a program on the backend."""
|
|
442
|
+
_LOGGER.debug("EXECUTE_PROGRAM: not usable for %s.", self.interface_id)
|
|
443
|
+
return True
|
|
444
|
+
|
|
445
|
+
@inspector
|
|
446
|
+
async def set_program_state(self, *, pid: str, state: bool) -> bool: # pragma: no cover
|
|
447
|
+
"""Set the program state on the backend."""
|
|
448
|
+
_LOGGER.debug("SET_PROGRAM_STATE: not usable for %s.", self.interface_id)
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
@abstractmethod
|
|
452
|
+
@inspector(measure_performance=True)
|
|
453
|
+
async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
|
|
454
|
+
"""Set a system variable on the backend."""
|
|
455
|
+
|
|
456
|
+
@abstractmethod
|
|
457
|
+
@inspector
|
|
458
|
+
async def delete_system_variable(self, *, name: str) -> bool:
|
|
459
|
+
"""Delete a system variable from the backend."""
|
|
460
|
+
|
|
461
|
+
@abstractmethod
|
|
462
|
+
@inspector
|
|
463
|
+
async def get_system_variable(self, *, name: str) -> Any:
|
|
464
|
+
"""Get single system variable from the backend."""
|
|
465
|
+
|
|
466
|
+
@abstractmethod
|
|
467
|
+
@inspector(re_raise=False)
|
|
468
|
+
async def get_all_system_variables(
|
|
469
|
+
self, *, markers: tuple[DescriptionMarker | str, ...]
|
|
470
|
+
) -> tuple[SystemVariableData, ...] | None:
|
|
471
|
+
"""Get all system variables from the backend."""
|
|
472
|
+
|
|
473
|
+
@inspector(re_raise=False)
|
|
474
|
+
async def get_all_programs(
|
|
475
|
+
self, *, markers: tuple[DescriptionMarker | str, ...]
|
|
476
|
+
) -> tuple[ProgramData, ...] | None: # pragma: no cover
|
|
477
|
+
"""Get all programs, if available."""
|
|
478
|
+
_LOGGER.debug("GET_ALL_PROGRAMS: not usable for %s.", self.interface_id)
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
482
|
+
async def get_all_rooms(self) -> dict[str, set[str]]: # pragma: no cover
|
|
483
|
+
"""Get all rooms, if available."""
|
|
484
|
+
_LOGGER.debug("GET_ALL_ROOMS: not usable for %s.", self.interface_id)
|
|
485
|
+
return {}
|
|
486
|
+
|
|
487
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
488
|
+
async def get_all_functions(self) -> dict[str, set[str]]: # pragma: no cover
|
|
489
|
+
"""Get all functions, if available."""
|
|
490
|
+
_LOGGER.debug("GET_ALL_FUNCTIONS: not usable for %s.", self.interface_id)
|
|
491
|
+
return {}
|
|
492
|
+
|
|
493
|
+
@abstractmethod
|
|
494
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
495
|
+
"""Get system information of the backend."""
|
|
496
|
+
|
|
497
|
+
def get_virtual_remote(self) -> Device | None:
|
|
498
|
+
"""Get the virtual remote for the Client."""
|
|
499
|
+
for model in VIRTUAL_REMOTE_MODELS:
|
|
500
|
+
for device in self.central.devices:
|
|
501
|
+
if device.interface_id == self.interface_id and device.model == model:
|
|
502
|
+
return device
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
@inspector(re_raise=False)
|
|
506
|
+
async def get_device_description(self, *, device_address: str) -> DeviceDescription | None:
|
|
507
|
+
"""Get device descriptions from the backend."""
|
|
508
|
+
try:
|
|
509
|
+
if device_description := cast(
|
|
510
|
+
DeviceDescription | None,
|
|
511
|
+
await self._proxy_read.getDeviceDescription(device_address),
|
|
512
|
+
):
|
|
513
|
+
return device_description
|
|
514
|
+
except BaseHomematicException as bhexc:
|
|
515
|
+
_LOGGER.warning("GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc))
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
@inspector(re_raise=False)
|
|
519
|
+
async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
|
|
520
|
+
"""Get all device descriptions from the backend."""
|
|
521
|
+
all_device_description: list[DeviceDescription] = []
|
|
522
|
+
if main_dd := await self.get_device_description(device_address=device_address):
|
|
523
|
+
all_device_description.append(main_dd)
|
|
524
|
+
else:
|
|
525
|
+
_LOGGER.warning(
|
|
526
|
+
"GET_ALL_DEVICE_DESCRIPTIONS: No device description for %s",
|
|
527
|
+
device_address,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if main_dd:
|
|
531
|
+
for channel_address in main_dd["CHILDREN"]:
|
|
532
|
+
if channel_dd := await self.get_device_description(device_address=channel_address):
|
|
533
|
+
all_device_description.append(channel_dd)
|
|
534
|
+
else:
|
|
535
|
+
_LOGGER.warning(
|
|
536
|
+
"GET_ALL_DEVICE_DESCRIPTIONS: No channel description for %s",
|
|
537
|
+
channel_address,
|
|
538
|
+
)
|
|
539
|
+
return tuple(all_device_description)
|
|
540
|
+
|
|
541
|
+
@inspector
|
|
542
|
+
async def add_link(self, *, sender_address: str, receiver_address: str, name: str, description: str) -> None:
|
|
543
|
+
"""Return a list of links."""
|
|
544
|
+
try:
|
|
545
|
+
await self._proxy.addLink(sender_address, receiver_address, name, description)
|
|
546
|
+
except BaseHomematicException as bhexc:
|
|
547
|
+
raise ClientException(
|
|
548
|
+
f"ADD_LINK failed with for: {sender_address}/{receiver_address}/{name}/{description}: {extract_exc_args(exc=bhexc)}"
|
|
549
|
+
) from bhexc
|
|
550
|
+
|
|
551
|
+
@inspector
|
|
552
|
+
async def remove_link(self, *, sender_address: str, receiver_address: str) -> None:
|
|
553
|
+
"""Return a list of links."""
|
|
554
|
+
try:
|
|
555
|
+
await self._proxy.removeLink(sender_address, receiver_address)
|
|
556
|
+
except BaseHomematicException as bhexc:
|
|
557
|
+
raise ClientException(
|
|
558
|
+
f"REMOVE_LINK failed with for: {sender_address}/{receiver_address}: {extract_exc_args(exc=bhexc)}"
|
|
559
|
+
) from bhexc
|
|
560
|
+
|
|
561
|
+
@inspector
|
|
562
|
+
async def get_link_peers(self, *, address: str) -> tuple[str, ...]:
|
|
563
|
+
"""Return a list of link pers."""
|
|
564
|
+
try:
|
|
565
|
+
return tuple(links) if (links := await self._proxy.getLinkPeers(address)) else ()
|
|
566
|
+
except BaseHomematicException as bhexc:
|
|
567
|
+
raise ClientException(
|
|
568
|
+
f"GET_LINK_PEERS failed with for: {address}: {extract_exc_args(exc=bhexc)}"
|
|
569
|
+
) from bhexc
|
|
570
|
+
|
|
571
|
+
@inspector
|
|
572
|
+
async def get_links(self, *, address: str, flags: int) -> dict[str, Any]:
|
|
573
|
+
"""Return a list of links."""
|
|
574
|
+
try:
|
|
575
|
+
return cast(dict[str, Any], await self._proxy.getLinks(address, flags))
|
|
576
|
+
except BaseHomematicException as bhexc:
|
|
577
|
+
raise ClientException(f"GET_LINKS failed with for: {address}: {extract_exc_args(exc=bhexc)}") from bhexc
|
|
578
|
+
|
|
579
|
+
@inspector
|
|
580
|
+
async def get_metadata(self, *, address: str, data_id: str) -> dict[str, Any]:
|
|
581
|
+
"""Return the metadata for an object."""
|
|
582
|
+
try:
|
|
583
|
+
return cast(dict[str, Any], await self._proxy.getMetadata(address, data_id))
|
|
584
|
+
except BaseHomematicException as bhexc:
|
|
585
|
+
raise ClientException(
|
|
586
|
+
f"GET_METADATA failed with for: {address}/{data_id}: {extract_exc_args(exc=bhexc)}"
|
|
587
|
+
) from bhexc
|
|
588
|
+
|
|
589
|
+
@inspector
|
|
590
|
+
async def set_metadata(self, *, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
|
|
591
|
+
"""Write the metadata for an object."""
|
|
592
|
+
try:
|
|
593
|
+
return cast(dict[str, Any], await self._proxy.setMetadata(address, data_id, value))
|
|
594
|
+
except BaseHomematicException as bhexc:
|
|
595
|
+
raise ClientException(
|
|
596
|
+
f"SET_METADATA failed with for: {address}/{data_id}/{value}: {extract_exc_args(exc=bhexc)}"
|
|
597
|
+
) from bhexc
|
|
598
|
+
|
|
599
|
+
@inspector(log_level=logging.NOTSET)
|
|
600
|
+
async def get_value(
|
|
601
|
+
self,
|
|
602
|
+
*,
|
|
603
|
+
channel_address: str,
|
|
604
|
+
paramset_key: ParamsetKey,
|
|
605
|
+
parameter: str,
|
|
606
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
607
|
+
) -> Any:
|
|
608
|
+
"""Return a value from the backend."""
|
|
609
|
+
try:
|
|
610
|
+
_LOGGER.debug(
|
|
611
|
+
"GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
|
|
612
|
+
channel_address,
|
|
613
|
+
parameter,
|
|
614
|
+
paramset_key,
|
|
615
|
+
call_source,
|
|
616
|
+
)
|
|
617
|
+
if paramset_key == ParamsetKey.VALUES:
|
|
618
|
+
return await self._proxy_read.getValue(channel_address, parameter)
|
|
619
|
+
paramset = await self._proxy_read.getParamset(channel_address, ParamsetKey.MASTER) or {}
|
|
620
|
+
return paramset.get(parameter)
|
|
621
|
+
except BaseHomematicException as bhexc:
|
|
622
|
+
raise ClientException(
|
|
623
|
+
f"GET_VALUE failed with for: {channel_address}/{parameter}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
|
|
624
|
+
) from bhexc
|
|
625
|
+
|
|
626
|
+
@inspector(measure_performance=True)
|
|
627
|
+
async def _set_value(
|
|
628
|
+
self,
|
|
629
|
+
*,
|
|
630
|
+
channel_address: str,
|
|
631
|
+
parameter: str,
|
|
632
|
+
value: Any,
|
|
633
|
+
wait_for_callback: int | None,
|
|
634
|
+
rx_mode: CommandRxMode | None = None,
|
|
635
|
+
check_against_pd: bool = False,
|
|
636
|
+
) -> set[DP_KEY_VALUE]:
|
|
637
|
+
"""Set single value on paramset VALUES."""
|
|
638
|
+
try:
|
|
639
|
+
checked_value = (
|
|
640
|
+
self._check_set_value(
|
|
641
|
+
channel_address=channel_address,
|
|
642
|
+
paramset_key=ParamsetKey.VALUES,
|
|
643
|
+
parameter=parameter,
|
|
644
|
+
value=value,
|
|
645
|
+
)
|
|
646
|
+
if check_against_pd
|
|
647
|
+
else value
|
|
648
|
+
)
|
|
649
|
+
_LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, checked_value)
|
|
650
|
+
if rx_mode and (device := self.central.get_device(address=channel_address)):
|
|
651
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
652
|
+
await self._exec_set_value(
|
|
653
|
+
channel_address=channel_address,
|
|
654
|
+
parameter=parameter,
|
|
655
|
+
value=value,
|
|
656
|
+
rx_mode=rx_mode,
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
raise ClientException(f"Unsupported rx_mode: {rx_mode}")
|
|
660
|
+
else:
|
|
661
|
+
await self._exec_set_value(channel_address=channel_address, parameter=parameter, value=value)
|
|
662
|
+
# store the send value in the last_value_send_cache
|
|
663
|
+
dpk_values = self._last_value_send_cache.add_set_value(
|
|
664
|
+
channel_address=channel_address, parameter=parameter, value=checked_value
|
|
665
|
+
)
|
|
666
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
667
|
+
|
|
668
|
+
if wait_for_callback is not None and (
|
|
669
|
+
device := self.central.get_device(address=get_device_address(address=channel_address))
|
|
670
|
+
):
|
|
671
|
+
await _wait_for_state_change_or_timeout(
|
|
672
|
+
device=device,
|
|
673
|
+
dpk_values=dpk_values,
|
|
674
|
+
wait_for_callback=wait_for_callback,
|
|
675
|
+
)
|
|
676
|
+
except BaseHomematicException as bhexc:
|
|
677
|
+
raise ClientException(
|
|
678
|
+
f"SET_VALUE failed for {channel_address}/{parameter}/{value}: {extract_exc_args(exc=bhexc)}"
|
|
679
|
+
) from bhexc
|
|
680
|
+
else:
|
|
681
|
+
return dpk_values
|
|
682
|
+
|
|
683
|
+
async def _exec_set_value(
|
|
684
|
+
self,
|
|
685
|
+
*,
|
|
686
|
+
channel_address: str,
|
|
687
|
+
parameter: str,
|
|
688
|
+
value: Any,
|
|
689
|
+
rx_mode: CommandRxMode | None = None,
|
|
690
|
+
) -> None:
|
|
691
|
+
"""Set single value on paramset VALUES."""
|
|
692
|
+
if rx_mode:
|
|
693
|
+
await self._proxy.setValue(channel_address, parameter, value, rx_mode)
|
|
694
|
+
else:
|
|
695
|
+
await self._proxy.setValue(channel_address, parameter, value)
|
|
696
|
+
|
|
697
|
+
def _check_set_value(self, *, channel_address: str, paramset_key: ParamsetKey, parameter: str, value: Any) -> Any:
|
|
698
|
+
"""Check set_value."""
|
|
699
|
+
return self._convert_value(
|
|
700
|
+
channel_address=channel_address,
|
|
701
|
+
paramset_key=paramset_key,
|
|
702
|
+
parameter=parameter,
|
|
703
|
+
value=value,
|
|
704
|
+
operation=Operations.WRITE,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
|
|
708
|
+
"""Write data point temp value."""
|
|
709
|
+
for dpk, value in dpk_values:
|
|
710
|
+
if (
|
|
711
|
+
data_point := self.central.get_generic_data_point(
|
|
712
|
+
channel_address=dpk.channel_address,
|
|
713
|
+
parameter=dpk.parameter,
|
|
714
|
+
paramset_key=dpk.paramset_key,
|
|
715
|
+
)
|
|
716
|
+
) and data_point.requires_polling:
|
|
717
|
+
data_point.write_temporary_value(value=value, write_at=datetime.now())
|
|
718
|
+
|
|
719
|
+
@inspector(re_raise=False, no_raise_return=set())
|
|
720
|
+
async def set_value(
|
|
721
|
+
self,
|
|
722
|
+
*,
|
|
723
|
+
channel_address: str,
|
|
724
|
+
paramset_key: ParamsetKey,
|
|
725
|
+
parameter: str,
|
|
726
|
+
value: Any,
|
|
727
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
728
|
+
rx_mode: CommandRxMode | None = None,
|
|
729
|
+
check_against_pd: bool = False,
|
|
730
|
+
) -> set[DP_KEY_VALUE]:
|
|
731
|
+
"""Set single value on paramset VALUES."""
|
|
732
|
+
if paramset_key == ParamsetKey.VALUES:
|
|
733
|
+
return await self._set_value(
|
|
734
|
+
channel_address=channel_address,
|
|
735
|
+
parameter=parameter,
|
|
736
|
+
value=value,
|
|
737
|
+
wait_for_callback=wait_for_callback,
|
|
738
|
+
rx_mode=rx_mode,
|
|
739
|
+
check_against_pd=check_against_pd,
|
|
740
|
+
)
|
|
741
|
+
return await self.put_paramset(
|
|
742
|
+
channel_address=channel_address,
|
|
743
|
+
paramset_key_or_link_address=paramset_key,
|
|
744
|
+
values={parameter: value},
|
|
745
|
+
wait_for_callback=wait_for_callback,
|
|
746
|
+
rx_mode=rx_mode,
|
|
747
|
+
check_against_pd=check_against_pd,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
@inspector
|
|
751
|
+
async def get_paramset(
|
|
752
|
+
self,
|
|
753
|
+
*,
|
|
754
|
+
address: str,
|
|
755
|
+
paramset_key: ParamsetKey | str,
|
|
756
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
757
|
+
) -> dict[str, Any]:
|
|
758
|
+
"""
|
|
759
|
+
Return a paramset from the backend.
|
|
760
|
+
|
|
761
|
+
Address is usually the channel_address,
|
|
762
|
+
but for bidcos devices there is a master paramset at the device.
|
|
763
|
+
"""
|
|
764
|
+
try:
|
|
765
|
+
_LOGGER.debug(
|
|
766
|
+
"GET_PARAMSET: address %s, paramset_key %s, source %s",
|
|
767
|
+
address,
|
|
768
|
+
paramset_key,
|
|
769
|
+
call_source,
|
|
770
|
+
)
|
|
771
|
+
return cast(dict[str, Any], await self._proxy_read.getParamset(address, paramset_key))
|
|
772
|
+
except BaseHomematicException as bhexc: # pragma: no cover
|
|
773
|
+
raise ClientException(
|
|
774
|
+
f"GET_PARAMSET failed with for {address}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
|
|
775
|
+
) from bhexc
|
|
776
|
+
|
|
777
|
+
@inspector(measure_performance=True)
|
|
778
|
+
async def put_paramset(
|
|
779
|
+
self,
|
|
780
|
+
*,
|
|
781
|
+
channel_address: str,
|
|
782
|
+
paramset_key_or_link_address: ParamsetKey | str,
|
|
783
|
+
values: dict[str, Any],
|
|
784
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
785
|
+
rx_mode: CommandRxMode | None = None,
|
|
786
|
+
check_against_pd: bool = False,
|
|
787
|
+
) -> set[DP_KEY_VALUE]:
|
|
788
|
+
"""
|
|
789
|
+
Set paramsets manually.
|
|
790
|
+
|
|
791
|
+
Address is usually the channel_address, but for bidcos devices there is a master paramset at the device.
|
|
792
|
+
Paramset_key can be a str with a channel address in case of manipulating a direct link.
|
|
793
|
+
If paramset_key is string and contains a channel address, then the LINK paramset must be used for a check.
|
|
794
|
+
"""
|
|
795
|
+
is_link_call: bool = False
|
|
796
|
+
checked_values = values
|
|
797
|
+
try:
|
|
798
|
+
if check_against_pd:
|
|
799
|
+
check_paramset_key = (
|
|
800
|
+
ParamsetKey(paramset_key_or_link_address)
|
|
801
|
+
if is_paramset_key(paramset_key=paramset_key_or_link_address)
|
|
802
|
+
else ParamsetKey.LINK
|
|
803
|
+
if (is_link_call := is_channel_address(address=paramset_key_or_link_address))
|
|
804
|
+
else None
|
|
805
|
+
)
|
|
806
|
+
if check_paramset_key:
|
|
807
|
+
checked_values = self._check_put_paramset(
|
|
808
|
+
channel_address=channel_address,
|
|
809
|
+
paramset_key=check_paramset_key,
|
|
810
|
+
values=values,
|
|
811
|
+
)
|
|
812
|
+
else:
|
|
813
|
+
raise ClientException(
|
|
814
|
+
"Parameter paramset_key is neither a valid ParamsetKey nor a channel address."
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
_LOGGER.debug("PUT_PARAMSET: %s, %s, %s", channel_address, paramset_key_or_link_address, checked_values)
|
|
818
|
+
if rx_mode and (device := self.central.get_device(address=channel_address)):
|
|
819
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
820
|
+
await self._exec_put_paramset(
|
|
821
|
+
channel_address=channel_address,
|
|
822
|
+
paramset_key=paramset_key_or_link_address,
|
|
823
|
+
values=checked_values,
|
|
824
|
+
rx_mode=rx_mode,
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
raise ClientException(f"Unsupported rx_mode: {rx_mode}")
|
|
828
|
+
else:
|
|
829
|
+
await self._exec_put_paramset(
|
|
830
|
+
channel_address=channel_address,
|
|
831
|
+
paramset_key=paramset_key_or_link_address,
|
|
832
|
+
values=checked_values,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# if a call is related to a link then no further action is needed
|
|
836
|
+
if is_link_call:
|
|
837
|
+
return set()
|
|
838
|
+
|
|
839
|
+
# store the send value in the last_value_send_cache
|
|
840
|
+
dpk_values = self._last_value_send_cache.add_put_paramset(
|
|
841
|
+
channel_address=channel_address,
|
|
842
|
+
paramset_key=ParamsetKey(paramset_key_or_link_address),
|
|
843
|
+
values=checked_values,
|
|
844
|
+
)
|
|
845
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
846
|
+
|
|
847
|
+
if (
|
|
848
|
+
self.interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED)
|
|
849
|
+
and paramset_key_or_link_address == ParamsetKey.MASTER
|
|
850
|
+
and (channel := self.central.get_channel(channel_address=channel_address)) is not None
|
|
851
|
+
):
|
|
852
|
+
|
|
853
|
+
async def poll_master_dp_values() -> None:
|
|
854
|
+
"""Load master paramset values."""
|
|
855
|
+
if not channel:
|
|
856
|
+
return
|
|
857
|
+
for interval in self.central.config.hm_master_poll_after_send_intervals:
|
|
858
|
+
await asyncio.sleep(interval)
|
|
859
|
+
for dp in channel.get_readable_data_points(
|
|
860
|
+
paramset_key=ParamsetKey(paramset_key_or_link_address)
|
|
861
|
+
):
|
|
862
|
+
await dp.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True)
|
|
863
|
+
|
|
864
|
+
self.central.looper.create_task(target=poll_master_dp_values(), name="poll_master_dp_values")
|
|
865
|
+
|
|
866
|
+
if wait_for_callback is not None and (
|
|
867
|
+
device := self.central.get_device(address=get_device_address(address=channel_address))
|
|
868
|
+
):
|
|
869
|
+
await _wait_for_state_change_or_timeout(
|
|
870
|
+
device=device,
|
|
871
|
+
dpk_values=dpk_values,
|
|
872
|
+
wait_for_callback=wait_for_callback,
|
|
873
|
+
)
|
|
874
|
+
except BaseHomematicException as bhexc:
|
|
875
|
+
raise ClientException(
|
|
876
|
+
f"PUT_PARAMSET failed for {channel_address}/{paramset_key_or_link_address}/{values}: {extract_exc_args(exc=bhexc)}"
|
|
877
|
+
) from bhexc
|
|
878
|
+
else:
|
|
879
|
+
return dpk_values
|
|
880
|
+
|
|
881
|
+
async def _exec_put_paramset(
|
|
882
|
+
self,
|
|
883
|
+
*,
|
|
884
|
+
channel_address: str,
|
|
885
|
+
paramset_key: ParamsetKey | str,
|
|
886
|
+
values: dict[str, Any],
|
|
887
|
+
rx_mode: CommandRxMode | None = None,
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Put paramset into the backend."""
|
|
890
|
+
if rx_mode:
|
|
891
|
+
await self._proxy.putParamset(channel_address, paramset_key, values, rx_mode)
|
|
892
|
+
else:
|
|
893
|
+
await self._proxy.putParamset(channel_address, paramset_key, values)
|
|
894
|
+
|
|
895
|
+
def _check_put_paramset(
|
|
896
|
+
self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
|
|
897
|
+
) -> dict[str, Any]:
|
|
898
|
+
"""Check put_paramset."""
|
|
899
|
+
checked_values: dict[str, Any] = {}
|
|
900
|
+
for param, value in values.items():
|
|
901
|
+
checked_values[param] = self._convert_value(
|
|
902
|
+
channel_address=channel_address,
|
|
903
|
+
paramset_key=paramset_key,
|
|
904
|
+
parameter=param,
|
|
905
|
+
value=value,
|
|
906
|
+
operation=Operations.WRITE,
|
|
907
|
+
)
|
|
908
|
+
return checked_values
|
|
909
|
+
|
|
910
|
+
def _convert_value(
|
|
911
|
+
self,
|
|
912
|
+
*,
|
|
913
|
+
channel_address: str,
|
|
914
|
+
paramset_key: ParamsetKey,
|
|
915
|
+
parameter: str,
|
|
916
|
+
value: Any,
|
|
917
|
+
operation: Operations,
|
|
918
|
+
) -> Any:
|
|
919
|
+
"""Check a single parameter against paramset descriptions and convert the value."""
|
|
920
|
+
if parameter_data := self.central.paramset_descriptions.get_parameter_data(
|
|
921
|
+
interface_id=self.interface_id,
|
|
922
|
+
channel_address=channel_address,
|
|
923
|
+
paramset_key=paramset_key,
|
|
924
|
+
parameter=parameter,
|
|
925
|
+
):
|
|
926
|
+
pd_type = parameter_data["TYPE"]
|
|
927
|
+
op_mask = int(operation)
|
|
928
|
+
if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
|
|
929
|
+
raise ClientException(
|
|
930
|
+
f"Parameter {parameter} does not support the requested operation {operation.value}"
|
|
931
|
+
)
|
|
932
|
+
# Only build a tuple if a value list exists
|
|
933
|
+
pd_value_list = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
|
|
934
|
+
return convert_value(value=value, target_type=pd_type, value_list=pd_value_list)
|
|
935
|
+
raise ClientException(
|
|
936
|
+
f"Parameter {parameter} could not be found: {self.interface_id}/{channel_address}/{paramset_key}"
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
def _get_parameter_type(
|
|
940
|
+
self,
|
|
941
|
+
*,
|
|
942
|
+
channel_address: str,
|
|
943
|
+
paramset_key: ParamsetKey,
|
|
944
|
+
parameter: str,
|
|
945
|
+
) -> ParameterType | None:
|
|
946
|
+
if parameter_data := self.central.paramset_descriptions.get_parameter_data(
|
|
947
|
+
interface_id=self.interface_id,
|
|
948
|
+
channel_address=channel_address,
|
|
949
|
+
paramset_key=paramset_key,
|
|
950
|
+
parameter=parameter,
|
|
951
|
+
):
|
|
952
|
+
return parameter_data["TYPE"]
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
@inspector(re_raise=False)
|
|
956
|
+
async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
|
|
957
|
+
"""Fetch a specific paramset and add it to the known ones."""
|
|
958
|
+
_LOGGER.debug("FETCH_PARAMSET_DESCRIPTION: %s for %s", paramset_key, channel_address)
|
|
959
|
+
|
|
960
|
+
if paramset_description := await self._get_paramset_description(
|
|
961
|
+
address=channel_address, paramset_key=paramset_key
|
|
962
|
+
):
|
|
963
|
+
self.central.paramset_descriptions.add(
|
|
964
|
+
interface_id=self.interface_id,
|
|
965
|
+
channel_address=channel_address,
|
|
966
|
+
paramset_key=paramset_key,
|
|
967
|
+
paramset_description=paramset_description,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
@inspector(re_raise=False)
|
|
971
|
+
async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
|
|
972
|
+
"""Fetch paramsets for provided device description."""
|
|
973
|
+
data = await self.get_paramset_descriptions(device_description=device_description)
|
|
974
|
+
for address, paramsets in data.items():
|
|
975
|
+
_LOGGER.debug("FETCH_PARAMSET_DESCRIPTIONS for %s", address)
|
|
976
|
+
for paramset_key, paramset_description in paramsets.items():
|
|
977
|
+
self.central.paramset_descriptions.add(
|
|
978
|
+
interface_id=self.interface_id,
|
|
979
|
+
channel_address=address,
|
|
980
|
+
paramset_key=paramset_key,
|
|
981
|
+
paramset_description=paramset_description,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
985
|
+
async def get_paramset_descriptions(
|
|
986
|
+
self, *, device_description: DeviceDescription
|
|
987
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
988
|
+
"""Get paramsets for provided device description."""
|
|
989
|
+
paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
990
|
+
address = device_description["ADDRESS"]
|
|
991
|
+
paramsets[address] = {}
|
|
992
|
+
_LOGGER.debug("GET_PARAMSET_DESCRIPTIONS for %s", address)
|
|
993
|
+
for p_key in device_description["PARAMSETS"]:
|
|
994
|
+
paramset_key = ParamsetKey(p_key)
|
|
995
|
+
if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
|
|
996
|
+
paramsets[address][paramset_key] = paramset_description
|
|
997
|
+
return paramsets
|
|
998
|
+
|
|
999
|
+
async def _get_paramset_description(
|
|
1000
|
+
self, *, address: str, paramset_key: ParamsetKey
|
|
1001
|
+
) -> dict[str, ParameterData] | None:
|
|
1002
|
+
"""Get paramset description from the backend."""
|
|
1003
|
+
try:
|
|
1004
|
+
return cast(
|
|
1005
|
+
dict[str, ParameterData],
|
|
1006
|
+
await self._proxy_read.getParamsetDescription(address, paramset_key),
|
|
1007
|
+
)
|
|
1008
|
+
except BaseHomematicException as bhexc:
|
|
1009
|
+
_LOGGER.debug(
|
|
1010
|
+
"GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
|
|
1011
|
+
bhexc.name,
|
|
1012
|
+
extract_exc_args(exc=bhexc),
|
|
1013
|
+
paramset_key,
|
|
1014
|
+
address,
|
|
1015
|
+
)
|
|
1016
|
+
return None
|
|
1017
|
+
|
|
1018
|
+
@inspector
|
|
1019
|
+
async def get_all_paramset_descriptions(
|
|
1020
|
+
self, *, device_descriptions: tuple[DeviceDescription, ...]
|
|
1021
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
1022
|
+
"""Get all paramset descriptions for provided device descriptions."""
|
|
1023
|
+
all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
1024
|
+
for device_description in device_descriptions:
|
|
1025
|
+
all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
|
|
1026
|
+
return all_paramsets
|
|
1027
|
+
|
|
1028
|
+
@inspector
|
|
1029
|
+
async def has_program_ids(self, *, channel_hmid: str) -> bool:
|
|
1030
|
+
"""Return if a channel has program ids."""
|
|
1031
|
+
return False
|
|
1032
|
+
|
|
1033
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1034
|
+
async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
|
|
1035
|
+
"""List devices of the backend."""
|
|
1036
|
+
try:
|
|
1037
|
+
return tuple(await self._proxy_read.listDevices())
|
|
1038
|
+
except BaseHomematicException as bhexc: # pragma: no cover
|
|
1039
|
+
_LOGGER.debug(
|
|
1040
|
+
"LIST_DEVICES failed: %s [%s]",
|
|
1041
|
+
bhexc.name,
|
|
1042
|
+
extract_exc_args(exc=bhexc),
|
|
1043
|
+
)
|
|
1044
|
+
return None
|
|
1045
|
+
|
|
1046
|
+
@inspector
|
|
1047
|
+
async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
|
|
1048
|
+
"""Report value usage."""
|
|
1049
|
+
return False
|
|
1050
|
+
|
|
1051
|
+
@inspector
|
|
1052
|
+
async def update_device_firmware(self, *, device_address: str) -> bool:
|
|
1053
|
+
"""Update the firmware of a Homematic device."""
|
|
1054
|
+
if device := self.central.get_device(address=device_address):
|
|
1055
|
+
_LOGGER.info(
|
|
1056
|
+
"UPDATE_DEVICE_FIRMWARE: Trying firmware update for %s",
|
|
1057
|
+
device_address,
|
|
1058
|
+
)
|
|
1059
|
+
try:
|
|
1060
|
+
update_result = (
|
|
1061
|
+
await self._proxy.installFirmware(device_address)
|
|
1062
|
+
if device.product_group in (ProductGroup.HMIPW, ProductGroup.HMIP)
|
|
1063
|
+
else await self._proxy.updateFirmware(device_address)
|
|
1064
|
+
)
|
|
1065
|
+
result = bool(update_result) if isinstance(update_result, bool) else bool(update_result[0])
|
|
1066
|
+
_LOGGER.info(
|
|
1067
|
+
"UPDATE_DEVICE_FIRMWARE: Executed firmware update for %s with result '%s'",
|
|
1068
|
+
device_address,
|
|
1069
|
+
"success" if result else "failed",
|
|
1070
|
+
)
|
|
1071
|
+
except BaseHomematicException as bhexc:
|
|
1072
|
+
raise ClientException(f"UPDATE_DEVICE_FIRMWARE failed]: {extract_exc_args(exc=bhexc)}") from bhexc
|
|
1073
|
+
return result
|
|
1074
|
+
return False
|
|
1075
|
+
|
|
1076
|
+
@inspector(re_raise=False)
|
|
1077
|
+
async def update_paramset_descriptions(self, *, device_address: str) -> None:
|
|
1078
|
+
"""Update paramsets descriptions for provided device_address."""
|
|
1079
|
+
if not self.central.device_descriptions.get_device_descriptions(interface_id=self.interface_id):
|
|
1080
|
+
_LOGGER.warning(
|
|
1081
|
+
"UPDATE_PARAMSET_DESCRIPTIONS failed: "
|
|
1082
|
+
"Interface missing in central cache. "
|
|
1083
|
+
"Not updating paramsets for %s",
|
|
1084
|
+
device_address,
|
|
1085
|
+
)
|
|
1086
|
+
return
|
|
1087
|
+
|
|
1088
|
+
if device_description := self.central.device_descriptions.find_device_description(
|
|
1089
|
+
interface_id=self.interface_id, device_address=device_address
|
|
1090
|
+
):
|
|
1091
|
+
await self.fetch_paramset_descriptions(device_description=device_description)
|
|
1092
|
+
else:
|
|
1093
|
+
_LOGGER.warning(
|
|
1094
|
+
"UPDATE_PARAMSET_DESCRIPTIONS failed: Channel missing in central.cache. Not updating paramsets for %s",
|
|
1095
|
+
device_address,
|
|
1096
|
+
)
|
|
1097
|
+
return
|
|
1098
|
+
await self.central.save_files(save_paramset_descriptions=True)
|
|
1099
|
+
|
|
1100
|
+
def __str__(self) -> str:
|
|
1101
|
+
"""Provide some useful information."""
|
|
1102
|
+
return f"interface_id: {self.interface_id}"
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
class ClientCCU(Client):
|
|
1106
|
+
"""Client implementation for CCU backend."""
|
|
1107
|
+
|
|
1108
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
1109
|
+
"""Initialize the Client."""
|
|
1110
|
+
self._json_rpc_client: Final = client_config.central.json_rpc_client
|
|
1111
|
+
super().__init__(client_config=client_config)
|
|
1112
|
+
|
|
1113
|
+
@property
|
|
1114
|
+
def model(self) -> str:
|
|
1115
|
+
"""Return the model of the backend."""
|
|
1116
|
+
return Backend.CCU
|
|
1117
|
+
|
|
1118
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1119
|
+
async def fetch_device_details(self) -> None:
|
|
1120
|
+
"""Get all names via JSON-RPS and store in data.NAMES."""
|
|
1121
|
+
if json_result := await self._json_rpc_client.get_device_details():
|
|
1122
|
+
for device in json_result:
|
|
1123
|
+
# ignore unknown interfaces
|
|
1124
|
+
if (interface := device[_JSON_INTERFACE]) and interface not in Interface:
|
|
1125
|
+
continue
|
|
1126
|
+
|
|
1127
|
+
device_address = device[_JSON_ADDRESS]
|
|
1128
|
+
self.central.device_details.add_interface(address=device_address, interface=Interface(interface))
|
|
1129
|
+
self.central.device_details.add_name(address=device_address, name=device[_JSON_NAME])
|
|
1130
|
+
self.central.device_details.add_address_id(address=device_address, hmid=device[_JSON_ID])
|
|
1131
|
+
for channel in device.get(_JSON_CHANNELS, []):
|
|
1132
|
+
channel_address = channel[_JSON_ADDRESS]
|
|
1133
|
+
self.central.device_details.add_name(address=channel_address, name=channel[_JSON_NAME])
|
|
1134
|
+
self.central.device_details.add_address_id(address=channel_address, hmid=channel[_JSON_ID])
|
|
1135
|
+
else:
|
|
1136
|
+
_LOGGER.debug("FETCH_DEVICE_DETAILS: Unable to fetch device details via JSON-RPC")
|
|
1137
|
+
|
|
1138
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1139
|
+
async def fetch_all_device_data(self) -> None:
|
|
1140
|
+
"""Fetch all device data from the backend."""
|
|
1141
|
+
try:
|
|
1142
|
+
if all_device_data := await self._json_rpc_client.get_all_device_data(interface=self.interface):
|
|
1143
|
+
_LOGGER.debug(
|
|
1144
|
+
"FETCH_ALL_DEVICE_DATA: Fetched all device data for interface %s",
|
|
1145
|
+
self.interface,
|
|
1146
|
+
)
|
|
1147
|
+
self.central.data_cache.add_data(interface=self.interface, all_device_data=all_device_data)
|
|
1148
|
+
return
|
|
1149
|
+
except ClientException:
|
|
1150
|
+
self.central.emit_interface_event(
|
|
1151
|
+
interface_id=self.interface_id,
|
|
1152
|
+
interface_event_type=InterfaceEventType.FETCH_DATA,
|
|
1153
|
+
data={EventKey.AVAILABLE: False},
|
|
1154
|
+
)
|
|
1155
|
+
raise
|
|
1156
|
+
|
|
1157
|
+
_LOGGER.debug(
|
|
1158
|
+
"FETCH_ALL_DEVICE_DATA: Unable to get all device data via JSON-RPC RegaScript for interface %s",
|
|
1159
|
+
self.interface,
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
1163
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
1164
|
+
"""Check if _proxy is still initialized."""
|
|
1165
|
+
try:
|
|
1166
|
+
dt_now = datetime.now()
|
|
1167
|
+
if handle_ping_pong and self.supports_ping_pong and self._is_initialized:
|
|
1168
|
+
callerId = (
|
|
1169
|
+
f"{self.interface_id}#{dt_now.strftime(format=DATETIME_FORMAT_MILLIS)}"
|
|
1170
|
+
if handle_ping_pong
|
|
1171
|
+
else self.interface_id
|
|
1172
|
+
)
|
|
1173
|
+
await self._proxy.ping(callerId)
|
|
1174
|
+
self._ping_pong_cache.handle_send_ping(ping_ts=dt_now)
|
|
1175
|
+
elif not self._is_initialized:
|
|
1176
|
+
await self._proxy.ping(self.interface_id)
|
|
1177
|
+
self.modified_at = dt_now
|
|
1178
|
+
except BaseHomematicException as bhexc:
|
|
1179
|
+
_LOGGER.debug(
|
|
1180
|
+
"CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
|
|
1181
|
+
bhexc.name,
|
|
1182
|
+
extract_exc_args(exc=bhexc),
|
|
1183
|
+
)
|
|
1184
|
+
else:
|
|
1185
|
+
return True
|
|
1186
|
+
self.modified_at = INIT_DATETIME
|
|
1187
|
+
return False
|
|
1188
|
+
|
|
1189
|
+
@inspector
|
|
1190
|
+
async def execute_program(self, *, pid: str) -> bool:
|
|
1191
|
+
"""Execute a program on the backend."""
|
|
1192
|
+
return await self._json_rpc_client.execute_program(pid=pid)
|
|
1193
|
+
|
|
1194
|
+
@inspector
|
|
1195
|
+
async def set_program_state(self, *, pid: str, state: bool) -> bool:
|
|
1196
|
+
"""Set the program state on the backend."""
|
|
1197
|
+
return await self._json_rpc_client.set_program_state(pid=pid, state=state)
|
|
1198
|
+
|
|
1199
|
+
@inspector
|
|
1200
|
+
async def has_program_ids(self, *, channel_hmid: str) -> bool:
|
|
1201
|
+
"""Return if a channel has program ids."""
|
|
1202
|
+
return await self._json_rpc_client.has_program_ids(channel_hmid=channel_hmid)
|
|
1203
|
+
|
|
1204
|
+
@inspector
|
|
1205
|
+
async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
|
|
1206
|
+
"""Report value usage."""
|
|
1207
|
+
try:
|
|
1208
|
+
return bool(await self._proxy.reportValueUsage(address, value_id, ref_counter))
|
|
1209
|
+
except BaseHomematicException as bhexc:
|
|
1210
|
+
raise ClientException(
|
|
1211
|
+
f"REPORT_VALUE_USAGE failed with: {address}/{value_id}/{ref_counter}: {extract_exc_args(exc=bhexc)}"
|
|
1212
|
+
) from bhexc
|
|
1213
|
+
|
|
1214
|
+
@inspector(measure_performance=True)
|
|
1215
|
+
async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
|
|
1216
|
+
"""Set a system variable on the backend."""
|
|
1217
|
+
return await self._json_rpc_client.set_system_variable(legacy_name=legacy_name, value=value)
|
|
1218
|
+
|
|
1219
|
+
@inspector
|
|
1220
|
+
async def delete_system_variable(self, *, name: str) -> bool:
|
|
1221
|
+
"""Delete a system variable from the backend."""
|
|
1222
|
+
return await self._json_rpc_client.delete_system_variable(name=name)
|
|
1223
|
+
|
|
1224
|
+
@inspector
|
|
1225
|
+
async def get_system_variable(self, *, name: str) -> Any:
|
|
1226
|
+
"""Get single system variable from the backend."""
|
|
1227
|
+
return await self._json_rpc_client.get_system_variable(name=name)
|
|
1228
|
+
|
|
1229
|
+
@inspector(re_raise=False)
|
|
1230
|
+
async def get_all_system_variables(
|
|
1231
|
+
self, *, markers: tuple[DescriptionMarker | str, ...]
|
|
1232
|
+
) -> tuple[SystemVariableData, ...] | None:
|
|
1233
|
+
"""Get all system variables from the backend."""
|
|
1234
|
+
return await self._json_rpc_client.get_all_system_variables(markers=markers)
|
|
1235
|
+
|
|
1236
|
+
@inspector(re_raise=False)
|
|
1237
|
+
async def get_all_programs(self, *, markers: tuple[DescriptionMarker | str, ...]) -> tuple[ProgramData, ...]:
|
|
1238
|
+
"""Get all programs, if available."""
|
|
1239
|
+
return await self._json_rpc_client.get_all_programs(markers=markers)
|
|
1240
|
+
|
|
1241
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
1242
|
+
async def get_all_rooms(self) -> dict[str, set[str]]:
|
|
1243
|
+
"""Get all rooms from the backend."""
|
|
1244
|
+
rooms: dict[str, set[str]] = {}
|
|
1245
|
+
channel_ids_room = await self._json_rpc_client.get_all_channel_ids_room()
|
|
1246
|
+
for address, channel_id in self.central.device_details.device_channel_ids.items():
|
|
1247
|
+
if names := channel_ids_room.get(channel_id):
|
|
1248
|
+
if address not in rooms:
|
|
1249
|
+
rooms[address] = set()
|
|
1250
|
+
rooms[address].update(names)
|
|
1251
|
+
return rooms
|
|
1252
|
+
|
|
1253
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
1254
|
+
async def get_all_functions(self) -> dict[str, set[str]]:
|
|
1255
|
+
"""Get all functions from the backend."""
|
|
1256
|
+
functions: dict[str, set[str]] = {}
|
|
1257
|
+
channel_ids_function = await self._json_rpc_client.get_all_channel_ids_function()
|
|
1258
|
+
for address, channel_id in self.central.device_details.device_channel_ids.items():
|
|
1259
|
+
if sections := channel_ids_function.get(channel_id):
|
|
1260
|
+
if address not in functions:
|
|
1261
|
+
functions[address] = set()
|
|
1262
|
+
functions[address].update(sections)
|
|
1263
|
+
return functions
|
|
1264
|
+
|
|
1265
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
1266
|
+
"""Get system information of the backend."""
|
|
1267
|
+
return await self._json_rpc_client.get_system_information()
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
class ClientJsonCCU(ClientCCU):
|
|
1271
|
+
"""Client implementation for CCU-like backend (CCU-Jack)."""
|
|
1272
|
+
|
|
1273
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
1274
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
1275
|
+
"""Check if proxy is still initialized."""
|
|
1276
|
+
return await self._json_rpc_client.is_present(interface=self.interface)
|
|
1277
|
+
|
|
1278
|
+
@inspector(re_raise=False)
|
|
1279
|
+
async def get_device_description(self, *, device_address: str) -> DeviceDescription | None:
|
|
1280
|
+
"""Get device descriptions from the backend."""
|
|
1281
|
+
try:
|
|
1282
|
+
if device_description := await self._json_rpc_client.get_device_description(
|
|
1283
|
+
interface=self.interface, address=device_address
|
|
1284
|
+
):
|
|
1285
|
+
return device_description
|
|
1286
|
+
except BaseHomematicException as bhexc:
|
|
1287
|
+
_LOGGER.warning("GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc))
|
|
1288
|
+
return None
|
|
1289
|
+
|
|
1290
|
+
@inspector
|
|
1291
|
+
async def get_paramset(
|
|
1292
|
+
self,
|
|
1293
|
+
*,
|
|
1294
|
+
address: str,
|
|
1295
|
+
paramset_key: ParamsetKey | str,
|
|
1296
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
1297
|
+
) -> dict[str, Any]:
|
|
1298
|
+
"""
|
|
1299
|
+
Return a paramset from the backend.
|
|
1300
|
+
|
|
1301
|
+
Address is usually the channel_address,
|
|
1302
|
+
but for bidcos devices there is a master paramset at the device.
|
|
1303
|
+
"""
|
|
1304
|
+
try:
|
|
1305
|
+
_LOGGER.debug(
|
|
1306
|
+
"GET_PARAMSET: address %s, paramset_key %s, source %s",
|
|
1307
|
+
address,
|
|
1308
|
+
paramset_key,
|
|
1309
|
+
call_source,
|
|
1310
|
+
)
|
|
1311
|
+
return (
|
|
1312
|
+
await self._json_rpc_client.get_paramset(
|
|
1313
|
+
interface=self.interface, address=address, paramset_key=paramset_key
|
|
1314
|
+
)
|
|
1315
|
+
or {}
|
|
1316
|
+
)
|
|
1317
|
+
except BaseHomematicException as bhexc:
|
|
1318
|
+
raise ClientException(
|
|
1319
|
+
f"GET_PARAMSET failed with for {address}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
|
|
1320
|
+
) from bhexc
|
|
1321
|
+
|
|
1322
|
+
@inspector(log_level=logging.NOTSET)
|
|
1323
|
+
async def get_value(
|
|
1324
|
+
self,
|
|
1325
|
+
*,
|
|
1326
|
+
channel_address: str,
|
|
1327
|
+
paramset_key: ParamsetKey,
|
|
1328
|
+
parameter: str,
|
|
1329
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
1330
|
+
) -> Any:
|
|
1331
|
+
"""Return a value from the backend."""
|
|
1332
|
+
try:
|
|
1333
|
+
_LOGGER.debug(
|
|
1334
|
+
"GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
|
|
1335
|
+
channel_address,
|
|
1336
|
+
parameter,
|
|
1337
|
+
paramset_key,
|
|
1338
|
+
call_source,
|
|
1339
|
+
)
|
|
1340
|
+
if paramset_key == ParamsetKey.VALUES:
|
|
1341
|
+
return await self._json_rpc_client.get_value(
|
|
1342
|
+
interface=self.interface,
|
|
1343
|
+
address=channel_address,
|
|
1344
|
+
paramset_key=paramset_key,
|
|
1345
|
+
parameter=parameter,
|
|
1346
|
+
)
|
|
1347
|
+
paramset = (
|
|
1348
|
+
await self._json_rpc_client.get_paramset(
|
|
1349
|
+
interface=self.interface,
|
|
1350
|
+
address=channel_address,
|
|
1351
|
+
paramset_key=ParamsetKey.MASTER,
|
|
1352
|
+
)
|
|
1353
|
+
or {}
|
|
1354
|
+
)
|
|
1355
|
+
return paramset.get(parameter)
|
|
1356
|
+
except BaseHomematicException as bhexc:
|
|
1357
|
+
raise ClientException(
|
|
1358
|
+
f"GET_VALUE failed with for: {channel_address}/{parameter}/{paramset_key}: {extract_exc_args(exc=bhexc)}"
|
|
1359
|
+
) from bhexc
|
|
1360
|
+
|
|
1361
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1362
|
+
async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
|
|
1363
|
+
"""List devices of Homematic backend."""
|
|
1364
|
+
try:
|
|
1365
|
+
return await self._json_rpc_client.list_devices(interface=self.interface)
|
|
1366
|
+
except BaseHomematicException as bhexc:
|
|
1367
|
+
_LOGGER.debug(
|
|
1368
|
+
"LIST_DEVICES failed with %s [%s]",
|
|
1369
|
+
bhexc.name,
|
|
1370
|
+
extract_exc_args(exc=bhexc),
|
|
1371
|
+
)
|
|
1372
|
+
return None
|
|
1373
|
+
|
|
1374
|
+
async def _get_paramset_description(
|
|
1375
|
+
self, *, address: str, paramset_key: ParamsetKey
|
|
1376
|
+
) -> dict[str, ParameterData] | None:
|
|
1377
|
+
"""Get paramset description from the backend."""
|
|
1378
|
+
try:
|
|
1379
|
+
return cast(
|
|
1380
|
+
dict[str, ParameterData],
|
|
1381
|
+
await self._json_rpc_client.get_paramset_description(
|
|
1382
|
+
interface=self.interface, address=address, paramset_key=paramset_key
|
|
1383
|
+
),
|
|
1384
|
+
)
|
|
1385
|
+
except BaseHomematicException as bhexc:
|
|
1386
|
+
_LOGGER.debug(
|
|
1387
|
+
"GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
|
|
1388
|
+
bhexc.name,
|
|
1389
|
+
extract_exc_args(exc=bhexc),
|
|
1390
|
+
paramset_key,
|
|
1391
|
+
address,
|
|
1392
|
+
)
|
|
1393
|
+
return None
|
|
1394
|
+
|
|
1395
|
+
async def _exec_put_paramset(
|
|
1396
|
+
self,
|
|
1397
|
+
*,
|
|
1398
|
+
channel_address: str,
|
|
1399
|
+
paramset_key: ParamsetKey | str,
|
|
1400
|
+
values: dict[str, Any],
|
|
1401
|
+
rx_mode: CommandRxMode | None = None,
|
|
1402
|
+
) -> None:
|
|
1403
|
+
"""Put paramset into the backend."""
|
|
1404
|
+
# _values: list[dict[str, Any]] = []
|
|
1405
|
+
for parameter, value in values.items():
|
|
1406
|
+
await self._exec_set_value(
|
|
1407
|
+
channel_address=channel_address, parameter=parameter, value=value, rx_mode=rx_mode
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
# Doesn't work. put_paramset not supported
|
|
1411
|
+
# if (
|
|
1412
|
+
# value_type := self._get_parameter_type(
|
|
1413
|
+
# channel_address=channel_address,
|
|
1414
|
+
# paramset_key=ParamsetKey.VALUES,
|
|
1415
|
+
# parameter=parameter,
|
|
1416
|
+
# )
|
|
1417
|
+
# ) is None:
|
|
1418
|
+
# raise ClientException(
|
|
1419
|
+
# f"PUT_PARAMSET failed: Unable to identify parameter type {channel_address}/{paramset_key}/{parameter}"
|
|
1420
|
+
# )
|
|
1421
|
+
# _type = _CCU_JSON_VALUE_TYPE.get(value_type, "string")
|
|
1422
|
+
#
|
|
1423
|
+
# _values.append({"name": parameter, "type":_type, "value": str(value)})
|
|
1424
|
+
#
|
|
1425
|
+
# await self._json_rpc_client.put_paramset(
|
|
1426
|
+
# interface=self.interface,
|
|
1427
|
+
# address=channel_address,
|
|
1428
|
+
# paramset_key=paramset_key,
|
|
1429
|
+
# values=_values,
|
|
1430
|
+
# )
|
|
1431
|
+
|
|
1432
|
+
async def _exec_set_value(
|
|
1433
|
+
self,
|
|
1434
|
+
*,
|
|
1435
|
+
channel_address: str,
|
|
1436
|
+
parameter: str,
|
|
1437
|
+
value: Any,
|
|
1438
|
+
rx_mode: CommandRxMode | None = None,
|
|
1439
|
+
) -> None:
|
|
1440
|
+
"""Set single value on paramset VALUES."""
|
|
1441
|
+
if (
|
|
1442
|
+
value_type := self._get_parameter_type(
|
|
1443
|
+
channel_address=channel_address,
|
|
1444
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1445
|
+
parameter=parameter,
|
|
1446
|
+
)
|
|
1447
|
+
) is None:
|
|
1448
|
+
raise ClientException(
|
|
1449
|
+
f"SET_VALUE failed: Unable to identify parameter type {channel_address}/{ParamsetKey.VALUES}/{parameter}"
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
_type = _CCU_JSON_VALUE_TYPE.get(value_type, "string")
|
|
1453
|
+
await self._json_rpc_client.set_value(
|
|
1454
|
+
interface=self.interface,
|
|
1455
|
+
address=channel_address,
|
|
1456
|
+
parameter=parameter,
|
|
1457
|
+
value_type=_type,
|
|
1458
|
+
value=value,
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
1462
|
+
"""Get system information of the backend."""
|
|
1463
|
+
return SystemInformation(
|
|
1464
|
+
available_interfaces=(self.interface,),
|
|
1465
|
+
serial=f"{self.interface}_{DUMMY_SERIAL}",
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
@inspector
|
|
1469
|
+
async def add_link(self, *, sender_address: str, receiver_address: str, name: str, description: str) -> None:
|
|
1470
|
+
"""Return a list of links."""
|
|
1471
|
+
_LOGGER.debug("ADD_LINK: not usable for %s.", self.interface_id)
|
|
1472
|
+
|
|
1473
|
+
@inspector
|
|
1474
|
+
async def remove_link(self, *, sender_address: str, receiver_address: str) -> None:
|
|
1475
|
+
"""Return a list of links."""
|
|
1476
|
+
_LOGGER.debug("REMOVE_LINK: not usable for %s.", self.interface_id)
|
|
1477
|
+
|
|
1478
|
+
@inspector
|
|
1479
|
+
async def get_link_peers(self, *, address: str) -> tuple[str, ...]:
|
|
1480
|
+
"""Return a list of link pers."""
|
|
1481
|
+
_LOGGER.debug("GET_LINK_PEERS: not usable for %s.", self.interface_id)
|
|
1482
|
+
return ()
|
|
1483
|
+
|
|
1484
|
+
@inspector
|
|
1485
|
+
async def get_links(self, *, address: str, flags: int) -> dict[str, Any]:
|
|
1486
|
+
"""Return a list of links."""
|
|
1487
|
+
_LOGGER.debug("GET_LINKS: not usable for %s.", self.interface_id)
|
|
1488
|
+
return {}
|
|
1489
|
+
|
|
1490
|
+
@inspector
|
|
1491
|
+
async def get_metadata(self, *, address: str, data_id: str) -> dict[str, Any]:
|
|
1492
|
+
"""Return the metadata for an object."""
|
|
1493
|
+
_LOGGER.debug("GET_METADATA: not usable for %s.", self.interface_id)
|
|
1494
|
+
return {}
|
|
1495
|
+
|
|
1496
|
+
@inspector
|
|
1497
|
+
async def set_metadata(self, *, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
|
|
1498
|
+
"""Write the metadata for an object."""
|
|
1499
|
+
_LOGGER.debug("SET_METADATA: not usable for %s.", self.interface_id)
|
|
1500
|
+
return {}
|
|
1501
|
+
|
|
1502
|
+
@inspector
|
|
1503
|
+
async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
|
|
1504
|
+
"""Report value usage."""
|
|
1505
|
+
_LOGGER.debug("REPORT_VALUE_USAGE: not usable for %s.", self.interface_id)
|
|
1506
|
+
return True
|
|
1507
|
+
|
|
1508
|
+
@inspector
|
|
1509
|
+
async def update_device_firmware(self, *, device_address: str) -> bool:
|
|
1510
|
+
"""Update the firmware of a Homematic device."""
|
|
1511
|
+
_LOGGER.debug("UPDATE_DEVICE_FIRMWARE: not usable for %s.", self.interface_id)
|
|
1512
|
+
return True
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
class ClientHomegear(ClientCCU):
|
|
1516
|
+
"""
|
|
1517
|
+
Client implementation for Homegear backend.
|
|
1518
|
+
|
|
1519
|
+
Inherit from ClientCCU to share common behavior used by tests and code paths
|
|
1520
|
+
that expect a CCU-like client interface for Homegear selections.
|
|
1521
|
+
"""
|
|
1522
|
+
|
|
1523
|
+
@property
|
|
1524
|
+
def model(self) -> str:
|
|
1525
|
+
"""Return the model of the backend."""
|
|
1526
|
+
if self._config.version:
|
|
1527
|
+
return Backend.PYDEVCCU if Backend.PYDEVCCU.lower() in self._config.version else Backend.HOMEGEAR
|
|
1528
|
+
return Backend.CCU
|
|
1529
|
+
|
|
1530
|
+
@property
|
|
1531
|
+
def supports_ping_pong(self) -> bool:
|
|
1532
|
+
"""Return the supports_ping_pong info of the backend."""
|
|
1533
|
+
return False
|
|
1534
|
+
|
|
1535
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1536
|
+
async def fetch_all_device_data(self) -> None:
|
|
1537
|
+
"""Fetch all device data from the backend."""
|
|
1538
|
+
return
|
|
1539
|
+
|
|
1540
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1541
|
+
async def fetch_device_details(self) -> None:
|
|
1542
|
+
"""Get all names from metadata (Homegear)."""
|
|
1543
|
+
_LOGGER.debug("FETCH_DEVICE_DETAILS: Fetching names via Metadata")
|
|
1544
|
+
for address in self.central.device_descriptions.get_device_descriptions(interface_id=self.interface_id):
|
|
1545
|
+
try:
|
|
1546
|
+
self.central.device_details.add_name(
|
|
1547
|
+
address=address,
|
|
1548
|
+
name=await self._proxy_read.getMetadata(address, _NAME),
|
|
1549
|
+
)
|
|
1550
|
+
except BaseHomematicException as bhexc: # pragma: no cover
|
|
1551
|
+
_LOGGER.warning(
|
|
1552
|
+
"%s [%s] Failed to fetch name for device %s",
|
|
1553
|
+
bhexc.name,
|
|
1554
|
+
extract_exc_args(exc=bhexc),
|
|
1555
|
+
address,
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
1559
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
1560
|
+
"""Check if proxy is still initialized."""
|
|
1561
|
+
try:
|
|
1562
|
+
await self._proxy.clientServerInitialized(self.interface_id)
|
|
1563
|
+
self.modified_at = datetime.now()
|
|
1564
|
+
except BaseHomematicException as bhexc: # pragma: no cover
|
|
1565
|
+
_LOGGER.debug(
|
|
1566
|
+
"CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
|
|
1567
|
+
bhexc.name,
|
|
1568
|
+
extract_exc_args(exc=bhexc),
|
|
1569
|
+
)
|
|
1570
|
+
else:
|
|
1571
|
+
return True
|
|
1572
|
+
self.modified_at = INIT_DATETIME
|
|
1573
|
+
return False
|
|
1574
|
+
|
|
1575
|
+
@inspector(measure_performance=True)
|
|
1576
|
+
async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
|
|
1577
|
+
"""Set a system variable on the backend."""
|
|
1578
|
+
await self._proxy.setSystemVariable(legacy_name, value)
|
|
1579
|
+
return True
|
|
1580
|
+
|
|
1581
|
+
@inspector
|
|
1582
|
+
async def delete_system_variable(self, *, name: str) -> bool:
|
|
1583
|
+
"""Delete a system variable from the backend."""
|
|
1584
|
+
await self._proxy.deleteSystemVariable(name)
|
|
1585
|
+
return True
|
|
1586
|
+
|
|
1587
|
+
@inspector
|
|
1588
|
+
async def get_system_variable(self, *, name: str) -> Any:
|
|
1589
|
+
"""Get single system variable from the backend."""
|
|
1590
|
+
return await self._proxy.getSystemVariable(name)
|
|
1591
|
+
|
|
1592
|
+
@inspector(re_raise=False)
|
|
1593
|
+
async def get_all_system_variables(
|
|
1594
|
+
self, *, markers: tuple[DescriptionMarker | str, ...]
|
|
1595
|
+
) -> tuple[SystemVariableData, ...] | None:
|
|
1596
|
+
"""Get all system variables from the backend."""
|
|
1597
|
+
variables: list[SystemVariableData] = []
|
|
1598
|
+
if hg_variables := await self._proxy.getAllSystemVariables():
|
|
1599
|
+
for name, value in hg_variables.items():
|
|
1600
|
+
variables.append(SystemVariableData(vid=name, legacy_name=name, value=value))
|
|
1601
|
+
return tuple(variables)
|
|
1602
|
+
|
|
1603
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
1604
|
+
"""Get system information of the backend."""
|
|
1605
|
+
return SystemInformation(available_interfaces=(Interface.BIDCOS_RF,), serial=f"{self.interface}_{DUMMY_SERIAL}")
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
class ClientConfig:
|
|
1609
|
+
"""Config for a Client."""
|
|
1610
|
+
|
|
1611
|
+
def __init__(
|
|
1612
|
+
self,
|
|
1613
|
+
*,
|
|
1614
|
+
central: hmcu.CentralUnit,
|
|
1615
|
+
interface_config: InterfaceConfig,
|
|
1616
|
+
) -> None:
|
|
1617
|
+
"""Initialize the config."""
|
|
1618
|
+
self.central: Final = central
|
|
1619
|
+
self.version: str = "0"
|
|
1620
|
+
self.system_information = SystemInformation()
|
|
1621
|
+
self.interface_config: Final = interface_config
|
|
1622
|
+
self.interface: Final = interface_config.interface
|
|
1623
|
+
self.interface_id: Final = interface_config.interface_id
|
|
1624
|
+
self.max_read_workers: Final[int] = central.config.max_read_workers
|
|
1625
|
+
self.has_credentials: Final[bool] = central.config.username is not None and central.config.password is not None
|
|
1626
|
+
self.supports_firmware_updates: Final = self.interface in INTERFACES_SUPPORTING_FIRMWARE_UPDATES
|
|
1627
|
+
self.supports_push_updates: Final = self.interface not in central.config.interfaces_requiring_periodic_refresh
|
|
1628
|
+
self.supports_rpc_callback: Final = self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK
|
|
1629
|
+
callback_host: Final = (
|
|
1630
|
+
central.config.callback_host if central.config.callback_host else central.callback_ip_addr
|
|
1631
|
+
)
|
|
1632
|
+
callback_port = (
|
|
1633
|
+
central.config.callback_port_xml_rpc
|
|
1634
|
+
if central.config.callback_port_xml_rpc
|
|
1635
|
+
else central.listen_port_xml_rpc
|
|
1636
|
+
)
|
|
1637
|
+
init_url = f"{callback_host}:{callback_port}"
|
|
1638
|
+
self.init_url: Final = f"http://{init_url}"
|
|
1639
|
+
|
|
1640
|
+
self.xml_rpc_uri: Final = build_xml_rpc_uri(
|
|
1641
|
+
host=central.config.host,
|
|
1642
|
+
port=interface_config.port,
|
|
1643
|
+
path=interface_config.remote_path,
|
|
1644
|
+
tls=central.config.tls,
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
async def create_client(self) -> Client:
|
|
1648
|
+
"""Identify the used client."""
|
|
1649
|
+
try:
|
|
1650
|
+
self.version = await self._get_version()
|
|
1651
|
+
client: Client | None
|
|
1652
|
+
if self.interface == Interface.BIDCOS_RF and ("Homegear" in self.version or "pydevccu" in self.version):
|
|
1653
|
+
client = ClientHomegear(client_config=self)
|
|
1654
|
+
elif self.interface in INTERFACES_REQUIRING_JSON_RPC_CLIENT:
|
|
1655
|
+
client = ClientJsonCCU(client_config=self)
|
|
1656
|
+
else:
|
|
1657
|
+
client = ClientCCU(client_config=self)
|
|
1658
|
+
|
|
1659
|
+
if client:
|
|
1660
|
+
await client.init_client()
|
|
1661
|
+
if await client.check_connection_availability(handle_ping_pong=False):
|
|
1662
|
+
return client
|
|
1663
|
+
raise NoConnectionException(f"No connection to {self.interface_id}")
|
|
1664
|
+
except BaseHomematicException:
|
|
1665
|
+
raise
|
|
1666
|
+
except Exception as exc: # pragma: no cover
|
|
1667
|
+
raise NoConnectionException(f"Unable to connect {extract_exc_args(exc=exc)}.") from exc
|
|
1668
|
+
|
|
1669
|
+
async def _get_version(self) -> str:
|
|
1670
|
+
"""Return the version of the the backend."""
|
|
1671
|
+
if self.interface in INTERFACES_REQUIRING_JSON_RPC_CLIENT:
|
|
1672
|
+
return "0"
|
|
1673
|
+
check_proxy = await self._create_simple_rpc_proxy(interface=self.interface)
|
|
1674
|
+
try:
|
|
1675
|
+
if (methods := check_proxy.supported_methods) and "getVersion" in methods:
|
|
1676
|
+
# BidCos-Wired does not support getVersion()
|
|
1677
|
+
return cast(str, await check_proxy.getVersion())
|
|
1678
|
+
except Exception as exc: # pragma: no cover
|
|
1679
|
+
raise NoConnectionException(f"Unable to connect {extract_exc_args(exc=exc)}.") from exc
|
|
1680
|
+
return "0"
|
|
1681
|
+
|
|
1682
|
+
async def create_rpc_proxy(
|
|
1683
|
+
self, *, interface: Interface, auth_enabled: bool | None = None, max_workers: int = DEFAULT_MAX_WORKERS
|
|
1684
|
+
) -> BaseRpcProxy:
|
|
1685
|
+
"""Return a RPC proxy for the backend communication."""
|
|
1686
|
+
return await self._create_xml_rpc_proxy(auth_enabled=auth_enabled, max_workers=max_workers)
|
|
1687
|
+
|
|
1688
|
+
async def _create_xml_rpc_proxy(
|
|
1689
|
+
self, *, auth_enabled: bool | None = None, max_workers: int = DEFAULT_MAX_WORKERS
|
|
1690
|
+
) -> AioXmlRpcProxy:
|
|
1691
|
+
"""Return a XmlRPC proxy for the backend communication."""
|
|
1692
|
+
config = self.central.config
|
|
1693
|
+
xml_rpc_headers = (
|
|
1694
|
+
build_xml_rpc_headers(
|
|
1695
|
+
username=config.username,
|
|
1696
|
+
password=config.password,
|
|
1697
|
+
)
|
|
1698
|
+
if auth_enabled
|
|
1699
|
+
else []
|
|
1700
|
+
)
|
|
1701
|
+
xml_proxy = AioXmlRpcProxy(
|
|
1702
|
+
max_workers=max_workers,
|
|
1703
|
+
interface_id=self.interface_id,
|
|
1704
|
+
connection_state=self.central.connection_state,
|
|
1705
|
+
uri=self.xml_rpc_uri,
|
|
1706
|
+
headers=xml_rpc_headers,
|
|
1707
|
+
tls=config.tls,
|
|
1708
|
+
verify_tls=config.verify_tls,
|
|
1709
|
+
session_recorder=self.central.recorder,
|
|
1710
|
+
)
|
|
1711
|
+
await xml_proxy.do_init()
|
|
1712
|
+
return xml_proxy
|
|
1713
|
+
|
|
1714
|
+
async def _create_simple_rpc_proxy(self, *, interface: Interface) -> BaseRpcProxy:
|
|
1715
|
+
"""Return a RPC proxy for the backend communication."""
|
|
1716
|
+
return await self._create_xml_rpc_proxy(auth_enabled=True, max_workers=0)
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
class InterfaceConfig:
|
|
1720
|
+
"""interface config for a Client."""
|
|
1721
|
+
|
|
1722
|
+
def __init__(
|
|
1723
|
+
self,
|
|
1724
|
+
*,
|
|
1725
|
+
central_name: str,
|
|
1726
|
+
interface: Interface,
|
|
1727
|
+
port: int,
|
|
1728
|
+
remote_path: str | None = None,
|
|
1729
|
+
) -> None:
|
|
1730
|
+
"""Init the interface config."""
|
|
1731
|
+
self.interface: Final[Interface] = interface
|
|
1732
|
+
|
|
1733
|
+
self.rpc_server: Final[RpcServerType] = INTERFACE_RPC_SERVER_TYPE[interface]
|
|
1734
|
+
self.interface_id: Final[str] = f"{central_name}-{self.interface}"
|
|
1735
|
+
self.port: Final = port
|
|
1736
|
+
self.remote_path: Final = remote_path
|
|
1737
|
+
self._init_validate()
|
|
1738
|
+
self._enabled: bool = True
|
|
1739
|
+
|
|
1740
|
+
def _init_validate(self) -> None:
|
|
1741
|
+
"""Validate the client_config."""
|
|
1742
|
+
if not self.port and self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK:
|
|
1743
|
+
raise ClientException(f"VALIDATE interface config failed: Port must defined for interface{self.interface}")
|
|
1744
|
+
|
|
1745
|
+
@property
|
|
1746
|
+
def enabled(self) -> bool:
|
|
1747
|
+
"""Return if the interface config is enabled."""
|
|
1748
|
+
return self._enabled
|
|
1749
|
+
|
|
1750
|
+
def disable(self) -> None:
|
|
1751
|
+
"""Disable the interface config."""
|
|
1752
|
+
self._enabled = False
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
async def create_client(
|
|
1756
|
+
central: hmcu.CentralUnit,
|
|
1757
|
+
interface_config: InterfaceConfig,
|
|
1758
|
+
) -> Client:
|
|
1759
|
+
"""Return a new client for with a given interface_config."""
|
|
1760
|
+
return await ClientConfig(central=central, interface_config=interface_config).create_client()
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def get_client(interface_id: str) -> Client | None:
|
|
1764
|
+
"""Return client by interface_id."""
|
|
1765
|
+
for central in hmcu.CENTRAL_INSTANCES.values():
|
|
1766
|
+
if central.has_client(interface_id=interface_id):
|
|
1767
|
+
return central.get_client(interface_id=interface_id)
|
|
1768
|
+
return None
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
@measure_execution_time
|
|
1772
|
+
async def _wait_for_state_change_or_timeout(
|
|
1773
|
+
*,
|
|
1774
|
+
device: Device,
|
|
1775
|
+
dpk_values: set[DP_KEY_VALUE],
|
|
1776
|
+
wait_for_callback: int,
|
|
1777
|
+
) -> None:
|
|
1778
|
+
"""Wait for a data_point to change state."""
|
|
1779
|
+
waits = [
|
|
1780
|
+
_track_single_data_point_state_change_or_timeout(
|
|
1781
|
+
device=device,
|
|
1782
|
+
dpk_value=dpk_value,
|
|
1783
|
+
wait_for_callback=wait_for_callback,
|
|
1784
|
+
)
|
|
1785
|
+
for dpk_value in dpk_values
|
|
1786
|
+
]
|
|
1787
|
+
await asyncio.gather(*waits)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
@measure_execution_time
|
|
1791
|
+
async def _track_single_data_point_state_change_or_timeout(
|
|
1792
|
+
*, device: Device, dpk_value: DP_KEY_VALUE, wait_for_callback: int
|
|
1793
|
+
) -> None:
|
|
1794
|
+
"""Wait for a data_point to change state."""
|
|
1795
|
+
ev = asyncio.Event()
|
|
1796
|
+
dpk, value = dpk_value
|
|
1797
|
+
|
|
1798
|
+
def _async_event_changed(*args: Any, **kwargs: Any) -> None:
|
|
1799
|
+
if dp:
|
|
1800
|
+
_LOGGER.debug(
|
|
1801
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Received event %s with value %s",
|
|
1802
|
+
dpk,
|
|
1803
|
+
dp.value,
|
|
1804
|
+
)
|
|
1805
|
+
if _isclose(value1=value, value2=dp.value):
|
|
1806
|
+
_LOGGER.debug(
|
|
1807
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Finished event %s with value %s",
|
|
1808
|
+
dpk,
|
|
1809
|
+
dp.value,
|
|
1810
|
+
)
|
|
1811
|
+
ev.set()
|
|
1812
|
+
|
|
1813
|
+
if dp := device.get_generic_data_point(
|
|
1814
|
+
channel_address=dpk.channel_address,
|
|
1815
|
+
parameter=dpk.parameter,
|
|
1816
|
+
paramset_key=ParamsetKey(dpk.paramset_key),
|
|
1817
|
+
):
|
|
1818
|
+
if not dp.supports_events:
|
|
1819
|
+
_LOGGER.debug(
|
|
1820
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: DataPoint supports no events %s",
|
|
1821
|
+
dpk,
|
|
1822
|
+
)
|
|
1823
|
+
return
|
|
1824
|
+
if (
|
|
1825
|
+
unsub := dp.register_data_point_updated_callback(
|
|
1826
|
+
cb=_async_event_changed, custom_id=InternalCustomID.DEFAULT
|
|
1827
|
+
)
|
|
1828
|
+
) is None:
|
|
1829
|
+
return
|
|
1830
|
+
|
|
1831
|
+
try:
|
|
1832
|
+
async with asyncio.timeout(wait_for_callback):
|
|
1833
|
+
await ev.wait()
|
|
1834
|
+
except TimeoutError:
|
|
1835
|
+
_LOGGER.debug(
|
|
1836
|
+
"TRACK_SINGLE_DATA_POINT_STATE_CHANGE_OR_TIMEOUT: Timeout waiting for event %s with value %s",
|
|
1837
|
+
dpk,
|
|
1838
|
+
dp.value,
|
|
1839
|
+
)
|
|
1840
|
+
finally:
|
|
1841
|
+
unsub()
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _isclose(*, value1: Any, value2: Any) -> bool:
|
|
1845
|
+
"""Check if the both values are close to each other."""
|
|
1846
|
+
if isinstance(value1, float):
|
|
1847
|
+
return bool(round(value1, 2) == round(value2, 2))
|
|
1848
|
+
return bool(value1 == value2)
|