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,629 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
XML-RPC transport proxy with concurrency control and connection awareness.
|
|
5
|
+
|
|
6
|
+
Overview
|
|
7
|
+
--------
|
|
8
|
+
XmlRpcProxy extends xmlrpc.client.ServerProxy to:
|
|
9
|
+
- Execute RPC calls in a thread pool to avoid blocking the event loop
|
|
10
|
+
- Integrate with CentralConnectionState to mark/report connection issues
|
|
11
|
+
- Optionally use TLS with configurable certificate verification
|
|
12
|
+
- Filter unsupported methods at runtime via system.listMethods
|
|
13
|
+
|
|
14
|
+
Notes
|
|
15
|
+
-----
|
|
16
|
+
- The proxy cleans and normalizes argument encodings for XML-RPC.
|
|
17
|
+
- Certain methods are allowed even when the connection is flagged down
|
|
18
|
+
(e.g., ping, init, getVersion) to support recovery.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
import asyncio
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
28
|
+
from contextlib import suppress
|
|
29
|
+
from enum import Enum, IntEnum, StrEnum
|
|
30
|
+
import errno
|
|
31
|
+
import http.client
|
|
32
|
+
import logging
|
|
33
|
+
from ssl import SSLContext, SSLError
|
|
34
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
35
|
+
import xmlrpc.client
|
|
36
|
+
|
|
37
|
+
from aiohomematic import central as hmcu, i18n
|
|
38
|
+
from aiohomematic.async_support import Looper
|
|
39
|
+
from aiohomematic.client._rpc_errors import RpcContext, map_xmlrpc_fault, sanitize_error_message
|
|
40
|
+
from aiohomematic.client.circuit_breaker import CircuitBreaker, CircuitBreakerConfig
|
|
41
|
+
from aiohomematic.const import ISO_8859_1
|
|
42
|
+
from aiohomematic.exceptions import (
|
|
43
|
+
AuthFailure,
|
|
44
|
+
BaseHomematicException,
|
|
45
|
+
CircuitBreakerOpenException,
|
|
46
|
+
ClientException,
|
|
47
|
+
NoConnectionException,
|
|
48
|
+
UnsupportedException,
|
|
49
|
+
)
|
|
50
|
+
from aiohomematic.property_decorators import DelegatedProperty
|
|
51
|
+
from aiohomematic.store.persistent import SessionRecorder
|
|
52
|
+
from aiohomematic.store.types import IncidentSeverity, IncidentType
|
|
53
|
+
from aiohomematic.support import extract_exc_args, get_tls_context, log_boundary_error
|
|
54
|
+
from aiohomematic.type_aliases import CallableAny
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from aiohomematic.central.events import EventBus
|
|
58
|
+
from aiohomematic.interfaces import IncidentRecorderProtocol
|
|
59
|
+
|
|
60
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
_CONTEXT: Final = "context"
|
|
63
|
+
_TLS: Final = "tls"
|
|
64
|
+
_VERIFY_TLS: Final = "verify_tls"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _RpcMethod(StrEnum):
|
|
68
|
+
"""Enum for Homematic json rpc methods types."""
|
|
69
|
+
|
|
70
|
+
GET_VERSION = "getVersion"
|
|
71
|
+
HOMEGEAR_INIT = "clientServerInitialized"
|
|
72
|
+
INIT = "init"
|
|
73
|
+
PING = "ping"
|
|
74
|
+
SYSTEM_LIST_METHODS = "system.listMethods"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_CIRCUIT_BREAKER_BYPASS_METHODS: Final[tuple[str, ...]] = (
|
|
78
|
+
_RpcMethod.GET_VERSION,
|
|
79
|
+
_RpcMethod.HOMEGEAR_INIT,
|
|
80
|
+
_RpcMethod.INIT,
|
|
81
|
+
_RpcMethod.PING,
|
|
82
|
+
_RpcMethod.SYSTEM_LIST_METHODS,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_SSL_ERROR_CODES: Final[dict[int, str]] = {
|
|
86
|
+
errno.ENOEXEC: "EOF occurred in violation of protocol",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_OS_ERROR_CODES: Final[dict[int, str]] = {
|
|
90
|
+
errno.ECONNREFUSED: "Connection refused",
|
|
91
|
+
errno.EHOSTUNREACH: "No route to host",
|
|
92
|
+
errno.ENETUNREACH: "Network is unreachable",
|
|
93
|
+
errno.ENOEXEC: "Exec",
|
|
94
|
+
errno.ETIMEDOUT: "Operation timed out",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _XmlRpcFaultCode(IntEnum):
|
|
99
|
+
"""
|
|
100
|
+
XML-RPC fault codes from the Homematic backend.
|
|
101
|
+
|
|
102
|
+
Reference: CCU documentation for XML-RPC fault codes.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
GENERIC_ERROR = -1
|
|
106
|
+
"""General error (often UNREACH - device temporarily unreachable)."""
|
|
107
|
+
|
|
108
|
+
UNKNOWN_DEVICE = -2
|
|
109
|
+
"""Unknown device or channel."""
|
|
110
|
+
|
|
111
|
+
UNKNOWN_PARAMSET = -3
|
|
112
|
+
"""Unknown paramset."""
|
|
113
|
+
|
|
114
|
+
ADDRESS_EXPECTED = -4
|
|
115
|
+
"""Device address was expected."""
|
|
116
|
+
|
|
117
|
+
UNKNOWN_PARAMETER = -5
|
|
118
|
+
"""Unknown parameter or value."""
|
|
119
|
+
|
|
120
|
+
OPERATION_NOT_SUPPORTED = -6
|
|
121
|
+
"""Operation not supported by this parameter."""
|
|
122
|
+
|
|
123
|
+
UPDATE_NOT_POSSIBLE = -7
|
|
124
|
+
"""Interface cannot perform update."""
|
|
125
|
+
|
|
126
|
+
INSUFFICIENT_DUTYCYCLE = -8
|
|
127
|
+
"""Not enough DutyCycle available."""
|
|
128
|
+
|
|
129
|
+
DEVICE_OUT_OF_RANGE = -9
|
|
130
|
+
"""Device is not in range."""
|
|
131
|
+
|
|
132
|
+
TRANSMISSION_PENDING = -10
|
|
133
|
+
"""Transmission to device pending."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Fault codes that are expected during initial data loading and normal operation.
|
|
137
|
+
# These indicate transient or known conditions, not actual system errors.
|
|
138
|
+
_EXPECTED_XMLRPC_FAULT_CODES: Final[frozenset[int]] = frozenset(
|
|
139
|
+
{
|
|
140
|
+
_XmlRpcFaultCode.GENERIC_ERROR,
|
|
141
|
+
_XmlRpcFaultCode.UNKNOWN_DEVICE,
|
|
142
|
+
_XmlRpcFaultCode.UNKNOWN_PARAMSET,
|
|
143
|
+
_XmlRpcFaultCode.UNKNOWN_PARAMETER,
|
|
144
|
+
_XmlRpcFaultCode.DEVICE_OUT_OF_RANGE,
|
|
145
|
+
_XmlRpcFaultCode.TRANSMISSION_PENDING,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# noinspection PyProtectedMember,PyUnresolvedReferences
|
|
151
|
+
class BaseRpcProxy(ABC):
|
|
152
|
+
"""ServerProxy implementation with ThreadPoolExecutor when request is executing."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
max_workers: int,
|
|
158
|
+
interface_id: str,
|
|
159
|
+
connection_state: hmcu.CentralConnectionState,
|
|
160
|
+
magic_method: CallableAny,
|
|
161
|
+
tls: bool = False,
|
|
162
|
+
verify_tls: bool = False,
|
|
163
|
+
session_recorder: SessionRecorder | None = None,
|
|
164
|
+
circuit_breaker_config: CircuitBreakerConfig | None = None,
|
|
165
|
+
event_bus: EventBus | None = None,
|
|
166
|
+
incident_recorder: IncidentRecorderProtocol | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Initialize new proxy for server and get local ip."""
|
|
169
|
+
self._interface_id: Final = interface_id
|
|
170
|
+
self._connection_state: Final = connection_state
|
|
171
|
+
self._session_recorder: Final = session_recorder
|
|
172
|
+
self._magic_method: Final = magic_method
|
|
173
|
+
self._looper: Final = Looper()
|
|
174
|
+
self._proxy_executor: Final = (
|
|
175
|
+
ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=interface_id) if max_workers > 0 else None
|
|
176
|
+
)
|
|
177
|
+
self._tls: Final[bool | SSLContext] = get_tls_context(verify_tls=verify_tls) if tls else False
|
|
178
|
+
self._supported_methods: tuple[str, ...] = ()
|
|
179
|
+
self._kwargs: dict[str, Any] = {}
|
|
180
|
+
if tls:
|
|
181
|
+
self._kwargs[_CONTEXT] = self._tls
|
|
182
|
+
# Due to magic method the log_context must be defined manually.
|
|
183
|
+
self.log_context: Final[Mapping[str, Any]] = {"interface_id": self._interface_id, "tls": tls}
|
|
184
|
+
|
|
185
|
+
# Incident recorder for diagnostic events
|
|
186
|
+
self._incident_recorder = incident_recorder
|
|
187
|
+
|
|
188
|
+
# Circuit breaker for preventing retry-storms during backend outages
|
|
189
|
+
self._circuit_breaker: Final = CircuitBreaker(
|
|
190
|
+
config=circuit_breaker_config,
|
|
191
|
+
interface_id=interface_id,
|
|
192
|
+
connection_state=connection_state,
|
|
193
|
+
issuer=self,
|
|
194
|
+
event_bus=event_bus,
|
|
195
|
+
incident_recorder=incident_recorder,
|
|
196
|
+
task_scheduler=self._looper,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def __getattr__(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
200
|
+
"""Magic method dispatcher."""
|
|
201
|
+
return self._magic_method(self._async_request, *args, **kwargs)
|
|
202
|
+
|
|
203
|
+
circuit_breaker: Final = DelegatedProperty[CircuitBreaker](path="_circuit_breaker")
|
|
204
|
+
supported_methods: Final = DelegatedProperty[tuple[str, ...]](path="_supported_methods")
|
|
205
|
+
|
|
206
|
+
def clear_connection_issue(self) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Clear the connection issue flag for this interface.
|
|
209
|
+
|
|
210
|
+
This should be called after a successful proxy init to ensure
|
|
211
|
+
that subsequent RPC calls are not blocked by stale connection issues
|
|
212
|
+
from previous failed attempts.
|
|
213
|
+
"""
|
|
214
|
+
self._connection_state.remove_issue(issuer=self, iid=self._interface_id)
|
|
215
|
+
|
|
216
|
+
@abstractmethod
|
|
217
|
+
async def do_init(self) -> None:
|
|
218
|
+
"""Initialize the rpc proxy."""
|
|
219
|
+
|
|
220
|
+
async def stop(self) -> None:
|
|
221
|
+
"""Stop depending services."""
|
|
222
|
+
await self._looper.block_till_done()
|
|
223
|
+
if self._proxy_executor:
|
|
224
|
+
self._proxy_executor.shutdown()
|
|
225
|
+
|
|
226
|
+
@abstractmethod
|
|
227
|
+
async def _async_request(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
228
|
+
"""Call method on server side."""
|
|
229
|
+
|
|
230
|
+
def _record_rpc_error_incident(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
method: str,
|
|
234
|
+
error_type: str,
|
|
235
|
+
error_message: str,
|
|
236
|
+
protocol: str = "xml-rpc",
|
|
237
|
+
is_expected: bool = False,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""
|
|
240
|
+
Record an RPC_ERROR incident for diagnostics.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
method: RPC method that failed.
|
|
244
|
+
error_type: Type of error (e.g., XMLRPCFault, OSError).
|
|
245
|
+
error_message: Error message from the exception.
|
|
246
|
+
protocol: RPC protocol used (xml-rpc or json-rpc).
|
|
247
|
+
is_expected: If True, use WARNING severity instead of ERROR.
|
|
248
|
+
Expected errors are common during data loading (e.g., unknown
|
|
249
|
+
parameters, unreachable devices) and should not clutter logs.
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
if (incident_recorder := self._incident_recorder) is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Sanitize error message to remove sensitive information
|
|
256
|
+
sanitized_message = sanitize_error_message(message=error_message)
|
|
257
|
+
|
|
258
|
+
# Use WARNING for expected errors to reduce log noise
|
|
259
|
+
severity = IncidentSeverity.WARNING if is_expected else IncidentSeverity.ERROR
|
|
260
|
+
|
|
261
|
+
context = {
|
|
262
|
+
"protocol": protocol,
|
|
263
|
+
"method": method,
|
|
264
|
+
"error_type": error_type,
|
|
265
|
+
"error_message": sanitized_message,
|
|
266
|
+
"tls_enabled": bool(self._tls),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async def _record() -> None:
|
|
270
|
+
try:
|
|
271
|
+
await incident_recorder.record_incident(
|
|
272
|
+
incident_type=IncidentType.RPC_ERROR,
|
|
273
|
+
severity=severity,
|
|
274
|
+
message=f"RPC error on {self._interface_id}: {error_type} during {method}",
|
|
275
|
+
interface_id=self._interface_id,
|
|
276
|
+
context=context,
|
|
277
|
+
)
|
|
278
|
+
except Exception as err: # pragma: no cover
|
|
279
|
+
_LOGGER.debug(
|
|
280
|
+
"RPC_PROXY: Failed to record RPC error incident for %s: %s",
|
|
281
|
+
self._interface_id,
|
|
282
|
+
err,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Schedule the async recording via looper
|
|
286
|
+
self._looper.create_task(
|
|
287
|
+
target=_record(),
|
|
288
|
+
name=f"record_rpc_error_incident_{self._interface_id}",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _record_session(
|
|
292
|
+
self, *, method: str, params: tuple[Any, ...], response: Any | None = None, exc: Exception | None = None
|
|
293
|
+
) -> bool:
|
|
294
|
+
"""Record the session."""
|
|
295
|
+
if method in (_RpcMethod.PING,):
|
|
296
|
+
return False
|
|
297
|
+
if self._session_recorder and self._session_recorder.active:
|
|
298
|
+
self._session_recorder.add_xml_rpc_session(method=method, params=params, response=response, session_exc=exc)
|
|
299
|
+
return True
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# noinspection PyProtectedMember,PyUnresolvedReferences
|
|
304
|
+
class AioXmlRpcProxy(BaseRpcProxy, xmlrpc.client.ServerProxy):
|
|
305
|
+
"""ServerProxy implementation with ThreadPoolExecutor when request is executing."""
|
|
306
|
+
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
max_workers: int,
|
|
311
|
+
interface_id: str,
|
|
312
|
+
connection_state: hmcu.CentralConnectionState,
|
|
313
|
+
uri: str,
|
|
314
|
+
headers: list[tuple[str, str]],
|
|
315
|
+
tls: bool = False,
|
|
316
|
+
verify_tls: bool = False,
|
|
317
|
+
session_recorder: SessionRecorder | None = None,
|
|
318
|
+
event_bus: EventBus | None = None,
|
|
319
|
+
incident_recorder: IncidentRecorderProtocol | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Initialize new proxy for server and get local ip."""
|
|
322
|
+
super().__init__(
|
|
323
|
+
max_workers=max_workers,
|
|
324
|
+
interface_id=interface_id,
|
|
325
|
+
connection_state=connection_state,
|
|
326
|
+
magic_method=xmlrpc.client._Method,
|
|
327
|
+
tls=tls,
|
|
328
|
+
verify_tls=verify_tls,
|
|
329
|
+
session_recorder=session_recorder,
|
|
330
|
+
event_bus=event_bus,
|
|
331
|
+
incident_recorder=incident_recorder,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
xmlrpc.client.ServerProxy.__init__(
|
|
335
|
+
self,
|
|
336
|
+
uri=uri,
|
|
337
|
+
encoding=ISO_8859_1,
|
|
338
|
+
headers=headers,
|
|
339
|
+
**self._kwargs,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def do_init(self) -> None:
|
|
343
|
+
"""Initialize the xml rpc proxy."""
|
|
344
|
+
if supported_methods := await self.system.listMethods():
|
|
345
|
+
# ping is missing in VirtualDevices interface but can be used.
|
|
346
|
+
supported_methods.append(_RpcMethod.PING)
|
|
347
|
+
self._supported_methods = tuple(supported_methods)
|
|
348
|
+
|
|
349
|
+
async def _async_request(self, *args: Any, **kwargs: Any) -> Any:
|
|
350
|
+
"""Call method on server side."""
|
|
351
|
+
parent = xmlrpc.client.ServerProxy
|
|
352
|
+
try:
|
|
353
|
+
method = args[0]
|
|
354
|
+
if self._supported_methods and method not in self._supported_methods:
|
|
355
|
+
raise UnsupportedException(i18n.tr(key="exception.client.xmlrpc.method_unsupported", method=method))
|
|
356
|
+
|
|
357
|
+
# Check circuit breaker state (allow recovery commands through)
|
|
358
|
+
if method not in _CIRCUIT_BREAKER_BYPASS_METHODS and not self._circuit_breaker.is_available:
|
|
359
|
+
self._circuit_breaker.record_rejection()
|
|
360
|
+
raise CircuitBreakerOpenException(
|
|
361
|
+
i18n.tr(key="exception.client.xmlrpc.circuit_open", interface_id=self._interface_id)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if method in _CIRCUIT_BREAKER_BYPASS_METHODS or not self._connection_state.has_issue(
|
|
365
|
+
issuer=self, iid=self._interface_id
|
|
366
|
+
):
|
|
367
|
+
args = _cleanup_args(*args)
|
|
368
|
+
_LOGGER.debug("XmlRPC.__ASYNC_REQUEST: %s", args)
|
|
369
|
+
result = await asyncio.shield(
|
|
370
|
+
self._looper.async_add_executor_job(
|
|
371
|
+
# pylint: disable=protected-access
|
|
372
|
+
parent._ServerProxy__request, # type: ignore[attr-defined]
|
|
373
|
+
self,
|
|
374
|
+
*args,
|
|
375
|
+
name="xmp_rpc_proxy",
|
|
376
|
+
executor=self._proxy_executor,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
self._record_session(method=method, params=args[1], response=result)
|
|
380
|
+
self._connection_state.remove_issue(issuer=self, iid=self._interface_id)
|
|
381
|
+
self._circuit_breaker.record_success()
|
|
382
|
+
return result
|
|
383
|
+
raise NoConnectionException(
|
|
384
|
+
i18n.tr(key="exception.client.xmlrpc.no_connection", interface_id=self._interface_id)
|
|
385
|
+
)
|
|
386
|
+
except BaseHomematicException as bhe:
|
|
387
|
+
self._record_session(method=args[0], params=args[1:], exc=bhe)
|
|
388
|
+
# Record failure for circuit breaker (connection-related exceptions)
|
|
389
|
+
# Don't record failure for CircuitBreakerOpenException - circuit is already open
|
|
390
|
+
if isinstance(bhe, NoConnectionException) and not isinstance(bhe, CircuitBreakerOpenException):
|
|
391
|
+
self._circuit_breaker.record_failure()
|
|
392
|
+
raise
|
|
393
|
+
except SSLError as sslerr: # pragma: no cover - SSL handshake/cert errors are OS/OpenSSL dependent and not reliably reproducible in CI
|
|
394
|
+
message = f"SSLError on {self._interface_id}: {extract_exc_args(exc=sslerr)}"
|
|
395
|
+
# Log ERROR only on first occurrence, DEBUG for subsequent failures
|
|
396
|
+
level = logging.DEBUG
|
|
397
|
+
if sslerr.args[0] in _SSL_ERROR_CODES:
|
|
398
|
+
message = (
|
|
399
|
+
f"{message} - {sslerr.args[0]}: {sslerr.args[1]}. "
|
|
400
|
+
f"Please check your configuration for {self._interface_id}."
|
|
401
|
+
)
|
|
402
|
+
if self._connection_state.add_issue(issuer=self, iid=self._interface_id):
|
|
403
|
+
level = logging.ERROR
|
|
404
|
+
|
|
405
|
+
log_boundary_error(
|
|
406
|
+
logger=_LOGGER,
|
|
407
|
+
boundary="xml-rpc",
|
|
408
|
+
action=str(args[0]),
|
|
409
|
+
err=sslerr,
|
|
410
|
+
level=level,
|
|
411
|
+
message=message,
|
|
412
|
+
log_context=self.log_context,
|
|
413
|
+
)
|
|
414
|
+
self._circuit_breaker.record_failure()
|
|
415
|
+
self._record_rpc_error_incident(
|
|
416
|
+
method=str(args[0]),
|
|
417
|
+
error_type="SSLError",
|
|
418
|
+
error_message=message,
|
|
419
|
+
)
|
|
420
|
+
raise NoConnectionException(
|
|
421
|
+
i18n.tr(key="exception.client.xmlrpc.ssl_error", interface_id=self._interface_id, reason=message)
|
|
422
|
+
) from sslerr
|
|
423
|
+
except OSError as oserr: # pragma: no cover - Network/socket errno differences are platform/environment specific; simulating reliably in CI would be flaky
|
|
424
|
+
# Log ERROR only on first occurrence, DEBUG for subsequent failures
|
|
425
|
+
level = (
|
|
426
|
+
logging.ERROR
|
|
427
|
+
if oserr.args[0] in _OS_ERROR_CODES
|
|
428
|
+
and self._connection_state.add_issue(issuer=self, iid=self._interface_id)
|
|
429
|
+
else logging.DEBUG
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
log_boundary_error(
|
|
433
|
+
logger=_LOGGER,
|
|
434
|
+
boundary="xml-rpc",
|
|
435
|
+
action=str(args[0]),
|
|
436
|
+
err=oserr,
|
|
437
|
+
level=level,
|
|
438
|
+
log_context=self.log_context,
|
|
439
|
+
)
|
|
440
|
+
self._circuit_breaker.record_failure()
|
|
441
|
+
self._record_rpc_error_incident(
|
|
442
|
+
method=str(args[0]),
|
|
443
|
+
error_type="OSError",
|
|
444
|
+
error_message=str(extract_exc_args(exc=oserr)),
|
|
445
|
+
)
|
|
446
|
+
raise NoConnectionException(
|
|
447
|
+
i18n.tr(
|
|
448
|
+
key="exception.client.xmlrpc.os_error",
|
|
449
|
+
interface_id=self._interface_id,
|
|
450
|
+
reason=extract_exc_args(exc=oserr),
|
|
451
|
+
)
|
|
452
|
+
) from oserr
|
|
453
|
+
except xmlrpc.client.Fault as flt:
|
|
454
|
+
ctx = RpcContext(protocol="xml-rpc", method=str(args[0]), interface=self._interface_id)
|
|
455
|
+
self._record_rpc_error_incident(
|
|
456
|
+
method=str(args[0]),
|
|
457
|
+
error_type="XMLRPCFault",
|
|
458
|
+
error_message=f"Code {flt.faultCode}: {flt.faultString}",
|
|
459
|
+
is_expected=flt.faultCode in _EXPECTED_XMLRPC_FAULT_CODES,
|
|
460
|
+
)
|
|
461
|
+
raise map_xmlrpc_fault(code=flt.faultCode, fault_string=flt.faultString, ctx=ctx) from flt
|
|
462
|
+
except TypeError as terr:
|
|
463
|
+
self._record_rpc_error_incident(
|
|
464
|
+
method=str(args[0]),
|
|
465
|
+
error_type="TypeError",
|
|
466
|
+
error_message=str(extract_exc_args(exc=terr)),
|
|
467
|
+
)
|
|
468
|
+
raise ClientException(terr) from terr
|
|
469
|
+
except xmlrpc.client.ProtocolError as perr:
|
|
470
|
+
if not self._connection_state.has_issue(issuer=self, iid=self._interface_id):
|
|
471
|
+
self._record_rpc_error_incident(
|
|
472
|
+
method=str(args[0]),
|
|
473
|
+
error_type="ProtocolError",
|
|
474
|
+
error_message=perr.errmsg,
|
|
475
|
+
)
|
|
476
|
+
if perr.errmsg == "Unauthorized":
|
|
477
|
+
raise AuthFailure(perr) from perr
|
|
478
|
+
raise NoConnectionException(
|
|
479
|
+
i18n.tr(
|
|
480
|
+
key="exception.client.xmlrpc.no_connection_with_reason",
|
|
481
|
+
context=str(self.log_context),
|
|
482
|
+
reason=perr.errmsg,
|
|
483
|
+
)
|
|
484
|
+
) from perr
|
|
485
|
+
except http.client.ImproperConnectionState as icserr:
|
|
486
|
+
# HTTP connection state errors (ResponseNotReady, CannotSendRequest, etc.)
|
|
487
|
+
# These indicate the connection is in an inconsistent state and should be retried
|
|
488
|
+
# Log at DEBUG level as this is expected during reconnection scenarios
|
|
489
|
+
log_boundary_error(
|
|
490
|
+
logger=_LOGGER,
|
|
491
|
+
boundary="xml-rpc",
|
|
492
|
+
action=str(args[0]),
|
|
493
|
+
err=icserr,
|
|
494
|
+
level=logging.DEBUG,
|
|
495
|
+
log_context=self.log_context,
|
|
496
|
+
)
|
|
497
|
+
# Note: We do NOT reset the transport here because:
|
|
498
|
+
# 1. transport.close() alone doesn't fix the issue (transport reuses closed connection)
|
|
499
|
+
# 2. setting transport=None causes AttributeError on retry
|
|
500
|
+
# The retry mechanism with backoff should handle transient connection issues.
|
|
501
|
+
# If the issue persists, circuit breaker will open and client will reconnect.
|
|
502
|
+
self._circuit_breaker.record_failure()
|
|
503
|
+
self._record_rpc_error_incident(
|
|
504
|
+
method=str(args[0]),
|
|
505
|
+
error_type="ImproperConnectionState",
|
|
506
|
+
error_message=str(extract_exc_args(exc=icserr)),
|
|
507
|
+
)
|
|
508
|
+
raise NoConnectionException(
|
|
509
|
+
i18n.tr(
|
|
510
|
+
key="exception.client.xmlrpc.http_connection_state_error",
|
|
511
|
+
interface_id=self._interface_id,
|
|
512
|
+
reason=extract_exc_args(exc=icserr),
|
|
513
|
+
)
|
|
514
|
+
) from icserr
|
|
515
|
+
except Exception as exc:
|
|
516
|
+
self._record_rpc_error_incident(
|
|
517
|
+
method=str(args[0]),
|
|
518
|
+
error_type=type(exc).__name__,
|
|
519
|
+
error_message=str(extract_exc_args(exc=exc)),
|
|
520
|
+
)
|
|
521
|
+
raise ClientException(exc) from exc
|
|
522
|
+
|
|
523
|
+
def _reset_transport(self) -> None:
|
|
524
|
+
"""
|
|
525
|
+
Reset the XML-RPC transport to force a new connection on next request.
|
|
526
|
+
|
|
527
|
+
This is necessary when the underlying HTTP connection gets into an
|
|
528
|
+
inconsistent state (e.g., after ResponseNotReady errors).
|
|
529
|
+
"""
|
|
530
|
+
# Close the transport connection, which will force a new connection
|
|
531
|
+
# on the next request. We DO NOT set transport to None because that
|
|
532
|
+
# causes AttributeError - ServerProxy expects the transport to exist.
|
|
533
|
+
if transport := self._ServerProxy__transport:
|
|
534
|
+
with suppress(Exception): # Best effort cleanup
|
|
535
|
+
transport.close()
|
|
536
|
+
_LOGGER.debug(
|
|
537
|
+
"XmlRPC._RESET_TRANSPORT: Transport closed for %s",
|
|
538
|
+
self._interface_id,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class NullRpcProxy(BaseRpcProxy):
|
|
543
|
+
"""
|
|
544
|
+
Null RPC proxy for clients that don't use XML-RPC.
|
|
545
|
+
|
|
546
|
+
Used by ClientJsonCCU to satisfy handler initialization requirements
|
|
547
|
+
without creating actual XML-RPC connections. All operations raise
|
|
548
|
+
UnsupportedException if called.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __init__(
|
|
552
|
+
self,
|
|
553
|
+
*,
|
|
554
|
+
interface_id: str,
|
|
555
|
+
connection_state: hmcu.CentralConnectionState,
|
|
556
|
+
event_bus: EventBus | None = None,
|
|
557
|
+
incident_recorder: IncidentRecorderProtocol | None = None,
|
|
558
|
+
) -> None:
|
|
559
|
+
"""Initialize null proxy."""
|
|
560
|
+
super().__init__(
|
|
561
|
+
max_workers=0,
|
|
562
|
+
interface_id=interface_id,
|
|
563
|
+
connection_state=connection_state,
|
|
564
|
+
magic_method=self._null_method,
|
|
565
|
+
tls=False,
|
|
566
|
+
verify_tls=False,
|
|
567
|
+
session_recorder=None,
|
|
568
|
+
event_bus=event_bus,
|
|
569
|
+
incident_recorder=incident_recorder,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
async def do_init(self) -> None:
|
|
573
|
+
"""No-op initialization."""
|
|
574
|
+
|
|
575
|
+
async def _async_request(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
|
|
576
|
+
"""Raise UnsupportedException for any RPC request."""
|
|
577
|
+
raise UnsupportedException(
|
|
578
|
+
i18n.tr(key="exception.client.xmlrpc.null_proxy_unsupported", interface_id=self._interface_id)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def _null_method(
|
|
582
|
+
self,
|
|
583
|
+
request_func: Any, # noqa: ARG002
|
|
584
|
+
*args: Any,
|
|
585
|
+
**kwargs: Any, # noqa: ARG002
|
|
586
|
+
) -> Any:
|
|
587
|
+
"""Return a callable that raises UnsupportedException."""
|
|
588
|
+
|
|
589
|
+
async def _raise(*args: Any, **kwargs: Any) -> None: # noqa: ARG001
|
|
590
|
+
raise UnsupportedException(
|
|
591
|
+
i18n.tr(key="exception.client.xmlrpc.null_proxy_unsupported", interface_id=self._interface_id)
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return _raise
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _cleanup_args(*args: Any) -> Any:
|
|
598
|
+
"""Cleanup the type of args."""
|
|
599
|
+
if len(args[1]) == 0:
|
|
600
|
+
return args
|
|
601
|
+
if len(args) == 2:
|
|
602
|
+
new_args: list[Any] = []
|
|
603
|
+
for data in args[1]:
|
|
604
|
+
if isinstance(data, dict):
|
|
605
|
+
new_args.append(_cleanup_paramset(paramset=data))
|
|
606
|
+
else:
|
|
607
|
+
new_args.append(_cleanup_item(item=data))
|
|
608
|
+
return (args[0], tuple(new_args))
|
|
609
|
+
_LOGGER.error("XmlRpcProxy command: Too many arguments") # i18n-log: ignore
|
|
610
|
+
return args
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _cleanup_item(*, item: Any) -> Any:
|
|
614
|
+
"""Cleanup a single item."""
|
|
615
|
+
if isinstance(item, StrEnum):
|
|
616
|
+
return str(item)
|
|
617
|
+
if isinstance(item, IntEnum):
|
|
618
|
+
return int(item)
|
|
619
|
+
if isinstance(item, Enum):
|
|
620
|
+
_LOGGER.error("XmlRpcProxy command: Enum is not supported as parameter value") # i18n-log: ignore
|
|
621
|
+
return item
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _cleanup_paramset(*, paramset: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
625
|
+
"""Cleanup a paramset."""
|
|
626
|
+
new_paramset: dict[str, Any] = {}
|
|
627
|
+
for name, value in paramset.items():
|
|
628
|
+
new_paramset[_cleanup_item(item=name)] = _cleanup_item(item=value)
|
|
629
|
+
return new_paramset
|