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.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. 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