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,760 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Async XML-RPC server module.
5
+
6
+ Provides an asyncio-native XML-RPC server using aiohttp for
7
+ receiving callbacks from the Homematic backend.
8
+
9
+ This is an experimental alternative to the thread-based XML-RPC server
10
+ (see ADR 0012). Enable via OptionalSettings.ASYNC_RPC_SERVER.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ from typing import TYPE_CHECKING, Any, Final
19
+ from xml.parsers.expat import ExpatError
20
+ import xmlrpc.client
21
+
22
+ from aiohttp import web
23
+ import orjson
24
+
25
+ from aiohomematic import client as hmcl, i18n
26
+ from aiohomematic.const import IP_ANY_V4, PORT_ANY, SystemEventType, UpdateDeviceHint
27
+ from aiohomematic.interfaces.central import RpcServerCentralProtocol
28
+ from aiohomematic.metrics import MetricKeys, emit_counter, emit_gauge, emit_latency
29
+ from aiohomematic.schemas import normalize_device_description
30
+ from aiohomematic.support import get_device_address, log_boundary_error
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Awaitable, Callable
34
+
35
+ from aiohomematic.central.events import EventBus
36
+
37
+ _LOGGER: Final = logging.getLogger(__name__)
38
+
39
+ # Type alias for async method handlers
40
+ type AsyncMethodHandler = Callable[..., Awaitable[Any]]
41
+
42
+
43
+ class XmlRpcProtocolError(Exception):
44
+ """Exception for XML-RPC protocol errors."""
45
+
46
+
47
+ class AsyncXmlRpcDispatcher:
48
+ """
49
+ Dispatcher for async XML-RPC method calls.
50
+
51
+ Parses XML-RPC requests and dispatches to registered async handlers.
52
+ Uses stdlib xmlrpc.client for parsing (no external dependencies).
53
+ """
54
+
55
+ def __init__(self) -> None:
56
+ """Initialize the dispatcher."""
57
+ self._methods: Final[dict[str, AsyncMethodHandler]] = {}
58
+
59
+ async def dispatch(self, *, xml_data: bytes) -> bytes:
60
+ """
61
+ Parse XML-RPC request and dispatch to handler.
62
+
63
+ Args:
64
+ xml_data: Raw XML-RPC request body
65
+
66
+ Returns:
67
+ XML-RPC response as bytes
68
+
69
+ """
70
+ try:
71
+ params, method_name = xmlrpc.client.loads(
72
+ xml_data,
73
+ use_builtin_types=True,
74
+ )
75
+ except ExpatError as err:
76
+ raise XmlRpcProtocolError(i18n.tr(key="exception.central.rpc_server.invalid_xml", error=err)) from err
77
+ except Exception as err:
78
+ raise XmlRpcProtocolError(i18n.tr(key="exception.central.rpc_server.parse_error", error=err)) from err
79
+
80
+ _LOGGER.debug(
81
+ "XML-RPC dispatch: method=%s, params=%s",
82
+ method_name,
83
+ params[:2] if len(params) > 2 else params,
84
+ )
85
+
86
+ # Look up method
87
+ if method_name not in self._methods:
88
+ fault = xmlrpc.client.Fault(
89
+ faultCode=-32601,
90
+ faultString=f"Method not found: {method_name}",
91
+ )
92
+ return xmlrpc.client.dumps(fault, allow_none=True).encode("utf-8")
93
+
94
+ # Execute method
95
+ try:
96
+ handler = self._methods[method_name]
97
+ # XML-RPC requires a tuple for response
98
+ # Homematic expects acknowledgment (True) for None results
99
+ if (result := await handler(*params)) is None:
100
+ result = True
101
+
102
+ return xmlrpc.client.dumps(
103
+ (result,),
104
+ methodresponse=True,
105
+ allow_none=True,
106
+ ).encode("utf-8")
107
+ except Exception as err:
108
+ _LOGGER.exception(i18n.tr(key="log.central.rpc_server.method_failed", method_name=method_name))
109
+ fault = xmlrpc.client.Fault(
110
+ faultCode=-32603,
111
+ faultString=str(err),
112
+ )
113
+ return xmlrpc.client.dumps(fault, allow_none=True).encode("utf-8")
114
+
115
+ def register_instance(self, *, instance: object) -> None:
116
+ """
117
+ Register all public methods of an instance.
118
+
119
+ Methods starting with underscore are ignored.
120
+ camelCase methods are registered as-is (required by Homematic protocol).
121
+ """
122
+ for name in dir(instance):
123
+ if name.startswith("_"):
124
+ continue
125
+ method = getattr(instance, name)
126
+ if callable(method):
127
+ self._methods[name] = method
128
+
129
+ def register_introspection_functions(self) -> None:
130
+ """Register XML-RPC introspection methods."""
131
+ self._methods["system.listMethods"] = self._system_list_methods
132
+ self._methods["system.methodHelp"] = self._system_method_help
133
+ self._methods["system.methodSignature"] = self._system_method_signature
134
+ self._methods["system.multicall"] = self._system_multicall
135
+
136
+ async def _system_list_methods(
137
+ self,
138
+ interface_id: str | None = None,
139
+ /,
140
+ ) -> list[str]:
141
+ """Return list of available methods."""
142
+ return sorted(self._methods.keys())
143
+
144
+ async def _system_method_help(self, method_name: str, /) -> str:
145
+ """Return help string for a method."""
146
+ if method := self._methods.get(method_name):
147
+ return method.__doc__ or ""
148
+ return ""
149
+
150
+ async def _system_method_signature(
151
+ self,
152
+ method_name: str,
153
+ /,
154
+ ) -> str:
155
+ """Return signature for a method (not implemented)."""
156
+ return "signatures not supported"
157
+
158
+ async def _system_multicall(
159
+ self,
160
+ calls: list[dict[str, Any]],
161
+ /,
162
+ ) -> list[Any]:
163
+ """
164
+ Execute multiple method calls in a single request.
165
+
166
+ This is the standard XML-RPC multicall method used by the Homematic
167
+ backend to batch multiple event notifications together.
168
+
169
+ Args:
170
+ calls: List of dicts with 'methodName' and 'params' keys
171
+
172
+ Returns:
173
+ List of results (each wrapped in a list) or fault dicts.
174
+
175
+ """
176
+ results: list[Any] = []
177
+ for call in calls:
178
+ method_name = call.get("methodName", "")
179
+ params = call.get("params", [])
180
+
181
+ if method_name not in self._methods:
182
+ results.append({"faultCode": -32601, "faultString": f"Method not found: {method_name}"})
183
+ continue
184
+
185
+ try:
186
+ handler = self._methods[method_name]
187
+ result = await handler(*params)
188
+ # XML-RPC multicall wraps each result in a list
189
+ results.append([result if result is not None else True])
190
+ except Exception as err: # noqa: BLE001
191
+ _LOGGER.debug("Multicall method %s failed: %s", method_name, err)
192
+ results.append({"faultCode": -32603, "faultString": str(err)})
193
+
194
+ return results
195
+
196
+
197
+ # pylint: disable=invalid-name
198
+ class AsyncRPCFunctions:
199
+ """
200
+ Async implementation of RPC callback functions.
201
+
202
+ Method names use camelCase as required by Homematic XML-RPC protocol.
203
+ """
204
+
205
+ # Disable kw-only linter for protocol compatibility
206
+ __kwonly_check__ = False
207
+
208
+ def __init__(self, *, rpc_server: AsyncXmlRpcServer) -> None:
209
+ """Initialize AsyncRPCFunctions."""
210
+ self._rpc_server: Final = rpc_server
211
+ # Store task references to prevent garbage collection (RUF006)
212
+ self._background_tasks: Final[set[asyncio.Task[None]]] = set()
213
+
214
+ @property
215
+ def active_tasks_count(self) -> int:
216
+ """Return the number of active background tasks."""
217
+ return len(self._background_tasks)
218
+
219
+ async def cancel_background_tasks(self) -> None:
220
+ """Cancel all background tasks and wait for them to complete."""
221
+ if not self._background_tasks:
222
+ return
223
+
224
+ _LOGGER.debug(
225
+ "Cancelling %d background tasks",
226
+ len(self._background_tasks),
227
+ )
228
+
229
+ # Cancel all tasks
230
+ for task in self._background_tasks:
231
+ task.cancel()
232
+
233
+ # Wait for all tasks to complete (with timeout)
234
+ if self._background_tasks:
235
+ await asyncio.wait(
236
+ self._background_tasks,
237
+ timeout=5.0,
238
+ )
239
+
240
+ async def deleteDevices(
241
+ self,
242
+ interface_id: str,
243
+ addresses: list[str],
244
+ /,
245
+ ) -> None:
246
+ """Delete devices sent from the backend."""
247
+ if entry := self._get_central_entry(interface_id=interface_id):
248
+ # Fire-and-forget: schedule task and return immediately
249
+ self._create_background_task(
250
+ entry.central.device_coordinator.delete_devices(
251
+ interface_id=interface_id,
252
+ addresses=tuple(addresses),
253
+ ),
254
+ name=f"deleteDevices-{interface_id}",
255
+ )
256
+
257
+ async def error(
258
+ self,
259
+ interface_id: str,
260
+ error_code: str,
261
+ msg: str,
262
+ /,
263
+ ) -> None:
264
+ """Handle error notification from backend."""
265
+ try:
266
+ raise RuntimeError(str(msg))
267
+ except RuntimeError as err:
268
+ log_boundary_error(
269
+ logger=_LOGGER,
270
+ boundary="rpc-server",
271
+ action="error",
272
+ err=err,
273
+ level=logging.WARNING,
274
+ log_context={"interface_id": interface_id, "error_code": int(error_code)},
275
+ )
276
+ _LOGGER.error(
277
+ i18n.tr(
278
+ key="log.central.rpc_server.error",
279
+ interface_id=interface_id,
280
+ error_code=int(error_code),
281
+ msg=str(msg),
282
+ )
283
+ )
284
+ self._publish_system_event(interface_id=interface_id, system_event=SystemEventType.ERROR)
285
+
286
+ async def event(
287
+ self,
288
+ interface_id: str,
289
+ channel_address: str,
290
+ parameter: str,
291
+ value: Any,
292
+ /,
293
+ ) -> None:
294
+ """Handle data point event from backend."""
295
+ if entry := self._get_central_entry(interface_id=interface_id):
296
+ # Fire-and-forget: schedule task and return immediately
297
+ self._create_background_task(
298
+ entry.central.event_coordinator.data_point_event(
299
+ interface_id=interface_id,
300
+ channel_address=channel_address,
301
+ parameter=parameter,
302
+ value=value,
303
+ ),
304
+ name=f"event-{interface_id}-{channel_address}-{parameter}",
305
+ )
306
+ else:
307
+ _LOGGER.debug(
308
+ "EVENT: No central found for interface_id=%s, channel=%s, param=%s",
309
+ interface_id,
310
+ channel_address,
311
+ parameter,
312
+ )
313
+
314
+ async def listDevices(
315
+ self,
316
+ interface_id: str,
317
+ /,
318
+ ) -> list[dict[str, Any]]:
319
+ """Return existing devices to the backend."""
320
+ # No normalization needed here - data is already normalized in cache
321
+ if entry := self._get_central_entry(interface_id=interface_id):
322
+ return [
323
+ dict(device_description)
324
+ for device_description in entry.central.device_coordinator.list_devices(interface_id=interface_id)
325
+ ]
326
+ return []
327
+
328
+ async def newDevices(
329
+ self,
330
+ interface_id: str,
331
+ device_descriptions: list[dict[str, Any]],
332
+ /,
333
+ ) -> None:
334
+ """Handle new devices from backend (normalized)."""
335
+ if entry := self._get_central_entry(interface_id=interface_id):
336
+ # Normalize at callback entry point
337
+ normalized = tuple(normalize_device_description(device_description=desc) for desc in device_descriptions)
338
+ # Fire-and-forget: schedule task and return immediately
339
+ self._create_background_task(
340
+ entry.central.device_coordinator.add_new_devices(
341
+ interface_id=interface_id,
342
+ device_descriptions=normalized,
343
+ ),
344
+ name=f"newDevices-{interface_id}",
345
+ )
346
+
347
+ async def readdedDevice(
348
+ self,
349
+ interface_id: str,
350
+ addresses: list[str],
351
+ /,
352
+ ) -> None:
353
+ """
354
+ Handle re-added device after re-pairing in learn mode.
355
+
356
+ Gets called when a known device is put into learn-mode while installation
357
+ mode is active. The device parameters may have changed, so we refresh
358
+ the device data.
359
+ """
360
+ _LOGGER.debug(
361
+ "READDEDDEVICES: interface_id = %s, addresses = %s",
362
+ interface_id,
363
+ str(addresses),
364
+ )
365
+
366
+ # Filter to device addresses only (exclude channel addresses)
367
+ if (entry := self._get_central_entry(interface_id=interface_id)) and (
368
+ device_addresses := tuple(addr for addr in addresses if ":" not in addr)
369
+ ):
370
+ self._create_background_task(
371
+ entry.central.device_coordinator.readd_device(
372
+ interface_id=interface_id, device_addresses=device_addresses
373
+ ),
374
+ name=f"readdedDevice-{interface_id}",
375
+ )
376
+
377
+ async def replaceDevice(
378
+ self,
379
+ interface_id: str,
380
+ old_device_address: str,
381
+ new_device_address: str,
382
+ /,
383
+ ) -> None:
384
+ """
385
+ Handle device replacement from CCU.
386
+
387
+ Gets called when a user replaces a broken device with a new one using the
388
+ CCU's "Replace device" function. The old device is removed and the new
389
+ device is created with fresh descriptions.
390
+ """
391
+ _LOGGER.debug(
392
+ "REPLACEDEVICE: interface_id = %s, oldDeviceAddress = %s, newDeviceAddress = %s",
393
+ interface_id,
394
+ old_device_address,
395
+ new_device_address,
396
+ )
397
+
398
+ if entry := self._get_central_entry(interface_id=interface_id):
399
+ self._create_background_task(
400
+ entry.central.device_coordinator.replace_device(
401
+ interface_id=interface_id,
402
+ old_device_address=old_device_address,
403
+ new_device_address=new_device_address,
404
+ ),
405
+ name=f"replaceDevice-{interface_id}-{old_device_address}-{new_device_address}",
406
+ )
407
+
408
+ async def updateDevice(
409
+ self,
410
+ interface_id: str,
411
+ address: str,
412
+ hint: int,
413
+ /,
414
+ ) -> None:
415
+ """
416
+ Handle device update notification after firmware update or link partner change.
417
+
418
+ When hint=0 (firmware update), this method triggers cache invalidation
419
+ and reloading of device/paramset descriptions. When hint=1 (link partner
420
+ change), it refreshes the link peer information for all channels.
421
+ """
422
+ _LOGGER.debug(
423
+ "UPDATEDEVICE: interface_id = %s, address = %s, hint = %s",
424
+ interface_id,
425
+ address,
426
+ str(hint),
427
+ )
428
+
429
+ if entry := self._get_central_entry(interface_id=interface_id):
430
+ device_address = get_device_address(address=address)
431
+ if hint == UpdateDeviceHint.FIRMWARE:
432
+ # Firmware update: invalidate cache and reload device
433
+ self._create_background_task(
434
+ entry.central.device_coordinator.update_device(
435
+ interface_id=interface_id, device_address=device_address
436
+ ),
437
+ name=f"updateDevice-firmware-{interface_id}-{device_address}",
438
+ )
439
+ elif hint == UpdateDeviceHint.LINKS:
440
+ # Link partner change: refresh link peer information
441
+ self._create_background_task(
442
+ entry.central.device_coordinator.refresh_device_link_peers(device_address=device_address),
443
+ name=f"updateDevice-links-{interface_id}-{device_address}",
444
+ )
445
+
446
+ def _create_background_task(self, coro: Any, /, *, name: str) -> None:
447
+ """Create a background task and track it to prevent garbage collection."""
448
+ task: asyncio.Task[None] = asyncio.create_task(coro, name=name)
449
+ self._background_tasks.add(task)
450
+ task.add_done_callback(self._on_background_task_done)
451
+
452
+ def _get_central_entry(self, *, interface_id: str) -> _AsyncCentralEntry | None:
453
+ """Return central entry by interface_id."""
454
+ return self._rpc_server.get_central_entry(interface_id=interface_id)
455
+
456
+ def _on_background_task_done(self, task: asyncio.Task[None]) -> None:
457
+ """Handle background task completion and log any errors."""
458
+ self._background_tasks.discard(task)
459
+ if task.cancelled():
460
+ return
461
+ if exc := task.exception():
462
+ _LOGGER.warning(
463
+ i18n.tr(
464
+ key="log.central.rpc_server.background_task_failed",
465
+ task_name=task.get_name(),
466
+ error=exc,
467
+ )
468
+ )
469
+
470
+ def _publish_system_event(self, *, interface_id: str, system_event: SystemEventType) -> None:
471
+ """Publish a system event to the event coordinator."""
472
+ if client := hmcl.get_client(interface_id=interface_id):
473
+ client.central.event_coordinator.publish_system_event(system_event=system_event)
474
+
475
+
476
+ class _AsyncCentralEntry:
477
+ """Container for central unit registration."""
478
+
479
+ __slots__ = ("central",)
480
+
481
+ def __init__(self, *, central: RpcServerCentralProtocol) -> None:
482
+ """Initialize central entry."""
483
+ self.central: Final = central
484
+
485
+
486
+ class AsyncXmlRpcServer:
487
+ """
488
+ Async XML-RPC server using aiohttp.
489
+
490
+ Singleton per (ip_addr, port) combination.
491
+ """
492
+
493
+ # Disable kw-only linter for aiohttp callback compatibility
494
+ __kwonly_check__ = False
495
+
496
+ _initialized: bool = False
497
+ _instances: Final[dict[tuple[str, int], AsyncXmlRpcServer]] = {}
498
+
499
+ def __init__(
500
+ self,
501
+ *,
502
+ ip_addr: str = IP_ANY_V4,
503
+ port: int = PORT_ANY,
504
+ ) -> None:
505
+ """Initialize the async XML-RPC server."""
506
+ if self._initialized:
507
+ return
508
+
509
+ self._ip_addr: Final = ip_addr
510
+ self._requested_port: Final = port
511
+ self._actual_port: int = port
512
+
513
+ self._centrals: Final[dict[str, _AsyncCentralEntry]] = {}
514
+ self._dispatcher: Final = AsyncXmlRpcDispatcher()
515
+ # Set client_max_size to 10MB to handle large XML-RPC requests
516
+ self._app: Final = web.Application(client_max_size=10 * 1024 * 1024)
517
+ self._runner: web.AppRunner | None = None
518
+ self._site: web.TCPSite | None = None
519
+ self._started: bool = False
520
+
521
+ # Register RPC functions
522
+ self._rpc_functions: Final = AsyncRPCFunctions(rpc_server=self)
523
+ self._dispatcher.register_instance(instance=self._rpc_functions)
524
+ self._dispatcher.register_introspection_functions()
525
+
526
+ # Local counters for health endpoint (work without central)
527
+ self._request_count: int = 0
528
+ self._error_count: int = 0
529
+
530
+ # Configure routes
531
+ self._app.router.add_post("/", self._handle_request)
532
+ self._app.router.add_post("/RPC2", self._handle_request)
533
+ self._app.router.add_get("/health", self._handle_health_check)
534
+
535
+ self._initialized = True
536
+
537
+ def __new__( # noqa: PYI034
538
+ cls,
539
+ *,
540
+ ip_addr: str = IP_ANY_V4,
541
+ port: int = PORT_ANY,
542
+ ) -> AsyncXmlRpcServer:
543
+ """Return existing instance or create new one."""
544
+ if (key := (ip_addr, port)) not in cls._instances:
545
+ _LOGGER.debug("Creating AsyncXmlRpcServer")
546
+ instance = super().__new__(cls)
547
+ cls._instances[key] = instance
548
+ return cls._instances[key]
549
+
550
+ @property
551
+ def _event_bus(self) -> EventBus | None:
552
+ """Return event bus from first registered central (for metrics)."""
553
+ for entry in self._centrals.values():
554
+ return entry.central.event_coordinator.event_bus
555
+ return None
556
+
557
+ @property
558
+ def listen_ip_addr(self) -> str:
559
+ """Return the listening IP address."""
560
+ return self._ip_addr
561
+
562
+ @property
563
+ def listen_port(self) -> int:
564
+ """Return the actual listening port."""
565
+ return self._actual_port
566
+
567
+ @property
568
+ def no_central_assigned(self) -> bool:
569
+ """Return True if no central is registered."""
570
+ return len(self._centrals) == 0
571
+
572
+ @property
573
+ def started(self) -> bool:
574
+ """Return True if server is running."""
575
+ return self._started
576
+
577
+ def add_central(
578
+ self,
579
+ *,
580
+ central: RpcServerCentralProtocol,
581
+ ) -> None:
582
+ """Register a central unit."""
583
+ if central.name not in self._centrals:
584
+ self._centrals[central.name] = _AsyncCentralEntry(central=central)
585
+
586
+ def get_central_entry(
587
+ self,
588
+ *,
589
+ interface_id: str,
590
+ ) -> _AsyncCentralEntry | None:
591
+ """Return central entry by interface_id."""
592
+ for entry in self._centrals.values():
593
+ if entry.central.client_coordinator.has_client(interface_id=interface_id):
594
+ return entry
595
+ return None
596
+
597
+ def remove_central(
598
+ self,
599
+ *,
600
+ central: RpcServerCentralProtocol,
601
+ ) -> None:
602
+ """Unregister a central unit."""
603
+ if central.name in self._centrals:
604
+ del self._centrals[central.name]
605
+
606
+ async def start(self) -> None:
607
+ """Start the HTTP server."""
608
+ if self._started:
609
+ return
610
+
611
+ self._runner = web.AppRunner(
612
+ self._app,
613
+ access_log=None, # Disable access logging
614
+ )
615
+ await self._runner.setup()
616
+
617
+ self._site = web.TCPSite(
618
+ self._runner,
619
+ self._ip_addr,
620
+ self._requested_port,
621
+ reuse_address=True,
622
+ )
623
+ await self._site.start()
624
+
625
+ # Get actual port (important when PORT_ANY is used)
626
+ # pylint: disable=protected-access
627
+ if (
628
+ self._site._server # noqa: SLF001
629
+ and hasattr(self._site._server, "sockets") # noqa: SLF001
630
+ and (sockets := self._site._server.sockets) # noqa: SLF001
631
+ ):
632
+ self._actual_port = sockets[0].getsockname()[1]
633
+
634
+ self._started = True
635
+ _LOGGER.debug(
636
+ "AsyncXmlRpcServer started on %s:%d",
637
+ self._ip_addr,
638
+ self._actual_port,
639
+ )
640
+
641
+ async def stop(self) -> None:
642
+ """Stop the HTTP server."""
643
+ if not self._started:
644
+ return
645
+
646
+ _LOGGER.debug("Stopping AsyncXmlRpcServer")
647
+
648
+ if self._site:
649
+ await self._site.stop()
650
+ self._site = None
651
+
652
+ if self._runner:
653
+ await self._runner.cleanup()
654
+ self._runner = None
655
+
656
+ # Cancel and wait for background tasks
657
+ await self._cancel_background_tasks()
658
+
659
+ self._started = False
660
+
661
+ # Remove from instances
662
+ if (key := (self._ip_addr, self._requested_port)) in self._instances:
663
+ del self._instances[key]
664
+
665
+ _LOGGER.debug("AsyncXmlRpcServer stopped")
666
+
667
+ async def _cancel_background_tasks(self) -> None:
668
+ """Cancel all background tasks and wait for them to complete."""
669
+ await self._rpc_functions.cancel_background_tasks()
670
+
671
+ async def _handle_health_check(
672
+ self,
673
+ request: web.Request, # noqa: ARG002
674
+ ) -> web.Response:
675
+ """Handle health check request."""
676
+ health_data = {
677
+ "status": "healthy" if self._started else "stopped",
678
+ "started": self._started,
679
+ "centrals_count": len(self._centrals),
680
+ "centrals": list(self._centrals.keys()),
681
+ "active_background_tasks": self._rpc_functions.active_tasks_count,
682
+ "request_count": self._request_count,
683
+ "error_count": self._error_count,
684
+ "listen_address": f"{self._ip_addr}:{self._actual_port}",
685
+ }
686
+ return web.Response(
687
+ body=orjson.dumps(health_data),
688
+ content_type="application/json",
689
+ charset="utf-8",
690
+ )
691
+
692
+ async def _handle_request(
693
+ self,
694
+ request: web.Request,
695
+ ) -> web.Response:
696
+ """Handle incoming XML-RPC request."""
697
+ start_time = time.perf_counter()
698
+ self._request_count += 1
699
+
700
+ # Emit request counter metric (if central registered)
701
+ if event_bus := self._event_bus:
702
+ emit_counter(event_bus=event_bus, key=MetricKeys.rpc_server_request())
703
+ emit_gauge(
704
+ event_bus=event_bus,
705
+ key=MetricKeys.rpc_server_active_tasks(),
706
+ value=self._rpc_functions.active_tasks_count,
707
+ )
708
+
709
+ try:
710
+ body = await request.read()
711
+ response_xml = await self._dispatcher.dispatch(xml_data=body)
712
+ return web.Response(
713
+ body=response_xml,
714
+ content_type="text/xml",
715
+ charset="utf-8",
716
+ )
717
+ except XmlRpcProtocolError as err:
718
+ self._error_count += 1
719
+ if event_bus := self._event_bus:
720
+ emit_counter(event_bus=event_bus, key=MetricKeys.rpc_server_error())
721
+ _LOGGER.warning(i18n.tr(key="log.central.rpc_server.protocol_error", error=err))
722
+ return web.Response(
723
+ status=400,
724
+ text="XML-RPC protocol error",
725
+ )
726
+ except Exception:
727
+ self._error_count += 1
728
+ if event_bus := self._event_bus:
729
+ emit_counter(event_bus=event_bus, key=MetricKeys.rpc_server_error())
730
+ _LOGGER.exception(i18n.tr(key="log.central.rpc_server.unexpected_error"))
731
+ return web.Response(
732
+ status=500,
733
+ text="Internal Server Error",
734
+ )
735
+ finally:
736
+ # Emit latency metric
737
+ if event_bus := self._event_bus:
738
+ duration_ms = (time.perf_counter() - start_time) * 1000
739
+ emit_latency(
740
+ event_bus=event_bus,
741
+ key=MetricKeys.rpc_server_request_latency(),
742
+ duration_ms=duration_ms,
743
+ )
744
+
745
+
746
+ async def create_async_xml_rpc_server(
747
+ *,
748
+ ip_addr: str = IP_ANY_V4,
749
+ port: int = PORT_ANY,
750
+ ) -> AsyncXmlRpcServer:
751
+ """Create and start an async XML-RPC server."""
752
+ server = AsyncXmlRpcServer(ip_addr=ip_addr, port=port)
753
+ if not server.started:
754
+ await server.start()
755
+ _LOGGER.debug(
756
+ "CREATE_ASYNC_XML_RPC_SERVER: Starting AsyncXmlRpcServer listening on %s:%i",
757
+ server.listen_ip_addr,
758
+ server.listen_port,
759
+ )
760
+ return server