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,1857 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Client implementations for Homematic CCU and compatible backends.
|
|
5
|
+
|
|
6
|
+
This module provides concrete client classes that handle communication with
|
|
7
|
+
Homematic backends via XML-RPC and JSON-RPC protocols.
|
|
8
|
+
|
|
9
|
+
Public API
|
|
10
|
+
----------
|
|
11
|
+
- ClientCCU: Primary client for CCU-compatible backends using XML-RPC for
|
|
12
|
+
device operations and optional JSON-RPC for metadata/program/sysvar access.
|
|
13
|
+
- ClientJsonCCU: Specialized client for CCU-Jack that prefers JSON-RPC
|
|
14
|
+
endpoints for all operations where available.
|
|
15
|
+
- ClientHomegear: Client for Homegear backend using XML-RPC exclusively.
|
|
16
|
+
- ClientConfig: Factory class that creates appropriate client instances
|
|
17
|
+
based on interface configuration and backend type.
|
|
18
|
+
|
|
19
|
+
Key features
|
|
20
|
+
------------
|
|
21
|
+
- Automatic protocol selection based on backend capabilities
|
|
22
|
+
- Connection health tracking via circuit breaker pattern
|
|
23
|
+
- Request coalescing for duplicate concurrent requests
|
|
24
|
+
- Paramset caching and lazy loading
|
|
25
|
+
- Program and system variable management (CCU backends)
|
|
26
|
+
- Firmware update support (where available)
|
|
27
|
+
|
|
28
|
+
Usage
|
|
29
|
+
-----
|
|
30
|
+
Clients are typically created through CentralUnit, but can be instantiated
|
|
31
|
+
directly via ClientConfig:
|
|
32
|
+
|
|
33
|
+
config = ClientConfig(client_deps=deps, interface_config=iface_cfg)
|
|
34
|
+
client = await config.create_client()
|
|
35
|
+
await client.init_client()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
from dataclasses import replace
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
import logging
|
|
44
|
+
from typing import Any, Final, cast
|
|
45
|
+
|
|
46
|
+
from aiohomematic import i18n
|
|
47
|
+
from aiohomematic.central.events import ClientStateChangedEvent, SystemStatusChangedEvent
|
|
48
|
+
from aiohomematic.client._rpc_errors import exception_to_failure_reason
|
|
49
|
+
from aiohomematic.client.backends.capabilities import (
|
|
50
|
+
CCU_CAPABILITIES,
|
|
51
|
+
HOMEGEAR_CAPABILITIES,
|
|
52
|
+
JSON_CCU_CAPABILITIES,
|
|
53
|
+
BackendCapabilities,
|
|
54
|
+
)
|
|
55
|
+
from aiohomematic.client.circuit_breaker import CircuitBreaker
|
|
56
|
+
from aiohomematic.client.config import InterfaceConfig
|
|
57
|
+
from aiohomematic.client.handlers import (
|
|
58
|
+
BackupHandler,
|
|
59
|
+
DeviceHandler,
|
|
60
|
+
FirmwareHandler,
|
|
61
|
+
LinkHandler,
|
|
62
|
+
MetadataHandler,
|
|
63
|
+
ProgramHandler,
|
|
64
|
+
SystemVariableHandler,
|
|
65
|
+
_wait_for_state_change_or_timeout,
|
|
66
|
+
)
|
|
67
|
+
from aiohomematic.client.request_coalescer import RequestCoalescer
|
|
68
|
+
from aiohomematic.client.rpc_proxy import AioXmlRpcProxy, BaseRpcProxy, NullRpcProxy
|
|
69
|
+
from aiohomematic.client.state_machine import ClientStateMachine
|
|
70
|
+
from aiohomematic.const import (
|
|
71
|
+
DATETIME_FORMAT_MILLIS,
|
|
72
|
+
DEFAULT_MAX_WORKERS,
|
|
73
|
+
DP_KEY_VALUE,
|
|
74
|
+
DUMMY_SERIAL,
|
|
75
|
+
INIT_DATETIME,
|
|
76
|
+
INTERFACES_REQUIRING_JSON_RPC_CLIENT,
|
|
77
|
+
INTERFACES_SUPPORTING_FIRMWARE_UPDATES,
|
|
78
|
+
INTERFACES_SUPPORTING_RPC_CALLBACK,
|
|
79
|
+
LINKABLE_INTERFACES,
|
|
80
|
+
VIRTUAL_REMOTE_MODELS,
|
|
81
|
+
WAIT_FOR_CALLBACK,
|
|
82
|
+
Backend,
|
|
83
|
+
BackupData,
|
|
84
|
+
CallSource,
|
|
85
|
+
CircuitState,
|
|
86
|
+
ClientState,
|
|
87
|
+
CommandRxMode,
|
|
88
|
+
DescriptionMarker,
|
|
89
|
+
DeviceDescription,
|
|
90
|
+
FailureReason,
|
|
91
|
+
ForcedDeviceAvailability,
|
|
92
|
+
InboxDeviceData,
|
|
93
|
+
Interface,
|
|
94
|
+
ParameterData,
|
|
95
|
+
ParameterType,
|
|
96
|
+
ParamsetKey,
|
|
97
|
+
ProductGroup,
|
|
98
|
+
ProgramData,
|
|
99
|
+
ProxyInitState,
|
|
100
|
+
ServiceMessageData,
|
|
101
|
+
ServiceMessageType,
|
|
102
|
+
SystemInformation,
|
|
103
|
+
SystemUpdateData,
|
|
104
|
+
SystemVariableData,
|
|
105
|
+
)
|
|
106
|
+
from aiohomematic.decorators import inspector
|
|
107
|
+
from aiohomematic.exceptions import BaseHomematicException, ClientException, NoConnectionException
|
|
108
|
+
from aiohomematic.interfaces.client import ClientDependenciesProtocol, ClientProtocol
|
|
109
|
+
from aiohomematic.interfaces.model import DeviceProtocol
|
|
110
|
+
from aiohomematic.property_decorators import DelegatedProperty
|
|
111
|
+
from aiohomematic.store.dynamic import CommandTracker, PingPongTracker
|
|
112
|
+
from aiohomematic.store.types import IncidentSeverity, IncidentType
|
|
113
|
+
from aiohomematic.support import (
|
|
114
|
+
LogContextMixin,
|
|
115
|
+
build_xml_rpc_headers,
|
|
116
|
+
build_xml_rpc_uri,
|
|
117
|
+
extract_exc_args,
|
|
118
|
+
get_device_address,
|
|
119
|
+
supports_rx_mode,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
123
|
+
|
|
124
|
+
_NAME: Final = "NAME"
|
|
125
|
+
|
|
126
|
+
_CCU_JSON_VALUE_TYPE: Final = {
|
|
127
|
+
"ACTION": "bool",
|
|
128
|
+
"BOOL": "bool",
|
|
129
|
+
"ENUM": "list",
|
|
130
|
+
"FLOAT": "double",
|
|
131
|
+
"INTEGER": "int",
|
|
132
|
+
"STRING": "string",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ClientCCU(ClientProtocol, LogContextMixin):
|
|
137
|
+
"""
|
|
138
|
+
Client object to access the backends via XML-RPC or JSON-RPC.
|
|
139
|
+
|
|
140
|
+
This class acts as a facade over specialized handler classes:
|
|
141
|
+
- DeviceHandler: Value read/write, paramset operations
|
|
142
|
+
- LinkHandler: Device linking operations
|
|
143
|
+
- FirmwareHandler: Firmware update operations
|
|
144
|
+
- SystemVariableHandler: System variable CRUD
|
|
145
|
+
- ProgramHandler: Program execution and state
|
|
146
|
+
- BackupHandler: Backup creation and download
|
|
147
|
+
- MetadataHandler: Metadata, renaming, rooms, functions, install mode
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
__slots__ = (
|
|
151
|
+
"_available",
|
|
152
|
+
"_backup_handler",
|
|
153
|
+
"_capabilities",
|
|
154
|
+
"_config",
|
|
155
|
+
"_connection_error_count",
|
|
156
|
+
"_device_ops_handler",
|
|
157
|
+
"_firmware_handler",
|
|
158
|
+
"_is_callback_alive",
|
|
159
|
+
"_is_initialized",
|
|
160
|
+
"_json_rpc_client",
|
|
161
|
+
"_last_value_send_tracker",
|
|
162
|
+
"_link_handler",
|
|
163
|
+
"_metadata_handler",
|
|
164
|
+
"_modified_at",
|
|
165
|
+
"_ping_pong_tracker",
|
|
166
|
+
"_program_handler",
|
|
167
|
+
"_proxy",
|
|
168
|
+
"_proxy_read",
|
|
169
|
+
"_reconnect_attempts",
|
|
170
|
+
"_state_machine",
|
|
171
|
+
"_sysvar_handler",
|
|
172
|
+
"_system_information",
|
|
173
|
+
"_unsubscribe_state_change",
|
|
174
|
+
"_unsubscribe_system_status",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
178
|
+
"""Initialize the Client."""
|
|
179
|
+
self._config: Final = client_config
|
|
180
|
+
# Initialize capabilities based on config (backup updated in init_client)
|
|
181
|
+
self._capabilities: BackendCapabilities = replace(
|
|
182
|
+
CCU_CAPABILITIES,
|
|
183
|
+
firmware_updates=client_config.has_firmware_updates,
|
|
184
|
+
linking=client_config.has_linking,
|
|
185
|
+
ping_pong=client_config.has_ping_pong,
|
|
186
|
+
push_updates=client_config.has_push_updates,
|
|
187
|
+
rpc_callback=client_config.has_rpc_callback,
|
|
188
|
+
)
|
|
189
|
+
self._json_rpc_client: Final = client_config.client_deps.json_rpc_client
|
|
190
|
+
self._last_value_send_tracker: Final = CommandTracker(
|
|
191
|
+
interface_id=client_config.interface_id,
|
|
192
|
+
)
|
|
193
|
+
self._state_machine: Final = ClientStateMachine(
|
|
194
|
+
interface_id=client_config.interface_id,
|
|
195
|
+
event_bus=client_config.client_deps.event_bus,
|
|
196
|
+
)
|
|
197
|
+
# Subscribe to state changes to emit SystemStatusChangedEvent for integration compatibility
|
|
198
|
+
self._unsubscribe_state_change = client_config.client_deps.event_bus.subscribe(
|
|
199
|
+
event_type=ClientStateChangedEvent,
|
|
200
|
+
event_key=client_config.interface_id,
|
|
201
|
+
handler=self._on_client_state_changed_event,
|
|
202
|
+
)
|
|
203
|
+
self._connection_error_count: int = 0
|
|
204
|
+
self._is_callback_alive: bool = True
|
|
205
|
+
self._reconnect_attempts: int = 0
|
|
206
|
+
self._ping_pong_tracker: Final = PingPongTracker(
|
|
207
|
+
event_bus_provider=client_config.client_deps,
|
|
208
|
+
central_info=client_config.client_deps,
|
|
209
|
+
interface_id=client_config.interface_id,
|
|
210
|
+
connection_state=client_config.client_deps.connection_state,
|
|
211
|
+
incident_recorder=client_config.client_deps.cache_coordinator.incident_store,
|
|
212
|
+
)
|
|
213
|
+
self._proxy: BaseRpcProxy
|
|
214
|
+
self._proxy_read: BaseRpcProxy
|
|
215
|
+
self._system_information: SystemInformation
|
|
216
|
+
self._modified_at: datetime = INIT_DATETIME
|
|
217
|
+
|
|
218
|
+
# Subscribe to connection state changes to clear ping/pong cache on reconnect.
|
|
219
|
+
# This prevents stale pending pongs from causing false mismatch alarms
|
|
220
|
+
# after CCU restart when PINGs sent during downtime cannot be answered.
|
|
221
|
+
self._unsubscribe_system_status = client_config.client_deps.event_bus.subscribe(
|
|
222
|
+
event_type=SystemStatusChangedEvent,
|
|
223
|
+
event_key=None,
|
|
224
|
+
handler=self._on_system_status_event,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Handler instances (initialized after proxy setup in init_client)
|
|
228
|
+
self._device_ops_handler: DeviceHandler
|
|
229
|
+
self._link_handler: LinkHandler
|
|
230
|
+
self._firmware_handler: FirmwareHandler
|
|
231
|
+
self._sysvar_handler: SystemVariableHandler
|
|
232
|
+
self._program_handler: ProgramHandler
|
|
233
|
+
self._backup_handler: BackupHandler
|
|
234
|
+
self._metadata_handler: MetadataHandler
|
|
235
|
+
|
|
236
|
+
def __str__(self) -> str:
|
|
237
|
+
"""Provide some useful information."""
|
|
238
|
+
return f"interface_id: {self.interface_id}"
|
|
239
|
+
|
|
240
|
+
available: Final = DelegatedProperty[bool](path="_state_machine.is_available")
|
|
241
|
+
central: Final = DelegatedProperty[ClientDependenciesProtocol](path="_config.client_deps")
|
|
242
|
+
interface: Final = DelegatedProperty[Interface](path="_config.interface")
|
|
243
|
+
interface_id: Final = DelegatedProperty[str](path="_config.interface_id", log_context=True)
|
|
244
|
+
last_value_send_tracker: Final = DelegatedProperty[CommandTracker](path="_last_value_send_tracker")
|
|
245
|
+
ping_pong_tracker: Final = DelegatedProperty[PingPongTracker](path="_ping_pong_tracker")
|
|
246
|
+
state: Final = DelegatedProperty[ClientState](path="_state_machine.state")
|
|
247
|
+
state_machine: Final = DelegatedProperty[ClientStateMachine](path="_state_machine")
|
|
248
|
+
system_information: Final = DelegatedProperty[SystemInformation](path="_system_information")
|
|
249
|
+
version: Final = DelegatedProperty[str](path="_config.version")
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def all_circuit_breakers_closed(self) -> bool:
|
|
253
|
+
"""Return True if all circuit breakers are in closed state."""
|
|
254
|
+
if self._proxy.circuit_breaker.state != CircuitState.CLOSED:
|
|
255
|
+
return False
|
|
256
|
+
if (
|
|
257
|
+
hasattr(self, "_proxy_read")
|
|
258
|
+
and self._proxy_read is not self._proxy
|
|
259
|
+
and self._proxy_read.circuit_breaker.state != CircuitState.CLOSED
|
|
260
|
+
):
|
|
261
|
+
return False
|
|
262
|
+
return self._json_rpc_client.circuit_breaker.state == CircuitState.CLOSED
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def capabilities(self) -> BackendCapabilities:
|
|
266
|
+
"""Return the capability flags for this backend."""
|
|
267
|
+
return self._capabilities
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def circuit_breaker(self) -> CircuitBreaker:
|
|
271
|
+
"""Return the primary circuit breaker for metrics access."""
|
|
272
|
+
return self._proxy.circuit_breaker
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def is_initialized(self) -> bool:
|
|
276
|
+
"""Return if interface is initialized."""
|
|
277
|
+
return self._state_machine.state in (
|
|
278
|
+
ClientState.CONNECTED,
|
|
279
|
+
ClientState.DISCONNECTED,
|
|
280
|
+
ClientState.RECONNECTING,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def model(self) -> str:
|
|
285
|
+
"""Return the model of the backend."""
|
|
286
|
+
return Backend.CCU
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def modified_at(self) -> datetime:
|
|
290
|
+
"""Return the last update datetime value."""
|
|
291
|
+
return self._modified_at
|
|
292
|
+
|
|
293
|
+
@modified_at.setter
|
|
294
|
+
def modified_at(self, value: datetime) -> None:
|
|
295
|
+
"""Write the last update datetime value."""
|
|
296
|
+
self._modified_at = value
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def request_coalescer(self) -> RequestCoalescer | None:
|
|
300
|
+
"""Return the request coalescer for metrics access."""
|
|
301
|
+
if hasattr(self, "_device_ops_handler"):
|
|
302
|
+
return self._device_ops_handler.paramset_description_coalescer
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
async def accept_device_in_inbox(self, *, device_address: str) -> bool:
|
|
306
|
+
"""Accept a device from the CCU inbox."""
|
|
307
|
+
return await self._metadata_handler.accept_device_in_inbox(device_address=device_address)
|
|
308
|
+
|
|
309
|
+
async def add_link(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
sender_address: str,
|
|
313
|
+
receiver_address: str,
|
|
314
|
+
name: str,
|
|
315
|
+
description: str,
|
|
316
|
+
) -> None:
|
|
317
|
+
"""Add a link between two devices."""
|
|
318
|
+
return await self._link_handler.add_link(
|
|
319
|
+
sender_address=sender_address,
|
|
320
|
+
receiver_address=receiver_address,
|
|
321
|
+
name=name,
|
|
322
|
+
description=description,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
326
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
327
|
+
"""Check if _proxy is still initialized."""
|
|
328
|
+
ping_timeout = self._config.client_deps.config.timeout_config.ping_timeout
|
|
329
|
+
try:
|
|
330
|
+
dt_now = datetime.now()
|
|
331
|
+
if handle_ping_pong and self._capabilities.ping_pong and self.is_initialized:
|
|
332
|
+
token = dt_now.strftime(format=DATETIME_FORMAT_MILLIS)
|
|
333
|
+
callerId = f"{self.interface_id}#{token}"
|
|
334
|
+
# Register token BEFORE sending ping to avoid race condition:
|
|
335
|
+
# CCU may respond with PONG before await returns
|
|
336
|
+
self._ping_pong_tracker.handle_send_ping(ping_token=token)
|
|
337
|
+
async with asyncio.timeout(ping_timeout):
|
|
338
|
+
await self._proxy.ping(callerId)
|
|
339
|
+
elif not self.is_initialized:
|
|
340
|
+
async with asyncio.timeout(ping_timeout):
|
|
341
|
+
await self._proxy.ping(self.interface_id)
|
|
342
|
+
self.modified_at = dt_now
|
|
343
|
+
except TimeoutError:
|
|
344
|
+
_LOGGER.debug(
|
|
345
|
+
"CHECK_CONNECTION_AVAILABILITY: Ping timeout after %.1fs for %s",
|
|
346
|
+
ping_timeout,
|
|
347
|
+
self.interface_id,
|
|
348
|
+
)
|
|
349
|
+
except BaseHomematicException as bhexc:
|
|
350
|
+
_LOGGER.debug(
|
|
351
|
+
"CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
|
|
352
|
+
bhexc.name,
|
|
353
|
+
extract_exc_args(exc=bhexc),
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
return True
|
|
357
|
+
self.modified_at = INIT_DATETIME
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
def clear_json_rpc_session(self) -> None:
|
|
361
|
+
"""Clear the JSON-RPC session to force re-authentication on next request."""
|
|
362
|
+
self._json_rpc_client.clear_session()
|
|
363
|
+
_LOGGER.debug(
|
|
364
|
+
"CLEAR_JSON_RPC_SESSION: Session cleared for %s",
|
|
365
|
+
self.interface_id,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
async def create_backup_and_download(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
max_wait_time: float = 300.0,
|
|
372
|
+
poll_interval: float = 5.0,
|
|
373
|
+
) -> BackupData | None:
|
|
374
|
+
"""Create a backup on the CCU and download it."""
|
|
375
|
+
return await self._backup_handler.create_backup_and_download(
|
|
376
|
+
max_wait_time=max_wait_time,
|
|
377
|
+
poll_interval=poll_interval,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def deinitialize_proxy(self) -> ProxyInitState:
|
|
381
|
+
"""De-init to stop the backend from sending events for this remote."""
|
|
382
|
+
if not self._capabilities.rpc_callback:
|
|
383
|
+
self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="no callback support")
|
|
384
|
+
return ProxyInitState.DE_INIT_SUCCESS
|
|
385
|
+
|
|
386
|
+
if self.modified_at == INIT_DATETIME:
|
|
387
|
+
_LOGGER.debug(
|
|
388
|
+
"PROXY_DE_INIT: Skipping de-init for %s (not initialized)",
|
|
389
|
+
self.interface_id,
|
|
390
|
+
)
|
|
391
|
+
return ProxyInitState.DE_INIT_SKIPPED
|
|
392
|
+
try:
|
|
393
|
+
_LOGGER.debug("PROXY_DE_INIT: init('%s')", self._config.init_url)
|
|
394
|
+
await self._proxy.init(self._config.init_url)
|
|
395
|
+
self._state_machine.transition_to(target=ClientState.DISCONNECTED, reason="proxy de-initialized")
|
|
396
|
+
except BaseHomematicException as bhexc:
|
|
397
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
398
|
+
"PROXY_DE_INIT failed: %s [%s] Unable to de-initialize proxy for %s",
|
|
399
|
+
bhexc.name,
|
|
400
|
+
extract_exc_args(exc=bhexc),
|
|
401
|
+
self.interface_id,
|
|
402
|
+
)
|
|
403
|
+
return ProxyInitState.DE_INIT_FAILED
|
|
404
|
+
|
|
405
|
+
self.modified_at = INIT_DATETIME
|
|
406
|
+
return ProxyInitState.DE_INIT_SUCCESS
|
|
407
|
+
|
|
408
|
+
async def delete_system_variable(self, *, name: str) -> bool:
|
|
409
|
+
"""Delete a system variable from the backend."""
|
|
410
|
+
return await self._sysvar_handler.delete_system_variable(name=name)
|
|
411
|
+
|
|
412
|
+
async def execute_program(self, *, pid: str) -> bool:
|
|
413
|
+
"""Execute a program on the backend."""
|
|
414
|
+
return await self._program_handler.execute_program(pid=pid)
|
|
415
|
+
|
|
416
|
+
async def fetch_all_device_data(self) -> None:
|
|
417
|
+
"""Fetch all device data from the backend."""
|
|
418
|
+
return await self._device_ops_handler.fetch_all_device_data()
|
|
419
|
+
|
|
420
|
+
async def fetch_device_details(self) -> None:
|
|
421
|
+
"""Get all names via JSON-RPS and store in data.NAMES."""
|
|
422
|
+
return await self._device_ops_handler.fetch_device_details()
|
|
423
|
+
|
|
424
|
+
async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
|
|
425
|
+
"""Fetch a specific paramset and add it to the known ones."""
|
|
426
|
+
return await self._device_ops_handler.fetch_paramset_description(
|
|
427
|
+
channel_address=channel_address,
|
|
428
|
+
paramset_key=paramset_key,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
|
|
432
|
+
"""Fetch paramsets for provided device description."""
|
|
433
|
+
return await self._device_ops_handler.fetch_paramset_descriptions(device_description=device_description)
|
|
434
|
+
|
|
435
|
+
async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
|
|
436
|
+
"""Get all device descriptions from the backend."""
|
|
437
|
+
return await self._device_ops_handler.get_all_device_descriptions(device_address=device_address)
|
|
438
|
+
|
|
439
|
+
async def get_all_functions(self) -> dict[str, set[str]]:
|
|
440
|
+
"""Get all functions from the backend."""
|
|
441
|
+
return await self._metadata_handler.get_all_functions()
|
|
442
|
+
|
|
443
|
+
async def get_all_paramset_descriptions(
|
|
444
|
+
self, *, device_descriptions: tuple[DeviceDescription, ...]
|
|
445
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
446
|
+
"""Get all paramset descriptions for provided device descriptions."""
|
|
447
|
+
return await self._device_ops_handler.get_all_paramset_descriptions(device_descriptions=device_descriptions)
|
|
448
|
+
|
|
449
|
+
async def get_all_programs(
|
|
450
|
+
self,
|
|
451
|
+
*,
|
|
452
|
+
markers: tuple[DescriptionMarker | str, ...],
|
|
453
|
+
) -> tuple[ProgramData, ...]:
|
|
454
|
+
"""Get all programs, if available."""
|
|
455
|
+
return await self._program_handler.get_all_programs(markers=markers)
|
|
456
|
+
|
|
457
|
+
async def get_all_rooms(self) -> dict[str, set[str]]:
|
|
458
|
+
"""Get all rooms from the backend."""
|
|
459
|
+
return await self._metadata_handler.get_all_rooms()
|
|
460
|
+
|
|
461
|
+
async def get_all_system_variables(
|
|
462
|
+
self,
|
|
463
|
+
*,
|
|
464
|
+
markers: tuple[DescriptionMarker | str, ...],
|
|
465
|
+
) -> tuple[SystemVariableData, ...] | None:
|
|
466
|
+
"""Get all system variables from the backend."""
|
|
467
|
+
return await self._sysvar_handler.get_all_system_variables(markers=markers)
|
|
468
|
+
|
|
469
|
+
async def get_device_description(self, *, address: str) -> DeviceDescription | None:
|
|
470
|
+
"""Get device descriptions from the backend."""
|
|
471
|
+
return await self._device_ops_handler.get_device_description(address=address)
|
|
472
|
+
|
|
473
|
+
async def get_inbox_devices(self) -> tuple[InboxDeviceData, ...]:
|
|
474
|
+
"""Get all devices in the inbox (not yet configured)."""
|
|
475
|
+
return await self._metadata_handler.get_inbox_devices()
|
|
476
|
+
|
|
477
|
+
async def get_install_mode(self) -> int:
|
|
478
|
+
"""Return the remaining time in install mode."""
|
|
479
|
+
return await self._metadata_handler.get_install_mode()
|
|
480
|
+
|
|
481
|
+
async def get_link_peers(self, *, address: str) -> tuple[str, ...]:
|
|
482
|
+
"""Return a list of link peers."""
|
|
483
|
+
return await self._link_handler.get_link_peers(address=address)
|
|
484
|
+
|
|
485
|
+
async def get_links(self, *, address: str, flags: int) -> dict[str, Any]:
|
|
486
|
+
"""Return a list of links."""
|
|
487
|
+
return await self._link_handler.get_links(address=address, flags=flags)
|
|
488
|
+
|
|
489
|
+
async def get_metadata(self, *, address: str, data_id: str) -> dict[str, Any]:
|
|
490
|
+
"""Return the metadata for an object."""
|
|
491
|
+
return await self._metadata_handler.get_metadata(address=address, data_id=data_id)
|
|
492
|
+
|
|
493
|
+
async def get_paramset(
|
|
494
|
+
self,
|
|
495
|
+
*,
|
|
496
|
+
address: str,
|
|
497
|
+
paramset_key: ParamsetKey | str,
|
|
498
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
499
|
+
) -> dict[str, Any]:
|
|
500
|
+
"""Return a paramset from the backend."""
|
|
501
|
+
return await self._device_ops_handler.get_paramset(
|
|
502
|
+
address=address,
|
|
503
|
+
paramset_key=paramset_key,
|
|
504
|
+
call_source=call_source,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
async def get_paramset_descriptions(
|
|
508
|
+
self, *, device_description: DeviceDescription
|
|
509
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
510
|
+
"""Get paramsets for provided device description."""
|
|
511
|
+
return await self._device_ops_handler.get_paramset_descriptions(device_description=device_description)
|
|
512
|
+
|
|
513
|
+
def get_product_group(self, *, model: str) -> ProductGroup:
|
|
514
|
+
"""Return the product group."""
|
|
515
|
+
l_model = model.lower()
|
|
516
|
+
if l_model.startswith("hmipw-"):
|
|
517
|
+
return ProductGroup.HMIPW
|
|
518
|
+
if l_model.startswith("hmip-"):
|
|
519
|
+
return ProductGroup.HMIP
|
|
520
|
+
if l_model.startswith("hmw-"):
|
|
521
|
+
return ProductGroup.HMW
|
|
522
|
+
if l_model.startswith("hm-"):
|
|
523
|
+
return ProductGroup.HM
|
|
524
|
+
if self.interface == Interface.HMIP_RF:
|
|
525
|
+
return ProductGroup.HMIP
|
|
526
|
+
if self.interface == Interface.BIDCOS_WIRED:
|
|
527
|
+
return ProductGroup.HMW
|
|
528
|
+
if self.interface == Interface.BIDCOS_RF:
|
|
529
|
+
return ProductGroup.HM
|
|
530
|
+
if self.interface == Interface.VIRTUAL_DEVICES:
|
|
531
|
+
return ProductGroup.VIRTUAL
|
|
532
|
+
return ProductGroup.UNKNOWN
|
|
533
|
+
|
|
534
|
+
async def get_rega_id_by_address(self, *, address: str) -> int | None:
|
|
535
|
+
"""Get the ReGa ID for a device or channel address."""
|
|
536
|
+
return await self._metadata_handler.get_rega_id_by_address(address=address)
|
|
537
|
+
|
|
538
|
+
async def get_service_messages(
|
|
539
|
+
self,
|
|
540
|
+
*,
|
|
541
|
+
message_type: ServiceMessageType | None = None,
|
|
542
|
+
) -> tuple[ServiceMessageData, ...]:
|
|
543
|
+
"""Get all active service messages from the backend."""
|
|
544
|
+
return await self._metadata_handler.get_service_messages(message_type=message_type)
|
|
545
|
+
|
|
546
|
+
async def get_system_update_info(self) -> SystemUpdateData | None:
|
|
547
|
+
"""Get system update information from the backend."""
|
|
548
|
+
return await self._metadata_handler.get_system_update_info()
|
|
549
|
+
|
|
550
|
+
async def get_system_variable(self, *, name: str) -> Any:
|
|
551
|
+
"""Get single system variable from the backend."""
|
|
552
|
+
return await self._sysvar_handler.get_system_variable(name=name)
|
|
553
|
+
|
|
554
|
+
async def get_value(
|
|
555
|
+
self,
|
|
556
|
+
*,
|
|
557
|
+
channel_address: str,
|
|
558
|
+
paramset_key: ParamsetKey,
|
|
559
|
+
parameter: str,
|
|
560
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
561
|
+
) -> Any:
|
|
562
|
+
"""Return a value from the backend."""
|
|
563
|
+
return await self._device_ops_handler.get_value(
|
|
564
|
+
channel_address=channel_address,
|
|
565
|
+
paramset_key=paramset_key,
|
|
566
|
+
parameter=parameter,
|
|
567
|
+
call_source=call_source,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def get_virtual_remote(self) -> DeviceProtocol | None:
|
|
571
|
+
"""Get the virtual remote for the Client."""
|
|
572
|
+
for model in VIRTUAL_REMOTE_MODELS:
|
|
573
|
+
for device in self.central.device_registry.devices:
|
|
574
|
+
if device.interface_id == self.interface_id and device.model == model:
|
|
575
|
+
return device
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
async def has_program_ids(self, *, rega_id: int) -> bool:
|
|
579
|
+
"""Return if a channel has program ids."""
|
|
580
|
+
return await self._program_handler.has_program_ids(rega_id=rega_id)
|
|
581
|
+
|
|
582
|
+
@inspector
|
|
583
|
+
async def init_client(self) -> None:
|
|
584
|
+
"""Initialize the client."""
|
|
585
|
+
self._state_machine.transition_to(target=ClientState.INITIALIZING)
|
|
586
|
+
try:
|
|
587
|
+
self._system_information = await self._get_system_information()
|
|
588
|
+
# Update capabilities with backup from system information
|
|
589
|
+
if not self._system_information.has_backup:
|
|
590
|
+
self._capabilities = replace(self._capabilities, backup=False)
|
|
591
|
+
if self._capabilities.rpc_callback:
|
|
592
|
+
self._proxy = await self._config.create_rpc_proxy(
|
|
593
|
+
interface=self.interface,
|
|
594
|
+
auth_enabled=self.system_information.auth_enabled,
|
|
595
|
+
)
|
|
596
|
+
self._proxy_read = await self._config.create_rpc_proxy(
|
|
597
|
+
interface=self.interface,
|
|
598
|
+
auth_enabled=self.system_information.auth_enabled,
|
|
599
|
+
max_workers=self._config.max_read_workers,
|
|
600
|
+
)
|
|
601
|
+
self._init_handlers()
|
|
602
|
+
self._state_machine.transition_to(target=ClientState.INITIALIZED)
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
self._state_machine.transition_to(
|
|
605
|
+
target=ClientState.FAILED,
|
|
606
|
+
reason=str(exc),
|
|
607
|
+
failure_reason=exception_to_failure_reason(exc=exc),
|
|
608
|
+
)
|
|
609
|
+
raise
|
|
610
|
+
|
|
611
|
+
async def initialize_proxy(self) -> ProxyInitState:
|
|
612
|
+
"""Initialize the proxy has to tell the backend where to send the events."""
|
|
613
|
+
self._state_machine.transition_to(target=ClientState.CONNECTING)
|
|
614
|
+
if not self._capabilities.rpc_callback:
|
|
615
|
+
if (device_descriptions := await self.list_devices()) is not None:
|
|
616
|
+
await self.central.device_coordinator.add_new_devices(
|
|
617
|
+
interface_id=self.interface_id, device_descriptions=device_descriptions
|
|
618
|
+
)
|
|
619
|
+
self._state_machine.transition_to(
|
|
620
|
+
target=ClientState.CONNECTED, reason="proxy initialized (no callback)"
|
|
621
|
+
)
|
|
622
|
+
return ProxyInitState.INIT_SUCCESS
|
|
623
|
+
self._state_machine.transition_to(
|
|
624
|
+
target=ClientState.FAILED,
|
|
625
|
+
reason="device listing failed",
|
|
626
|
+
failure_reason=FailureReason.NETWORK,
|
|
627
|
+
)
|
|
628
|
+
# Mark devices as unavailable when device listing fails
|
|
629
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
630
|
+
return ProxyInitState.INIT_FAILED
|
|
631
|
+
# Record modified_at before init to detect callback during init
|
|
632
|
+
# This is used to work around VirtualDevices service bug where init()
|
|
633
|
+
# times out but listDevices callback was successfully received
|
|
634
|
+
modified_at_before_init = self.modified_at
|
|
635
|
+
init_success = False
|
|
636
|
+
try:
|
|
637
|
+
_LOGGER.debug("PROXY_INIT: init('%s', '%s')", self._config.init_url, self.interface_id)
|
|
638
|
+
self._ping_pong_tracker.clear()
|
|
639
|
+
await self._proxy.init(self._config.init_url, self.interface_id)
|
|
640
|
+
init_success = True
|
|
641
|
+
except BaseHomematicException as bhexc:
|
|
642
|
+
# Check if we received a callback during init (modified_at was updated)
|
|
643
|
+
# This happens when init() times out but the CCU successfully processed it
|
|
644
|
+
# and called back listDevices. Common with VirtualDevices service bug.
|
|
645
|
+
if self.modified_at > modified_at_before_init:
|
|
646
|
+
_LOGGER.info( # i18n-log: ignore
|
|
647
|
+
"PROXY_INIT: init() failed but callback received for %s - treating as success",
|
|
648
|
+
self.interface_id,
|
|
649
|
+
)
|
|
650
|
+
init_success = True
|
|
651
|
+
else:
|
|
652
|
+
_LOGGER.error( # i18n-log: ignore
|
|
653
|
+
"PROXY_INIT failed: %s [%s] Unable to initialize proxy for %s",
|
|
654
|
+
bhexc.name,
|
|
655
|
+
extract_exc_args(exc=bhexc),
|
|
656
|
+
self.interface_id,
|
|
657
|
+
)
|
|
658
|
+
self.modified_at = INIT_DATETIME
|
|
659
|
+
self._state_machine.transition_to(
|
|
660
|
+
target=ClientState.FAILED,
|
|
661
|
+
reason="proxy init failed",
|
|
662
|
+
failure_reason=exception_to_failure_reason(exc=bhexc),
|
|
663
|
+
)
|
|
664
|
+
# Mark devices as unavailable when proxy init fails
|
|
665
|
+
# This ensures data points show unavailable during CCU restart/recovery
|
|
666
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
667
|
+
return ProxyInitState.INIT_FAILED
|
|
668
|
+
|
|
669
|
+
if init_success:
|
|
670
|
+
self._state_machine.transition_to(target=ClientState.CONNECTED, reason="proxy initialized")
|
|
671
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
|
|
672
|
+
# Clear any stale connection issues from failed attempts during reconnection
|
|
673
|
+
# This ensures subsequent RPC calls are not blocked
|
|
674
|
+
self._proxy.clear_connection_issue()
|
|
675
|
+
_LOGGER.debug("PROXY_INIT: Proxy for %s initialized", self.interface_id)
|
|
676
|
+
|
|
677
|
+
# Recreate proxies AFTER successful init to get fresh HTTP transport
|
|
678
|
+
# This prevents "ResponseNotReady" errors on subsequent requests that occur
|
|
679
|
+
# when the HTTP connection is in an inconsistent state after reconnection.
|
|
680
|
+
# The callback URL remains unchanged (XML-RPC server port stays the same).
|
|
681
|
+
try:
|
|
682
|
+
_LOGGER.debug(
|
|
683
|
+
"PROXY_INIT: Recreating proxy objects for %s to get fresh HTTP transport",
|
|
684
|
+
self.interface_id,
|
|
685
|
+
)
|
|
686
|
+
self._proxy = await self._config.create_rpc_proxy(
|
|
687
|
+
interface=self.interface,
|
|
688
|
+
auth_enabled=self.system_information.auth_enabled,
|
|
689
|
+
)
|
|
690
|
+
self._proxy_read = await self._config.create_rpc_proxy(
|
|
691
|
+
interface=self.interface,
|
|
692
|
+
auth_enabled=self.system_information.auth_enabled,
|
|
693
|
+
max_workers=self._config.max_read_workers,
|
|
694
|
+
)
|
|
695
|
+
self._init_handlers()
|
|
696
|
+
_LOGGER.debug("PROXY_INIT: Proxies recreated with fresh transport for %s", self.interface_id)
|
|
697
|
+
except Exception as exc:
|
|
698
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
699
|
+
"PROXY_INIT: Failed to recreate proxies for %s: %s - continuing with existing proxies",
|
|
700
|
+
self.interface_id,
|
|
701
|
+
exc,
|
|
702
|
+
)
|
|
703
|
+
self.modified_at = datetime.now()
|
|
704
|
+
return ProxyInitState.INIT_SUCCESS
|
|
705
|
+
|
|
706
|
+
def is_callback_alive(self) -> bool:
|
|
707
|
+
"""Return if XmlRPC-Server is alive based on received events for this client."""
|
|
708
|
+
if not self._capabilities.ping_pong:
|
|
709
|
+
return True
|
|
710
|
+
|
|
711
|
+
# If client is in RECONNECTING or FAILED state, callback is definitely not alive
|
|
712
|
+
# This ensures reconnection continues after CCU restart until init() succeeds
|
|
713
|
+
if self._state_machine.is_failed or self._state_machine.state == ClientState.RECONNECTING:
|
|
714
|
+
return False
|
|
715
|
+
|
|
716
|
+
# Check event timestamp for all other states (including startup states)
|
|
717
|
+
if (
|
|
718
|
+
last_events_dt := self.central.event_coordinator.get_last_event_seen_for_interface(
|
|
719
|
+
interface_id=self.interface_id
|
|
720
|
+
)
|
|
721
|
+
) is not None:
|
|
722
|
+
callback_warn = self._config.client_deps.config.timeout_config.callback_warn_interval
|
|
723
|
+
if (seconds_since_last_event := (datetime.now() - last_events_dt).total_seconds()) > callback_warn:
|
|
724
|
+
if self._is_callback_alive:
|
|
725
|
+
self.central.event_bus.publish_sync(
|
|
726
|
+
event=SystemStatusChangedEvent(
|
|
727
|
+
timestamp=datetime.now(),
|
|
728
|
+
callback_state=(self.interface_id, False),
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
self._is_callback_alive = False
|
|
732
|
+
self._record_callback_timeout_incident(
|
|
733
|
+
seconds_since_last_event=seconds_since_last_event,
|
|
734
|
+
callback_warn_interval=callback_warn,
|
|
735
|
+
last_event_time=last_events_dt,
|
|
736
|
+
)
|
|
737
|
+
_LOGGER.error(
|
|
738
|
+
i18n.tr(
|
|
739
|
+
key="log.client.is_callback_alive.no_events",
|
|
740
|
+
interface_id=self.interface_id,
|
|
741
|
+
seconds=int(seconds_since_last_event),
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
return False
|
|
745
|
+
|
|
746
|
+
if not self._is_callback_alive:
|
|
747
|
+
self.central.event_bus.publish_sync(
|
|
748
|
+
event=SystemStatusChangedEvent(
|
|
749
|
+
timestamp=datetime.now(),
|
|
750
|
+
callback_state=(self.interface_id, True),
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
self._is_callback_alive = True
|
|
754
|
+
return True
|
|
755
|
+
|
|
756
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
757
|
+
async def is_connected(self) -> bool:
|
|
758
|
+
"""
|
|
759
|
+
Perform actions required for connectivity check.
|
|
760
|
+
|
|
761
|
+
Connection is not connected if consecutive checks exceed threshold.
|
|
762
|
+
Return connectivity state.
|
|
763
|
+
"""
|
|
764
|
+
if await self.check_connection_availability(handle_ping_pong=True) is True:
|
|
765
|
+
self._connection_error_count = 0
|
|
766
|
+
else:
|
|
767
|
+
self._connection_error_count += 1
|
|
768
|
+
|
|
769
|
+
error_threshold = self._config.client_deps.config.timeout_config.connectivity_error_threshold
|
|
770
|
+
if self._connection_error_count > error_threshold:
|
|
771
|
+
self._mark_all_devices_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
|
|
772
|
+
# Update state machine to reflect connection loss
|
|
773
|
+
if self._state_machine.state == ClientState.CONNECTED:
|
|
774
|
+
self._state_machine.transition_to(
|
|
775
|
+
target=ClientState.DISCONNECTED,
|
|
776
|
+
reason=f"connection check failed (>{error_threshold} errors)",
|
|
777
|
+
)
|
|
778
|
+
return False
|
|
779
|
+
if not self._capabilities.push_updates:
|
|
780
|
+
return True
|
|
781
|
+
|
|
782
|
+
callback_warn = self._config.client_deps.config.timeout_config.callback_warn_interval
|
|
783
|
+
return (datetime.now() - self.modified_at).total_seconds() < callback_warn
|
|
784
|
+
|
|
785
|
+
async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
|
|
786
|
+
"""List devices of the backend."""
|
|
787
|
+
return await self._device_ops_handler.list_devices()
|
|
788
|
+
|
|
789
|
+
async def put_paramset(
|
|
790
|
+
self,
|
|
791
|
+
*,
|
|
792
|
+
channel_address: str,
|
|
793
|
+
paramset_key_or_link_address: ParamsetKey | str,
|
|
794
|
+
values: dict[str, Any],
|
|
795
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
796
|
+
rx_mode: CommandRxMode | None = None,
|
|
797
|
+
check_against_pd: bool = False,
|
|
798
|
+
) -> set[DP_KEY_VALUE]:
|
|
799
|
+
"""Set paramsets manually."""
|
|
800
|
+
return await self._device_ops_handler.put_paramset(
|
|
801
|
+
channel_address=channel_address,
|
|
802
|
+
paramset_key_or_link_address=paramset_key_or_link_address,
|
|
803
|
+
values=values,
|
|
804
|
+
wait_for_callback=wait_for_callback,
|
|
805
|
+
rx_mode=rx_mode,
|
|
806
|
+
check_against_pd=check_against_pd,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
async def reconnect(self) -> bool:
|
|
810
|
+
"""Re-init all RPC clients with exponential backoff."""
|
|
811
|
+
if self._state_machine.can_reconnect:
|
|
812
|
+
self._state_machine.transition_to(target=ClientState.RECONNECTING)
|
|
813
|
+
|
|
814
|
+
# Calculate exponential backoff delay using timeout_config
|
|
815
|
+
timeout_cfg = self._config.client_deps.config.timeout_config
|
|
816
|
+
delay = min(
|
|
817
|
+
timeout_cfg.reconnect_initial_delay * (timeout_cfg.reconnect_backoff_factor**self._reconnect_attempts),
|
|
818
|
+
timeout_cfg.reconnect_max_delay,
|
|
819
|
+
)
|
|
820
|
+
_LOGGER.debug(
|
|
821
|
+
"RECONNECT: waiting to re-connect client %s for %.1fs (attempt %d)",
|
|
822
|
+
self.interface_id,
|
|
823
|
+
delay,
|
|
824
|
+
self._reconnect_attempts + 1,
|
|
825
|
+
)
|
|
826
|
+
await asyncio.sleep(delay)
|
|
827
|
+
|
|
828
|
+
if await self.reinitialize_proxy() == ProxyInitState.INIT_SUCCESS:
|
|
829
|
+
# Reset circuit breakers after successful reconnect to allow
|
|
830
|
+
# immediate data refresh without waiting for recovery timeout
|
|
831
|
+
self.reset_circuit_breakers()
|
|
832
|
+
self._reconnect_attempts = 0 # Reset on success
|
|
833
|
+
self._connection_error_count = 0 # Reset error count on success
|
|
834
|
+
_LOGGER.info(
|
|
835
|
+
i18n.tr(
|
|
836
|
+
key="log.client.reconnect.reconnected",
|
|
837
|
+
interface_id=self.interface_id,
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
return True
|
|
841
|
+
# Increment attempt counter for next reconnect try
|
|
842
|
+
self._reconnect_attempts += 1
|
|
843
|
+
# State machine already transitioned in reinitialize_proxy
|
|
844
|
+
return False
|
|
845
|
+
|
|
846
|
+
async def reinitialize_proxy(self) -> ProxyInitState:
|
|
847
|
+
"""Reinit Proxy."""
|
|
848
|
+
await self.deinitialize_proxy()
|
|
849
|
+
return await self.initialize_proxy()
|
|
850
|
+
|
|
851
|
+
async def remove_link(self, *, sender_address: str, receiver_address: str) -> None:
|
|
852
|
+
"""Remove a link between two devices."""
|
|
853
|
+
return await self._link_handler.remove_link(
|
|
854
|
+
sender_address=sender_address,
|
|
855
|
+
receiver_address=receiver_address,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
async def rename_channel(self, *, rega_id: int, new_name: str) -> bool:
|
|
859
|
+
"""Rename a channel on the CCU."""
|
|
860
|
+
return await self._metadata_handler.rename_channel(rega_id=rega_id, new_name=new_name)
|
|
861
|
+
|
|
862
|
+
async def rename_device(self, *, rega_id: int, new_name: str) -> bool:
|
|
863
|
+
"""Rename a device on the CCU."""
|
|
864
|
+
return await self._metadata_handler.rename_device(rega_id=rega_id, new_name=new_name)
|
|
865
|
+
|
|
866
|
+
async def report_value_usage(self, *, address: str, value_id: str, ref_counter: int) -> bool:
|
|
867
|
+
"""Report value usage."""
|
|
868
|
+
return await self._device_ops_handler.report_value_usage(
|
|
869
|
+
address=address,
|
|
870
|
+
value_id=value_id,
|
|
871
|
+
ref_counter=ref_counter,
|
|
872
|
+
supports=self._capabilities.value_usage_reporting,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def reset_circuit_breakers(self) -> None:
|
|
876
|
+
"""Reset all circuit breakers to closed state."""
|
|
877
|
+
self._proxy.circuit_breaker.reset()
|
|
878
|
+
if hasattr(self, "_proxy_read") and self._proxy_read is not self._proxy:
|
|
879
|
+
self._proxy_read.circuit_breaker.reset()
|
|
880
|
+
self._json_rpc_client.circuit_breaker.reset()
|
|
881
|
+
_LOGGER.debug(
|
|
882
|
+
"RESET_CIRCUIT_BREAKERS: All circuit breakers reset for %s",
|
|
883
|
+
self.interface_id,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
async def set_install_mode(
|
|
887
|
+
self,
|
|
888
|
+
*,
|
|
889
|
+
on: bool = True,
|
|
890
|
+
time: int = 60,
|
|
891
|
+
mode: int = 1,
|
|
892
|
+
device_address: str | None = None,
|
|
893
|
+
) -> bool:
|
|
894
|
+
"""Set the install mode on the backend."""
|
|
895
|
+
return await self._metadata_handler.set_install_mode(
|
|
896
|
+
on=on,
|
|
897
|
+
time=time,
|
|
898
|
+
mode=mode,
|
|
899
|
+
device_address=device_address,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
async def set_metadata(self, *, address: str, data_id: str, value: dict[str, Any]) -> dict[str, Any]:
|
|
903
|
+
"""Write the metadata for an object."""
|
|
904
|
+
return await self._metadata_handler.set_metadata(address=address, data_id=data_id, value=value)
|
|
905
|
+
|
|
906
|
+
async def set_program_state(self, *, pid: str, state: bool) -> bool:
|
|
907
|
+
"""Set the program state on the backend."""
|
|
908
|
+
return await self._program_handler.set_program_state(pid=pid, state=state)
|
|
909
|
+
|
|
910
|
+
async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
|
|
911
|
+
"""Set a system variable on the backend."""
|
|
912
|
+
return await self._sysvar_handler.set_system_variable(legacy_name=legacy_name, value=value)
|
|
913
|
+
|
|
914
|
+
async def set_value(
|
|
915
|
+
self,
|
|
916
|
+
*,
|
|
917
|
+
channel_address: str,
|
|
918
|
+
paramset_key: ParamsetKey,
|
|
919
|
+
parameter: str,
|
|
920
|
+
value: Any,
|
|
921
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
922
|
+
rx_mode: CommandRxMode | None = None,
|
|
923
|
+
check_against_pd: bool = False,
|
|
924
|
+
) -> set[DP_KEY_VALUE]:
|
|
925
|
+
"""Set single value on paramset VALUES."""
|
|
926
|
+
return await self._device_ops_handler.set_value(
|
|
927
|
+
channel_address=channel_address,
|
|
928
|
+
paramset_key=paramset_key,
|
|
929
|
+
parameter=parameter,
|
|
930
|
+
value=value,
|
|
931
|
+
wait_for_callback=wait_for_callback,
|
|
932
|
+
rx_mode=rx_mode,
|
|
933
|
+
check_against_pd=check_against_pd,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
async def stop(self) -> None:
|
|
937
|
+
"""Stop depending services."""
|
|
938
|
+
# Unsubscribe from state change events before stopping
|
|
939
|
+
self._unsubscribe_state_change()
|
|
940
|
+
self._unsubscribe_system_status()
|
|
941
|
+
self._state_machine.transition_to(target=ClientState.STOPPING, reason="stop() called")
|
|
942
|
+
if self._capabilities.rpc_callback:
|
|
943
|
+
await self._proxy.stop()
|
|
944
|
+
await self._proxy_read.stop()
|
|
945
|
+
self._state_machine.transition_to(target=ClientState.STOPPED, reason="services stopped")
|
|
946
|
+
|
|
947
|
+
async def trigger_firmware_update(self) -> bool:
|
|
948
|
+
"""Trigger the CCU firmware update process."""
|
|
949
|
+
return await self._firmware_handler.trigger_firmware_update()
|
|
950
|
+
|
|
951
|
+
async def update_device_firmware(self, *, device_address: str) -> bool:
|
|
952
|
+
"""Update the firmware of a Homematic device."""
|
|
953
|
+
return await self._firmware_handler.update_device_firmware(device_address=device_address)
|
|
954
|
+
|
|
955
|
+
async def update_paramset_descriptions(self, *, device_address: str) -> None:
|
|
956
|
+
"""Update paramsets descriptions for provided device_address."""
|
|
957
|
+
return await self._device_ops_handler.update_paramset_descriptions(device_address=device_address)
|
|
958
|
+
|
|
959
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
960
|
+
"""Get system information of the backend."""
|
|
961
|
+
return await self._json_rpc_client.get_system_information()
|
|
962
|
+
|
|
963
|
+
def _init_handlers(self) -> None:
|
|
964
|
+
"""Initialize all handler instances."""
|
|
965
|
+
self._device_ops_handler = DeviceHandler(
|
|
966
|
+
client_deps=self._config.client_deps,
|
|
967
|
+
interface=self._config.interface,
|
|
968
|
+
interface_id=self._config.interface_id,
|
|
969
|
+
json_rpc_client=self._json_rpc_client,
|
|
970
|
+
proxy=self._proxy,
|
|
971
|
+
proxy_read=self._proxy_read,
|
|
972
|
+
last_value_send_tracker=self._last_value_send_tracker,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
self._link_handler = LinkHandler(
|
|
976
|
+
client_deps=self._config.client_deps,
|
|
977
|
+
interface=self._config.interface,
|
|
978
|
+
interface_id=self._config.interface_id,
|
|
979
|
+
json_rpc_client=self._json_rpc_client,
|
|
980
|
+
proxy=self._proxy,
|
|
981
|
+
proxy_read=self._proxy_read,
|
|
982
|
+
has_linking=self._capabilities.linking,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
self._firmware_handler = FirmwareHandler(
|
|
986
|
+
client_deps=self._config.client_deps,
|
|
987
|
+
interface=self._config.interface,
|
|
988
|
+
interface_id=self._config.interface_id,
|
|
989
|
+
json_rpc_client=self._json_rpc_client,
|
|
990
|
+
proxy=self._proxy,
|
|
991
|
+
proxy_read=self._proxy_read,
|
|
992
|
+
has_device_firmware_update=self._capabilities.device_firmware_update,
|
|
993
|
+
has_firmware_update_trigger=self._capabilities.firmware_update_trigger,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
self._sysvar_handler = SystemVariableHandler(
|
|
997
|
+
client_deps=self._config.client_deps,
|
|
998
|
+
interface=self._config.interface,
|
|
999
|
+
interface_id=self._config.interface_id,
|
|
1000
|
+
json_rpc_client=self._json_rpc_client,
|
|
1001
|
+
proxy=self._proxy,
|
|
1002
|
+
proxy_read=self._proxy_read,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
self._program_handler = ProgramHandler(
|
|
1006
|
+
client_deps=self._config.client_deps,
|
|
1007
|
+
interface=self._config.interface,
|
|
1008
|
+
interface_id=self._config.interface_id,
|
|
1009
|
+
json_rpc_client=self._json_rpc_client,
|
|
1010
|
+
proxy=self._proxy,
|
|
1011
|
+
proxy_read=self._proxy_read,
|
|
1012
|
+
has_programs=self._capabilities.programs,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
self._backup_handler = BackupHandler(
|
|
1016
|
+
client_deps=self._config.client_deps,
|
|
1017
|
+
interface=self._config.interface,
|
|
1018
|
+
interface_id=self._config.interface_id,
|
|
1019
|
+
json_rpc_client=self._json_rpc_client,
|
|
1020
|
+
proxy=self._proxy,
|
|
1021
|
+
proxy_read=self._proxy_read,
|
|
1022
|
+
has_backup=self._capabilities.backup,
|
|
1023
|
+
system_information=self._system_information,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
self._metadata_handler = MetadataHandler(
|
|
1027
|
+
client_deps=self._config.client_deps,
|
|
1028
|
+
interface=self._config.interface,
|
|
1029
|
+
interface_id=self._config.interface_id,
|
|
1030
|
+
json_rpc_client=self._json_rpc_client,
|
|
1031
|
+
proxy=self._proxy,
|
|
1032
|
+
proxy_read=self._proxy_read,
|
|
1033
|
+
has_functions=self._capabilities.functions,
|
|
1034
|
+
has_inbox_devices=self._capabilities.inbox_devices,
|
|
1035
|
+
has_install_mode=self._capabilities.install_mode,
|
|
1036
|
+
has_metadata=self._capabilities.metadata,
|
|
1037
|
+
has_rega_id_lookup=self._capabilities.rega_id_lookup,
|
|
1038
|
+
has_rename=self._capabilities.rename,
|
|
1039
|
+
has_rooms=self._capabilities.rooms,
|
|
1040
|
+
has_service_messages=self._capabilities.service_messages,
|
|
1041
|
+
has_system_update_info=self._capabilities.system_update_info,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
def _mark_all_devices_forced_availability(self, *, forced_availability: ForcedDeviceAvailability) -> None:
|
|
1045
|
+
"""Mark device's availability state for this interface."""
|
|
1046
|
+
available = forced_availability != ForcedDeviceAvailability.FORCE_FALSE
|
|
1047
|
+
# Always update devices when marking unavailable (FORCE_FALSE) to ensure
|
|
1048
|
+
# data points show unavailable during connection failures.
|
|
1049
|
+
# Only skip updates when already in matching available state.
|
|
1050
|
+
if not available or self._state_machine.is_available != available:
|
|
1051
|
+
for device in self.central.device_registry.devices:
|
|
1052
|
+
if device.interface_id == self.interface_id:
|
|
1053
|
+
device.set_forced_availability(forced_availability=forced_availability)
|
|
1054
|
+
_LOGGER.debug(
|
|
1055
|
+
"MARK_ALL_DEVICES_FORCED_AVAILABILITY: marked all devices %s for %s",
|
|
1056
|
+
"available" if available else "unavailable",
|
|
1057
|
+
self.interface_id,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
def _on_client_state_changed_event(self, *, event: ClientStateChangedEvent) -> None:
|
|
1061
|
+
"""Handle client state machine transitions by emitting SystemStatusChangedEvent for integration compatibility."""
|
|
1062
|
+
self._config.client_deps.event_bus.publish_sync(
|
|
1063
|
+
event=SystemStatusChangedEvent(
|
|
1064
|
+
timestamp=datetime.now(),
|
|
1065
|
+
client_state=(event.interface_id, ClientState(event.old_state), ClientState(event.new_state)),
|
|
1066
|
+
)
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
def _on_system_status_event(self, *, event: SystemStatusChangedEvent) -> None:
|
|
1070
|
+
"""Handle system status events to clear ping/pong cache on reconnect."""
|
|
1071
|
+
if event.connection_state and event.connection_state[0] == self.interface_id and event.connection_state[1]:
|
|
1072
|
+
# Clear stale ping/pong state when connection is restored.
|
|
1073
|
+
# PINGs sent during CCU downtime cannot receive PONGs, so the cache
|
|
1074
|
+
# would contain stale entries that cause false mismatch alarms.
|
|
1075
|
+
self._ping_pong_tracker.clear()
|
|
1076
|
+
_LOGGER.debug(
|
|
1077
|
+
"PING PONG CACHE: Cleared on connection restored: %s",
|
|
1078
|
+
self.interface_id,
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
def _record_callback_timeout_incident(
|
|
1082
|
+
self,
|
|
1083
|
+
*,
|
|
1084
|
+
seconds_since_last_event: float,
|
|
1085
|
+
callback_warn_interval: float,
|
|
1086
|
+
last_event_time: datetime,
|
|
1087
|
+
) -> None:
|
|
1088
|
+
"""Record a CALLBACK_TIMEOUT incident for diagnostics."""
|
|
1089
|
+
incident_recorder = self._config.client_deps.cache_coordinator.incident_store
|
|
1090
|
+
|
|
1091
|
+
# Get circuit breaker state safely (_proxy may not be set during early startup)
|
|
1092
|
+
circuit_breaker_state: str | None = None
|
|
1093
|
+
if hasattr(self, "_proxy") and hasattr(self._proxy, "circuit_breaker"):
|
|
1094
|
+
circuit_breaker_state = self._proxy.circuit_breaker.state.value
|
|
1095
|
+
|
|
1096
|
+
context = {
|
|
1097
|
+
"seconds_since_last_event": round(seconds_since_last_event, 2),
|
|
1098
|
+
"callback_warn_interval": callback_warn_interval,
|
|
1099
|
+
"last_event_time": last_event_time.strftime(DATETIME_FORMAT_MILLIS),
|
|
1100
|
+
"client_state": self._state_machine.state.value,
|
|
1101
|
+
"circuit_breaker_state": circuit_breaker_state,
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async def _record() -> None:
|
|
1105
|
+
try:
|
|
1106
|
+
await incident_recorder.record_incident(
|
|
1107
|
+
incident_type=IncidentType.CALLBACK_TIMEOUT,
|
|
1108
|
+
severity=IncidentSeverity.WARNING,
|
|
1109
|
+
message=f"No callback received for {self.interface_id} in {int(seconds_since_last_event)} seconds",
|
|
1110
|
+
interface_id=self.interface_id,
|
|
1111
|
+
context=context,
|
|
1112
|
+
)
|
|
1113
|
+
except Exception as err:
|
|
1114
|
+
_LOGGER.debug("Failed to record CALLBACK_TIMEOUT incident: %s", err)
|
|
1115
|
+
|
|
1116
|
+
self._config.client_deps.looper.create_task(
|
|
1117
|
+
target=_record(),
|
|
1118
|
+
name=f"record_callback_timeout_incident_{self.interface_id}",
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
async def _set_value(
|
|
1122
|
+
self,
|
|
1123
|
+
*,
|
|
1124
|
+
channel_address: str,
|
|
1125
|
+
parameter: str,
|
|
1126
|
+
value: Any,
|
|
1127
|
+
wait_for_callback: int | None,
|
|
1128
|
+
rx_mode: CommandRxMode | None = None,
|
|
1129
|
+
check_against_pd: bool = False,
|
|
1130
|
+
) -> set[DP_KEY_VALUE]:
|
|
1131
|
+
"""Set single value on paramset VALUES (internal implementation)."""
|
|
1132
|
+
return await self._device_ops_handler.set_value_internal(
|
|
1133
|
+
channel_address=channel_address,
|
|
1134
|
+
parameter=parameter,
|
|
1135
|
+
value=value,
|
|
1136
|
+
wait_for_callback=wait_for_callback,
|
|
1137
|
+
rx_mode=rx_mode,
|
|
1138
|
+
check_against_pd=check_against_pd,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
class ClientJsonCCU(ClientCCU):
|
|
1143
|
+
"""Client implementation for CCU-like backend (CCU-Jack)."""
|
|
1144
|
+
|
|
1145
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
1146
|
+
"""Initialize the Client."""
|
|
1147
|
+
super().__init__(client_config=client_config)
|
|
1148
|
+
# Override capabilities with JSON_CCU_CAPABILITIES
|
|
1149
|
+
self._capabilities = replace(
|
|
1150
|
+
JSON_CCU_CAPABILITIES,
|
|
1151
|
+
push_updates=client_config.has_push_updates,
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
1155
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
1156
|
+
"""Check if proxy is still initialized."""
|
|
1157
|
+
ping_timeout = self._config.client_deps.config.timeout_config.ping_timeout
|
|
1158
|
+
try:
|
|
1159
|
+
async with asyncio.timeout(ping_timeout):
|
|
1160
|
+
return await self._json_rpc_client.is_present(interface=self.interface)
|
|
1161
|
+
except TimeoutError:
|
|
1162
|
+
_LOGGER.debug(
|
|
1163
|
+
"CHECK_CONNECTION_AVAILABILITY: Timeout after %.1fs for %s",
|
|
1164
|
+
ping_timeout,
|
|
1165
|
+
self.interface_id,
|
|
1166
|
+
)
|
|
1167
|
+
return False
|
|
1168
|
+
|
|
1169
|
+
async def fetch_paramset_description(self, *, channel_address: str, paramset_key: ParamsetKey) -> None:
|
|
1170
|
+
"""Fetch a specific paramset and add it to the known ones."""
|
|
1171
|
+
_LOGGER.debug("FETCH_PARAMSET_DESCRIPTION for %s/%s", channel_address, paramset_key)
|
|
1172
|
+
if paramset_description := await self._get_paramset_description(
|
|
1173
|
+
address=channel_address, paramset_key=paramset_key
|
|
1174
|
+
):
|
|
1175
|
+
self.central.cache_coordinator.paramset_descriptions.add(
|
|
1176
|
+
interface_id=self.interface_id,
|
|
1177
|
+
channel_address=channel_address,
|
|
1178
|
+
paramset_key=paramset_key,
|
|
1179
|
+
paramset_description=paramset_description,
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
async def fetch_paramset_descriptions(self, *, device_description: DeviceDescription) -> None:
|
|
1183
|
+
"""Fetch paramsets for provided device description."""
|
|
1184
|
+
data = await self.get_paramset_descriptions(device_description=device_description)
|
|
1185
|
+
for address, paramsets in data.items():
|
|
1186
|
+
_LOGGER.debug("FETCH_PARAMSET_DESCRIPTIONS for %s", address)
|
|
1187
|
+
for paramset_key, paramset_description in paramsets.items():
|
|
1188
|
+
self.central.cache_coordinator.paramset_descriptions.add(
|
|
1189
|
+
interface_id=self.interface_id,
|
|
1190
|
+
channel_address=address,
|
|
1191
|
+
paramset_key=paramset_key,
|
|
1192
|
+
paramset_description=paramset_description,
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
@inspector(re_raise=False)
|
|
1196
|
+
async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...]:
|
|
1197
|
+
"""Return device description and all child channel descriptions."""
|
|
1198
|
+
all_device_description: list[DeviceDescription] = []
|
|
1199
|
+
if main_dd := await self.get_device_description(address=device_address):
|
|
1200
|
+
all_device_description.append(main_dd)
|
|
1201
|
+
else:
|
|
1202
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
1203
|
+
"GET_ALL_DEVICE_DESCRIPTIONS: No device description for %s",
|
|
1204
|
+
device_address,
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
if main_dd:
|
|
1208
|
+
for channel_address in main_dd.get("CHILDREN", []):
|
|
1209
|
+
if channel_dd := await self.get_device_description(address=channel_address):
|
|
1210
|
+
all_device_description.append(channel_dd)
|
|
1211
|
+
else:
|
|
1212
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
1213
|
+
"GET_ALL_DEVICE_DESCRIPTIONS: No channel description for %s",
|
|
1214
|
+
channel_address,
|
|
1215
|
+
)
|
|
1216
|
+
return tuple(all_device_description)
|
|
1217
|
+
|
|
1218
|
+
async def get_all_paramset_descriptions(
|
|
1219
|
+
self, *, device_descriptions: tuple[DeviceDescription, ...]
|
|
1220
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
1221
|
+
"""Get all paramset descriptions for provided device descriptions."""
|
|
1222
|
+
all_paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
1223
|
+
for device_description in device_descriptions:
|
|
1224
|
+
all_paramsets.update(await self.get_paramset_descriptions(device_description=device_description))
|
|
1225
|
+
return all_paramsets
|
|
1226
|
+
|
|
1227
|
+
@inspector(re_raise=False)
|
|
1228
|
+
async def get_device_description(self, *, address: str) -> DeviceDescription | None:
|
|
1229
|
+
"""Get device descriptions from the backend."""
|
|
1230
|
+
try:
|
|
1231
|
+
if device_description := await self._json_rpc_client.get_device_description(
|
|
1232
|
+
interface=self.interface, address=address
|
|
1233
|
+
):
|
|
1234
|
+
return device_description
|
|
1235
|
+
except BaseHomematicException as bhexc:
|
|
1236
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
1237
|
+
"GET_DEVICE_DESCRIPTIONS failed: %s [%s]", bhexc.name, extract_exc_args(exc=bhexc)
|
|
1238
|
+
)
|
|
1239
|
+
return None
|
|
1240
|
+
|
|
1241
|
+
@inspector
|
|
1242
|
+
async def get_paramset(
|
|
1243
|
+
self,
|
|
1244
|
+
*,
|
|
1245
|
+
address: str,
|
|
1246
|
+
paramset_key: ParamsetKey | str,
|
|
1247
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
1248
|
+
) -> dict[str, Any]:
|
|
1249
|
+
"""Return a paramset from the backend."""
|
|
1250
|
+
try:
|
|
1251
|
+
_LOGGER.debug(
|
|
1252
|
+
"GET_PARAMSET: address %s, paramset_key %s, source %s",
|
|
1253
|
+
address,
|
|
1254
|
+
paramset_key,
|
|
1255
|
+
call_source,
|
|
1256
|
+
)
|
|
1257
|
+
return (
|
|
1258
|
+
await self._json_rpc_client.get_paramset(
|
|
1259
|
+
interface=self.interface, address=address, paramset_key=paramset_key
|
|
1260
|
+
)
|
|
1261
|
+
or {}
|
|
1262
|
+
)
|
|
1263
|
+
except BaseHomematicException as bhexc:
|
|
1264
|
+
raise ClientException(
|
|
1265
|
+
i18n.tr(
|
|
1266
|
+
key="exception.client.json_ccu.get_paramset.failed",
|
|
1267
|
+
address=address,
|
|
1268
|
+
paramset_key=paramset_key,
|
|
1269
|
+
reason=extract_exc_args(exc=bhexc),
|
|
1270
|
+
)
|
|
1271
|
+
) from bhexc
|
|
1272
|
+
|
|
1273
|
+
@inspector(re_raise=False, no_raise_return={})
|
|
1274
|
+
async def get_paramset_descriptions(
|
|
1275
|
+
self, *, device_description: DeviceDescription
|
|
1276
|
+
) -> dict[str, dict[ParamsetKey, dict[str, ParameterData]]]:
|
|
1277
|
+
"""Get paramsets for provided device description."""
|
|
1278
|
+
paramsets: dict[str, dict[ParamsetKey, dict[str, ParameterData]]] = {}
|
|
1279
|
+
address = device_description["ADDRESS"]
|
|
1280
|
+
paramsets[address] = {}
|
|
1281
|
+
_LOGGER.debug("GET_PARAMSET_DESCRIPTIONS for %s", address)
|
|
1282
|
+
for p_key in device_description["PARAMSETS"]:
|
|
1283
|
+
paramset_key = ParamsetKey(p_key)
|
|
1284
|
+
if paramset_description := await self._get_paramset_description(address=address, paramset_key=paramset_key):
|
|
1285
|
+
paramsets[address][paramset_key] = paramset_description
|
|
1286
|
+
return paramsets
|
|
1287
|
+
|
|
1288
|
+
@inspector(log_level=logging.NOTSET)
|
|
1289
|
+
async def get_value(
|
|
1290
|
+
self,
|
|
1291
|
+
*,
|
|
1292
|
+
channel_address: str,
|
|
1293
|
+
paramset_key: ParamsetKey,
|
|
1294
|
+
parameter: str,
|
|
1295
|
+
call_source: CallSource = CallSource.MANUAL_OR_SCHEDULED,
|
|
1296
|
+
) -> Any:
|
|
1297
|
+
"""Return a value from the backend."""
|
|
1298
|
+
try:
|
|
1299
|
+
_LOGGER.debug(
|
|
1300
|
+
"GET_VALUE: channel_address %s, parameter %s, paramset_key, %s, source:%s",
|
|
1301
|
+
channel_address,
|
|
1302
|
+
parameter,
|
|
1303
|
+
paramset_key,
|
|
1304
|
+
call_source,
|
|
1305
|
+
)
|
|
1306
|
+
if paramset_key == ParamsetKey.VALUES:
|
|
1307
|
+
return await self._json_rpc_client.get_value(
|
|
1308
|
+
interface=self.interface,
|
|
1309
|
+
address=channel_address,
|
|
1310
|
+
paramset_key=paramset_key,
|
|
1311
|
+
parameter=parameter,
|
|
1312
|
+
)
|
|
1313
|
+
paramset = (
|
|
1314
|
+
await self._json_rpc_client.get_paramset(
|
|
1315
|
+
interface=self.interface,
|
|
1316
|
+
address=channel_address,
|
|
1317
|
+
paramset_key=ParamsetKey.MASTER,
|
|
1318
|
+
)
|
|
1319
|
+
or {}
|
|
1320
|
+
)
|
|
1321
|
+
return paramset.get(parameter)
|
|
1322
|
+
except BaseHomematicException as bhexc:
|
|
1323
|
+
raise ClientException(
|
|
1324
|
+
i18n.tr(
|
|
1325
|
+
key="exception.client.json_ccu.get_value.failed",
|
|
1326
|
+
channel_address=channel_address,
|
|
1327
|
+
parameter=parameter,
|
|
1328
|
+
paramset_key=paramset_key,
|
|
1329
|
+
reason=extract_exc_args(exc=bhexc),
|
|
1330
|
+
)
|
|
1331
|
+
) from bhexc
|
|
1332
|
+
|
|
1333
|
+
@inspector
|
|
1334
|
+
async def init_client(self) -> None:
|
|
1335
|
+
"""Initialize the client."""
|
|
1336
|
+
self._state_machine.transition_to(target=ClientState.INITIALIZING)
|
|
1337
|
+
try:
|
|
1338
|
+
self._system_information = await self._get_system_information()
|
|
1339
|
+
# Use NullRpcProxy since ClientJsonCCU uses JSON-RPC exclusively.
|
|
1340
|
+
# The handlers are needed for JSON-RPC operations but don't use proxies.
|
|
1341
|
+
self._proxy = NullRpcProxy(
|
|
1342
|
+
interface_id=self.interface_id,
|
|
1343
|
+
connection_state=self._config.client_deps.connection_state,
|
|
1344
|
+
event_bus=self._config.client_deps.event_bus,
|
|
1345
|
+
)
|
|
1346
|
+
self._proxy_read = self._proxy
|
|
1347
|
+
self._init_handlers()
|
|
1348
|
+
self._state_machine.transition_to(target=ClientState.INITIALIZED)
|
|
1349
|
+
except Exception as exc:
|
|
1350
|
+
self._state_machine.transition_to(
|
|
1351
|
+
target=ClientState.FAILED,
|
|
1352
|
+
reason=str(exc),
|
|
1353
|
+
failure_reason=exception_to_failure_reason(exc=exc),
|
|
1354
|
+
)
|
|
1355
|
+
raise
|
|
1356
|
+
|
|
1357
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1358
|
+
async def list_devices(self) -> tuple[DeviceDescription, ...] | None:
|
|
1359
|
+
"""List devices of Homematic backend."""
|
|
1360
|
+
try:
|
|
1361
|
+
return await self._json_rpc_client.list_devices(interface=self.interface)
|
|
1362
|
+
except BaseHomematicException as bhexc:
|
|
1363
|
+
_LOGGER.debug(
|
|
1364
|
+
"LIST_DEVICES failed with %s [%s]",
|
|
1365
|
+
bhexc.name,
|
|
1366
|
+
extract_exc_args(exc=bhexc),
|
|
1367
|
+
)
|
|
1368
|
+
return None
|
|
1369
|
+
|
|
1370
|
+
@inspector(re_raise=False, no_raise_return=set())
|
|
1371
|
+
async def put_paramset(
|
|
1372
|
+
self,
|
|
1373
|
+
*,
|
|
1374
|
+
channel_address: str,
|
|
1375
|
+
paramset_key_or_link_address: ParamsetKey | str,
|
|
1376
|
+
values: dict[str, Any],
|
|
1377
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
1378
|
+
rx_mode: CommandRxMode | None = None,
|
|
1379
|
+
check_against_pd: bool = False,
|
|
1380
|
+
) -> set[DP_KEY_VALUE]:
|
|
1381
|
+
"""
|
|
1382
|
+
Set paramsets manually via JSON-RPC.
|
|
1383
|
+
|
|
1384
|
+
Overrides the base class to use JSON-RPC instead of XML-RPC proxy.
|
|
1385
|
+
"""
|
|
1386
|
+
try:
|
|
1387
|
+
await self._exec_put_paramset(
|
|
1388
|
+
channel_address=channel_address,
|
|
1389
|
+
paramset_key=paramset_key_or_link_address,
|
|
1390
|
+
values=values,
|
|
1391
|
+
rx_mode=rx_mode,
|
|
1392
|
+
)
|
|
1393
|
+
# store the send value in the last_value_send_tracker
|
|
1394
|
+
dpk_values = self._last_value_send_tracker.add_put_paramset(
|
|
1395
|
+
channel_address=channel_address,
|
|
1396
|
+
paramset_key=ParamsetKey(paramset_key_or_link_address),
|
|
1397
|
+
values=values,
|
|
1398
|
+
)
|
|
1399
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
1400
|
+
except BaseHomematicException as bhexc:
|
|
1401
|
+
raise ClientException(
|
|
1402
|
+
i18n.tr(
|
|
1403
|
+
key="exception.client.put_paramset.failed",
|
|
1404
|
+
channel_address=channel_address,
|
|
1405
|
+
paramset_key=paramset_key_or_link_address,
|
|
1406
|
+
values=values,
|
|
1407
|
+
reason=extract_exc_args(exc=bhexc),
|
|
1408
|
+
)
|
|
1409
|
+
) from bhexc
|
|
1410
|
+
else:
|
|
1411
|
+
return dpk_values
|
|
1412
|
+
|
|
1413
|
+
@inspector(re_raise=False, no_raise_return=set())
|
|
1414
|
+
async def set_value(
|
|
1415
|
+
self,
|
|
1416
|
+
*,
|
|
1417
|
+
channel_address: str,
|
|
1418
|
+
paramset_key: ParamsetKey,
|
|
1419
|
+
parameter: str,
|
|
1420
|
+
value: Any,
|
|
1421
|
+
wait_for_callback: int | None = WAIT_FOR_CALLBACK,
|
|
1422
|
+
rx_mode: CommandRxMode | None = None,
|
|
1423
|
+
check_against_pd: bool = False,
|
|
1424
|
+
) -> set[DP_KEY_VALUE]:
|
|
1425
|
+
"""
|
|
1426
|
+
Set single value on paramset VALUES via JSON-RPC.
|
|
1427
|
+
|
|
1428
|
+
Overrides the base class to use JSON-RPC instead of XML-RPC proxy.
|
|
1429
|
+
"""
|
|
1430
|
+
if paramset_key != ParamsetKey.VALUES:
|
|
1431
|
+
return await self.put_paramset(
|
|
1432
|
+
channel_address=channel_address,
|
|
1433
|
+
paramset_key_or_link_address=paramset_key,
|
|
1434
|
+
values={parameter: value},
|
|
1435
|
+
wait_for_callback=wait_for_callback,
|
|
1436
|
+
rx_mode=rx_mode,
|
|
1437
|
+
check_against_pd=check_against_pd,
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
try:
|
|
1441
|
+
_LOGGER.debug("SET_VALUE: %s, %s, %s", channel_address, parameter, value)
|
|
1442
|
+
if rx_mode and (device := self.central.device_coordinator.get_device(address=channel_address)):
|
|
1443
|
+
if supports_rx_mode(command_rx_mode=rx_mode, rx_modes=device.rx_modes):
|
|
1444
|
+
await self._exec_set_value(
|
|
1445
|
+
channel_address=channel_address,
|
|
1446
|
+
parameter=parameter,
|
|
1447
|
+
value=value,
|
|
1448
|
+
rx_mode=rx_mode,
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
raise ClientException(i18n.tr(key="exception.client.rx_mode.unsupported", rx_mode=rx_mode))
|
|
1452
|
+
else:
|
|
1453
|
+
await self._exec_set_value(channel_address=channel_address, parameter=parameter, value=value)
|
|
1454
|
+
|
|
1455
|
+
# store the send value in the last_value_send_tracker
|
|
1456
|
+
dpk_values = self._last_value_send_tracker.add_set_value(
|
|
1457
|
+
channel_address=channel_address, parameter=parameter, value=value
|
|
1458
|
+
)
|
|
1459
|
+
self._write_temporary_value(dpk_values=dpk_values)
|
|
1460
|
+
|
|
1461
|
+
if wait_for_callback is not None and (
|
|
1462
|
+
device := self.central.device_coordinator.get_device(
|
|
1463
|
+
address=get_device_address(address=channel_address)
|
|
1464
|
+
)
|
|
1465
|
+
):
|
|
1466
|
+
await _wait_for_state_change_or_timeout(
|
|
1467
|
+
device=device,
|
|
1468
|
+
dpk_values=dpk_values,
|
|
1469
|
+
wait_for_callback=wait_for_callback,
|
|
1470
|
+
)
|
|
1471
|
+
except BaseHomematicException as bhexc:
|
|
1472
|
+
raise ClientException(
|
|
1473
|
+
i18n.tr(
|
|
1474
|
+
key="exception.client.set_value.failed",
|
|
1475
|
+
channel_address=channel_address,
|
|
1476
|
+
parameter=parameter,
|
|
1477
|
+
value=value,
|
|
1478
|
+
reason=extract_exc_args(exc=bhexc),
|
|
1479
|
+
)
|
|
1480
|
+
) from bhexc
|
|
1481
|
+
else:
|
|
1482
|
+
return dpk_values
|
|
1483
|
+
|
|
1484
|
+
@inspector(re_raise=False)
|
|
1485
|
+
async def update_paramset_descriptions(self, *, device_address: str) -> None:
|
|
1486
|
+
"""Re-fetch and update paramset descriptions for a device."""
|
|
1487
|
+
if not self.central.cache_coordinator.device_descriptions.get_device_descriptions(
|
|
1488
|
+
interface_id=self.interface_id
|
|
1489
|
+
):
|
|
1490
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
1491
|
+
"UPDATE_PARAMSET_DESCRIPTIONS failed: Interface missing in central cache. Not updating paramsets for %s",
|
|
1492
|
+
device_address,
|
|
1493
|
+
)
|
|
1494
|
+
return
|
|
1495
|
+
|
|
1496
|
+
if device_description := self.central.cache_coordinator.device_descriptions.find_device_description(
|
|
1497
|
+
interface_id=self.interface_id, device_address=device_address
|
|
1498
|
+
):
|
|
1499
|
+
await self.fetch_paramset_descriptions(device_description=device_description)
|
|
1500
|
+
else:
|
|
1501
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
1502
|
+
"UPDATE_PARAMSET_DESCRIPTIONS failed: Channel missing in central.cache. Not updating paramsets for %s",
|
|
1503
|
+
device_address,
|
|
1504
|
+
)
|
|
1505
|
+
return
|
|
1506
|
+
await self.central.save_files(save_paramset_descriptions=True)
|
|
1507
|
+
|
|
1508
|
+
async def _exec_put_paramset(
|
|
1509
|
+
self,
|
|
1510
|
+
*,
|
|
1511
|
+
channel_address: str,
|
|
1512
|
+
paramset_key: ParamsetKey | str,
|
|
1513
|
+
values: dict[str, Any],
|
|
1514
|
+
rx_mode: CommandRxMode | None = None,
|
|
1515
|
+
) -> None:
|
|
1516
|
+
"""Put paramset into the backend."""
|
|
1517
|
+
for parameter, value in values.items():
|
|
1518
|
+
await self._exec_set_value(
|
|
1519
|
+
channel_address=channel_address, parameter=parameter, value=value, rx_mode=rx_mode
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
async def _exec_set_value(
|
|
1523
|
+
self,
|
|
1524
|
+
*,
|
|
1525
|
+
channel_address: str,
|
|
1526
|
+
parameter: str,
|
|
1527
|
+
value: Any,
|
|
1528
|
+
rx_mode: CommandRxMode | None = None,
|
|
1529
|
+
) -> None:
|
|
1530
|
+
"""Set single value on paramset VALUES."""
|
|
1531
|
+
if (
|
|
1532
|
+
value_type := self._get_parameter_type(
|
|
1533
|
+
channel_address=channel_address,
|
|
1534
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1535
|
+
parameter=parameter,
|
|
1536
|
+
)
|
|
1537
|
+
) is None:
|
|
1538
|
+
raise ClientException(
|
|
1539
|
+
i18n.tr(
|
|
1540
|
+
key="exception.client.json_ccu.set_value.unknown_type",
|
|
1541
|
+
channel_address=channel_address,
|
|
1542
|
+
paramset_key=ParamsetKey.VALUES,
|
|
1543
|
+
parameter=parameter,
|
|
1544
|
+
)
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
_type = _CCU_JSON_VALUE_TYPE.get(value_type, "string")
|
|
1548
|
+
await self._json_rpc_client.set_value(
|
|
1549
|
+
interface=self.interface,
|
|
1550
|
+
address=channel_address,
|
|
1551
|
+
parameter=parameter,
|
|
1552
|
+
value_type=_type,
|
|
1553
|
+
value=value,
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
def _get_parameter_type(
|
|
1557
|
+
self,
|
|
1558
|
+
*,
|
|
1559
|
+
channel_address: str,
|
|
1560
|
+
paramset_key: ParamsetKey,
|
|
1561
|
+
parameter: str,
|
|
1562
|
+
) -> ParameterType | None:
|
|
1563
|
+
"""Return the parameter type for a given parameter."""
|
|
1564
|
+
if parameter_data := self.central.cache_coordinator.paramset_descriptions.get_parameter_data(
|
|
1565
|
+
interface_id=self.interface_id,
|
|
1566
|
+
channel_address=channel_address,
|
|
1567
|
+
paramset_key=paramset_key,
|
|
1568
|
+
parameter=parameter,
|
|
1569
|
+
):
|
|
1570
|
+
return parameter_data["TYPE"]
|
|
1571
|
+
return None
|
|
1572
|
+
|
|
1573
|
+
async def _get_paramset_description(
|
|
1574
|
+
self, *, address: str, paramset_key: ParamsetKey
|
|
1575
|
+
) -> dict[str, ParameterData] | None:
|
|
1576
|
+
"""Get paramset description from the backend."""
|
|
1577
|
+
try:
|
|
1578
|
+
return cast(
|
|
1579
|
+
dict[str, ParameterData],
|
|
1580
|
+
await self._json_rpc_client.get_paramset_description(
|
|
1581
|
+
interface=self.interface, address=address, paramset_key=paramset_key
|
|
1582
|
+
),
|
|
1583
|
+
)
|
|
1584
|
+
except BaseHomematicException as bhexc:
|
|
1585
|
+
_LOGGER.debug(
|
|
1586
|
+
"GET_PARAMSET_DESCRIPTIONS failed with %s [%s] for %s address %s",
|
|
1587
|
+
bhexc.name,
|
|
1588
|
+
extract_exc_args(exc=bhexc),
|
|
1589
|
+
paramset_key,
|
|
1590
|
+
address,
|
|
1591
|
+
)
|
|
1592
|
+
return None
|
|
1593
|
+
|
|
1594
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
1595
|
+
"""Get system information of the backend."""
|
|
1596
|
+
return SystemInformation(
|
|
1597
|
+
available_interfaces=(self.interface,),
|
|
1598
|
+
serial=f"{self.interface}_{DUMMY_SERIAL}",
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
def _write_temporary_value(self, *, dpk_values: set[DP_KEY_VALUE]) -> None:
|
|
1602
|
+
"""Write temporary values to polling data points for immediate UI feedback."""
|
|
1603
|
+
for dpk, value in dpk_values:
|
|
1604
|
+
if (
|
|
1605
|
+
data_point := self.central.get_generic_data_point(
|
|
1606
|
+
channel_address=dpk.channel_address,
|
|
1607
|
+
parameter=dpk.parameter,
|
|
1608
|
+
paramset_key=dpk.paramset_key,
|
|
1609
|
+
)
|
|
1610
|
+
) and data_point.requires_polling:
|
|
1611
|
+
data_point.write_temporary_value(value=value, write_at=datetime.now())
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
class ClientHomegear(ClientCCU):
|
|
1615
|
+
"""
|
|
1616
|
+
Client implementation for Homegear backend.
|
|
1617
|
+
|
|
1618
|
+
Inherit from ClientCCU to share common behavior used by tests and code paths
|
|
1619
|
+
that expect a CCU-like client interface for Homegear selections.
|
|
1620
|
+
"""
|
|
1621
|
+
|
|
1622
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
1623
|
+
"""Initialize the Client."""
|
|
1624
|
+
super().__init__(client_config=client_config)
|
|
1625
|
+
# Override capabilities with HOMEGEAR_CAPABILITIES
|
|
1626
|
+
self._capabilities = replace(
|
|
1627
|
+
HOMEGEAR_CAPABILITIES,
|
|
1628
|
+
push_updates=client_config.has_push_updates,
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
@property
|
|
1632
|
+
def model(self) -> str:
|
|
1633
|
+
"""Return the model of the backend."""
|
|
1634
|
+
if self._config.version:
|
|
1635
|
+
return Backend.PYDEVCCU if Backend.PYDEVCCU.lower() in self._config.version else Backend.HOMEGEAR
|
|
1636
|
+
return Backend.CCU
|
|
1637
|
+
|
|
1638
|
+
@inspector(re_raise=False, no_raise_return=False)
|
|
1639
|
+
async def check_connection_availability(self, *, handle_ping_pong: bool) -> bool:
|
|
1640
|
+
"""Check if proxy is still initialized."""
|
|
1641
|
+
ping_timeout = self._config.client_deps.config.timeout_config.ping_timeout
|
|
1642
|
+
try:
|
|
1643
|
+
async with asyncio.timeout(ping_timeout):
|
|
1644
|
+
await self._proxy.clientServerInitialized(self.interface_id)
|
|
1645
|
+
self.modified_at = datetime.now()
|
|
1646
|
+
except TimeoutError:
|
|
1647
|
+
_LOGGER.debug(
|
|
1648
|
+
"CHECK_CONNECTION_AVAILABILITY: Timeout after %.1fs for %s",
|
|
1649
|
+
ping_timeout,
|
|
1650
|
+
self.interface_id,
|
|
1651
|
+
)
|
|
1652
|
+
except BaseHomematicException as bhexc:
|
|
1653
|
+
_LOGGER.debug(
|
|
1654
|
+
"CHECK_CONNECTION_AVAILABILITY failed: %s [%s]",
|
|
1655
|
+
bhexc.name,
|
|
1656
|
+
extract_exc_args(exc=bhexc),
|
|
1657
|
+
)
|
|
1658
|
+
else:
|
|
1659
|
+
return True
|
|
1660
|
+
self.modified_at = INIT_DATETIME
|
|
1661
|
+
return False
|
|
1662
|
+
|
|
1663
|
+
@inspector
|
|
1664
|
+
async def delete_system_variable(self, *, name: str) -> bool:
|
|
1665
|
+
"""Delete a system variable from the backend."""
|
|
1666
|
+
await self._proxy.deleteSystemVariable(name)
|
|
1667
|
+
return True
|
|
1668
|
+
|
|
1669
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1670
|
+
async def fetch_all_device_data(self) -> None:
|
|
1671
|
+
"""Fetch all device data from the backend."""
|
|
1672
|
+
return
|
|
1673
|
+
|
|
1674
|
+
@inspector(re_raise=False, measure_performance=True)
|
|
1675
|
+
async def fetch_device_details(self) -> None:
|
|
1676
|
+
"""Get all names from metadata (Homegear)."""
|
|
1677
|
+
_LOGGER.debug("FETCH_DEVICE_DETAILS: Fetching names via Metadata")
|
|
1678
|
+
for address in self.central.cache_coordinator.device_descriptions.get_device_descriptions(
|
|
1679
|
+
interface_id=self.interface_id
|
|
1680
|
+
):
|
|
1681
|
+
try:
|
|
1682
|
+
self.central.cache_coordinator.device_details.add_name(
|
|
1683
|
+
address=address,
|
|
1684
|
+
name=await self._proxy_read.getMetadata(address, _NAME),
|
|
1685
|
+
)
|
|
1686
|
+
except BaseHomematicException as bhexc:
|
|
1687
|
+
_LOGGER.warning( # i18n-log: ignore
|
|
1688
|
+
"%s [%s] Failed to fetch name for device %s",
|
|
1689
|
+
bhexc.name,
|
|
1690
|
+
extract_exc_args(exc=bhexc),
|
|
1691
|
+
address,
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
@inspector(re_raise=False)
|
|
1695
|
+
async def get_all_system_variables(
|
|
1696
|
+
self, *, markers: tuple[DescriptionMarker | str, ...]
|
|
1697
|
+
) -> tuple[SystemVariableData, ...] | None:
|
|
1698
|
+
"""Get all system variables from the backend."""
|
|
1699
|
+
variables: list[SystemVariableData] = []
|
|
1700
|
+
if hg_variables := await self._proxy.getAllSystemVariables():
|
|
1701
|
+
for name, value in hg_variables.items():
|
|
1702
|
+
variables.append(SystemVariableData(vid=name, legacy_name=name, value=value))
|
|
1703
|
+
return tuple(variables)
|
|
1704
|
+
|
|
1705
|
+
@inspector
|
|
1706
|
+
async def get_system_variable(self, *, name: str) -> Any:
|
|
1707
|
+
"""Get single system variable from the backend."""
|
|
1708
|
+
return await self._proxy.getSystemVariable(name)
|
|
1709
|
+
|
|
1710
|
+
@inspector(measure_performance=True)
|
|
1711
|
+
async def set_system_variable(self, *, legacy_name: str, value: Any) -> bool:
|
|
1712
|
+
"""Set a system variable on the backend."""
|
|
1713
|
+
await self._proxy.setSystemVariable(legacy_name, value)
|
|
1714
|
+
return True
|
|
1715
|
+
|
|
1716
|
+
async def _get_system_information(self) -> SystemInformation:
|
|
1717
|
+
"""Get system information of the backend."""
|
|
1718
|
+
return SystemInformation(available_interfaces=(Interface.BIDCOS_RF,), serial=f"{self.interface}_{DUMMY_SERIAL}")
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
class ClientConfig:
|
|
1722
|
+
"""Config for a Client."""
|
|
1723
|
+
|
|
1724
|
+
def __init__(
|
|
1725
|
+
self,
|
|
1726
|
+
*,
|
|
1727
|
+
client_deps: ClientDependenciesProtocol,
|
|
1728
|
+
interface_config: InterfaceConfig,
|
|
1729
|
+
) -> None:
|
|
1730
|
+
"""Initialize the config."""
|
|
1731
|
+
self.client_deps: Final[ClientDependenciesProtocol] = client_deps
|
|
1732
|
+
self.version: str = "0"
|
|
1733
|
+
self.system_information = SystemInformation()
|
|
1734
|
+
self.interface_config: Final = interface_config
|
|
1735
|
+
self.interface: Final = interface_config.interface
|
|
1736
|
+
self.interface_id: Final = interface_config.interface_id
|
|
1737
|
+
self.max_read_workers: Final[int] = client_deps.config.max_read_workers
|
|
1738
|
+
self.has_credentials: Final[bool] = (
|
|
1739
|
+
client_deps.config.username is not None and client_deps.config.password is not None
|
|
1740
|
+
)
|
|
1741
|
+
self.has_linking: Final = self.interface in LINKABLE_INTERFACES
|
|
1742
|
+
self.has_firmware_updates: Final = self.interface in INTERFACES_SUPPORTING_FIRMWARE_UPDATES
|
|
1743
|
+
self.has_ping_pong: Final = self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK
|
|
1744
|
+
self.has_push_updates: Final = self.interface not in client_deps.config.interfaces_requiring_periodic_refresh
|
|
1745
|
+
self.has_rpc_callback: Final = self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK
|
|
1746
|
+
callback_host: Final = (
|
|
1747
|
+
client_deps.config.callback_host if client_deps.config.callback_host else client_deps.callback_ip_addr
|
|
1748
|
+
)
|
|
1749
|
+
callback_port = (
|
|
1750
|
+
client_deps.config.callback_port_xml_rpc
|
|
1751
|
+
if client_deps.config.callback_port_xml_rpc
|
|
1752
|
+
else client_deps.listen_port_xml_rpc
|
|
1753
|
+
)
|
|
1754
|
+
init_url = f"{callback_host}:{callback_port}"
|
|
1755
|
+
self.init_url: Final = f"http://{init_url}"
|
|
1756
|
+
|
|
1757
|
+
self.xml_rpc_uri: Final = build_xml_rpc_uri(
|
|
1758
|
+
host=client_deps.config.host,
|
|
1759
|
+
port=interface_config.port,
|
|
1760
|
+
path=interface_config.remote_path,
|
|
1761
|
+
tls=client_deps.config.tls,
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
async def create_client(self) -> ClientProtocol:
|
|
1765
|
+
"""Identify the used client."""
|
|
1766
|
+
try:
|
|
1767
|
+
self.version = await self._get_version()
|
|
1768
|
+
client: ClientProtocol | None
|
|
1769
|
+
if self.interface == Interface.BIDCOS_RF and ("Homegear" in self.version or "pydevccu" in self.version):
|
|
1770
|
+
client = ClientHomegear(client_config=self)
|
|
1771
|
+
elif self.interface in INTERFACES_REQUIRING_JSON_RPC_CLIENT:
|
|
1772
|
+
client = ClientJsonCCU(client_config=self)
|
|
1773
|
+
else:
|
|
1774
|
+
client = ClientCCU(client_config=self)
|
|
1775
|
+
|
|
1776
|
+
if client:
|
|
1777
|
+
await client.init_client()
|
|
1778
|
+
if await client.check_connection_availability(handle_ping_pong=False):
|
|
1779
|
+
return client
|
|
1780
|
+
raise NoConnectionException(
|
|
1781
|
+
i18n.tr(key="exception.client.client_config.no_connection", interface_id=self.interface_id)
|
|
1782
|
+
)
|
|
1783
|
+
except BaseHomematicException:
|
|
1784
|
+
raise
|
|
1785
|
+
except Exception as exc:
|
|
1786
|
+
raise NoConnectionException(
|
|
1787
|
+
i18n.tr(
|
|
1788
|
+
key="exception.client.client_config.unable_to_connect",
|
|
1789
|
+
reason=extract_exc_args(exc=exc),
|
|
1790
|
+
)
|
|
1791
|
+
) from exc
|
|
1792
|
+
|
|
1793
|
+
async def create_rpc_proxy(
|
|
1794
|
+
self,
|
|
1795
|
+
*,
|
|
1796
|
+
interface: Interface,
|
|
1797
|
+
auth_enabled: bool | None = None,
|
|
1798
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
1799
|
+
) -> BaseRpcProxy:
|
|
1800
|
+
"""Return a RPC proxy for the backend communication."""
|
|
1801
|
+
return await self._create_xml_rpc_proxy(
|
|
1802
|
+
auth_enabled=auth_enabled,
|
|
1803
|
+
max_workers=max_workers,
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
async def _create_simple_rpc_proxy(self, *, interface: Interface) -> BaseRpcProxy:
|
|
1807
|
+
"""Return a RPC proxy for the backend communication."""
|
|
1808
|
+
return await self._create_xml_rpc_proxy(auth_enabled=True, max_workers=0)
|
|
1809
|
+
|
|
1810
|
+
async def _create_xml_rpc_proxy(
|
|
1811
|
+
self,
|
|
1812
|
+
*,
|
|
1813
|
+
auth_enabled: bool | None = None,
|
|
1814
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
1815
|
+
) -> AioXmlRpcProxy:
|
|
1816
|
+
"""Return a XmlRPC proxy for the backend communication."""
|
|
1817
|
+
config = self.client_deps.config
|
|
1818
|
+
xml_rpc_headers = (
|
|
1819
|
+
build_xml_rpc_headers(
|
|
1820
|
+
username=config.username,
|
|
1821
|
+
password=config.password,
|
|
1822
|
+
)
|
|
1823
|
+
if auth_enabled
|
|
1824
|
+
else []
|
|
1825
|
+
)
|
|
1826
|
+
xml_proxy = AioXmlRpcProxy(
|
|
1827
|
+
max_workers=max_workers,
|
|
1828
|
+
interface_id=self.interface_id,
|
|
1829
|
+
connection_state=self.client_deps.connection_state,
|
|
1830
|
+
uri=self.xml_rpc_uri,
|
|
1831
|
+
headers=xml_rpc_headers,
|
|
1832
|
+
tls=config.tls,
|
|
1833
|
+
verify_tls=config.verify_tls,
|
|
1834
|
+
session_recorder=self.client_deps.cache_coordinator.recorder,
|
|
1835
|
+
event_bus=self.client_deps.event_bus,
|
|
1836
|
+
incident_recorder=self.client_deps.cache_coordinator.incident_store,
|
|
1837
|
+
)
|
|
1838
|
+
await xml_proxy.do_init()
|
|
1839
|
+
return xml_proxy
|
|
1840
|
+
|
|
1841
|
+
async def _get_version(self) -> str:
|
|
1842
|
+
"""Return the version of the the backend."""
|
|
1843
|
+
if self.interface in INTERFACES_REQUIRING_JSON_RPC_CLIENT:
|
|
1844
|
+
return "0"
|
|
1845
|
+
check_proxy = await self._create_simple_rpc_proxy(interface=self.interface)
|
|
1846
|
+
try:
|
|
1847
|
+
if (methods := check_proxy.supported_methods) and "getVersion" in methods:
|
|
1848
|
+
# BidCos-Wired does not support getVersion()
|
|
1849
|
+
return cast(str, await check_proxy.getVersion())
|
|
1850
|
+
except Exception as exc:
|
|
1851
|
+
raise NoConnectionException(
|
|
1852
|
+
i18n.tr(
|
|
1853
|
+
key="exception.client.client_config.unable_to_connect",
|
|
1854
|
+
reason=extract_exc_args(exc=exc),
|
|
1855
|
+
)
|
|
1856
|
+
) from exc
|
|
1857
|
+
return "0"
|