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,353 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ XML-RPC server module.
5
+
6
+ Provides the XML-RPC server which handles communication
7
+ with the backend.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import logging
14
+ import threading
15
+ from typing import Any, Final, cast
16
+ from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
17
+
18
+ from aiohomematic import i18n
19
+ from aiohomematic.central.decorators import callback_backend_system
20
+ from aiohomematic.const import IP_ANY_V4, PORT_ANY, SystemEventType, UpdateDeviceHint
21
+ from aiohomematic.interfaces.central import RpcServerCentralProtocol, RpcServerTaskSchedulerProtocol
22
+ from aiohomematic.property_decorators import DelegatedProperty
23
+ from aiohomematic.schemas import normalize_device_description
24
+ from aiohomematic.support import get_device_address, log_boundary_error
25
+
26
+ _LOGGER: Final = logging.getLogger(__name__)
27
+
28
+
29
+ # pylint: disable=invalid-name
30
+ class RPCFunctions:
31
+ """The RPC functions the backend will expect."""
32
+
33
+ # Disable kw-only linter
34
+ __kwonly_check__ = False
35
+
36
+ def __init__(self, *, rpc_server: RpcServer) -> None:
37
+ """Initialize RPCFunctions."""
38
+ self._rpc_server: Final = rpc_server
39
+
40
+ def deleteDevices(self, interface_id: str, addresses: list[str], /) -> None:
41
+ """Delete devices send from the backend."""
42
+ if entry := self.get_central_entry(interface_id=interface_id):
43
+ entry.looper.create_task(
44
+ target=lambda: entry.central.device_coordinator.delete_devices(
45
+ interface_id=interface_id, addresses=tuple(addresses)
46
+ ),
47
+ name=f"deleteDevices-{interface_id}",
48
+ )
49
+
50
+ @callback_backend_system(system_event=SystemEventType.ERROR)
51
+ def error(self, interface_id: str, error_code: str, msg: str, /) -> None:
52
+ """When some error occurs the backend will send its error message here."""
53
+ # Structured boundary log (warning level). RPC server received error notification.
54
+ try:
55
+ raise RuntimeError(str(msg))
56
+ except RuntimeError as err:
57
+ log_boundary_error(
58
+ logger=_LOGGER,
59
+ boundary="rpc-server",
60
+ action="error",
61
+ err=err,
62
+ level=logging.WARNING,
63
+ log_context={"interface_id": interface_id, "error_code": int(error_code)},
64
+ )
65
+ _LOGGER.error(
66
+ i18n.tr(
67
+ key="log.central.rpc_server.error",
68
+ interface_id=interface_id,
69
+ error_code=int(error_code),
70
+ msg=str(msg),
71
+ )
72
+ )
73
+
74
+ def event(self, interface_id: str, channel_address: str, parameter: str, value: Any, /) -> None:
75
+ """If a device publishes some sort event, we will handle it here."""
76
+ if entry := self.get_central_entry(interface_id=interface_id):
77
+ entry.looper.create_task(
78
+ target=lambda: entry.central.event_coordinator.data_point_event(
79
+ interface_id=interface_id,
80
+ channel_address=channel_address,
81
+ parameter=parameter,
82
+ value=value,
83
+ ),
84
+ name=f"event-{interface_id}-{channel_address}-{parameter}",
85
+ )
86
+
87
+ def get_central_entry(self, *, interface_id: str) -> _CentralEntry | None:
88
+ """Return the central entry by interface_id."""
89
+ return self._rpc_server.get_central_entry(interface_id=interface_id)
90
+
91
+ def listDevices(self, interface_id: str, /) -> list[dict[str, Any]]:
92
+ """Return already existing devices to the backend."""
93
+ # No normalization needed here - data is already normalized in cache
94
+ if entry := self.get_central_entry(interface_id=interface_id):
95
+ return [
96
+ dict(device_description)
97
+ for device_description in entry.central.device_coordinator.list_devices(interface_id=interface_id)
98
+ ]
99
+ return []
100
+
101
+ def newDevices(self, interface_id: str, device_descriptions: list[dict[str, Any]], /) -> None:
102
+ """Add new devices send from the backend (normalized)."""
103
+ if entry := self.get_central_entry(interface_id=interface_id):
104
+ # Normalize at callback entry point
105
+ normalized = tuple(normalize_device_description(device_description=desc) for desc in device_descriptions)
106
+ entry.looper.create_task(
107
+ target=entry.central.device_coordinator.add_new_devices(
108
+ interface_id=interface_id, device_descriptions=normalized
109
+ ),
110
+ name=f"newDevices-{interface_id}",
111
+ )
112
+
113
+ def readdedDevice(self, interface_id: str, addresses: list[str], /) -> None:
114
+ """
115
+ Handle re-added device after re-pairing in learn mode.
116
+
117
+ Gets called when a known device is put into learn-mode while installation
118
+ mode is active. The device parameters may have changed, so we refresh
119
+ the device data.
120
+ """
121
+ _LOGGER.debug(
122
+ "READDEDDEVICES: interface_id = %s, addresses = %s",
123
+ interface_id,
124
+ str(addresses),
125
+ )
126
+
127
+ # Filter to device addresses only (exclude channel addresses)
128
+ if (entry := self.get_central_entry(interface_id=interface_id)) and (
129
+ device_addresses := tuple(addr for addr in addresses if ":" not in addr)
130
+ ):
131
+ entry.looper.create_task(
132
+ target=lambda: entry.central.device_coordinator.readd_device(
133
+ interface_id=interface_id, device_addresses=device_addresses
134
+ ),
135
+ name=f"readdedDevice-{interface_id}",
136
+ )
137
+
138
+ def replaceDevice(self, interface_id: str, old_device_address: str, new_device_address: str, /) -> None:
139
+ """
140
+ Handle device replacement from CCU.
141
+
142
+ Gets called when a user replaces a broken device with a new one using the
143
+ CCU's "Replace device" function. The old device is removed and the new
144
+ device is created with fresh descriptions.
145
+ """
146
+ _LOGGER.debug(
147
+ "REPLACEDEVICE: interface_id = %s, oldDeviceAddress = %s, newDeviceAddress = %s",
148
+ interface_id,
149
+ old_device_address,
150
+ new_device_address,
151
+ )
152
+
153
+ if entry := self.get_central_entry(interface_id=interface_id):
154
+ entry.looper.create_task(
155
+ target=lambda: entry.central.device_coordinator.replace_device(
156
+ interface_id=interface_id,
157
+ old_device_address=old_device_address,
158
+ new_device_address=new_device_address,
159
+ ),
160
+ name=f"replaceDevice-{interface_id}-{old_device_address}-{new_device_address}",
161
+ )
162
+
163
+ def updateDevice(self, interface_id: str, address: str, hint: int, /) -> None:
164
+ """
165
+ Update a device after firmware update or link partner change.
166
+
167
+ When hint=0 (firmware update), this method triggers cache invalidation
168
+ and reloading of device/paramset descriptions. When hint=1 (link partner
169
+ change), it refreshes the link peer information for all channels.
170
+ """
171
+ _LOGGER.debug(
172
+ "UPDATEDEVICE: interface_id = %s, address = %s, hint = %s",
173
+ interface_id,
174
+ address,
175
+ str(hint),
176
+ )
177
+
178
+ if entry := self.get_central_entry(interface_id=interface_id):
179
+ device_address = get_device_address(address=address)
180
+ if hint == UpdateDeviceHint.FIRMWARE:
181
+ # Firmware update: invalidate cache and reload device
182
+ entry.looper.create_task(
183
+ target=lambda: entry.central.device_coordinator.update_device(
184
+ interface_id=interface_id, device_address=device_address
185
+ ),
186
+ name=f"updateDevice-firmware-{interface_id}-{device_address}",
187
+ )
188
+ elif hint == UpdateDeviceHint.LINKS:
189
+ # Link partner change: refresh link peer information
190
+ entry.looper.create_task(
191
+ target=lambda: entry.central.device_coordinator.refresh_device_link_peers(
192
+ device_address=device_address
193
+ ),
194
+ name=f"updateDevice-links-{interface_id}-{device_address}",
195
+ )
196
+
197
+
198
+ # Restrict to specific paths.
199
+ class RequestHandler(SimpleXMLRPCRequestHandler):
200
+ """We handle requests to / and /RPC2."""
201
+
202
+ rpc_paths = (
203
+ "/",
204
+ "/RPC2",
205
+ )
206
+
207
+
208
+ class HomematicXMLRPCServer(SimpleXMLRPCServer):
209
+ """
210
+ Simple XML-RPC server.
211
+
212
+ Simple XML-RPC server that allows functions and a single instance
213
+ to be installed to handle requests. The default implementation
214
+ attempts to dispatch XML-RPC calls to the functions or instance
215
+ installed in the server. Override the _dispatch method inherited
216
+ from SimpleXMLRPCDispatcher to change this behavior.
217
+
218
+ This implementation adds an additional method:
219
+ system_listMethods(self, interface_id: str.
220
+ """
221
+
222
+ __kwonly_check__ = False
223
+
224
+ def system_listMethods(self, interface_id: str | None = None, /) -> list[str]:
225
+ """Return a list of the methods supported by the server."""
226
+ return SimpleXMLRPCServer.system_listMethods(self)
227
+
228
+
229
+ class _CentralEntry:
230
+ """Container for central unit with its task scheduler."""
231
+
232
+ __slots__ = ("central", "looper")
233
+
234
+ def __init__(self, *, central: RpcServerCentralProtocol, looper: RpcServerTaskSchedulerProtocol) -> None:
235
+ """Initialize central entry."""
236
+ self.central: Final = central
237
+ self.looper: Final = looper
238
+
239
+
240
+ class RpcServer(threading.Thread):
241
+ """RPC server thread to handle messages from the backend."""
242
+
243
+ _initialized: bool = False
244
+ _instances: Final[dict[tuple[str, int], RpcServer]] = {}
245
+
246
+ def __init__(self, *, server: SimpleXMLRPCServer) -> None:
247
+ """Initialize XmlRPC server."""
248
+ self._server = server
249
+ self._server.register_introspection_functions()
250
+ self._server.register_multicall_functions()
251
+ self._server.register_instance(RPCFunctions(rpc_server=self), allow_dotted_names=True)
252
+ self._initialized = True
253
+ self._address: Final[tuple[str, int]] = cast(tuple[str, int], server.server_address)
254
+ self._listen_ip_addr: Final = self._address[0]
255
+ self._listen_port: Final = self._address[1]
256
+ self._centrals: Final[dict[str, _CentralEntry]] = {}
257
+ self._instances[self._address] = self
258
+ threading.Thread.__init__(self, name=f"RpcServer {self._listen_ip_addr}:{self._listen_port}")
259
+
260
+ listen_ip_addr: Final = DelegatedProperty[str](path="_listen_ip_addr")
261
+ listen_port: Final = DelegatedProperty[int](path="_listen_port")
262
+
263
+ @property
264
+ def no_central_assigned(self) -> bool:
265
+ """Return if no central is assigned."""
266
+ return len(self._centrals) == 0
267
+
268
+ @property
269
+ def started(self) -> bool:
270
+ """Return if thread is active."""
271
+ return self._started.is_set() is True # type: ignore[attr-defined]
272
+
273
+ def add_central(self, *, central: RpcServerCentralProtocol, looper: RpcServerTaskSchedulerProtocol) -> None:
274
+ """Register a central in the RPC-Server."""
275
+ if not self._centrals.get(central.name):
276
+ self._centrals[central.name] = _CentralEntry(central=central, looper=looper)
277
+
278
+ def get_central_entry(self, *, interface_id: str) -> _CentralEntry | None:
279
+ """Return a central entry by interface_id."""
280
+ for entry in self._centrals.values():
281
+ if entry.central.client_coordinator.has_client(interface_id=interface_id):
282
+ return entry
283
+ return None
284
+
285
+ def remove_central(self, *, central: RpcServerCentralProtocol) -> None:
286
+ """Unregister a central from RPC-Server."""
287
+ if self._centrals.get(central.name):
288
+ del self._centrals[central.name]
289
+
290
+ def run(self) -> None:
291
+ """Run the RPC-Server thread."""
292
+ _LOGGER.debug(
293
+ "RUN: Starting RPC-Server listening on %s:%i",
294
+ self._listen_ip_addr,
295
+ self._listen_port,
296
+ )
297
+ if self._server:
298
+ self._server.serve_forever()
299
+
300
+ def stop(self) -> None:
301
+ """Stop the RPC-Server."""
302
+ _LOGGER.debug("STOP: Shutting down RPC-Server")
303
+ self._server.shutdown()
304
+ _LOGGER.debug("STOP: Stopping RPC-Server")
305
+ self._server.server_close()
306
+ # Ensure the server thread has actually terminated to avoid slow teardown
307
+ with contextlib.suppress(RuntimeError):
308
+ self.join(timeout=1.0)
309
+ _LOGGER.debug("STOP: RPC-Server stopped")
310
+ if self._address in self._instances:
311
+ del self._instances[self._address]
312
+
313
+
314
+ class XmlRpcServer(RpcServer):
315
+ """XML-RPC server thread to handle messages from the backend."""
316
+
317
+ def __init__(
318
+ self,
319
+ *,
320
+ ip_addr: str,
321
+ port: int,
322
+ ) -> None:
323
+ """Initialize XmlRPC server."""
324
+ if self._initialized:
325
+ return
326
+ super().__init__(
327
+ server=HomematicXMLRPCServer(
328
+ addr=(ip_addr, port),
329
+ requestHandler=RequestHandler,
330
+ logRequests=False,
331
+ allow_none=True,
332
+ )
333
+ )
334
+
335
+ def __new__(cls, ip_addr: str, port: int) -> XmlRpcServer: # noqa: PYI034 # kwonly: disable
336
+ """Create new RPC server."""
337
+ if (rpc := cls._instances.get((ip_addr, port))) is None:
338
+ _LOGGER.debug("Creating XmlRpc server")
339
+ return super().__new__(cls)
340
+ return cast(XmlRpcServer, rpc)
341
+
342
+
343
+ def create_xml_rpc_server(*, ip_addr: str = IP_ANY_V4, port: int = PORT_ANY) -> XmlRpcServer:
344
+ """Register the rpc server."""
345
+ rpc = XmlRpcServer(ip_addr=ip_addr, port=port)
346
+ if not rpc.started:
347
+ rpc.start()
348
+ _LOGGER.debug(
349
+ "CREATE_XML_RPC_SERVER: Starting XmlRPC-Server listening on %s:%i",
350
+ rpc.listen_ip_addr,
351
+ rpc.listen_port,
352
+ )
353
+ return rpc