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,1304 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Unified InterfaceClient implementation.
|
|
5
|
+
|
|
6
|
+
Uses the Backend Strategy Pattern to abstract transport differences
|
|
7
|
+
(CCU, CCU-Jack, Homegear) behind a common interface.
|
|
8
|
+
|
|
9
|
+
Public API
|
|
10
|
+
----------
|
|
11
|
+
- InterfaceClient: Unified client for all Homematic backend types
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
20
|
+
|
|
21
|
+
from aiohomematic import i18n
|
|
22
|
+
from aiohomematic.central.events import ClientStateChangedEvent, SystemStatusChangedEvent
|
|
23
|
+
from aiohomematic.client._rpc_errors import exception_to_failure_reason
|
|
24
|
+
from aiohomematic.client.backends.protocol import BackendOperationsProtocol
|
|
25
|
+
from aiohomematic.client.handlers.device_ops import _wait_for_state_change_or_timeout
|
|
26
|
+
from aiohomematic.client.request_coalescer import RequestCoalescer, make_coalesce_key
|
|
27
|
+
from aiohomematic.client.state_machine import ClientStateMachine
|
|
28
|
+
from aiohomematic.const import (
|
|
29
|
+
DATETIME_FORMAT_MILLIS,
|
|
30
|
+
DP_KEY_VALUE,
|
|
31
|
+
INIT_DATETIME,
|
|
32
|
+
VIRTUAL_REMOTE_MODELS,
|
|
33
|
+
WAIT_FOR_CALLBACK,
|
|
34
|
+
BackupData,
|
|
35
|
+
CallSource,
|
|
36
|
+
ClientState,
|
|
37
|
+
CommandRxMode,
|
|
38
|
+
DescriptionMarker,
|
|
39
|
+
DeviceDescription,
|
|
40
|
+
FailureReason,
|
|
41
|
+
ForcedDeviceAvailability,
|
|
42
|
+
InboxDeviceData,
|
|
43
|
+
Interface,
|
|
44
|
+
Operations,
|
|
45
|
+
ParameterData,
|
|
46
|
+
ParameterType,
|
|
47
|
+
ParamsetKey,
|
|
48
|
+
ProductGroup,
|
|
49
|
+
ProgramData,
|
|
50
|
+
ProxyInitState,
|
|
51
|
+
ServiceMessageData,
|
|
52
|
+
ServiceMessageType,
|
|
53
|
+
SystemInformation,
|
|
54
|
+
SystemUpdateData,
|
|
55
|
+
SystemVariableData,
|
|
56
|
+
)
|
|
57
|
+
from aiohomematic.decorators import inspector
|
|
58
|
+
from aiohomematic.exceptions import BaseHomematicException, ClientException, ValidationException
|
|
59
|
+
from aiohomematic.interfaces.client import ClientDependenciesProtocol, ClientProtocol
|
|
60
|
+
from aiohomematic.model.support import convert_value
|
|
61
|
+
from aiohomematic.property_decorators import DelegatedProperty
|
|
62
|
+
from aiohomematic.store.dynamic import CommandTracker, PingPongTracker
|
|
63
|
+
from aiohomematic.store.types import IncidentSeverity, IncidentType
|
|
64
|
+
from aiohomematic.support import (
|
|
65
|
+
LogContextMixin,
|
|
66
|
+
extract_exc_args,
|
|
67
|
+
get_device_address,
|
|
68
|
+
is_channel_address,
|
|
69
|
+
is_paramset_key,
|
|
70
|
+
supports_rx_mode,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from aiohomematic.client.backends.capabilities import BackendCapabilities
|
|
75
|
+
from aiohomematic.client.circuit_breaker import CircuitBreaker
|
|
76
|
+
from aiohomematic.client.config import InterfaceConfig
|
|
77
|
+
from aiohomematic.interfaces.model import ChannelProtocol, DeviceProtocol
|
|
78
|
+
|
|
79
|
+
__all__ = ["InterfaceClient"]
|
|
80
|
+
|
|
81
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class InterfaceClient(ClientProtocol, LogContextMixin):
|
|
85
|
+
"""
|
|
86
|
+
Unified client for all Homematic backend types.
|
|
87
|
+
|
|
88
|
+
Uses BackendOperationsProtocol to abstract transport differences:
|
|
89
|
+
- CCU: XML-RPC for device ops, JSON-RPC for metadata
|
|
90
|
+
- CCU-Jack: JSON-RPC exclusively
|
|
91
|
+
- Homegear: XML-RPC with Homegear-specific methods
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
__slots__ = (
|
|
95
|
+
"_backend",
|
|
96
|
+
"_central",
|
|
97
|
+
"_connection_error_count",
|
|
98
|
+
"_device_description_coalescer",
|
|
99
|
+
"_interface_config",
|
|
100
|
+
"_is_callback_alive",
|
|
101
|
+
"_last_value_send_tracker",
|
|
102
|
+
"_modified_at",
|
|
103
|
+
"_paramset_description_coalescer",
|
|
104
|
+
"_ping_pong_tracker",
|
|
105
|
+
"_reconnect_attempts",
|
|
106
|
+
"_state_machine",
|
|
107
|
+
"_unsubscribe_state_change",
|
|
108
|
+
"_unsubscribe_system_status",
|
|
109
|
+
"_version",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
backend: BackendOperationsProtocol,
|
|
116
|
+
central: ClientDependenciesProtocol,
|
|
117
|
+
interface_config: InterfaceConfig,
|
|
118
|
+
version: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Initialize InterfaceClient."""
|
|
121
|
+
self._backend: Final = backend
|
|
122
|
+
self._central: Final = central
|
|
123
|
+
self._interface_config: Final = interface_config
|
|
124
|
+
self._version: Final = version
|
|
125
|
+
self._last_value_send_tracker: Final = CommandTracker(
|
|
126
|
+
interface_id=backend.interface_id,
|
|
127
|
+
)
|
|
128
|
+
self._state_machine: Final = ClientStateMachine(
|
|
129
|
+
interface_id=backend.interface_id,
|
|
130
|
+
event_bus=central.event_bus,
|
|
131
|
+
)
|
|
132
|
+
# Subscribe to state changes for integration compatibility
|
|
133
|
+
self._unsubscribe_state_change = central.event_bus.subscribe(
|
|
134
|
+
event_type=ClientStateChangedEvent,
|
|
135
|
+
event_key=backend.interface_id,
|
|
136
|
+
handler=self._on_client_state_changed_event,
|
|
137
|
+
)
|
|
138
|
+
self._connection_error_count: int = 0
|
|
139
|
+
self._is_callback_alive: bool = True
|
|
140
|
+
self._reconnect_attempts: int = 0
|
|
141
|
+
self._ping_pong_tracker: Final = PingPongTracker(
|
|
142
|
+
event_bus_provider=central,
|
|
143
|
+
central_info=central,
|
|
144
|
+
interface_id=backend.interface_id,
|
|
145
|
+
connection_state=central.connection_state,
|
|
146
|
+
incident_recorder=central.cache_coordinator.incident_store,
|
|
147
|
+
)
|
|
148
|
+
self._device_description_coalescer: Final = RequestCoalescer(
|
|
149
|
+
name=f"device_desc:{backend.interface_id}",
|
|
150
|
+
event_bus=central.event_bus,
|
|
151
|
+
interface_id=backend.interface_id,
|
|
152
|
+
)
|
|
153
|
+
self._paramset_description_coalescer: Final = RequestCoalescer(
|
|
154
|
+
name=f"paramset:{backend.interface_id}",
|
|
155
|
+
event_bus=central.event_bus,
|
|
156
|
+
interface_id=backend.interface_id,
|
|
157
|
+
)
|
|
158
|
+
self._modified_at: datetime = INIT_DATETIME
|
|
159
|
+
|
|
160
|
+
# Subscribe to connection state changes
|
|
161
|
+
self._unsubscribe_system_status = central.event_bus.subscribe(
|
|
162
|
+
event_type=SystemStatusChangedEvent,
|
|
163
|
+
event_key=None,
|
|
164
|
+
handler=self._on_system_status_event,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def __str__(self) -> str:
|
|
168
|
+
"""Provide information."""
|
|
169
|
+
return f"interface_id: {self.interface_id}"
|
|
170
|
+
|
|
171
|
+
available: Final = DelegatedProperty[bool](path="_state_machine.is_available")
|
|
172
|
+
central: Final = DelegatedProperty[ClientDependenciesProtocol](path="_central")
|
|
173
|
+
last_value_send_tracker: Final = DelegatedProperty[CommandTracker](path="_last_value_send_tracker")
|
|
174
|
+
ping_pong_tracker: Final = DelegatedProperty[PingPongTracker](path="_ping_pong_tracker")
|
|
175
|
+
state: Final = DelegatedProperty[ClientState](path="_state_machine.state")
|
|
176
|
+
state_machine: Final = DelegatedProperty[ClientStateMachine](path="_state_machine")
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def all_circuit_breakers_closed(self) -> bool:
|
|
180
|
+
"""Return True if all circuit breakers are in closed state."""
|
|
181
|
+
return self._backend.all_circuit_breakers_closed
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def capabilities(self) -> BackendCapabilities:
|
|
185
|
+
"""Return the capability flags for this backend."""
|
|
186
|
+
return self._backend.capabilities
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def circuit_breaker(self) -> CircuitBreaker | None:
|
|
190
|
+
"""Return the primary circuit breaker for metrics access."""
|
|
191
|
+
return self._backend.circuit_breaker
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def interface(self) -> Interface:
|
|
195
|
+
"""Return the interface type."""
|
|
196
|
+
return self._backend.interface
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def interface_id(self) -> str:
|
|
200
|
+
"""Return the interface identifier."""
|
|
201
|
+
return self._backend.interface_id
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def is_initialized(self) -> bool:
|
|
205
|
+
"""Return if interface is initialized."""
|
|
206
|
+
return self._state_machine.state in (
|
|
207
|
+
ClientState.CONNECTED,
|
|
208
|
+
ClientState.DISCONNECTED,
|
|
209
|
+
ClientState.RECONNECTING,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def model(self) -> str:
|
|
214
|
+
"""Return the backend model."""
|
|
215
|
+
return self._backend.model
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def modified_at(self) -> datetime:
|
|
219
|
+
"""Return the last update datetime value."""
|
|
220
|
+
return self._modified_at
|
|
221
|
+
|
|
222
|
+
@modified_at.setter
|
|
223
|
+
def modified_at(self, value: datetime) -> None:
|
|
224
|
+
"""Write the last update datetime value."""
|
|
225
|
+
self._modified_at = value
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def request_coalescer(self) -> RequestCoalescer | None:
|
|
229
|
+
"""Return the request coalescer for metrics access."""
|
|
230
|
+
return self._paramset_description_coalescer
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def system_information(self) -> SystemInformation:
|
|
234
|
+
"""Return system information."""
|
|
235
|
+
return self._backend.system_information
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def version(self) -> str:
|
|
239
|
+
"""Return the version."""
|
|
240
|
+
return self._version
|
|
241
|
+
|
|
242
|
+
async def accept_device_in_inbox(self, *, device_address: str) -> bool:
|
|
243
|
+
"""Accept a device from the CCU inbox."""
|
|
244
|
+
if not self._backend.capabilities.inbox_devices:
|
|
245
|
+
return False
|
|
246
|
+
return await self._backend.accept_device_in_inbox(device_address=device_address)
|
|
247
|
+
|
|
248
|
+
async def add_link(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
sender_address: str,
|
|
252
|
+
receiver_address: str,
|
|
253
|
+
name: str,
|
|
254
|
+
description: str,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Add a link between two devices."""
|
|
257
|
+
if not self._backend.capabilities.linking:
|
|
258
|
+
return
|
|
259
|
+
await self._backend.add_link(
|
|
260
|
+
sender_address=sender_address,
|
|
261
|
+
receiver_address=receiver_address,
|
|
262
|
+
name=name,
|
|
263
|
+
description=description,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
267
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
268
|
+
"""Check if proxy is still initialized."""
|
|
269
|
+
try:
|
|
270
|
+
dt_now = datetime.now()
|
|
271
|
+
caller_id: str | None = None
|
|
272
|
+
if handle_ping_pong and self._backend.capabilities.ping_pong and self.is_initialized:
|
|
273
|
+
token = dt_now.strftime(format=DATETIME_FORMAT_MILLIS)
|
|
274
|
+
caller_id = f"{self.interface_id}#{token}"
|
|
275
|
+
# Register token BEFORE sending ping to avoid race condition:
|
|
276
|
+
# CCU may respond with PONG before await returns
|
|
277
|
+
self._ping_pong_tracker.handle_send_ping(ping_token=token)
|
|
278
|
+
if await self._backend.check_connection(handle_ping_pong=handle_ping_pong, caller_id=caller_id):
|
|
279
|
+
self.modified_at = dt_now
|
|
280
|
+
return True
|
|
281
|
+
except BaseHomematicException as bhexc:
|
|
282
|
+
_LOGGER.debug(
|
|
283
|
+
"CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
|
|
284
|
+
bhexc.name,
|
|
285
|
+
extract_exc_args(exc=bhexc),
|
|
286
|
+
)
|
|
287
|
+
self.modified_at = INIT_DATETIME
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def clear_json_rpc_session(self) -> None:
|
|
291
|
+
"""Clear the JSON-RPC session."""
|
|
292
|
+
self._central.json_rpc_client.clear_session()
|
|
293
|
+
_LOGGER.debug(
|
|
294
|
+
"CLEAR_JSON_RPC_SESSION: Session cleared for %s",
|
|
295
|
+
self.interface_id,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async def create_backup_and_download(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
max_wait_time: float = 300.0,
|
|
302
|
+
poll_interval: float = 5.0,
|
|
303
|
+
) -> BackupData | None:
|
|
304
|
+
"""Create a backup on the CCU and download it."""
|
|
305
|
+
if not self._backend.capabilities.backup:
|
|
306
|
+
return None
|
|
307
|
+
return await self._backend.create_backup_and_download(max_wait_time=max_wait_time, poll_interval=poll_interval)
|
|
308
|
+
|
|
309
|
+
async def deinitialize_proxy(self) -> ProxyInitState:
|
|
310
|
+
"""De-initialize the proxy."""
|
|
311
|
+
if not self._backend.capabilities.rpc_callback:
|
|
312
|
+
self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="no callback support")
|
|
313
|
+
return ProxyInitState.DE_INIT_SUCCESS
|
|
314
|
+
|
|
315
|
+
if self.modified_at == INIT_DATETIME:
|
|
316
|
+
return ProxyInitState.DE_INIT_SKIPPED
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
init_url = self._get_init_url()
|
|
320
|
+
_LOGGER.debug("PROXY_DE_INIT: init('%s')", init_url)
|
|
321
|
+
await self._backend.deinit_proxy(init_url=init_url)
|
|
322
|
+
self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="proxy de-initialized")
|
|
323
|
+
except BaseHomematicException as bhexc:
|
|
324
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
325
|
+
"PROXY_DE_INIT failed: %s [%s] Unable to de-initialize proxy for %s",
|
|
326
|
+
bhexc.name,
|
|
327
|
+
extract_exc_args(exc=bhexc),
|
|
328
|
+
self.interface_id,
|
|
329
|
+
)
|
|
330
|
+
return ProxyInitState.DE_INIT_FAILED
|
|
331
|
+
|
|
332
|
+
self.modified_at = INIT_DATETIME
|
|
333
|
+
return ProxyInitState.DE_INIT_SUCCESS
|
|
334
|
+
|
|
335
|
+
async def delete_system_variable(self, *, name: str) -> bool:
|
|
336
|
+
"""Delete a system variable from the backend."""
|
|
337
|
+
return await self._backend.delete_system_variable(name=name)
|
|
338
|
+
|
|
339
|
+
async def execute_program(self, *, pid: str) -> bool:
|
|
340
|
+
"""Execute a program on the backend."""
|
|
341
|
+
if not self._backend.capabilities.programs:
|
|
342
|
+
return False
|
|
343
|
+
return await self._backend.execute_program(pid=pid)
|
|
344
|
+
|
|
345
|
+
async def fetch_all_device_data(self) -> None:
|
|
346
|
+
"""Fetch all device data from the backend."""
|
|
347
|
+
if all_device_data := await self._backend.get_all_device_data(interface=self.interface):
|
|
348
|
+
self._central.cache_coordinator.data_cache.add_data(
|
|
349
|
+
interface=self.interface, all_device_data=all_device_data
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def fetch_device_details(self) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Fetch device names and details from the backend.
|
|
355
|
+
|
|
356
|
+
For CCU: Uses JSON-RPC to fetch all details in one call.
|
|
357
|
+
For Homegear: Uses getMetadata to fetch names for each known address.
|
|
358
|
+
"""
|
|
359
|
+
# Get known addresses for backends that need them (e.g., Homegear)
|
|
360
|
+
addresses = tuple(
|
|
361
|
+
self._central.cache_coordinator.device_descriptions.get_device_descriptions(
|
|
362
|
+
interface_id=self.interface_id
|
|
363
|
+
).keys()
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if device_details := await self._backend.get_device_details(addresses=addresses):
|
|
367
|
+
for device in device_details:
|
|
368
|
+
device_address = device["address"]
|
|
369
|
+
self._central.cache_coordinator.device_details.add_name(address=device_address, name=device["name"])
|
|
370
|
+
# Only add rega_id if it's meaningful (non-zero for CCU, 0 for Homegear)
|
|
371
|
+
if device["id"]:
|
|
372
|
+
self._central.cache_coordinator.device_details.add_address_rega_id(
|
|
373
|
+
address=device_address, rega_id=device["id"]
|
|
374
|
+
)
|
|
375
|
+
self._central.cache_coordinator.device_details.add_interface(
|
|
376
|
+
address=device_address, interface=self.interface
|
|
377
|
+
)
|
|
378
|
+
# Process nested channels array (CCU provides these, Homegear doesn't)
|
|
379
|
+
for channel in device["channels"]:
|
|
380
|
+
channel_address = channel["address"]
|
|
381
|
+
self._central.cache_coordinator.device_details.add_name(
|
|
382
|
+
address=channel_address, name=channel["name"]
|
|
383
|
+
)
|
|
384
|
+
if channel["id"]:
|
|
385
|
+
self._central.cache_coordinator.device_details.add_address_rega_id(
|
|
386
|
+
address=channel_address, rega_id=channel["id"]
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
|
|
390
|
+
"""Fetch a specific paramset and add it to the known ones."""
|
|
391
|
+
if paramset_description := await self._get_paramset_description(
|
|
392
|
+
address=channel_address, paramset_key=paramset_key
|
|
393
|
+
):
|
|
394
|
+
self._central.cache_coordinator.paramset_descriptions.add(
|
|
395
|
+
interface_id=self.interface_id,
|
|
396
|
+
channel_address=channel_address,
|
|
397
|
+
paramset_key=paramset_key,
|
|
398
|
+
paramset_description=paramset_description,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
|
|
402
|
+
"""Fetch paramsets for provided device description."""
|
|
403
|
+
data = await self.get_paramset_descriptions(device_description=device_description)
|
|
404
|
+
for address, paramsets in data.items():
|
|
405
|
+
for paramset_key, paramset_description in paramsets.items():
|
|
406
|
+
self._central.cache_coordinator.paramset_descriptions.add(
|
|
407
|
+
interface_id=self.interface_id,
|
|
408
|
+
channel_address=address,
|
|
409
|
+
paramset_key=paramset_key,
|
|
410
|
+
paramset_description=paramset_description,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
|
|
414
|
+
"""Get all device descriptions from the backend."""
|
|
415
|
+
all_device_description: list[DeviceDescription] = []
|
|
416
|
+
if main_dd := await self.get_device_description(address=device_address):
|
|
417
|
+
all_device_description.append(main_dd)
|
|
418
|
+
channel_descriptions = [
|
|
419
|
+
channel_dd
|
|
420
|
+
for channel_address in main_dd.get("CHILDREN", [])
|
|
421
|
+
if (channel_dd := await self.get_device_description(address=channel_address))
|
|
422
|
+
]
|
|
423
|
+
all_device_description.extend(channel_descriptions)
|
|
424
|
+
return tuple(all_device_description)
|
|
425
|
+
|
|
426
|
+
async def get_all_functions(self) -> dict[str, set[str]]:
|
|
427
|
+
"""Get all functions from the backend."""
|
|
428
|
+
if not self._backend.capabilities.functions:
|
|
429
|
+
return {}
|
|
430
|
+
return await self._backend.get_all_functions()
|
|
431
|
+
|
|
432
|
+
async def get_all_paramset_descriptions(
|
|
433
|
+
self, *, device_descriptions: tuple[DeviceDescription, ...]
|
|
434
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
435
|
+
"""Get all paramset descriptions for provided device descriptions."""
|
|
436
|
+
all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
437
|
+
for device_description in device_descriptions:
|
|
438
|
+
all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
|
|
439
|
+
return all_paramsets
|
|
440
|
+
|
|
441
|
+
async def get_all_programs(
|
|
442
|
+
self,
|
|
443
|
+
*,
|
|
444
|
+
markers: tuple[DescriptionMarker | str, ...],
|
|
445
|
+
) -> tuple[ProgramData, ...]:
|
|
446
|
+
"""Get all programs, if available."""
|
|
447
|
+
if not self._backend.capabilities.programs:
|
|
448
|
+
return ()
|
|
449
|
+
return await self._backend.get_all_programs(markers=markers)
|
|
450
|
+
|
|
451
|
+
async def get_all_rooms(self) -> dict[str, set[str]]:
|
|
452
|
+
"""Get all rooms from the backend."""
|
|
453
|
+
if not self._backend.capabilities.rooms:
|
|
454
|
+
return {}
|
|
455
|
+
return await self._backend.get_all_rooms()
|
|
456
|
+
|
|
457
|
+
async def get_all_system_variables(
|
|
458
|
+
self,
|
|
459
|
+
*,
|
|
460
|
+
markers: tuple[DescriptionMarker | str, ...],
|
|
461
|
+
) -> tuple[SystemVariableData, ...] | None:
|
|
462
|
+
"""Get all system variables from the backend."""
|
|
463
|
+
return await self._backend.get_all_system_variables(markers=markers)
|
|
464
|
+
|
|
465
|
+
async def get_device_description(self, *, address: str) -> DeviceDescription | None:
|
|
466
|
+
"""Get device description from the backend with request coalescing."""
|
|
467
|
+
key = make_coalesce_key(method="getDeviceDescription", args=(address,))
|
|
468
|
+
|
|
469
|
+
async def _fetch() -> DeviceDescription | None:
|
|
470
|
+
try:
|
|
471
|
+
return await self._backend.get_device_description(address=address)
|
|
472
|
+
except BaseHomematicException as bhexc:
|
|
473
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
474
|
+
"GET_DEVICE_DESCRIPTION failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc)
|
|
475
|
+
)
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
return await self._device_description_coalescer.execute(key=key, executor=_fetch)
|
|
479
|
+
|
|
480
|
+
async def get_inbox_devices(self) -> tuple[InboxDeviceData, ...]:
|
|
481
|
+
"""Get all devices in the inbox (not yet configured)."""
|
|
482
|
+
if not self._backend.capabilities.inbox_devices:
|
|
483
|
+
return ()
|
|
484
|
+
return await self._backend.get_inbox_devices()
|
|
485
|
+
|
|
486
|
+
async def get_install_mode(self) -> int:
|
|
487
|
+
"""Return the remaining time in install mode."""
|
|
488
|
+
if not self._backend.capabilities.install_mode:
|
|
489
|
+
return 0
|
|
490
|
+
return await self._backend.get_install_mode()
|
|
491
|
+
|
|
492
|
+
async def get_link_peers(self, *, address: str) -> tuple[str, ...]:
|
|
493
|
+
"""Return a list of link peers."""
|
|
494
|
+
if not self._backend.capabilities.linking:
|
|
495
|
+
return ()
|
|
496
|
+
return await self._backend.get_link_peers(address=address)
|
|
497
|
+
|
|
498
|
+
async def get_links(self, *, address: str, flags: int) -> dict[str, Any]:
|
|
499
|
+
"""Return a list of links."""
|
|
500
|
+
if not self._backend.capabilities.linking:
|
|
501
|
+
return {}
|
|
502
|
+
return await self._backend.get_links(address=address, flags=flags)
|
|
503
|
+
|
|
504
|
+
async def get_metadata(self, *, address: str, data_id: str) -> dict[str, Any]:
|
|
505
|
+
"""Return the metadata for an object."""
|
|
506
|
+
if not self._backend.capabilities.metadata:
|
|
507
|
+
return {}
|
|
508
|
+
return await self._backend.get_metadata(address=address, data_id=data_id)
|
|
509
|
+
|
|
510
|
+
async def get_paramset(
|
|
511
|
+
self,
|
|
512
|
+
*,
|
|
513
|
+
address: str,
|
|
514
|
+
paramset_key: ParamsetKey | str,
|
|
515
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
516
|
+
) -> dict[str, Any]:
|
|
517
|
+
"""Return a paramset from the backend."""
|
|
518
|
+
return await self._backend.get_paramset(address=address, paramset_key=paramset_key)
|
|
519
|
+
|
|
520
|
+
async def get_paramset_descriptions(
|
|
521
|
+
self, *, device_description: DeviceDescription
|
|
522
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
523
|
+
"""Get paramsets for provided device description."""
|
|
524
|
+
paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
525
|
+
address = device_description["ADDRESS"]
|
|
526
|
+
paramsets[address] = {}
|
|
527
|
+
for p_key in device_description["PARAMSETS"]:
|
|
528
|
+
paramset_key = ParamsetKey(p_key)
|
|
529
|
+
if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
|
|
530
|
+
paramsets[address][paramset_key] = paramset_description
|
|
531
|
+
return paramsets
|
|
532
|
+
|
|
533
|
+
def get_product_group(self, *, model: str) -> ProductGroup:
|
|
534
|
+
"""Return the product group."""
|
|
535
|
+
l_model = model.lower()
|
|
536
|
+
if l_model.startswith("hmipw-"):
|
|
537
|
+
return ProductGroup.HMIPW
|
|
538
|
+
if l_model.startswith("hmip-"):
|
|
539
|
+
return ProductGroup.HMIP
|
|
540
|
+
if l_model.startswith("hmw-"):
|
|
541
|
+
return ProductGroup.HMW
|
|
542
|
+
if l_model.startswith("hm-"):
|
|
543
|
+
return ProductGroup.HM
|
|
544
|
+
if self.interface == Interface.HMIP_RF:
|
|
545
|
+
return ProductGroup.HMIP
|
|
546
|
+
if self.interface == Interface.BIDCOS_WIRED:
|
|
547
|
+
return ProductGroup.HMW
|
|
548
|
+
if self.interface == Interface.BIDCOS_RF:
|
|
549
|
+
return ProductGroup.HM
|
|
550
|
+
if self.interface == Interface.VIRTUAL_DEVICES:
|
|
551
|
+
return ProductGroup.VIRTUAL
|
|
552
|
+
return ProductGroup.UNKNOWN
|
|
553
|
+
|
|
554
|
+
async def get_rega_id_by_address(self, *, address: str) -> int | None:
|
|
555
|
+
"""Get the ReGa ID for a device or channel address."""
|
|
556
|
+
if not self._backend.capabilities.rega_id_lookup:
|
|
557
|
+
return None
|
|
558
|
+
return await self._backend.get_rega_id_by_address(address=address)
|
|
559
|
+
|
|
560
|
+
async def get_service_messages(
|
|
561
|
+
self,
|
|
562
|
+
*,
|
|
563
|
+
message_type: ServiceMessageType | None = None,
|
|
564
|
+
) -> tuple[ServiceMessageData, ...]:
|
|
565
|
+
"""Get all active service messages from the backend."""
|
|
566
|
+
if not self._backend.capabilities.service_messages:
|
|
567
|
+
return ()
|
|
568
|
+
return await self._backend.get_service_messages(message_type=message_type)
|
|
569
|
+
|
|
570
|
+
async def get_system_update_info(self) -> SystemUpdateData | None:
|
|
571
|
+
"""Get system update information from the backend."""
|
|
572
|
+
if not self._backend.capabilities.system_update_info:
|
|
573
|
+
return None
|
|
574
|
+
return await self._backend.get_system_update_info()
|
|
575
|
+
|
|
576
|
+
async def get_system_variable(self, *, name: str) -> Any:
|
|
577
|
+
"""Get single system variable from the backend."""
|
|
578
|
+
return await self._backend.get_system_variable(name=name)
|
|
579
|
+
|
|
580
|
+
async def get_value(
|
|
581
|
+
self,
|
|
582
|
+
*,
|
|
583
|
+
channel_address: str,
|
|
584
|
+
paramset_key: ParamsetKey,
|
|
585
|
+
parameter: str,
|
|
586
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
587
|
+
) -> Any:
|
|
588
|
+
"""Return a value from the backend."""
|
|
589
|
+
return await self._backend.get_value(address=channel_address, parameter=parameter)
|
|
590
|
+
|
|
591
|
+
def get_virtual_remote(self) -> DeviceProtocol | None:
|
|
592
|
+
"""Get the virtual remote for the Client."""
|
|
593
|
+
for model in VIRTUAL_REMOTE_MODELS:
|
|
594
|
+
for device in self._central.device_registry.devices:
|
|
595
|
+
if device.interface_id == self.interface_id and device.model == model:
|
|
596
|
+
return device
|
|
597
|
+
return None
|
|
598
|
+
|
|
599
|
+
async def has_program_ids(self, *, rega_id: int) -> bool:
|
|
600
|
+
"""Return if a channel has program ids."""
|
|
601
|
+
if not self._backend.capabilities.programs:
|
|
602
|
+
return False
|
|
603
|
+
return await self._backend.has_program_ids(rega_id=rega_id)
|
|
604
|
+
|
|
605
|
+
@inspector
|
|
606
|
+
async def init_client(self) -> None:
|
|
607
|
+
"""Initialize the client."""
|
|
608
|
+
self._state_machine.transition_to(target=ClientState.INITIALIZING)
|
|
609
|
+
try:
|
|
610
|
+
self._state_machine.transition_to(target=ClientState.INITIALIZED)
|
|
611
|
+
except Exception as exc:
|
|
612
|
+
self._state_machine.transition_to(
|
|
613
|
+
target=ClientState.FAILED,
|
|
614
|
+
reason=str(exc),
|
|
615
|
+
failure_reason=exception_to_failure_reason(exc=exc),
|
|
616
|
+
)
|
|
617
|
+
raise
|
|
618
|
+
|
|
619
|
+
async def initialize_proxy(self) -> ProxyInitState:
|
|
620
|
+
"""Initialize the proxy."""
|
|
621
|
+
self._state_machine.transition_to(target=ClientState.CONNECTING)
|
|
622
|
+
if not self._backend.capabilities.rpc_callback:
|
|
623
|
+
if (device_descriptions := await self.list_devices()) is not None:
|
|
624
|
+
await self._central.device_coordinator.add_new_devices(
|
|
625
|
+
interface_id=self.interface_id, device_descriptions=device_descriptions
|
|
626
|
+
)
|
|
627
|
+
self._state_machine.transition_to(
|
|
628
|
+
target=ClientState.CONNECTED, reason="proxy initialized (no callback)"
|
|
629
|
+
)
|
|
630
|
+
return ProxyInitState.INIT_SUCCESS
|
|
631
|
+
self._state_machine.transition_to(
|
|
632
|
+
target=ClientState.FAILED,
|
|
633
|
+
reason="device listing failed",
|
|
634
|
+
failure_reason=FailureReason.NETWORK,
|
|
635
|
+
)
|
|
636
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
637
|
+
return ProxyInitState.INIT_FAILED
|
|
638
|
+
|
|
639
|
+
# Record modified_at before init to detect callback during init
|
|
640
|
+
# This is used to work around VirtualDevices service bug where init()
|
|
641
|
+
# times out but listDevices callback was successfully received
|
|
642
|
+
modified_at_before_init = self.modified_at
|
|
643
|
+
init_success = False
|
|
644
|
+
try:
|
|
645
|
+
self._ping_pong_tracker.clear()
|
|
646
|
+
init_url = self._get_init_url()
|
|
647
|
+
_LOGGER.debug("PROXY_INIT: init('%s', '%s')", init_url, self.interface_id)
|
|
648
|
+
await self._backend.init_proxy(init_url=init_url, interface_id=self.interface_id)
|
|
649
|
+
init_success = True
|
|
650
|
+
except BaseHomematicException as bhexc:
|
|
651
|
+
# Check if we received a callback during init (modified_at was updated)
|
|
652
|
+
# This happens when init() times out but the CCU successfully processed it
|
|
653
|
+
# and called back listDevices. Common with VirtualDevices service bug.
|
|
654
|
+
if self.modified_at > modified_at_before_init:
|
|
655
|
+
_LOGGER.info( # i18n-log: ignore
|
|
656
|
+
"PROXY_INIT: init() failed but callback received for %s - treating as success",
|
|
657
|
+
self.interface_id,
|
|
658
|
+
)
|
|
659
|
+
init_success = True
|
|
660
|
+
else:
|
|
661
|
+
_LOGGER.error( # i18n-log: ignore
|
|
662
|
+
"PROXY_INIT failed: %s [%s] Unable to initialize proxy for %s",
|
|
663
|
+
bhexc.name,
|
|
664
|
+
extract_exc_args(exc=bhexc),
|
|
665
|
+
self.interface_id,
|
|
666
|
+
)
|
|
667
|
+
self.modified_at = INIT_DATETIME
|
|
668
|
+
self._state_machine.transition_to(
|
|
669
|
+
target=ClientState.FAILED,
|
|
670
|
+
reason="proxy init failed",
|
|
671
|
+
failure_reason=exception_to_failure_reason(exc=bhexc),
|
|
672
|
+
)
|
|
673
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
674
|
+
return ProxyInitState.INIT_FAILED
|
|
675
|
+
|
|
676
|
+
if init_success:
|
|
677
|
+
self._state_machine.transition_to(target=ClientState.CONNECTED, reason="proxy initialized")
|
|
678
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
|
|
679
|
+
_LOGGER.debug("PROXY_INIT: Proxy for %s initialized", self.interface_id)
|
|
680
|
+
|
|
681
|
+
self.modified_at = datetime.now()
|
|
682
|
+
return ProxyInitState.INIT_SUCCESS
|
|
683
|
+
|
|
684
|
+
def is_callback_alive(self) -> bool:
|
|
685
|
+
"""Return if XmlRPC-Server is alive based on received events."""
|
|
686
|
+
if not self._backend.capabilities.ping_pong:
|
|
687
|
+
return True
|
|
688
|
+
|
|
689
|
+
if self._state_machine.is_failed or self._state_machine.state == ClientState.RECONNECTING:
|
|
690
|
+
return False
|
|
691
|
+
|
|
692
|
+
if (
|
|
693
|
+
last_events_dt := self._central.event_coordinator.get_last_event_seen_for_interface(
|
|
694
|
+
interface_id=self.interface_id
|
|
695
|
+
)
|
|
696
|
+
) is not None:
|
|
697
|
+
callback_warn = self._central.config.timeout_config.callback_warn_interval
|
|
698
|
+
if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > callback_warn:
|
|
699
|
+
if self._is_callback_alive:
|
|
700
|
+
self._central.event_bus.publish_sync(
|
|
701
|
+
event=SystemStatusChangedEvent(
|
|
702
|
+
timestamp=datetime.now(),
|
|
703
|
+
callback_state=(self.interface_id, False),
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
self._is_callback_alive = False
|
|
707
|
+
self._record_callback_timeout_incident(
|
|
708
|
+
seconds_since_last_event=seconds_since_last_event,
|
|
709
|
+
callback_warn_interval=callback_warn,
|
|
710
|
+
last_event_time=last_events_dt,
|
|
711
|
+
)
|
|
712
|
+
_LOGGER.error(
|
|
713
|
+
i18n.tr(
|
|
714
|
+
key="log.client.is_callback_alive.no_events",
|
|
715
|
+
interface_id=self.interface_id,
|
|
716
|
+
seconds=int(seconds_since_last_event),
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
if not self._is_callback_alive:
|
|
722
|
+
self._central.event_bus.publish_sync(
|
|
723
|
+
event=SystemStatusChangedEvent(
|
|
724
|
+
timestamp=datetime.now(),
|
|
725
|
+
callback_state=(self.interface_id, True),
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
self._is_callback_alive = True
|
|
729
|
+
return True
|
|
730
|
+
|
|
731
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
732
|
+
async def is_connected(self) -> bool:
|
|
733
|
+
"""Perform connectivity check."""
|
|
734
|
+
if await self.check_connection_availability(handle_ping_pong=True) is True:
|
|
735
|
+
self._connection_error_count = 0
|
|
736
|
+
else:
|
|
737
|
+
self._connection_error_count += 1
|
|
738
|
+
|
|
739
|
+
error_threshold = self._central.config.timeout_config.connectivity_error_threshold
|
|
740
|
+
if self._connection_error_count > error_threshold:
|
|
741
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
742
|
+
if self._state_machine.state == ClientState.CONNECTED:
|
|
743
|
+
self._state_machine.transition_to(
|
|
744
|
+
target=ClientState.DISCONNECTED,
|
|
745
|
+
reason=f"connection check failed (>{error_threshold} errors)",
|
|
746
|
+
)
|
|
747
|
+
return False
|
|
748
|
+
if not self._backend.capabilities.push_updates:
|
|
749
|
+
return True
|
|
750
|
+
|
|
751
|
+
callback_warn = self._central.config.timeout_config.callback_warn_interval
|
|
752
|
+
return (datetime.now() - self.modified_at).total_seconds() < callback_warn
|
|
753
|
+
|
|
754
|
+
async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
|
|
755
|
+
"""List devices of the backend."""
|
|
756
|
+
return await self._backend.list_devices()
|
|
757
|
+
|
|
758
|
+
@inspector(re_raise=False, no_raise_return=set())
|
|
759
|
+
async def put_paramset(
|
|
760
|
+
self,
|
|
761
|
+
*,
|
|
762
|
+
channel_address: str,
|
|
763
|
+
paramset_key_or_link_address: ParamsetKey | str,
|
|
764
|
+
values: dict[str, Any],
|
|
765
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
766
|
+
rx_mode: CommandRxMode | None = None,
|
|
767
|
+
check_against_pd: bool = False,
|
|
768
|
+
) -> set[DP_KEY_VALUE]:
|
|
769
|
+
"""Set paramsets manually."""
|
|
770
|
+
is_link_call: bool = False
|
|
771
|
+
checked_values = values
|
|
772
|
+
try:
|
|
773
|
+
# Validate values if requested
|
|
774
|
+
if check_against_pd:
|
|
775
|
+
check_paramset_key = (
|
|
776
|
+
ParamsetKey(paramset_key_or_link_address)
|
|
777
|
+
if is_paramset_key(paramset_key=paramset_key_or_link_address)
|
|
778
|
+
else ParamsetKey.LINK
|
|
779
|
+
if (is_link_call := is_channel_address(address=paramset_key_or_link_address))
|
|
780
|
+
else None
|
|
781
|
+
)
|
|
782
|
+
if check_paramset_key:
|
|
783
|
+
checked_values = self._check_put_paramset(
|
|
784
|
+
channel_address=channel_address,
|
|
785
|
+
paramset_key=check_paramset_key,
|
|
786
|
+
values=values,
|
|
787
|
+
)
|
|
788
|
+
else:
|
|
789
|
+
raise ClientException(i18n.tr(key="exception.client.paramset_key.invalid"))
|
|
790
|
+
|
|
791
|
+
if rx_mode and (device := self._central.device_coordinator.get_device(address=channel_address)):
|
|
792
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
793
|
+
await self._backend.put_paramset(
|
|
794
|
+
address=channel_address,
|
|
795
|
+
paramset_key=paramset_key_or_link_address,
|
|
796
|
+
values=checked_values,
|
|
797
|
+
rx_mode=rx_mode,
|
|
798
|
+
)
|
|
799
|
+
else:
|
|
800
|
+
raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
|
|
801
|
+
else:
|
|
802
|
+
await self._backend.put_paramset(
|
|
803
|
+
address=channel_address,
|
|
804
|
+
paramset_key=paramset_key_or_link_address,
|
|
805
|
+
values=checked_values,
|
|
806
|
+
rx_mode=rx_mode,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# If a call is related to a link then no further action is needed
|
|
810
|
+
if is_link_call:
|
|
811
|
+
return set()
|
|
812
|
+
|
|
813
|
+
# Store the sent values and write temporary values for UI feedback
|
|
814
|
+
dpk_values = self._last_value_send_tracker.add_put_paramset(
|
|
815
|
+
channel_address=channel_address,
|
|
816
|
+
paramset_key=ParamsetKey(paramset_key_or_link_address),
|
|
817
|
+
values=checked_values,
|
|
818
|
+
)
|
|
819
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
820
|
+
|
|
821
|
+
# Schedule master paramset polling for BidCos interfaces
|
|
822
|
+
if (
|
|
823
|
+
self.interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED)
|
|
824
|
+
and paramset_key_or_link_address == ParamsetKey.MASTER
|
|
825
|
+
and (channel := self._central.device_coordinator.get_channel(channel_address=channel_address))
|
|
826
|
+
is not None
|
|
827
|
+
):
|
|
828
|
+
await self._poll_master_values(channel=channel, paramset_key=ParamsetKey(paramset_key_or_link_address))
|
|
829
|
+
|
|
830
|
+
if wait_for_callback is not None and (
|
|
831
|
+
device := self._central.device_coordinator.get_device(
|
|
832
|
+
address=get_device_address(address=channel_address)
|
|
833
|
+
)
|
|
834
|
+
):
|
|
835
|
+
await self._wait_for_state_change(
|
|
836
|
+
device=device, dpk_values=dpk_values, wait_for_callback=wait_for_callback
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
except BaseHomematicException as bhexc:
|
|
840
|
+
raise ClientException(
|
|
841
|
+
i18n.tr(
|
|
842
|
+
key="exception.client.put_paramset.failed",
|
|
843
|
+
channel_address=channel_address,
|
|
844
|
+
paramset_key=paramset_key_or_link_address,
|
|
845
|
+
values=values,
|
|
846
|
+
reason=extract_exc_args(exc=bhexc),
|
|
847
|
+
)
|
|
848
|
+
) from bhexc
|
|
849
|
+
else:
|
|
850
|
+
return dpk_values
|
|
851
|
+
|
|
852
|
+
async def reconnect(self) -> bool:
|
|
853
|
+
"""Re-init all RPC clients with exponential backoff."""
|
|
854
|
+
if self._state_machine.can_reconnect:
|
|
855
|
+
self._state_machine.transition_to(target=ClientState.RECONNECTING)
|
|
856
|
+
|
|
857
|
+
timeout_cfg = self._central.config.timeout_config
|
|
858
|
+
delay = min(
|
|
859
|
+
timeout_cfg.reconnect_initial_delay * (timeout_cfg.reconnect_backoff_factor**self._reconnect_attempts),
|
|
860
|
+
timeout_cfg.reconnect_max_delay,
|
|
861
|
+
)
|
|
862
|
+
_LOGGER.debug(
|
|
863
|
+
"RECONNECT: waiting to re-connect client %s for %.1fs (attempt %d)",
|
|
864
|
+
self.interface_id,
|
|
865
|
+
delay,
|
|
866
|
+
self._reconnect_attempts + 1,
|
|
867
|
+
)
|
|
868
|
+
await asyncio.sleep(delay)
|
|
869
|
+
|
|
870
|
+
if await self.reinitialize_proxy() == ProxyInitState.INIT_SUCCESS:
|
|
871
|
+
self.reset_circuit_breakers()
|
|
872
|
+
self._reconnect_attempts = 0
|
|
873
|
+
self._connection_error_count = 0
|
|
874
|
+
_LOGGER.info(
|
|
875
|
+
i18n.tr(
|
|
876
|
+
key="log.client.reconnect.reconnected",
|
|
877
|
+
interface_id=self.interface_id,
|
|
878
|
+
)
|
|
879
|
+
)
|
|
880
|
+
return True
|
|
881
|
+
self._reconnect_attempts += 1
|
|
882
|
+
return False
|
|
883
|
+
|
|
884
|
+
async def reinitialize_proxy(self) -> ProxyInitState:
|
|
885
|
+
"""Reinitialize proxy."""
|
|
886
|
+
await self.deinitialize_proxy()
|
|
887
|
+
return await self.initialize_proxy()
|
|
888
|
+
|
|
889
|
+
async def remove_link(self, *, sender_address: str, receiver_address: str) -> None:
|
|
890
|
+
"""Remove a link between two devices."""
|
|
891
|
+
if not self._backend.capabilities.linking:
|
|
892
|
+
return
|
|
893
|
+
await self._backend.remove_link(sender_address=sender_address, receiver_address=receiver_address)
|
|
894
|
+
|
|
895
|
+
async def rename_channel(self, *, rega_id: int, new_name: str) -> bool:
|
|
896
|
+
"""Rename a channel on the CCU."""
|
|
897
|
+
if not self._backend.capabilities.rename:
|
|
898
|
+
return False
|
|
899
|
+
return await self._backend.rename_channel(rega_id=rega_id, new_name=new_name)
|
|
900
|
+
|
|
901
|
+
async def rename_device(self, *, rega_id: int, new_name: str) -> bool:
|
|
902
|
+
"""Rename a device on the CCU."""
|
|
903
|
+
if not self._backend.capabilities.rename:
|
|
904
|
+
return False
|
|
905
|
+
return await self._backend.rename_device(rega_id=rega_id, new_name=new_name)
|
|
906
|
+
|
|
907
|
+
async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
|
|
908
|
+
"""Report value usage."""
|
|
909
|
+
if not self._backend.capabilities.value_usage_reporting:
|
|
910
|
+
return False
|
|
911
|
+
return await self._backend.report_value_usage(address=address, value_id=value_id, ref_counter=ref_counter)
|
|
912
|
+
|
|
913
|
+
def reset_circuit_breakers(self) -> None:
|
|
914
|
+
"""Reset all circuit breakers to closed state."""
|
|
915
|
+
self._backend.reset_circuit_breakers()
|
|
916
|
+
|
|
917
|
+
async def set_install_mode(
|
|
918
|
+
self,
|
|
919
|
+
*,
|
|
920
|
+
on: bool = True,
|
|
921
|
+
time: int = 60,
|
|
922
|
+
mode: int = 1,
|
|
923
|
+
device_address: str | None = None,
|
|
924
|
+
) -> bool:
|
|
925
|
+
"""Set the install mode on the backend."""
|
|
926
|
+
if not self._backend.capabilities.install_mode:
|
|
927
|
+
return False
|
|
928
|
+
return await self._backend.set_install_mode(on=on, time=time, mode=mode, device_address=device_address)
|
|
929
|
+
|
|
930
|
+
async def set_metadata(self, *, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
|
|
931
|
+
"""Write the metadata for an object."""
|
|
932
|
+
if not self._backend.capabilities.metadata:
|
|
933
|
+
return {}
|
|
934
|
+
return await self._backend.set_metadata(address=address, data_id=data_id, value=value)
|
|
935
|
+
|
|
936
|
+
async def set_program_state(self, *, pid: str, state: bool) -> bool:
|
|
937
|
+
"""Set the program state on the backend."""
|
|
938
|
+
if not self._backend.capabilities.programs:
|
|
939
|
+
return False
|
|
940
|
+
return await self._backend.set_program_state(pid=pid, state=state)
|
|
941
|
+
|
|
942
|
+
async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
|
|
943
|
+
"""Set a system variable on the backend."""
|
|
944
|
+
return await self._backend.set_system_variable(name=legacy_name, value=value)
|
|
945
|
+
|
|
946
|
+
@inspector(re_raise=False, no_raise_return=set())
|
|
947
|
+
async def set_value(
|
|
948
|
+
self,
|
|
949
|
+
*,
|
|
950
|
+
channel_address: str,
|
|
951
|
+
paramset_key: ParamsetKey,
|
|
952
|
+
parameter: str,
|
|
953
|
+
value: Any,
|
|
954
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
955
|
+
rx_mode: CommandRxMode | None = None,
|
|
956
|
+
check_against_pd: bool = False,
|
|
957
|
+
) -> set[DP_KEY_VALUE]:
|
|
958
|
+
"""Set single value on paramset VALUES."""
|
|
959
|
+
if paramset_key != ParamsetKey.VALUES:
|
|
960
|
+
return await self.put_paramset(
|
|
961
|
+
channel_address=channel_address,
|
|
962
|
+
paramset_key_or_link_address=paramset_key,
|
|
963
|
+
values={parameter: value},
|
|
964
|
+
wait_for_callback=wait_for_callback,
|
|
965
|
+
rx_mode=rx_mode,
|
|
966
|
+
check_against_pd=check_against_pd,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
dpk_values: set[DP_KEY_VALUE] = set()
|
|
970
|
+
try:
|
|
971
|
+
# Validate and convert value if requested
|
|
972
|
+
checked_value = (
|
|
973
|
+
self._check_set_value(
|
|
974
|
+
channel_address=channel_address,
|
|
975
|
+
paramset_key=ParamsetKey.VALUES,
|
|
976
|
+
parameter=parameter,
|
|
977
|
+
value=value,
|
|
978
|
+
)
|
|
979
|
+
if check_against_pd
|
|
980
|
+
else value
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
if rx_mode and (device := self._central.device_coordinator.get_device(address=channel_address)):
|
|
984
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
985
|
+
await self._backend.set_value(
|
|
986
|
+
address=channel_address, parameter=parameter, value=checked_value, rx_mode=rx_mode
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
|
|
990
|
+
else:
|
|
991
|
+
await self._backend.set_value(address=channel_address, parameter=parameter, value=checked_value)
|
|
992
|
+
|
|
993
|
+
# Store the sent value and write temporary value for UI feedback
|
|
994
|
+
dpk_values = self._last_value_send_tracker.add_set_value(
|
|
995
|
+
channel_address=channel_address, parameter=parameter, value=checked_value
|
|
996
|
+
)
|
|
997
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
998
|
+
|
|
999
|
+
if wait_for_callback is not None and (
|
|
1000
|
+
device := self._central.device_coordinator.get_device(
|
|
1001
|
+
address=get_device_address(address=channel_address)
|
|
1002
|
+
)
|
|
1003
|
+
):
|
|
1004
|
+
await self._wait_for_state_change(
|
|
1005
|
+
device=device, dpk_values=dpk_values, wait_for_callback=wait_for_callback
|
|
1006
|
+
)
|
|
1007
|
+
except BaseHomematicException as bhexc:
|
|
1008
|
+
raise ClientException(
|
|
1009
|
+
i18n.tr(
|
|
1010
|
+
key="exception.client.set_value.failed",
|
|
1011
|
+
channel_address=channel_address,
|
|
1012
|
+
parameter=parameter,
|
|
1013
|
+
value=value,
|
|
1014
|
+
reason=extract_exc_args(exc=bhexc),
|
|
1015
|
+
)
|
|
1016
|
+
) from bhexc
|
|
1017
|
+
return dpk_values
|
|
1018
|
+
|
|
1019
|
+
async def stop(self) -> None:
|
|
1020
|
+
"""Stop depending services."""
|
|
1021
|
+
self._unsubscribe_state_change()
|
|
1022
|
+
self._unsubscribe_system_status()
|
|
1023
|
+
self._state_machine.transition_to(target=ClientState.STOPPING, reason="stop() called")
|
|
1024
|
+
await self._backend.stop()
|
|
1025
|
+
self._state_machine.transition_to(target=ClientState.STOPPED, reason="services stopped")
|
|
1026
|
+
|
|
1027
|
+
async def trigger_firmware_update(self) -> bool:
|
|
1028
|
+
"""Trigger the CCU firmware update process."""
|
|
1029
|
+
if not self._backend.capabilities.firmware_update_trigger:
|
|
1030
|
+
return False
|
|
1031
|
+
return await self._backend.trigger_firmware_update()
|
|
1032
|
+
|
|
1033
|
+
async def update_device_firmware(self, *, device_address: str) -> bool:
|
|
1034
|
+
"""Update the firmware of a Homematic device."""
|
|
1035
|
+
if not self._backend.capabilities.device_firmware_update:
|
|
1036
|
+
return False
|
|
1037
|
+
return await self._backend.update_device_firmware(device_address=device_address)
|
|
1038
|
+
|
|
1039
|
+
async def update_paramset_descriptions(self, *, device_address: str) -> None:
|
|
1040
|
+
"""Update paramsets descriptions for provided device_address."""
|
|
1041
|
+
if device_description := self._central.cache_coordinator.device_descriptions.find_device_description(
|
|
1042
|
+
interface_id=self.interface_id, device_address=device_address
|
|
1043
|
+
):
|
|
1044
|
+
await self.fetch_paramset_descriptions(device_description=device_description)
|
|
1045
|
+
await self._central.save_files(save_paramset_descriptions=True)
|
|
1046
|
+
|
|
1047
|
+
def _check_put_paramset(
|
|
1048
|
+
self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
|
|
1049
|
+
) -> dict[str, Any]:
|
|
1050
|
+
"""
|
|
1051
|
+
Validate and convert all values in a paramset against their descriptions.
|
|
1052
|
+
|
|
1053
|
+
Iterates through each parameter in the values dict, converting types
|
|
1054
|
+
and validating against MIN/MAX constraints.
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
Dict with validated/converted values.
|
|
1058
|
+
|
|
1059
|
+
Raises:
|
|
1060
|
+
ClientException: If any parameter validation fails.
|
|
1061
|
+
|
|
1062
|
+
"""
|
|
1063
|
+
checked_values: dict[str, Any] = {}
|
|
1064
|
+
for param, value in values.items():
|
|
1065
|
+
checked_values[param] = self._convert_value(
|
|
1066
|
+
channel_address=channel_address,
|
|
1067
|
+
paramset_key=paramset_key,
|
|
1068
|
+
parameter=param,
|
|
1069
|
+
value=value,
|
|
1070
|
+
operation=Operations.WRITE,
|
|
1071
|
+
)
|
|
1072
|
+
return checked_values
|
|
1073
|
+
|
|
1074
|
+
def _check_set_value(self, *, channel_address: str, paramset_key: ParamsetKey, parameter: str, value: Any) -> Any:
|
|
1075
|
+
"""Validate and convert a single value against its parameter description."""
|
|
1076
|
+
return self._convert_value(
|
|
1077
|
+
channel_address=channel_address,
|
|
1078
|
+
paramset_key=paramset_key,
|
|
1079
|
+
parameter=parameter,
|
|
1080
|
+
value=value,
|
|
1081
|
+
operation=Operations.WRITE,
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def _convert_value(
|
|
1085
|
+
self,
|
|
1086
|
+
*,
|
|
1087
|
+
channel_address: str,
|
|
1088
|
+
paramset_key: ParamsetKey,
|
|
1089
|
+
parameter: str,
|
|
1090
|
+
value: Any,
|
|
1091
|
+
operation: Operations,
|
|
1092
|
+
) -> Any:
|
|
1093
|
+
"""
|
|
1094
|
+
Validate and convert a parameter value against its description.
|
|
1095
|
+
|
|
1096
|
+
Performs the following checks:
|
|
1097
|
+
1. Parameter exists in paramset description
|
|
1098
|
+
2. Requested operation (READ/WRITE/EVENT) is supported
|
|
1099
|
+
3. Value is converted to the correct type (INTEGER, FLOAT, BOOL, ENUM, STRING)
|
|
1100
|
+
4. For numeric types, value is within MIN/MAX bounds
|
|
1101
|
+
|
|
1102
|
+
Returns:
|
|
1103
|
+
Converted value matching the parameter's type definition.
|
|
1104
|
+
|
|
1105
|
+
Raises:
|
|
1106
|
+
ClientException: If parameter not found or operation not supported.
|
|
1107
|
+
ValidationException: If value is outside MIN/MAX bounds.
|
|
1108
|
+
|
|
1109
|
+
"""
|
|
1110
|
+
if parameter_data := self._central.cache_coordinator.paramset_descriptions.get_parameter_data(
|
|
1111
|
+
interface_id=self.interface_id,
|
|
1112
|
+
channel_address=channel_address,
|
|
1113
|
+
paramset_key=paramset_key,
|
|
1114
|
+
parameter=parameter,
|
|
1115
|
+
):
|
|
1116
|
+
pd_type = parameter_data["TYPE"]
|
|
1117
|
+
op_mask = int(operation)
|
|
1118
|
+
if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
|
|
1119
|
+
raise ClientException(
|
|
1120
|
+
i18n.tr(
|
|
1121
|
+
key="exception.client.parameter.operation_unsupported",
|
|
1122
|
+
parameter=parameter,
|
|
1123
|
+
operation=operation.value,
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
# Only build a tuple if a value list exists
|
|
1127
|
+
pd_value_list = tuple(parameter_data["VALUE_LIST"]) if parameter_data.get("VALUE_LIST") else None
|
|
1128
|
+
converted_value = convert_value(value=value, target_type=pd_type, value_list=pd_value_list)
|
|
1129
|
+
|
|
1130
|
+
# Validate MIN/MAX constraints for numeric types
|
|
1131
|
+
if pd_type in (ParameterType.INTEGER, ParameterType.FLOAT) and converted_value is not None:
|
|
1132
|
+
pd_min = parameter_data.get("MIN")
|
|
1133
|
+
pd_max = parameter_data.get("MAX")
|
|
1134
|
+
if pd_min is not None and converted_value < pd_min:
|
|
1135
|
+
raise ValidationException(
|
|
1136
|
+
i18n.tr(
|
|
1137
|
+
key="exception.client.parameter.value_below_min",
|
|
1138
|
+
parameter=parameter,
|
|
1139
|
+
value=converted_value,
|
|
1140
|
+
min_value=pd_min,
|
|
1141
|
+
)
|
|
1142
|
+
)
|
|
1143
|
+
if pd_max is not None and converted_value > pd_max:
|
|
1144
|
+
raise ValidationException(
|
|
1145
|
+
i18n.tr(
|
|
1146
|
+
key="exception.client.parameter.value_above_max",
|
|
1147
|
+
parameter=parameter,
|
|
1148
|
+
value=converted_value,
|
|
1149
|
+
max_value=pd_max,
|
|
1150
|
+
)
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
return converted_value
|
|
1154
|
+
raise ClientException(
|
|
1155
|
+
i18n.tr(
|
|
1156
|
+
key="exception.client.parameter.not_found",
|
|
1157
|
+
parameter=parameter,
|
|
1158
|
+
interface_id=self.interface_id,
|
|
1159
|
+
channel_address=channel_address,
|
|
1160
|
+
paramset_key=paramset_key,
|
|
1161
|
+
)
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
def _get_init_url(self) -> str:
|
|
1165
|
+
"""Return the init URL."""
|
|
1166
|
+
callback_host = (
|
|
1167
|
+
self._central.config.callback_host if self._central.config.callback_host else self._central.callback_ip_addr
|
|
1168
|
+
)
|
|
1169
|
+
callback_port = (
|
|
1170
|
+
self._central.config.callback_port_xml_rpc
|
|
1171
|
+
if self._central.config.callback_port_xml_rpc
|
|
1172
|
+
else self._central.listen_port_xml_rpc
|
|
1173
|
+
)
|
|
1174
|
+
return f"http://{callback_host}:{callback_port}"
|
|
1175
|
+
|
|
1176
|
+
async def _get_paramset_description(
|
|
1177
|
+
self, *, address: str, paramset_key: ParamsetKey
|
|
1178
|
+
) -> dict[str, ParameterData] | None:
|
|
1179
|
+
"""
|
|
1180
|
+
Fetch a paramset description via backend, with request coalescing.
|
|
1181
|
+
|
|
1182
|
+
Uses request coalescing to deduplicate concurrent requests for the same
|
|
1183
|
+
address and paramset_key combination. This is particularly beneficial
|
|
1184
|
+
during device discovery when multiple channels request the same descriptions.
|
|
1185
|
+
"""
|
|
1186
|
+
key = make_coalesce_key(method="getParamsetDescription", args=(address, paramset_key))
|
|
1187
|
+
|
|
1188
|
+
async def _fetch() -> dict[str, ParameterData] | None:
|
|
1189
|
+
try:
|
|
1190
|
+
return await self._backend.get_paramset_description(address=address, paramset_key=paramset_key)
|
|
1191
|
+
except BaseHomematicException as bhexc:
|
|
1192
|
+
_LOGGER.debug(
|
|
1193
|
+
"GET_PARAMSET_DESCRIPTION failed with %s [%s] for %s address %s",
|
|
1194
|
+
bhexc.name,
|
|
1195
|
+
extract_exc_args(exc=bhexc),
|
|
1196
|
+
paramset_key,
|
|
1197
|
+
address,
|
|
1198
|
+
)
|
|
1199
|
+
return None
|
|
1200
|
+
|
|
1201
|
+
return await self._paramset_description_coalescer.execute(key=key, executor=_fetch)
|
|
1202
|
+
|
|
1203
|
+
def _mark_all_devices_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
|
|
1204
|
+
"""Mark device's availability state for this interface."""
|
|
1205
|
+
available = forced_availability != ForcedDeviceAvailability.FORCE_FALSE
|
|
1206
|
+
if not available or self._state_machine.is_available != available:
|
|
1207
|
+
for device in self._central.device_registry.devices:
|
|
1208
|
+
if device.interface_id == self.interface_id:
|
|
1209
|
+
device.set_forced_availability(forced_availability=forced_availability)
|
|
1210
|
+
_LOGGER.debug(
|
|
1211
|
+
"MARK_ALL_DEVICES_FORCED_AVAILABILITY: marked all devices %s for %s",
|
|
1212
|
+
"available" if available else "unavailable",
|
|
1213
|
+
self.interface_id,
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
def _on_client_state_changed_event(self, *, event: ClientStateChangedEvent) -> None:
|
|
1217
|
+
"""Handle client state machine transitions."""
|
|
1218
|
+
self._central.event_bus.publish_sync(
|
|
1219
|
+
event=SystemStatusChangedEvent(
|
|
1220
|
+
timestamp=datetime.now(),
|
|
1221
|
+
client_state=(event.interface_id, ClientState(event.old_state), ClientState(event.new_state)),
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
def _on_system_status_event(self, *, event: SystemStatusChangedEvent) -> None:
|
|
1226
|
+
"""Handle system status events."""
|
|
1227
|
+
if event.connection_state and event.connection_state[0] == self.interface_id and event.connection_state[1]:
|
|
1228
|
+
self._ping_pong_tracker.clear()
|
|
1229
|
+
_LOGGER.debug(
|
|
1230
|
+
"PING PONG CACHE: Cleared on connection restored: %s",
|
|
1231
|
+
self.interface_id,
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
async def _poll_master_values(self, *, channel: ChannelProtocol, paramset_key: ParamsetKey) -> None:
|
|
1235
|
+
"""Poll master paramset values after write for BidCos devices."""
|
|
1236
|
+
|
|
1237
|
+
async def poll_master_dp_values() -> None:
|
|
1238
|
+
"""Load master paramset values with intervals."""
|
|
1239
|
+
for interval in self._central.config.schedule_timer_config.master_poll_after_send_intervals:
|
|
1240
|
+
await asyncio.sleep(interval)
|
|
1241
|
+
for dp in channel.get_readable_data_points(paramset_key=paramset_key):
|
|
1242
|
+
await dp.load_data_point_value(call_source=CallSource.MANUAL_OR_SCHEDULED, direct_call=True)
|
|
1243
|
+
|
|
1244
|
+
self._central.looper.create_task(target=poll_master_dp_values(), name="poll_master_dp_values")
|
|
1245
|
+
|
|
1246
|
+
def _record_callback_timeout_incident(
|
|
1247
|
+
self,
|
|
1248
|
+
*,
|
|
1249
|
+
seconds_since_last_event: float,
|
|
1250
|
+
callback_warn_interval: float,
|
|
1251
|
+
last_event_time: datetime,
|
|
1252
|
+
) -> None:
|
|
1253
|
+
"""Record a CALLBACK_TIMEOUT incident for diagnostics."""
|
|
1254
|
+
incident_recorder = self._central.cache_coordinator.incident_store
|
|
1255
|
+
|
|
1256
|
+
# Get circuit breaker state safely
|
|
1257
|
+
circuit_breaker_state: str | None = None
|
|
1258
|
+
if (cb := self._backend.circuit_breaker) is not None:
|
|
1259
|
+
circuit_breaker_state = cb.state.value
|
|
1260
|
+
|
|
1261
|
+
context = {
|
|
1262
|
+
"seconds_since_last_event": round(seconds_since_last_event, 2),
|
|
1263
|
+
"callback_warn_interval": callback_warn_interval,
|
|
1264
|
+
"last_event_time": last_event_time.strftime(DATETIME_FORMAT_MILLIS),
|
|
1265
|
+
"client_state": self._state_machine.state.value,
|
|
1266
|
+
"circuit_breaker_state": circuit_breaker_state,
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
async def _record() -> None:
|
|
1270
|
+
try:
|
|
1271
|
+
await incident_recorder.record_incident(
|
|
1272
|
+
incident_type=IncidentType.CALLBACK_TIMEOUT,
|
|
1273
|
+
severity=IncidentSeverity.WARNING,
|
|
1274
|
+
message=f"No callback received for {self.interface_id} in {int(seconds_since_last_event)} seconds",
|
|
1275
|
+
interface_id=self.interface_id,
|
|
1276
|
+
context=context,
|
|
1277
|
+
)
|
|
1278
|
+
except Exception as err:
|
|
1279
|
+
_LOGGER.debug("Failed to record CALLBACK_TIMEOUT incident: %s", err)
|
|
1280
|
+
|
|
1281
|
+
self._central.looper.create_task(
|
|
1282
|
+
target=_record(),
|
|
1283
|
+
name=f"record_callback_timeout_incident_{self.interface_id}",
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
async def _wait_for_state_change(
|
|
1287
|
+
self, *, device: DeviceProtocol, dpk_values: set[DP_KEY_VALUE], wait_for_callback: int
|
|
1288
|
+
) -> None:
|
|
1289
|
+
"""Wait for device state change or timeout."""
|
|
1290
|
+
await _wait_for_state_change_or_timeout(
|
|
1291
|
+
device=device, dpk_values=dpk_values, wait_for_callback=wait_for_callback
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
|
|
1295
|
+
"""Write temporary values to polling data points for immediate UI feedback."""
|
|
1296
|
+
for dpk, value in dpk_values:
|
|
1297
|
+
if (
|
|
1298
|
+
data_point := self._central.get_generic_data_point(
|
|
1299
|
+
channel_address=dpk.channel_address,
|
|
1300
|
+
parameter=dpk.parameter,
|
|
1301
|
+
paramset_key=dpk.paramset_key,
|
|
1302
|
+
)
|
|
1303
|
+
) and data_point.requires_polling:
|
|
1304
|
+
data_point.write_temporary_value(value=value, write_at=datetime.now())
|