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,1152 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Central unit orchestration for Homematic CCU and compatible backends.
5
+
6
+ This module provides the CentralUnit class that orchestrates interfaces, devices,
7
+ channels, data points, events, and background jobs for a Homematic CCU.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import Mapping, Set as AbstractSet
14
+ import logging
15
+ from typing import Final
16
+
17
+ from aiohomematic import client as hmcl, i18n
18
+ from aiohomematic.async_support import Looper
19
+ from aiohomematic.central import async_rpc_server as async_rpc, rpc_server as rpc
20
+ from aiohomematic.central.connection_state import CentralConnectionState
21
+ from aiohomematic.central.coordinators import (
22
+ CacheCoordinator,
23
+ ClientCoordinator,
24
+ ConnectionRecoveryCoordinator,
25
+ DeviceCoordinator,
26
+ EventCoordinator,
27
+ HubCoordinator,
28
+ )
29
+ from aiohomematic.central.device_registry import DeviceRegistry
30
+ from aiohomematic.central.events import EventBus, SystemStatusChangedEvent
31
+ from aiohomematic.central.health import CentralHealth, HealthTracker
32
+ from aiohomematic.central.scheduler import BackgroundScheduler
33
+ from aiohomematic.central.state_machine import CentralStateMachine
34
+ from aiohomematic.client import AioJsonRpcAioHttpClient
35
+ from aiohomematic.const import (
36
+ CATEGORIES,
37
+ DATA_POINT_EVENTS,
38
+ DEFAULT_LOCALE,
39
+ IGNORE_FOR_UN_IGNORE_PARAMETERS,
40
+ IP_ANY_V4,
41
+ LOCAL_HOST,
42
+ PORT_ANY,
43
+ PRIMARY_CLIENT_CANDIDATE_INTERFACES,
44
+ UN_IGNORE_WILDCARD,
45
+ BackupData,
46
+ CentralState,
47
+ ClientState,
48
+ DataPointCategory,
49
+ DeviceTriggerEventType,
50
+ FailureReason,
51
+ ForcedDeviceAvailability,
52
+ Interface,
53
+ Operations,
54
+ OptionalSettings,
55
+ ParamsetKey,
56
+ SystemInformation,
57
+ )
58
+ from aiohomematic.decorators import inspector
59
+ from aiohomematic.exceptions import AioHomematicException, BaseHomematicException, NoClientsException
60
+ from aiohomematic.interfaces.central import CentralConfigProtocol, CentralProtocol
61
+ from aiohomematic.interfaces.client import ClientProtocol
62
+ from aiohomematic.interfaces.model import (
63
+ CallbackDataPointProtocol,
64
+ CustomDataPointProtocol,
65
+ DeviceProtocol,
66
+ GenericDataPointProtocol,
67
+ GenericDataPointProtocolAny,
68
+ GenericEventProtocolAny,
69
+ )
70
+ from aiohomematic.metrics import MetricsAggregator, MetricsObserver
71
+ from aiohomematic.model.hub import InstallModeDpType
72
+ from aiohomematic.property_decorators import DelegatedProperty, Kind, info_property
73
+ from aiohomematic.store import LocalStorageFactory, StorageFactoryProtocol
74
+ from aiohomematic.support import (
75
+ LogContextMixin,
76
+ PayloadMixin,
77
+ extract_exc_args,
78
+ get_channel_no,
79
+ get_device_address,
80
+ get_ip_addr,
81
+ )
82
+
83
+ _LOGGER: Final = logging.getLogger(__name__)
84
+
85
+ # {central_name, central}
86
+ CENTRAL_INSTANCES: Final[dict[str, CentralUnit]] = {}
87
+
88
+
89
+ class CentralUnit(
90
+ PayloadMixin,
91
+ LogContextMixin,
92
+ CentralProtocol,
93
+ ):
94
+ """Central unit that collects everything to handle communication from/to the backend."""
95
+
96
+ def __init__(self, *, central_config: CentralConfigProtocol) -> None:
97
+ """Initialize the central unit."""
98
+ # Keep the config for the central
99
+ self._config: Final[CentralConfigProtocol] = central_config
100
+ # Apply locale for translations
101
+ try:
102
+ i18n.set_locale(locale=self._config.locale)
103
+ except Exception: # pragma: no cover - keep init robust
104
+ i18n.set_locale(locale=DEFAULT_LOCALE)
105
+ self._url: Final = self._config.create_central_url()
106
+ self._model: str | None = None
107
+ self._looper = Looper()
108
+ self._xml_rpc_server: rpc.XmlRpcServer | async_rpc.AsyncXmlRpcServer | None = None
109
+ self._json_rpc_client: AioJsonRpcAioHttpClient | None = None
110
+
111
+ # Initialize event bus and state machine early (needed by coordinators)
112
+ self._event_bus: Final = EventBus(
113
+ enable_event_logging=_LOGGER.isEnabledFor(logging.DEBUG),
114
+ task_scheduler=self.looper,
115
+ )
116
+ self._central_state_machine: Final = CentralStateMachine(
117
+ central_name=self._config.name,
118
+ event_bus=self._event_bus,
119
+ )
120
+ self._health_tracker: Final = HealthTracker(
121
+ central_name=self._config.name,
122
+ state_machine=self._central_state_machine,
123
+ event_bus=self._event_bus,
124
+ )
125
+
126
+ # Initialize storage factory (use provided or create local)
127
+ self._storage_factory: Final[StorageFactoryProtocol] = central_config.storage_factory or LocalStorageFactory(
128
+ base_directory=central_config.storage_directory,
129
+ central_name=central_config.name,
130
+ task_scheduler=self.looper,
131
+ )
132
+
133
+ # Initialize coordinators
134
+ self._client_coordinator: Final = ClientCoordinator(
135
+ client_factory=self,
136
+ central_info=self,
137
+ config_provider=self,
138
+ coordinator_provider=self,
139
+ event_bus_provider=self,
140
+ health_tracker=self._health_tracker,
141
+ system_info_provider=self,
142
+ )
143
+ self._cache_coordinator: Final = CacheCoordinator(
144
+ central_info=self,
145
+ client_provider=self._client_coordinator,
146
+ config_provider=self,
147
+ data_point_provider=self,
148
+ device_provider=self,
149
+ event_bus_provider=self,
150
+ primary_client_provider=self._client_coordinator,
151
+ session_recorder_active=self.config.session_recorder_start,
152
+ storage_factory=self._storage_factory,
153
+ task_scheduler=self.looper,
154
+ )
155
+ self._event_coordinator: Final = EventCoordinator(
156
+ client_provider=self._client_coordinator,
157
+ event_bus=self._event_bus,
158
+ health_tracker=self._health_tracker,
159
+ task_scheduler=self.looper,
160
+ )
161
+
162
+ self._connection_state: Final = CentralConnectionState(event_bus_provider=self)
163
+ self._device_registry: Final = DeviceRegistry(
164
+ central_info=self,
165
+ client_provider=self._client_coordinator,
166
+ )
167
+ self._device_coordinator: Final = DeviceCoordinator(
168
+ central_info=self,
169
+ client_provider=self._client_coordinator,
170
+ config_provider=self,
171
+ coordinator_provider=self,
172
+ data_cache_provider=self._cache_coordinator.data_cache,
173
+ data_point_provider=self,
174
+ device_description_provider=self._cache_coordinator.device_descriptions,
175
+ device_details_provider=self._cache_coordinator.device_details,
176
+ event_bus_provider=self,
177
+ event_publisher=self._event_coordinator,
178
+ event_subscription_manager=self._event_coordinator,
179
+ file_operations=self,
180
+ parameter_visibility_provider=self._cache_coordinator.parameter_visibility,
181
+ paramset_description_provider=self._cache_coordinator.paramset_descriptions,
182
+ task_scheduler=self.looper,
183
+ )
184
+ self._hub_coordinator: Final = HubCoordinator(
185
+ central_info=self,
186
+ channel_lookup=self._device_coordinator,
187
+ client_provider=self._client_coordinator,
188
+ config_provider=self,
189
+ event_bus_provider=self,
190
+ event_publisher=self._event_coordinator,
191
+ health_tracker=self._health_tracker,
192
+ metrics_provider=self,
193
+ parameter_visibility_provider=self._cache_coordinator.parameter_visibility,
194
+ paramset_description_provider=self._cache_coordinator.paramset_descriptions,
195
+ primary_client_provider=self._client_coordinator,
196
+ task_scheduler=self.looper,
197
+ )
198
+
199
+ CENTRAL_INSTANCES[self.name] = self
200
+ self._scheduler: Final = BackgroundScheduler(
201
+ central_info=self,
202
+ config_provider=self,
203
+ client_coordinator=self._client_coordinator,
204
+ connection_state_provider=self,
205
+ device_data_refresher=self,
206
+ firmware_data_refresher=self._device_coordinator,
207
+ event_coordinator=self._event_coordinator,
208
+ hub_data_fetcher=self._hub_coordinator,
209
+ event_bus_provider=self,
210
+ state_provider=self,
211
+ )
212
+
213
+ # Unified connection recovery coordinator (event-driven)
214
+ self._connection_recovery_coordinator: Final = ConnectionRecoveryCoordinator(
215
+ central_info=self,
216
+ config_provider=self,
217
+ client_provider=self._client_coordinator,
218
+ coordinator_provider=self,
219
+ device_data_refresher=self,
220
+ event_bus=self._event_bus,
221
+ task_scheduler=self.looper,
222
+ state_machine=self._central_state_machine,
223
+ )
224
+
225
+ # Metrics observer for event-driven metrics (single source of truth)
226
+ self._metrics_observer: Final = MetricsObserver(event_bus=self._event_bus)
227
+
228
+ # Metrics aggregator for detailed observability (queries observer + components)
229
+ self._metrics_aggregator: Final = MetricsAggregator(
230
+ central_name=self.name,
231
+ client_provider=self._client_coordinator,
232
+ device_provider=self._device_registry,
233
+ event_bus=self._event_bus,
234
+ health_tracker=self._health_tracker,
235
+ data_cache=self._cache_coordinator.data_cache,
236
+ observer=self._metrics_observer,
237
+ hub_data_point_manager=self._hub_coordinator,
238
+ cache_provider=self._cache_coordinator,
239
+ recovery_provider=self._connection_recovery_coordinator,
240
+ )
241
+
242
+ # Subscribe to system status events to update central state machine
243
+ self._unsubscribe_system_status = self.event_bus.subscribe(
244
+ event_type=SystemStatusChangedEvent,
245
+ event_key=None, # Subscribe to all system status events
246
+ handler=self._on_system_status_event,
247
+ )
248
+
249
+ self._version: str | None = None
250
+ self._rpc_callback_ip: str = IP_ANY_V4
251
+ self._listen_ip_addr: str = IP_ANY_V4
252
+ self._listen_port_xml_rpc: int = PORT_ANY
253
+
254
+ def __str__(self) -> str:
255
+ """Provide some useful information."""
256
+ return f"central: {self.name}"
257
+
258
+ available: Final = DelegatedProperty[bool](path="_client_coordinator.available")
259
+ cache_coordinator: Final = DelegatedProperty[CacheCoordinator](path="_cache_coordinator")
260
+ callback_ip_addr: Final = DelegatedProperty[str](path="_rpc_callback_ip")
261
+ central_state_machine: Final = DelegatedProperty[CentralStateMachine](path="_central_state_machine")
262
+ client_coordinator: Final = DelegatedProperty[ClientCoordinator](path="_client_coordinator")
263
+ config: Final = DelegatedProperty[CentralConfigProtocol](path="_config")
264
+ connection_recovery_coordinator: Final = DelegatedProperty[ConnectionRecoveryCoordinator](
265
+ path="_connection_recovery_coordinator"
266
+ )
267
+ connection_state: Final = DelegatedProperty["CentralConnectionState"](path="_connection_state")
268
+ device_coordinator: Final = DelegatedProperty[DeviceCoordinator](path="_device_coordinator")
269
+ device_registry: Final = DelegatedProperty[DeviceRegistry](path="_device_registry")
270
+ devices: Final = DelegatedProperty[tuple[DeviceProtocol, ...]](path="_device_registry.devices")
271
+ event_bus: Final = DelegatedProperty[EventBus](path="_event_bus")
272
+ event_coordinator: Final = DelegatedProperty[EventCoordinator](path="_event_coordinator")
273
+ health: Final = DelegatedProperty[CentralHealth](path="_health_tracker.health")
274
+ health_tracker: Final = DelegatedProperty[HealthTracker](path="_health_tracker")
275
+ hub_coordinator: Final = DelegatedProperty[HubCoordinator](path="_hub_coordinator")
276
+ interfaces: Final = DelegatedProperty[frozenset[Interface]](path="_client_coordinator.interfaces")
277
+ listen_ip_addr: Final = DelegatedProperty[str](path="_listen_ip_addr")
278
+ listen_port_xml_rpc: Final = DelegatedProperty[int](path="_listen_port_xml_rpc")
279
+ looper: Final = DelegatedProperty[Looper](path="_looper")
280
+ metrics: Final = DelegatedProperty[MetricsObserver](path="_metrics_observer")
281
+ metrics_aggregator: Final = DelegatedProperty[MetricsAggregator](path="_metrics_aggregator")
282
+ name: Final = DelegatedProperty[str](path="_config.name", kind=Kind.INFO, log_context=True)
283
+ state: Final = DelegatedProperty[CentralState](path="_central_state_machine.state")
284
+ url: Final = DelegatedProperty[str](path="_url", kind=Kind.INFO, log_context=True)
285
+
286
+ @property
287
+ def _has_active_threads(self) -> bool:
288
+ """Return if active sub threads are alive."""
289
+ # BackgroundScheduler is async-based, not a thread
290
+ # Only check XML-RPC server thread (async server doesn't use threads)
291
+ if not self._xml_rpc_server or not self._xml_rpc_server.no_central_assigned:
292
+ return False
293
+ if isinstance(self._xml_rpc_server, async_rpc.AsyncXmlRpcServer):
294
+ return self._xml_rpc_server.started
295
+ return self._xml_rpc_server.is_alive()
296
+
297
+ @property
298
+ def has_ping_pong(self) -> bool:
299
+ """Return the backend supports ping pong."""
300
+ if primary_client := self._client_coordinator.primary_client:
301
+ return primary_client.capabilities.ping_pong
302
+ return False
303
+
304
+ @property
305
+ def json_rpc_client(self) -> AioJsonRpcAioHttpClient:
306
+ """Return the json rpc client."""
307
+ if not self._json_rpc_client:
308
+ # Use primary client's interface_id for health tracking
309
+ primary_interface_id = (
310
+ self._client_coordinator.primary_client.interface_id
311
+ if self._client_coordinator.primary_client
312
+ else None
313
+ )
314
+ self._json_rpc_client = AioJsonRpcAioHttpClient(
315
+ username=self._config.username,
316
+ password=self._config.password,
317
+ device_url=self._url,
318
+ connection_state=self._connection_state,
319
+ interface_id=primary_interface_id,
320
+ client_session=self._config.client_session,
321
+ tls=self._config.tls,
322
+ verify_tls=self._config.verify_tls,
323
+ session_recorder=self._cache_coordinator.recorder,
324
+ event_bus=self._event_bus,
325
+ incident_recorder=self._cache_coordinator.incident_store,
326
+ )
327
+ return self._json_rpc_client
328
+
329
+ @property
330
+ def system_information(self) -> SystemInformation:
331
+ """Return the system_information of the backend."""
332
+ if client := self._client_coordinator.primary_client:
333
+ return client.system_information
334
+ return SystemInformation()
335
+
336
+ @info_property(log_context=True)
337
+ def model(self) -> str | None:
338
+ """Return the model of the backend."""
339
+ if not self._model and (client := self._client_coordinator.primary_client):
340
+ self._model = client.model
341
+ return self._model
342
+
343
+ @info_property
344
+ def version(self) -> str | None:
345
+ """Return the version of the backend."""
346
+ if self._version is None:
347
+ versions = [client.version for client in self._client_coordinator.clients if client.version]
348
+ self._version = max(versions) if versions else None
349
+ return self._version
350
+
351
+ async def accept_device_in_inbox(self, *, device_address: str) -> bool:
352
+ """
353
+ Accept a device from the CCU inbox.
354
+
355
+ Args:
356
+ device_address: The address of the device to accept.
357
+
358
+ Returns:
359
+ True if the device was successfully accepted, False otherwise.
360
+
361
+ """
362
+ if not (client := self._client_coordinator.primary_client):
363
+ _LOGGER.warning(
364
+ i18n.tr(
365
+ key="log.central.accept_device_in_inbox.no_client", device_address=device_address, name=self.name
366
+ )
367
+ )
368
+ return False
369
+
370
+ result = await client.accept_device_in_inbox(device_address=device_address)
371
+ return bool(result)
372
+
373
+ async def create_backup_and_download(self) -> BackupData | None:
374
+ """
375
+ Create a backup on the CCU and download it.
376
+
377
+ Returns:
378
+ BackupData with filename and content, or None if backup creation or download failed.
379
+
380
+ """
381
+ if client := self._client_coordinator.primary_client:
382
+ return await client.create_backup_and_download()
383
+ return None
384
+
385
+ async def create_client_instance(
386
+ self,
387
+ *,
388
+ interface_config: hmcl.InterfaceConfig,
389
+ ) -> ClientProtocol:
390
+ """
391
+ Create a client for the given interface configuration.
392
+
393
+ This method implements the ClientFactoryProtocol protocol to enable
394
+ dependency injection without requiring the full CentralUnit.
395
+
396
+ Args:
397
+ ----
398
+ interface_config: Configuration for the interface
399
+
400
+ Returns:
401
+ -------
402
+ Client instance for the interface
403
+
404
+ """
405
+ return await hmcl.create_client(
406
+ client_deps=self,
407
+ interface_config=interface_config,
408
+ )
409
+
410
+ def get_custom_data_point(self, *, address: str, channel_no: int) -> CustomDataPointProtocol | None:
411
+ """Return the hm custom_data_point."""
412
+ if device := self._device_coordinator.get_device(address=address):
413
+ return device.get_custom_data_point(channel_no=channel_no)
414
+ return None
415
+
416
+ def get_data_point_by_custom_id(self, *, custom_id: str) -> CallbackDataPointProtocol | None:
417
+ """Return Homematic data_point by custom_id."""
418
+ for dp in self.get_data_points(registered=True):
419
+ if dp.custom_id == custom_id:
420
+ return dp
421
+ return None
422
+
423
+ def get_data_points(
424
+ self,
425
+ *,
426
+ category: DataPointCategory | None = None,
427
+ interface: Interface | None = None,
428
+ exclude_no_create: bool = True,
429
+ registered: bool | None = None,
430
+ ) -> tuple[CallbackDataPointProtocol, ...]:
431
+ """Return all externally registered data points."""
432
+ all_data_points: list[CallbackDataPointProtocol] = []
433
+ for device in self._device_registry.devices:
434
+ if interface and interface != device.interface:
435
+ continue
436
+ all_data_points.extend(
437
+ device.get_data_points(category=category, exclude_no_create=exclude_no_create, registered=registered)
438
+ )
439
+ return tuple(all_data_points)
440
+
441
+ def get_event(
442
+ self, *, channel_address: str | None = None, parameter: str | None = None, state_path: str | None = None
443
+ ) -> GenericEventProtocolAny | None:
444
+ """Return the hm event."""
445
+ if channel_address is None:
446
+ for dev in self._device_registry.devices:
447
+ if event := dev.get_generic_event(parameter=parameter, state_path=state_path):
448
+ return event
449
+ return None
450
+
451
+ if device := self._device_coordinator.get_device(address=channel_address):
452
+ return device.get_generic_event(channel_address=channel_address, parameter=parameter, state_path=state_path)
453
+ return None
454
+
455
+ def get_events(
456
+ self, *, event_type: DeviceTriggerEventType, registered: bool | None = None
457
+ ) -> tuple[tuple[GenericEventProtocolAny, ...], ...]:
458
+ """Return all channel event data points."""
459
+ hm_channel_events: list[tuple[GenericEventProtocolAny, ...]] = []
460
+ for device in self._device_registry.devices:
461
+ for channel_events in device.get_events(event_type=event_type).values():
462
+ if registered is None or (channel_events[0].is_registered == registered):
463
+ hm_channel_events.append(channel_events)
464
+ continue
465
+ return tuple(hm_channel_events)
466
+
467
+ def get_generic_data_point(
468
+ self,
469
+ *,
470
+ channel_address: str | None = None,
471
+ parameter: str | None = None,
472
+ paramset_key: ParamsetKey | None = None,
473
+ state_path: str | None = None,
474
+ ) -> GenericDataPointProtocolAny | None:
475
+ """Get data_point by channel_address and parameter."""
476
+ if channel_address is None:
477
+ for dev in self._device_registry.devices:
478
+ if dp := dev.get_generic_data_point(
479
+ parameter=parameter, paramset_key=paramset_key, state_path=state_path
480
+ ):
481
+ return dp
482
+ return None
483
+
484
+ if device := self._device_coordinator.get_device(address=channel_address):
485
+ return device.get_generic_data_point(
486
+ channel_address=channel_address, parameter=parameter, paramset_key=paramset_key, state_path=state_path
487
+ )
488
+ return None
489
+
490
+ async def get_install_mode(self, *, interface: Interface) -> int:
491
+ """
492
+ Return the remaining time in install mode for an interface.
493
+
494
+ Args:
495
+ interface: The interface to query (HMIP_RF or BIDCOS_RF).
496
+
497
+ Returns:
498
+ Remaining time in seconds, or 0 if not in install mode.
499
+
500
+ """
501
+ try:
502
+ client = self._client_coordinator.get_client(interface=interface)
503
+ return await client.get_install_mode()
504
+ except AioHomematicException:
505
+ return 0
506
+
507
+ def get_parameters(
508
+ self,
509
+ *,
510
+ paramset_key: ParamsetKey,
511
+ operations: tuple[Operations, ...],
512
+ full_format: bool = False,
513
+ un_ignore_candidates_only: bool = False,
514
+ use_channel_wildcard: bool = False,
515
+ ) -> tuple[str, ...]:
516
+ """
517
+ Return all parameters from VALUES paramset.
518
+
519
+ Performance optimized to minimize repeated lookups and computations
520
+ when iterating over all channels and parameters.
521
+ """
522
+ parameters: set[str] = set()
523
+
524
+ # Precompute operations mask to avoid repeated checks in the inner loop
525
+ op_mask: int = 0
526
+ for op in operations:
527
+ op_mask |= int(op)
528
+
529
+ raw_psd = self._cache_coordinator.paramset_descriptions.raw_paramset_descriptions
530
+ ignore_set = IGNORE_FOR_UN_IGNORE_PARAMETERS
531
+
532
+ # Prepare optional helpers only if needed
533
+ get_model = self._cache_coordinator.device_descriptions.get_model if full_format else None
534
+ model_cache: dict[str, str | None] = {}
535
+ channel_no_cache: dict[str, int | None] = {}
536
+
537
+ for channels in raw_psd.values():
538
+ for channel_address, channel_paramsets in channels.items():
539
+ # Resolve model lazily and cache per device address when full_format is requested
540
+ model: str | None = None
541
+ if get_model is not None:
542
+ dev_addr = get_device_address(address=channel_address)
543
+ if (model := model_cache.get(dev_addr)) is None:
544
+ model = get_model(device_address=dev_addr)
545
+ model_cache[dev_addr] = model
546
+
547
+ if (paramset := channel_paramsets.get(paramset_key)) is None:
548
+ continue
549
+
550
+ for parameter, parameter_data in paramset.items():
551
+ # Fast bitmask check: ensure all requested ops are present
552
+ if (int(parameter_data["OPERATIONS"]) & op_mask) != op_mask:
553
+ continue
554
+
555
+ if un_ignore_candidates_only:
556
+ # Cheap check first to avoid expensive dp lookup when possible
557
+ if parameter in ignore_set:
558
+ continue
559
+ dp = self.get_generic_data_point(
560
+ channel_address=channel_address,
561
+ parameter=parameter,
562
+ paramset_key=paramset_key,
563
+ )
564
+ if dp and dp.enabled_default and not dp.is_un_ignored:
565
+ continue
566
+
567
+ if not full_format:
568
+ parameters.add(parameter)
569
+ continue
570
+
571
+ if use_channel_wildcard:
572
+ channel_repr: int | str | None = UN_IGNORE_WILDCARD
573
+ elif channel_address in channel_no_cache:
574
+ channel_repr = channel_no_cache[channel_address]
575
+ else:
576
+ channel_repr = get_channel_no(address=channel_address)
577
+ channel_no_cache[channel_address] = channel_repr
578
+
579
+ # Build the full parameter string
580
+ if channel_repr is None:
581
+ parameters.add(f"{parameter}:{paramset_key}@{model}:")
582
+ else:
583
+ parameters.add(f"{parameter}:{paramset_key}@{model}:{channel_repr}")
584
+
585
+ return tuple(parameters)
586
+
587
+ def get_readable_generic_data_points(
588
+ self, *, paramset_key: ParamsetKey | None = None, interface: Interface | None = None
589
+ ) -> tuple[GenericDataPointProtocolAny, ...]:
590
+ """Return the readable generic data points."""
591
+ return tuple(
592
+ ge
593
+ for ge in self.get_data_points(interface=interface)
594
+ if (
595
+ isinstance(ge, GenericDataPointProtocol)
596
+ and ge.is_readable
597
+ and ((paramset_key and ge.paramset_key == paramset_key) or paramset_key is None)
598
+ )
599
+ )
600
+
601
+ def get_state_paths(self, *, rpc_callback_supported: bool | None = None) -> tuple[str, ...]:
602
+ """Return the data point paths."""
603
+ data_point_paths: list[str] = []
604
+ for device in self._device_registry.devices:
605
+ if rpc_callback_supported is None or device.client.capabilities.rpc_callback == rpc_callback_supported:
606
+ data_point_paths.extend(device.data_point_paths)
607
+ data_point_paths.extend(self.hub_coordinator.data_point_paths)
608
+ return tuple(data_point_paths)
609
+
610
+ def get_un_ignore_candidates(self, *, include_master: bool = False) -> list[str]:
611
+ """Return the candidates for un_ignore."""
612
+ candidates = sorted(
613
+ # 1. request simple parameter list for values parameters
614
+ self.get_parameters(
615
+ paramset_key=ParamsetKey.VALUES,
616
+ operations=(Operations.READ, Operations.EVENT),
617
+ un_ignore_candidates_only=True,
618
+ )
619
+ # 2. request full_format parameter list with channel wildcard for values parameters
620
+ + self.get_parameters(
621
+ paramset_key=ParamsetKey.VALUES,
622
+ operations=(Operations.READ, Operations.EVENT),
623
+ full_format=True,
624
+ un_ignore_candidates_only=True,
625
+ use_channel_wildcard=True,
626
+ )
627
+ # 3. request full_format parameter list for values parameters
628
+ + self.get_parameters(
629
+ paramset_key=ParamsetKey.VALUES,
630
+ operations=(Operations.READ, Operations.EVENT),
631
+ full_format=True,
632
+ un_ignore_candidates_only=True,
633
+ )
634
+ )
635
+ if include_master:
636
+ # 4. request full_format parameter list for master parameters
637
+ candidates += sorted(
638
+ self.get_parameters(
639
+ paramset_key=ParamsetKey.MASTER,
640
+ operations=(Operations.READ,),
641
+ full_format=True,
642
+ un_ignore_candidates_only=True,
643
+ )
644
+ )
645
+ return candidates
646
+
647
+ async def init_install_mode(self) -> Mapping[Interface, InstallModeDpType]:
648
+ """
649
+ Initialize install mode data points (internal use - use hub_coordinator for external access).
650
+
651
+ Creates data points, fetches initial state from backend, and publishes refresh event.
652
+ Returns a dict of InstallModeDpType by Interface.
653
+ """
654
+ return await self._hub_coordinator.init_install_mode()
655
+
656
+ @inspector(measure_performance=True)
657
+ async def load_and_refresh_data_point_data(
658
+ self,
659
+ *,
660
+ interface: Interface,
661
+ paramset_key: ParamsetKey | None = None,
662
+ direct_call: bool = False,
663
+ ) -> None:
664
+ """Refresh data_point data."""
665
+ if paramset_key != ParamsetKey.MASTER:
666
+ await self._cache_coordinator.data_cache.load(interface=interface)
667
+ await self._cache_coordinator.data_cache.refresh_data_point_data(
668
+ paramset_key=paramset_key, interface=interface, direct_call=direct_call
669
+ )
670
+
671
+ async def rename_device(self, *, device_address: str, name: str, include_channels: bool = False) -> bool:
672
+ """
673
+ Rename a device on the CCU.
674
+
675
+ Args:
676
+ device_address: The address of the device to rename.
677
+ name: The new name for the device.
678
+ include_channels: If True, also rename all channels using the format "name:channel_no".
679
+
680
+ Returns:
681
+ True if the device was successfully renamed, False otherwise.
682
+
683
+ """
684
+ if (device := self._device_coordinator.get_device(address=device_address)) is None:
685
+ _LOGGER.warning(
686
+ i18n.tr(key="log.central.rename_device.not_found", device_address=device_address, name=self.name)
687
+ )
688
+ return False
689
+
690
+ if not await device.client.rename_device(rega_id=device.rega_id, new_name=name):
691
+ return False
692
+
693
+ if include_channels:
694
+ for channel in device.channels.values():
695
+ if channel.no is not None:
696
+ channel_name = f"{name}:{channel.no}"
697
+ await device.client.rename_channel(rega_id=channel.rega_id, new_name=channel_name)
698
+
699
+ return True
700
+
701
+ async def save_files(
702
+ self,
703
+ *,
704
+ save_device_descriptions: bool = False,
705
+ save_paramset_descriptions: bool = False,
706
+ ) -> None:
707
+ """Save files (internal use - use cache_coordinator for external access)."""
708
+ await self._cache_coordinator.save_all(
709
+ save_device_descriptions=save_device_descriptions,
710
+ save_paramset_descriptions=save_paramset_descriptions,
711
+ )
712
+
713
+ async def set_install_mode(
714
+ self,
715
+ *,
716
+ interface: Interface,
717
+ on: bool = True,
718
+ time: int = 60,
719
+ mode: int = 1,
720
+ device_address: str | None = None,
721
+ ) -> bool:
722
+ """
723
+ Set the install mode on the backend for a specific interface.
724
+
725
+ Args:
726
+ interface: The interface to set install mode on (HMIP_RF or BIDCOS_RF).
727
+ on: Enable or disable install mode.
728
+ time: Duration in seconds (default 60).
729
+ mode: Mode 1=normal, 2=set all ROAMING devices into install mode.
730
+ device_address: Optional device address to limit pairing.
731
+
732
+ Returns:
733
+ True if successful.
734
+
735
+ """
736
+ try:
737
+ client = self._client_coordinator.get_client(interface=interface)
738
+ return await client.set_install_mode(on=on, time=time, mode=mode, device_address=device_address)
739
+ except AioHomematicException:
740
+ return False
741
+
742
+ async def start(self) -> None:
743
+ """Start processing of the central unit."""
744
+ _LOGGER.debug("START: Central %s is %s", self.name, self.state)
745
+ if self.state == CentralState.INITIALIZING:
746
+ _LOGGER.debug("START: Central %s already starting", self.name)
747
+ return
748
+
749
+ if self.state == CentralState.RUNNING:
750
+ _LOGGER.debug("START: Central %s already started", self.name)
751
+ return
752
+
753
+ # Transition central state machine to INITIALIZING
754
+ if self._central_state_machine.can_transition_to(target=CentralState.INITIALIZING):
755
+ self._central_state_machine.transition_to(
756
+ target=CentralState.INITIALIZING,
757
+ reason="start() called",
758
+ )
759
+
760
+ if self._config.session_recorder_start:
761
+ await self._cache_coordinator.recorder.deactivate(
762
+ delay=self._config.session_recorder_start_for_seconds,
763
+ auto_save=True,
764
+ randomize_output=self._config.session_recorder_randomize_output,
765
+ use_ts_in_file_name=False,
766
+ )
767
+ _LOGGER.debug("START: Starting Recorder for %s seconds", self._config.session_recorder_start_for_seconds)
768
+
769
+ _LOGGER.debug("START: Initializing Central %s", self.name)
770
+ if self._config.enabled_interface_configs and (
771
+ ip_addr := await self._identify_ip_addr(port=self._config.connection_check_port)
772
+ ):
773
+ self._rpc_callback_ip = ip_addr
774
+ self._listen_ip_addr = self._config.listen_ip_addr if self._config.listen_ip_addr else ip_addr
775
+
776
+ port_xml_rpc: int = (
777
+ self._config.listen_port_xml_rpc
778
+ if self._config.listen_port_xml_rpc
779
+ else self._config.callback_port_xml_rpc or self._config.default_callback_port_xml_rpc
780
+ )
781
+ try:
782
+ if self._config.enable_xml_rpc_server:
783
+ if OptionalSettings.ASYNC_RPC_SERVER in self._config.optional_settings:
784
+ # Use async XML-RPC server (opt-in)
785
+ async_server = await async_rpc.create_async_xml_rpc_server(
786
+ ip_addr=self._listen_ip_addr, port=port_xml_rpc
787
+ )
788
+ self._xml_rpc_server = async_server
789
+ self._listen_port_xml_rpc = async_server.listen_port
790
+ async_server.add_central(central=self)
791
+ else:
792
+ # Use thread-based XML-RPC server (default)
793
+ xml_rpc_server = rpc.create_xml_rpc_server(ip_addr=self._listen_ip_addr, port=port_xml_rpc)
794
+ self._xml_rpc_server = xml_rpc_server
795
+ self._listen_port_xml_rpc = xml_rpc_server.listen_port
796
+ xml_rpc_server.add_central(central=self, looper=self.looper)
797
+ except OSError as oserr: # pragma: no cover - environment/OS-specific socket binding failures are not reliably reproducible in CI
798
+ if self._central_state_machine.can_transition_to(target=CentralState.FAILED):
799
+ self._central_state_machine.transition_to(
800
+ target=CentralState.FAILED,
801
+ reason=f"XML-RPC server failed: {extract_exc_args(exc=oserr)}",
802
+ failure_reason=FailureReason.INTERNAL,
803
+ )
804
+ raise AioHomematicException(
805
+ i18n.tr(
806
+ key="exception.central.start.failed",
807
+ name=self.name,
808
+ reason=extract_exc_args(exc=oserr),
809
+ )
810
+ ) from oserr
811
+
812
+ if self._config.start_direct:
813
+ if await self._client_coordinator.start_clients():
814
+ for client in self._client_coordinator.clients:
815
+ await self._device_coordinator.refresh_device_descriptions_and_create_missing_devices(
816
+ client=client,
817
+ refresh_only_existing=False,
818
+ )
819
+ else:
820
+ # Device creation is now done inside start_clients() before hub init
821
+ await self._client_coordinator.start_clients()
822
+ if self._config.enable_xml_rpc_server:
823
+ self._start_scheduler()
824
+
825
+ # Transition central state machine based on client status
826
+ clients = self._client_coordinator.clients
827
+ _LOGGER.debug(
828
+ "START: Central %s is %s, clients: %s",
829
+ self.name,
830
+ self.state,
831
+ {c.interface_id: c.state.value for c in clients},
832
+ )
833
+ # Note: all() returns True for empty iterables, so we must check clients exist
834
+ all_connected = bool(clients) and all(client.state == ClientState.CONNECTED for client in clients)
835
+ any_connected = any(client.state == ClientState.CONNECTED for client in clients)
836
+ if all_connected and self._central_state_machine.can_transition_to(target=CentralState.RUNNING):
837
+ self._central_state_machine.transition_to(
838
+ target=CentralState.RUNNING,
839
+ reason="all clients connected",
840
+ )
841
+ elif (
842
+ any_connected
843
+ and not all_connected
844
+ and self._central_state_machine.can_transition_to(target=CentralState.DEGRADED)
845
+ ):
846
+ # Build map of disconnected interfaces with their failure reasons
847
+ degraded_interfaces: dict[str, FailureReason] = {
848
+ client.interface_id: (
849
+ reason
850
+ if (reason := client.state_machine.failure_reason) != FailureReason.NONE
851
+ else FailureReason.UNKNOWN
852
+ )
853
+ for client in clients
854
+ if client.state != ClientState.CONNECTED
855
+ }
856
+ self._central_state_machine.transition_to(
857
+ target=CentralState.DEGRADED,
858
+ reason=f"clients not connected: {', '.join(degraded_interfaces.keys())}",
859
+ degraded_interfaces=degraded_interfaces,
860
+ )
861
+ elif not any_connected and self._central_state_machine.can_transition_to(target=CentralState.FAILED):
862
+ self._central_state_machine.transition_to(
863
+ target=CentralState.FAILED,
864
+ reason="no clients connected",
865
+ failure_reason=self._client_coordinator.last_failure_reason,
866
+ failure_interface_id=self._client_coordinator.last_failure_interface_id,
867
+ )
868
+
869
+ async def stop(self) -> None:
870
+ """Stop processing of the central unit."""
871
+ _LOGGER.debug("STOP: Central %s is %s", self.name, self.state)
872
+ if self.state == CentralState.STOPPED:
873
+ _LOGGER.debug("STOP: Central %s is already stopped", self.name)
874
+ return
875
+
876
+ # Transition to STOPPED directly (no intermediate STOPPING state in CentralState)
877
+ _LOGGER.debug("STOP: Stopping Central %s", self.name)
878
+
879
+ await self.save_files(save_device_descriptions=True, save_paramset_descriptions=True)
880
+ await self._stop_scheduler()
881
+ self._metrics_observer.stop()
882
+ self._connection_recovery_coordinator.stop()
883
+ await self._client_coordinator.stop_clients()
884
+ if self._json_rpc_client and self._json_rpc_client.is_activated:
885
+ await self._json_rpc_client.logout()
886
+ await self._json_rpc_client.stop()
887
+
888
+ if self._xml_rpc_server:
889
+ # un-register this instance from XmlRPC-Server
890
+ self._xml_rpc_server.remove_central(central=self)
891
+ # un-register and stop XmlRPC-Server, if possible
892
+ if self._xml_rpc_server.no_central_assigned:
893
+ if isinstance(self._xml_rpc_server, async_rpc.AsyncXmlRpcServer):
894
+ await self._xml_rpc_server.stop()
895
+ else:
896
+ self._xml_rpc_server.stop()
897
+ _LOGGER.debug("STOP: XmlRPC-Server stopped")
898
+ else:
899
+ _LOGGER.debug("STOP: shared XmlRPC-Server NOT stopped. There is still another central instance registered")
900
+
901
+ _LOGGER.debug("STOP: Removing instance")
902
+ if self.name in CENTRAL_INSTANCES:
903
+ del CENTRAL_INSTANCES[self.name]
904
+
905
+ # Clear hub coordinator subscriptions (sysvar event subscriptions)
906
+ self._hub_coordinator.clear()
907
+ _LOGGER.debug("STOP: Hub coordinator subscriptions cleared")
908
+
909
+ # Clear cache coordinator subscriptions (device removed event subscription)
910
+ self._cache_coordinator.stop()
911
+ _LOGGER.debug("STOP: Cache coordinator subscriptions cleared")
912
+
913
+ # Clear event coordinator subscriptions (status event subscriptions)
914
+ self._event_coordinator.clear()
915
+ _LOGGER.debug("STOP: Event coordinator subscriptions cleared")
916
+
917
+ # Clear external subscriptions (from Home Assistant integration)
918
+ # These are subscriptions made via subscribe_to_device_removed(), subscribe_to_firmware_updated(), etc.
919
+ # The integration is responsible for unsubscribing, but we clean up as a fallback
920
+ self._event_coordinator.event_bus.clear_external_subscriptions()
921
+ _LOGGER.debug("STOP: External subscriptions cleared")
922
+
923
+ # Unsubscribe from system status events
924
+ self._unsubscribe_system_status()
925
+ _LOGGER.debug("STOP: Central system status subscription cleared")
926
+
927
+ # Log any leaked subscriptions before clearing (only when debug logging is enabled)
928
+ if _LOGGER.isEnabledFor(logging.DEBUG):
929
+ self._event_coordinator.event_bus.log_leaked_subscriptions()
930
+
931
+ # Clear EventBus subscriptions to prevent memory leaks
932
+ self._event_coordinator.event_bus.clear_subscriptions()
933
+ _LOGGER.debug("STOP: EventBus subscriptions cleared")
934
+
935
+ # Clear all in-memory caches (device_details, data_cache, parameter_visibility)
936
+ self._cache_coordinator.clear_on_stop()
937
+ _LOGGER.debug("STOP: In-memory caches cleared")
938
+
939
+ # Clear client-level trackers (command tracker, ping-pong tracker)
940
+ for client in self._client_coordinator.clients:
941
+ client.last_value_send_tracker.clear()
942
+ client.ping_pong_tracker.clear()
943
+ _LOGGER.debug("STOP: Client caches cleared")
944
+
945
+ # cancel outstanding tasks to speed up teardown
946
+ self.looper.cancel_tasks()
947
+ # wait until tasks are finished (with wait_time safeguard)
948
+ await self.looper.block_till_done(wait_time=5.0)
949
+
950
+ # Wait briefly for any auxiliary threads to finish without blocking forever
951
+ max_wait_seconds = 5.0
952
+ interval = 0.05
953
+ waited = 0.0
954
+ while self._has_active_threads and waited < max_wait_seconds:
955
+ await asyncio.sleep(interval)
956
+ waited += interval
957
+ _LOGGER.debug("STOP: Central %s is %s", self.name, self.state)
958
+
959
+ # Transition central state machine to STOPPED
960
+ if self._central_state_machine.can_transition_to(target=CentralState.STOPPED):
961
+ self._central_state_machine.transition_to(
962
+ target=CentralState.STOPPED,
963
+ reason="stop() completed",
964
+ )
965
+
966
+ async def validate_config_and_get_system_information(self) -> SystemInformation:
967
+ """Validate the central configuration."""
968
+ if len(self._config.enabled_interface_configs) == 0:
969
+ raise NoClientsException(i18n.tr(key="exception.central.validate_config.no_clients"))
970
+
971
+ system_information = SystemInformation()
972
+ for interface_config in self._config.enabled_interface_configs:
973
+ try:
974
+ client = await hmcl.create_client(client_deps=self, interface_config=interface_config)
975
+ except BaseHomematicException as bhexc:
976
+ _LOGGER.error(
977
+ i18n.tr(
978
+ key="log.central.validate_config_and_get_system_information.client_failed",
979
+ interface=str(interface_config.interface),
980
+ reason=extract_exc_args(exc=bhexc),
981
+ )
982
+ )
983
+ raise
984
+ if client.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES and not system_information.serial:
985
+ system_information = client.system_information
986
+ return system_information
987
+
988
+ async def _identify_ip_addr(self, *, port: int) -> str:
989
+ ip_addr: str | None = None
990
+ while ip_addr is None:
991
+ try:
992
+ ip_addr = await get_ip_addr(host=self._config.host, port=port)
993
+ except AioHomematicException:
994
+ ip_addr = LOCAL_HOST
995
+ if ip_addr is None:
996
+ schedule_cfg = self._config.schedule_timer_config
997
+ timeout_cfg = self._config.timeout_config
998
+ _LOGGER.warning( # i18n-log: ignore
999
+ "GET_IP_ADDR: Waiting for %.1f s,", schedule_cfg.connection_checker_interval
1000
+ )
1001
+ await asyncio.sleep(timeout_cfg.rpc_timeout / 10)
1002
+ return ip_addr
1003
+
1004
+ def _on_system_status_event(self, *, event: SystemStatusChangedEvent) -> None:
1005
+ """Handle system status events and update central state machine accordingly."""
1006
+ # Only handle client state changes
1007
+ if event.client_state is None:
1008
+ return
1009
+
1010
+ interface_id, old_state, new_state = event.client_state
1011
+
1012
+ # Update health tracker with new client state
1013
+ self._health_tracker.update_client_health(
1014
+ interface_id=interface_id,
1015
+ old_state=old_state,
1016
+ new_state=new_state,
1017
+ )
1018
+
1019
+ # Immediately mark devices as unavailable when client disconnects or fails
1020
+ if new_state in (ClientState.DISCONNECTED, ClientState.FAILED):
1021
+ for device in self._device_registry.devices:
1022
+ if device.interface_id == interface_id:
1023
+ device.set_forced_availability(forced_availability=ForcedDeviceAvailability.FORCE_FALSE)
1024
+ _LOGGER.debug(
1025
+ "CLIENT_STATE_CHANGE: Marked all devices unavailable for %s (state=%s)",
1026
+ interface_id,
1027
+ new_state.value,
1028
+ )
1029
+
1030
+ # Reset forced availability when client reconnects successfully
1031
+ if new_state == ClientState.CONNECTED and old_state in (
1032
+ ClientState.DISCONNECTED,
1033
+ ClientState.FAILED,
1034
+ ClientState.RECONNECTING,
1035
+ ):
1036
+ for device in self._device_registry.devices:
1037
+ if device.interface_id == interface_id:
1038
+ device.set_forced_availability(forced_availability=ForcedDeviceAvailability.NOT_SET)
1039
+ _LOGGER.debug(
1040
+ "CLIENT_STATE_CHANGE: Reset device availability for %s (reconnected)",
1041
+ interface_id,
1042
+ )
1043
+
1044
+ # Determine overall central state based on all client states
1045
+ clients = self._client_coordinator.clients
1046
+ # Note: all() returns True for empty iterables, so we must check clients exist
1047
+ all_connected = bool(clients) and all(client.state == ClientState.CONNECTED for client in clients)
1048
+ any_connected = any(client.state == ClientState.CONNECTED for client in clients)
1049
+
1050
+ # Only transition if central is in a state that allows it
1051
+ if (current_state := self._central_state_machine.state) not in (CentralState.STARTING, CentralState.STOPPED):
1052
+ # Don't transition to RUNNING if recovery is still in progress for any interface.
1053
+ # The ConnectionRecoveryCoordinator will handle the transition when all recoveries complete.
1054
+ if (
1055
+ all_connected
1056
+ and not self._connection_recovery_coordinator.in_recovery
1057
+ and self._central_state_machine.can_transition_to(target=CentralState.RUNNING)
1058
+ ):
1059
+ self._central_state_machine.transition_to(
1060
+ target=CentralState.RUNNING,
1061
+ reason=f"all clients connected (triggered by {interface_id})",
1062
+ )
1063
+ elif (
1064
+ any_connected
1065
+ and not all_connected
1066
+ and current_state == CentralState.RUNNING
1067
+ and self._central_state_machine.can_transition_to(target=CentralState.DEGRADED)
1068
+ ):
1069
+ # Only transition to DEGRADED from RUNNING when some (but not all) clients connected
1070
+ degraded_interfaces: dict[str, FailureReason] = {
1071
+ client.interface_id: (
1072
+ reason
1073
+ if (reason := client.state_machine.failure_reason) != FailureReason.NONE
1074
+ else FailureReason.UNKNOWN
1075
+ )
1076
+ for client in clients
1077
+ if client.state != ClientState.CONNECTED
1078
+ }
1079
+ self._central_state_machine.transition_to(
1080
+ target=CentralState.DEGRADED,
1081
+ reason=f"clients not connected: {', '.join(degraded_interfaces.keys())}",
1082
+ degraded_interfaces=degraded_interfaces,
1083
+ )
1084
+ elif (
1085
+ not any_connected
1086
+ and current_state in (CentralState.RUNNING, CentralState.DEGRADED)
1087
+ and self._central_state_machine.can_transition_to(target=CentralState.FAILED)
1088
+ ):
1089
+ # All clients failed - get failure reason from first failed client
1090
+ failure_reason = FailureReason.NETWORK # Default for disconnection
1091
+ failure_interface_id: str | None = None
1092
+ for client in clients:
1093
+ if client.state_machine.is_failed and client.state_machine.failure_reason != FailureReason.NONE:
1094
+ failure_reason = client.state_machine.failure_reason
1095
+ failure_interface_id = client.interface_id
1096
+ break
1097
+ self._central_state_machine.transition_to(
1098
+ target=CentralState.FAILED,
1099
+ reason="all clients disconnected",
1100
+ failure_reason=failure_reason,
1101
+ failure_interface_id=failure_interface_id,
1102
+ )
1103
+
1104
+ def _start_scheduler(self) -> None:
1105
+ """Start the background scheduler."""
1106
+ _LOGGER.debug(
1107
+ "START_SCHEDULER: Starting scheduler for %s",
1108
+ self.name,
1109
+ )
1110
+ # Schedule async start() method via looper
1111
+ self._looper.create_task(
1112
+ target=self._scheduler.start(),
1113
+ name=f"start_scheduler_{self.name}",
1114
+ )
1115
+
1116
+ async def _stop_scheduler(self) -> None:
1117
+ """Stop the background scheduler."""
1118
+ await self._scheduler.stop()
1119
+ _LOGGER.debug(
1120
+ "STOP_SCHEDULER: Stopped scheduler for %s",
1121
+ self.name,
1122
+ )
1123
+
1124
+
1125
+ def _get_new_data_points(
1126
+ *,
1127
+ new_devices: set[DeviceProtocol],
1128
+ ) -> Mapping[DataPointCategory, AbstractSet[CallbackDataPointProtocol]]:
1129
+ """Return new data points by category."""
1130
+ data_points_by_category: dict[DataPointCategory, set[CallbackDataPointProtocol]] = {
1131
+ category: set() for category in CATEGORIES if category != DataPointCategory.EVENT
1132
+ }
1133
+
1134
+ for device in new_devices:
1135
+ for category, data_points in data_points_by_category.items():
1136
+ data_points.update(device.get_data_points(category=category, exclude_no_create=True, registered=False))
1137
+
1138
+ return data_points_by_category
1139
+
1140
+
1141
+ def _get_new_channel_events(*, new_devices: set[DeviceProtocol]) -> tuple[tuple[GenericEventProtocolAny, ...], ...]:
1142
+ """Return new channel events by category."""
1143
+ channel_events: list[tuple[GenericEventProtocolAny, ...]] = []
1144
+
1145
+ for device in new_devices:
1146
+ for event_type in DATA_POINT_EVENTS:
1147
+ if (hm_channel_events := list(device.get_events(event_type=event_type, registered=False).values())) and len(
1148
+ hm_channel_events
1149
+ ) > 0:
1150
+ channel_events.append(hm_channel_events) # type: ignore[arg-type] # noqa:PERF401
1151
+
1152
+ return tuple(channel_events)