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,250 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Async event loop utilities and task management.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Callable, Collection
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from concurrent.futures._base import CancelledError
15
+ import contextlib
16
+ from functools import wraps
17
+ import logging
18
+ from time import monotonic
19
+ from typing import Any, Final, cast
20
+
21
+ from aiohomematic.const import BLOCK_LOG_TIMEOUT
22
+ from aiohomematic.exceptions import AioHomematicException
23
+ from aiohomematic.interfaces import TaskSchedulerProtocol
24
+ import aiohomematic.support as hms
25
+ from aiohomematic.support import extract_exc_args
26
+ from aiohomematic.type_aliases import AsyncTaskFactoryAny, CallableAny, CoroutineAny
27
+
28
+ _LOGGER: Final = logging.getLogger(__name__)
29
+
30
+
31
+ class Looper(TaskSchedulerProtocol):
32
+ """Helper class for event loop support."""
33
+
34
+ __slots__ = ("_loop_store", "_tasks")
35
+
36
+ def __init__(self) -> None:
37
+ """Initialize the loop helper."""
38
+ self._tasks: Final[set[asyncio.Future[Any]]] = set()
39
+ self._loop_store: asyncio.AbstractEventLoop | None = None
40
+
41
+ @property
42
+ def _loop(self) -> asyncio.AbstractEventLoop:
43
+ """
44
+ Get the event loop, lazily acquiring it on first use.
45
+
46
+ Uses get_running_loop() when called from async context (preferred),
47
+ falls back to get_event_loop() for cross-thread scheduling scenarios.
48
+ """
49
+ if self._loop_store is None:
50
+ try:
51
+ self._loop_store = asyncio.get_running_loop()
52
+ except RuntimeError:
53
+ # Called from non-async context (e.g., during startup or from another thread)
54
+ # This path is used by call_soon_threadsafe for cross-thread task scheduling
55
+ self._loop_store = asyncio.get_event_loop()
56
+ return self._loop_store
57
+
58
+ def async_add_executor_job[T](
59
+ self,
60
+ target: Callable[..., T],
61
+ *args: Any,
62
+ name: str,
63
+ executor: ThreadPoolExecutor | None = None,
64
+ ) -> asyncio.Future[T]:
65
+ """Add an executor job from within the event_loop."""
66
+ try:
67
+ task = self._loop.run_in_executor(executor, target, *args)
68
+ self._tasks.add(task)
69
+ task.add_done_callback(self._tasks.remove)
70
+ except (TimeoutError, CancelledError) as err: # pragma: no cover
71
+ message = f"async_add_executor_job: task cancelled for {name} [{extract_exc_args(exc=err)}]"
72
+ _LOGGER.debug(message)
73
+ raise AioHomematicException(message) from err
74
+ return task
75
+
76
+ async def block_till_done(self, *, wait_time: float | None = None) -> None:
77
+ """
78
+ Block until all pending work is done.
79
+
80
+ If wait_time is set, stop waiting after the given number of seconds and log remaining tasks.
81
+ """
82
+ # To flush out any call_soon_threadsafe
83
+ await asyncio.sleep(0)
84
+ start_time: float | None = None
85
+ deadline: float | None = (monotonic() + wait_time) if wait_time is not None else None
86
+ current_task = asyncio.current_task()
87
+ while tasks := [task for task in self._tasks if task is not current_task and not cancelling(task=task)]:
88
+ # If we have a deadline and have exceeded it, log remaining tasks and break
89
+ if deadline is not None and monotonic() >= deadline:
90
+ for task in tasks:
91
+ _LOGGER.warning( # i18n-log: ignore
92
+ "Shutdown timeout reached; task still pending: %s", task
93
+ )
94
+ break
95
+
96
+ pending_after_wait = await self._await_and_log_pending(pending=tasks, deadline=deadline)
97
+
98
+ # If deadline has been reached and tasks are still pending, log and break
99
+ if deadline is not None and monotonic() >= deadline and pending_after_wait:
100
+ for task in pending_after_wait:
101
+ _LOGGER.warning( # i18n-log: ignore
102
+ "Shutdown timeout reached; task still pending: %s", task
103
+ )
104
+ break
105
+
106
+ if start_time is None:
107
+ # Avoid calling monotonic() until we know
108
+ # we may need to start logging blocked tasks.
109
+ start_time = 0
110
+ elif start_time == 0:
111
+ # If we have waited twice then we set the start
112
+ # time
113
+ start_time = monotonic()
114
+ elif monotonic() - start_time > BLOCK_LOG_TIMEOUT:
115
+ # We have waited at least three loops and new tasks
116
+ # continue to block. At this point we start
117
+ # logging all waiting tasks.
118
+ for task in tasks:
119
+ _LOGGER.debug("Waiting for task: %s", task)
120
+
121
+ def cancel_tasks(self) -> None:
122
+ """Cancel running tasks."""
123
+ for task in self._tasks.copy():
124
+ if not task.cancelled():
125
+ task.cancel()
126
+
127
+ def create_task(self, *, target: CoroutineAny | AsyncTaskFactoryAny, name: str) -> None:
128
+ """
129
+ Schedule a coroutine to run in the loop.
130
+
131
+ Accepts either an already-created coroutine object or a zero-argument
132
+ callable that returns a coroutine. The callable form defers coroutine
133
+ creation until inside the event loop, which avoids "was never awaited"
134
+ warnings if callers only inspect the parameters (e.g. in tests).
135
+ """
136
+ try:
137
+ self._loop.call_soon_threadsafe(self._async_create_task, target, name)
138
+ except CancelledError:
139
+ # Scheduling failed; if a coroutine object was provided, close it to
140
+ # avoid 'was never awaited' warnings.
141
+ if asyncio.iscoroutine(target):
142
+ with contextlib.suppress(Exception):
143
+ getattr(target, "close", lambda: None)()
144
+ _LOGGER.debug("create_task: task cancelled for %s", name)
145
+ return
146
+
147
+ def run_coroutine(self, *, coro: CoroutineAny, name: str) -> Any:
148
+ """Call coroutine from sync."""
149
+ try:
150
+ return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
151
+ except CancelledError: # pragma: no cover
152
+ _LOGGER.debug(
153
+ "run_coroutine: coroutine interrupted for %s",
154
+ name,
155
+ )
156
+ return None
157
+
158
+ def _async_create_task( # kwonly: disable
159
+ self, target: CoroutineAny | AsyncTaskFactoryAny, name: str
160
+ ) -> asyncio.Task[Any]:
161
+ """Create a task from within the event loop. Must be run in the event loop."""
162
+ # If target is a callable, call it here to create the coroutine inside the loop
163
+ coro: CoroutineAny = target if asyncio.iscoroutine(target) else target()
164
+ task = self._loop.create_task(coro, name=name)
165
+ self._tasks.add(task)
166
+ task.add_done_callback(self._tasks.remove)
167
+ task.add_done_callback(_log_task_exception)
168
+ return task
169
+
170
+ async def _await_and_log_pending(
171
+ self, *, pending: Collection[asyncio.Future[Any]], deadline: float | None
172
+ ) -> set[asyncio.Future[Any]]:
173
+ """
174
+ Await and log tasks that take a long time, respecting an optional deadline.
175
+
176
+ Returns the set of pending tasks if the deadline has been reached (or zero timeout),
177
+ allowing the caller to decide about timeout logging. Returns an empty set if no tasks are pending.
178
+ """
179
+ wait_time = 0.0
180
+ pending_set: set[asyncio.Future[Any]] = set(pending)
181
+ while pending_set:
182
+ if deadline is None:
183
+ timeout = BLOCK_LOG_TIMEOUT
184
+ else:
185
+ remaining = int(max(0.0, deadline - monotonic()))
186
+ if (timeout := min(BLOCK_LOG_TIMEOUT, remaining)) == 0.0:
187
+ # Deadline reached; return current pending to caller for warning log
188
+ return pending_set
189
+ done, still_pending = await asyncio.wait(pending_set, timeout=timeout)
190
+ if not (pending_set := set(still_pending)):
191
+ return set()
192
+ wait_time += timeout
193
+ for task in pending_set:
194
+ _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task)
195
+ # If the deadline was reached during the wait, let caller handle warning
196
+ if deadline is not None and monotonic() >= deadline:
197
+ return pending_set
198
+ return set()
199
+
200
+
201
+ def _log_task_exception(task: asyncio.Task[Any]) -> None: # kwonly: disable
202
+ """Log unhandled exceptions in background tasks."""
203
+ if task.cancelled():
204
+ return
205
+ if exc := task.exception():
206
+ _LOGGER.exception( # i18n-log: ignore
207
+ "TASK_EXCEPTION: Unhandled exception in task '%s'",
208
+ task.get_name(),
209
+ exc_info=exc,
210
+ )
211
+
212
+
213
+ def cancelling(*, task: asyncio.Future[Any]) -> bool:
214
+ """Return True if task is cancelling."""
215
+ return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_())
216
+
217
+
218
+ def loop_check[**P, R](func: Callable[P, R]) -> Callable[P, R]: # kwonly: disable
219
+ """
220
+ Annotation to mark method that must be run within the event loop.
221
+
222
+ Always wraps the function, but only performs loop checks when debug is enabled.
223
+ This allows tests to monkeypatch aiohomematic.support.debug_enabled at runtime.
224
+ """
225
+ _with_loop: set[CallableAny] = set()
226
+
227
+ @wraps(func)
228
+ def wrapper_loop_check(*args: P.args, **kwargs: P.kwargs) -> R:
229
+ """Wrap loop check."""
230
+ return_value = func(*args, **kwargs)
231
+
232
+ # Only perform the (potentially expensive) loop check when debug is enabled.
233
+ if hms.debug_enabled():
234
+ try:
235
+ asyncio.get_running_loop()
236
+ loop_running = True
237
+ except Exception:
238
+ loop_running = False
239
+
240
+ if not loop_running and func not in _with_loop:
241
+ _with_loop.add(func)
242
+ _LOGGER.error( # i18n-log: ignore
243
+ "Method %s must run in the event_loop. No loop detected.",
244
+ func.__name__,
245
+ )
246
+
247
+ return return_value
248
+
249
+ setattr(func, "_loop_check", True)
250
+ return cast(Callable[P, R], wrapper_loop_check)
@@ -0,0 +1,462 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Backend detection module for aiohomematic.
5
+
6
+ Detect Homematic backend type (CCU or Homegear/PyDevCCU) and discover
7
+ available interfaces without requiring a fully initialized environment.
8
+
9
+ Public API of this module is defined by __all__.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from dataclasses import dataclass
16
+ import logging
17
+ from typing import Final
18
+
19
+ from aiohttp import ClientSession
20
+
21
+ from aiohomematic import i18n
22
+ from aiohomematic.central import CentralConnectionState
23
+ from aiohomematic.client import AioJsonRpcAioHttpClient
24
+ from aiohomematic.client.rpc_proxy import AioXmlRpcProxy
25
+ from aiohomematic.const import (
26
+ DETECTION_PORT_BIDCOS_RF,
27
+ DETECTION_PORT_BIDCOS_WIRED,
28
+ DETECTION_PORT_HMIP_RF,
29
+ DETECTION_PORT_JSON_RPC,
30
+ Backend,
31
+ Interface,
32
+ )
33
+ from aiohomematic.exceptions import AuthFailure, BaseHomematicException, NoConnectionException
34
+ from aiohomematic.support import build_xml_rpc_headers, build_xml_rpc_uri, validate_host
35
+
36
+ __all__ = [
37
+ "BackendDetectionResult",
38
+ "DetectionConfig",
39
+ "detect_backend",
40
+ ]
41
+
42
+ _LOGGER: Final = logging.getLogger(__name__)
43
+
44
+ # Detection timeout per request (shorter than normal operation)
45
+ _DETECTION_TIMEOUT: Final = 5.0
46
+
47
+ # Total detection timeout (max time for entire detection process)
48
+ _DETECTION_TOTAL_TIMEOUT: Final = 15.0
49
+
50
+ # XML-RPC method names
51
+ _XML_METHOD_GET_VERSION: Final = "getVersion"
52
+
53
+
54
+ @dataclass(frozen=True, kw_only=True, slots=True)
55
+ class DetectionConfig:
56
+ """Configuration for backend detection."""
57
+
58
+ host: str
59
+ username: str = ""
60
+ password: str = ""
61
+ request_timeout: float = _DETECTION_TIMEOUT
62
+ total_timeout: float = _DETECTION_TOTAL_TIMEOUT
63
+ verify_tls: bool = False
64
+
65
+
66
+ @dataclass(frozen=True, kw_only=True, slots=True)
67
+ class BackendDetectionResult:
68
+ """Result of backend detection."""
69
+
70
+ backend: Backend
71
+ available_interfaces: tuple[Interface, ...]
72
+ detected_port: int
73
+ tls: bool
74
+ host: str
75
+ version: str | None = None
76
+ auth_enabled: bool | None = None
77
+ https_redirect_enabled: bool | None = None
78
+
79
+
80
+ async def detect_backend(
81
+ *,
82
+ config: DetectionConfig,
83
+ client_session: ClientSession | None = None,
84
+ ) -> BackendDetectionResult | None:
85
+ """
86
+ Detect backend type and available interfaces.
87
+
88
+ Probe XML-RPC ports to find a working connection, determine if the backend
89
+ is CCU or Homegear/PyDevCCU, and query available interfaces.
90
+
91
+ Args:
92
+ config: Detection configuration with host and credentials.
93
+ client_session: Optional aiohttp ClientSession for JSON-RPC requests.
94
+
95
+ Returns:
96
+ BackendDetectionResult if a backend was found, None otherwise.
97
+
98
+ Raises:
99
+ ValidationException: If host format is invalid.
100
+ AuthFailure: If authentication fails with the provided credentials.
101
+
102
+ """
103
+ # Validate input
104
+ validate_host(host=config.host)
105
+
106
+ _LOGGER.info(
107
+ i18n.tr(
108
+ key="log.backend_detection.detect_backend.starting",
109
+ host=config.host,
110
+ total_timeout=config.total_timeout,
111
+ )
112
+ )
113
+
114
+ try:
115
+ async with asyncio.timeout(config.total_timeout):
116
+ return await _do_detect_backend(config=config, client_session=client_session)
117
+ except TimeoutError:
118
+ _LOGGER.warning(
119
+ i18n.tr(
120
+ key="log.backend_detection.detect_backend.total_timeout",
121
+ host=config.host,
122
+ total_timeout=config.total_timeout,
123
+ )
124
+ )
125
+ return None
126
+
127
+
128
+ async def _do_detect_backend(
129
+ *,
130
+ config: DetectionConfig,
131
+ client_session: ClientSession | None = None,
132
+ ) -> BackendDetectionResult | None:
133
+ """Perform the actual backend detection logic."""
134
+ # Define ports to probe: (Interface, port, tls)
135
+ ports_to_probe: list[tuple[Interface, int, bool]] = [
136
+ # Try non-TLS ports first
137
+ (Interface.HMIP_RF, DETECTION_PORT_HMIP_RF[0], False),
138
+ (Interface.BIDCOS_RF, DETECTION_PORT_BIDCOS_RF[0], False),
139
+ (Interface.BIDCOS_WIRED, DETECTION_PORT_BIDCOS_WIRED[0], False),
140
+ # Then TLS ports
141
+ (Interface.HMIP_RF, DETECTION_PORT_HMIP_RF[1], True),
142
+ (Interface.BIDCOS_RF, DETECTION_PORT_BIDCOS_RF[1], True),
143
+ (Interface.BIDCOS_WIRED, DETECTION_PORT_BIDCOS_WIRED[1], True),
144
+ ]
145
+
146
+ for interface, port, tls in ports_to_probe:
147
+ _LOGGER.info(
148
+ i18n.tr(
149
+ key="log.backend_detection.detect_backend.probing",
150
+ host=config.host,
151
+ port=port,
152
+ tls=tls,
153
+ interface=interface,
154
+ )
155
+ )
156
+
157
+ version = await _probe_xml_rpc_port(
158
+ host=config.host,
159
+ port=port,
160
+ tls=tls,
161
+ username=config.username,
162
+ password=config.password,
163
+ verify_tls=config.verify_tls,
164
+ request_timeout=config.request_timeout,
165
+ )
166
+
167
+ if version is None:
168
+ continue
169
+
170
+ _LOGGER.info(i18n.tr(key="log.backend_detection.detect_backend.found_version", version=version, port=port))
171
+
172
+ # Determine backend type from version string
173
+ backend = _determine_backend(version=version)
174
+ _LOGGER.info(i18n.tr(key="log.backend_detection.detect_backend.backend_type", backend=backend))
175
+
176
+ if backend in (Backend.HOMEGEAR, Backend.PYDEVCCU):
177
+ # Homegear/PyDevCCU only supports BidCos-RF
178
+ return BackendDetectionResult(
179
+ backend=backend,
180
+ available_interfaces=(Interface.BIDCOS_RF,),
181
+ detected_port=port,
182
+ tls=tls,
183
+ host=config.host,
184
+ version=version,
185
+ auth_enabled=None,
186
+ )
187
+
188
+ # CCU: Query JSON-RPC for available interfaces
189
+ # This may raise AuthFailure if authentication fails
190
+ interfaces, auth_enabled, https_redirect_enabled = await _query_ccu_interfaces(
191
+ host=config.host,
192
+ username=config.username,
193
+ password=config.password,
194
+ verify_tls=config.verify_tls,
195
+ client_session=client_session,
196
+ )
197
+
198
+ if interfaces:
199
+ _LOGGER.info(i18n.tr(key="log.backend_detection.detect_backend.found_interfaces", interfaces=interfaces))
200
+ else:
201
+ # Fallback: use the interface we connected to
202
+ _LOGGER.info(i18n.tr(key="log.backend_detection.detect_backend.json_rpc_fallback"))
203
+ interfaces = (interface,)
204
+
205
+ return BackendDetectionResult(
206
+ backend=Backend.CCU,
207
+ available_interfaces=interfaces,
208
+ detected_port=port,
209
+ tls=tls,
210
+ host=config.host,
211
+ version=version,
212
+ auth_enabled=auth_enabled,
213
+ https_redirect_enabled=https_redirect_enabled,
214
+ )
215
+
216
+ _LOGGER.info(i18n.tr(key="log.backend_detection.detect_backend.no_backend_found", host=config.host))
217
+ return None
218
+
219
+
220
+ def _determine_backend(*, version: str) -> Backend:
221
+ """Determine backend type from version string."""
222
+ version_lower = version.lower()
223
+ if "homegear" in version_lower:
224
+ return Backend.HOMEGEAR
225
+ if "pydevccu" in version_lower:
226
+ return Backend.PYDEVCCU
227
+ return Backend.CCU
228
+
229
+
230
+ async def _probe_xml_rpc_port(
231
+ *,
232
+ host: str,
233
+ port: int,
234
+ tls: bool,
235
+ username: str,
236
+ password: str,
237
+ verify_tls: bool,
238
+ request_timeout: float,
239
+ ) -> str | None:
240
+ """
241
+ Probe a single XML-RPC port and return the version string if successful.
242
+
243
+ Uses AioXmlRpcProxy for consistent error handling with the rest of the client.
244
+
245
+ Returns:
246
+ Version string if connection successful, None otherwise.
247
+
248
+ Raises:
249
+ AuthFailure: If authentication fails with the provided credentials.
250
+
251
+ """
252
+ uri = build_xml_rpc_uri(host=host, port=port, path=None, tls=tls)
253
+ headers = build_xml_rpc_headers(username=username, password=password) if username else []
254
+ interface_id = f"detect-{host}:{port}"
255
+
256
+ proxy: AioXmlRpcProxy | None = None
257
+ try:
258
+ proxy = AioXmlRpcProxy(
259
+ max_workers=1,
260
+ interface_id=interface_id,
261
+ connection_state=CentralConnectionState(),
262
+ uri=uri,
263
+ headers=headers,
264
+ tls=tls,
265
+ verify_tls=verify_tls,
266
+ )
267
+
268
+ # Initialize proxy and get supported methods
269
+ await asyncio.wait_for(proxy.do_init(), timeout=request_timeout)
270
+
271
+ # Try to get version if available
272
+ if _XML_METHOD_GET_VERSION in (proxy.supported_methods or ()):
273
+ version = await asyncio.wait_for(proxy.getVersion(), timeout=request_timeout)
274
+ return str(version) if version else ""
275
+ # If getVersion not available, return empty string to indicate connection worked
276
+ return "" # noqa: TRY300
277
+
278
+ except AuthFailure:
279
+ # Re-raise authentication failures - wrong credentials should not try other ports
280
+ raise
281
+ except NoConnectionException as exc:
282
+ # Connection failed on this port - log and try next port
283
+ _LOGGER.info(
284
+ i18n.tr(
285
+ key="log.backend_detection.xml_rpc.probe_failed",
286
+ host=host,
287
+ port=port,
288
+ exc_type=type(exc).__name__,
289
+ reason=exc,
290
+ )
291
+ )
292
+ return None
293
+ except TimeoutError:
294
+ _LOGGER.info(i18n.tr(key="log.backend_detection.xml_rpc.probe_timeout", host=host, port=port))
295
+ return None
296
+ except BaseHomematicException as exc:
297
+ _LOGGER.info(
298
+ i18n.tr(
299
+ key="log.backend_detection.xml_rpc.probe_failed",
300
+ host=host,
301
+ port=port,
302
+ exc_type=type(exc).__name__,
303
+ reason=exc,
304
+ )
305
+ )
306
+ return None
307
+ except Exception as exc: # noqa: BLE001
308
+ _LOGGER.info(
309
+ i18n.tr(
310
+ key="log.backend_detection.xml_rpc.probe_error",
311
+ host=host,
312
+ port=port,
313
+ exc_type=type(exc).__name__,
314
+ reason=exc,
315
+ )
316
+ )
317
+ return None
318
+ finally:
319
+ if proxy:
320
+ await proxy.stop()
321
+
322
+
323
+ async def _query_ccu_interfaces(
324
+ *,
325
+ host: str,
326
+ username: str,
327
+ password: str,
328
+ verify_tls: bool,
329
+ client_session: ClientSession | None,
330
+ ) -> tuple[tuple[Interface, ...], bool | None, bool | None]:
331
+ """
332
+ Query CCU for available interfaces via JSON-RPC.
333
+
334
+ Uses AioJsonRpcAioHttpClient to query system information.
335
+ Tries both HTTP (port 80) and HTTPS (port 443).
336
+
337
+ Returns:
338
+ Tuple of (interfaces, auth_enabled, https_redirect_enabled).
339
+ Returns empty tuple if query fails.
340
+
341
+ Raises:
342
+ AuthFailure: If authentication fails with the provided credentials.
343
+
344
+ """
345
+ for port, tls in DETECTION_PORT_JSON_RPC:
346
+ result = await _query_json_rpc_interfaces(
347
+ host=host,
348
+ port=port,
349
+ tls=tls,
350
+ username=username,
351
+ password=password,
352
+ verify_tls=verify_tls,
353
+ client_session=client_session,
354
+ )
355
+ if result is not None:
356
+ return result
357
+
358
+ return ((), None, None)
359
+
360
+
361
+ async def _query_json_rpc_interfaces(
362
+ *,
363
+ host: str,
364
+ port: int,
365
+ tls: bool,
366
+ username: str,
367
+ password: str,
368
+ verify_tls: bool,
369
+ client_session: ClientSession | None,
370
+ ) -> tuple[tuple[Interface, ...], bool | None, bool | None] | None:
371
+ """
372
+ Query interfaces via JSON-RPC on a specific port using AioJsonRpcAioHttpClient.
373
+
374
+ This function performs two checks:
375
+ 1. Queries available_interfaces from system information (installed interfaces)
376
+ 2. Verifies each interface is actually running via is_present() check
377
+
378
+ Returns:
379
+ Tuple of (interfaces, auth_enabled, https_redirect_enabled) if successful, None if failed.
380
+
381
+ Raises:
382
+ AuthFailure: If authentication fails with the provided credentials.
383
+
384
+ """
385
+ scheme = "https" if tls else "http"
386
+ device_url = f"{scheme}://{host}:{port}"
387
+
388
+ _LOGGER.info(i18n.tr(key="log.backend_detection.json_rpc.querying", url=device_url))
389
+
390
+ json_rpc_client: AioJsonRpcAioHttpClient | None = None
391
+ try:
392
+ json_rpc_client = AioJsonRpcAioHttpClient(
393
+ username=username,
394
+ password=password,
395
+ device_url=device_url,
396
+ connection_state=CentralConnectionState(),
397
+ client_session=client_session,
398
+ tls=tls,
399
+ verify_tls=verify_tls,
400
+ )
401
+
402
+ system_info = await json_rpc_client.get_system_information()
403
+
404
+ # Convert interface strings to Interface enums
405
+ installed_interfaces: list[Interface] = []
406
+ for iface_name in system_info.available_interfaces:
407
+ try:
408
+ installed_interfaces.append(Interface(iface_name))
409
+ except ValueError:
410
+ _LOGGER.info(i18n.tr(key="log.backend_detection.json_rpc.unknown_interface", interface=iface_name))
411
+
412
+ # Verify each interface is actually running via is_present check
413
+ present_interfaces: list[Interface] = []
414
+ for interface in installed_interfaces:
415
+ try:
416
+ if await json_rpc_client.is_present(interface=interface):
417
+ present_interfaces.append(interface)
418
+ _LOGGER.debug(
419
+ i18n.tr(
420
+ key="log.backend_detection.json_rpc.interface_present",
421
+ interface=interface.value,
422
+ )
423
+ )
424
+ else:
425
+ _LOGGER.warning(
426
+ i18n.tr(
427
+ key="log.backend_detection.json_rpc.interface_not_present",
428
+ interface=interface.value,
429
+ )
430
+ )
431
+ except Exception as exc: # noqa: BLE001
432
+ _LOGGER.warning(
433
+ i18n.tr(
434
+ key="log.backend_detection.json_rpc.is_present_failed",
435
+ interface=interface.value,
436
+ reason=str(exc),
437
+ )
438
+ )
439
+
440
+ return tuple(present_interfaces), system_info.auth_enabled, system_info.https_redirect_enabled # noqa: TRY300
441
+
442
+ except AuthFailure:
443
+ # Re-raise authentication failures so they can be handled by the caller
444
+ _LOGGER.warning(i18n.tr(key="log.backend_detection.json_rpc.auth_failed", url=device_url))
445
+ raise
446
+ except NoConnectionException:
447
+ # Connection failed on this port - log and try next port
448
+ _LOGGER.info(i18n.tr(key="log.backend_detection.json_rpc.connection_failed", url=device_url))
449
+ return None
450
+ except Exception as exc: # noqa: BLE001
451
+ _LOGGER.info(
452
+ i18n.tr(
453
+ key="log.backend_detection.json_rpc.query_failed",
454
+ url=device_url,
455
+ exc_type=type(exc).__name__,
456
+ reason=exc,
457
+ )
458
+ )
459
+ return None
460
+ finally:
461
+ if json_rpc_client:
462
+ await json_rpc_client.logout()