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,1166 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Device coordinator for managing device lifecycle and operations.
5
+
6
+ This module provides centralized device management including creation,
7
+ registration, removal, and device-related operations.
8
+
9
+ The DeviceCoordinator provides:
10
+ - Device creation and initialization
11
+ - Device registration via DeviceRegistry
12
+ - Device removal and cleanup
13
+ - Device description management
14
+ - Data point and event creation for devices
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from collections import defaultdict
21
+ from collections.abc import Mapping
22
+ from datetime import datetime
23
+ import logging
24
+ from typing import TYPE_CHECKING, Any, Final
25
+
26
+ from aiohomematic import i18n
27
+ from aiohomematic.central.decorators import callback_backend_system
28
+ from aiohomematic.central.events import DeviceRemovedEvent
29
+ from aiohomematic.const import (
30
+ CATEGORIES,
31
+ DATA_POINT_EVENTS,
32
+ DataPointCategory,
33
+ DeviceDescription,
34
+ DeviceFirmwareState,
35
+ ParamsetKey,
36
+ SourceOfDeviceCreation,
37
+ SystemEventType,
38
+ )
39
+ from aiohomematic.decorators import inspector
40
+ from aiohomematic.exceptions import AioHomematicException
41
+ from aiohomematic.interfaces import (
42
+ CallbackDataPointProtocol,
43
+ CentralInfoProtocol,
44
+ ChannelProtocol,
45
+ ClientProviderProtocol,
46
+ ConfigProviderProtocol,
47
+ CoordinatorProviderProtocol,
48
+ DataCacheProviderProtocol,
49
+ DataPointProviderProtocol,
50
+ DeviceDescriptionProviderProtocol,
51
+ DeviceDetailsProviderProtocol,
52
+ DeviceProtocol,
53
+ EventBusProviderProtocol,
54
+ EventPublisherProtocol,
55
+ EventSubscriptionManagerProtocol,
56
+ FileOperationsProtocol,
57
+ GenericEventProtocolAny,
58
+ ParameterVisibilityProviderProtocol,
59
+ ParamsetDescriptionProviderProtocol,
60
+ TaskSchedulerProtocol,
61
+ )
62
+ from aiohomematic.interfaces.central import FirmwareDataRefresherProtocol
63
+ from aiohomematic.interfaces.client import DeviceDiscoveryAndMetadataProtocol, DeviceDiscoveryWithIdentityProtocol
64
+ from aiohomematic.model import create_data_points_and_events
65
+ from aiohomematic.model.custom import create_custom_data_points
66
+ from aiohomematic.model.device import Device
67
+ from aiohomematic.property_decorators import DelegatedProperty
68
+ from aiohomematic.support import extract_exc_args
69
+
70
+ if TYPE_CHECKING:
71
+ from aiohomematic.central import DeviceRegistry # noqa: F401
72
+
73
+ _LOGGER: Final = logging.getLogger(__name__)
74
+
75
+
76
+ class DeviceCoordinator(FirmwareDataRefresherProtocol):
77
+ """Coordinator for device lifecycle and operations."""
78
+
79
+ __slots__ = (
80
+ "_central_info",
81
+ "_client_provider",
82
+ "_config_provider",
83
+ "_coordinator_provider",
84
+ "_data_cache_provider",
85
+ "_data_point_provider",
86
+ "_delayed_device_descriptions",
87
+ "_device_add_semaphore",
88
+ "_device_description_provider",
89
+ "_device_details_provider",
90
+ "_event_bus_provider",
91
+ "_event_publisher",
92
+ "_event_subscription_manager",
93
+ "_file_operations",
94
+ "_parameter_visibility_provider",
95
+ "_paramset_description_provider",
96
+ "_task_scheduler",
97
+ )
98
+
99
+ def __init__(
100
+ self,
101
+ *,
102
+ central_info: CentralInfoProtocol,
103
+ client_provider: ClientProviderProtocol,
104
+ config_provider: ConfigProviderProtocol,
105
+ coordinator_provider: CoordinatorProviderProtocol,
106
+ data_cache_provider: DataCacheProviderProtocol,
107
+ data_point_provider: DataPointProviderProtocol,
108
+ device_description_provider: DeviceDescriptionProviderProtocol,
109
+ device_details_provider: DeviceDetailsProviderProtocol,
110
+ event_bus_provider: EventBusProviderProtocol,
111
+ event_publisher: EventPublisherProtocol,
112
+ event_subscription_manager: EventSubscriptionManagerProtocol,
113
+ file_operations: FileOperationsProtocol,
114
+ parameter_visibility_provider: ParameterVisibilityProviderProtocol,
115
+ paramset_description_provider: ParamsetDescriptionProviderProtocol,
116
+ task_scheduler: TaskSchedulerProtocol,
117
+ ) -> None:
118
+ """
119
+ Initialize the device coordinator.
120
+
121
+ Args:
122
+ ----
123
+ central_info: Provider for central system information
124
+ client_provider: Provider for client access
125
+ config_provider: Provider for configuration access
126
+ coordinator_provider: Provider for accessing other coordinators
127
+ data_cache_provider: Provider for data cache access
128
+ data_point_provider: Provider for data point access
129
+ device_description_provider: Provider for device descriptions
130
+ device_details_provider: Provider for device details
131
+ event_bus_provider: Provider for event bus access
132
+ event_publisher: Provider for event publisher access
133
+ event_subscription_manager: Manager for event subscriptions
134
+ file_operations: Provider for file operations
135
+ parameter_visibility_provider: Provider for parameter visibility rules
136
+ paramset_description_provider: Provider for paramset descriptions
137
+ task_scheduler: Scheduler for async tasks
138
+
139
+ """
140
+ self._central_info: Final = central_info
141
+ self._client_provider: Final = client_provider
142
+ self._config_provider: Final = config_provider
143
+ self._coordinator_provider: Final = coordinator_provider
144
+ self._data_cache_provider: Final = data_cache_provider
145
+ self._data_point_provider: Final = data_point_provider
146
+ self._device_description_provider: Final = device_description_provider
147
+ self._device_details_provider: Final = device_details_provider
148
+ self._event_bus_provider: Final = event_bus_provider
149
+ self._event_publisher: Final = event_publisher
150
+ self._event_subscription_manager: Final = event_subscription_manager
151
+ self._file_operations: Final = file_operations
152
+ self._parameter_visibility_provider: Final = parameter_visibility_provider
153
+ self._paramset_description_provider: Final = paramset_description_provider
154
+ self._task_scheduler: Final = task_scheduler
155
+ self._delayed_device_descriptions: Final[dict[str, list[DeviceDescription]]] = defaultdict(list)
156
+ self._device_add_semaphore: Final = asyncio.Semaphore()
157
+
158
+ device_registry: Final = DelegatedProperty["DeviceRegistry"](path="_coordinator_provider.device_registry")
159
+
160
+ @property
161
+ def devices(self) -> tuple[DeviceProtocol, ...]:
162
+ """Return all devices."""
163
+ return self.device_registry.devices
164
+
165
+ @callback_backend_system(system_event=SystemEventType.NEW_DEVICES)
166
+ async def add_new_devices(self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]) -> None:
167
+ """
168
+ Add new devices to central unit (callback from backend).
169
+
170
+ Args:
171
+ ----
172
+ interface_id: Interface identifier
173
+ device_descriptions: Tuple of device descriptions
174
+
175
+ """
176
+ source = (
177
+ SourceOfDeviceCreation.NEW
178
+ if self._coordinator_provider.cache_coordinator.device_descriptions.has_device_descriptions(
179
+ interface_id=interface_id
180
+ )
181
+ else SourceOfDeviceCreation.INIT
182
+ )
183
+ await self._add_new_devices(interface_id=interface_id, device_descriptions=device_descriptions, source=source)
184
+
185
+ async def add_new_devices_manually(self, *, interface_id: str, address_names: Mapping[str, str | None]) -> None:
186
+ """
187
+ Add new devices manually triggered to central unit.
188
+
189
+ Args:
190
+ interface_id: Interface identifier.
191
+ address_names: Device addresses and their names.
192
+
193
+ """
194
+ if not self._coordinator_provider.client_coordinator.has_client(interface_id=interface_id):
195
+ _LOGGER.error( # i18n-log: ignore
196
+ "ADD_NEW_DEVICES_MANUALLY failed: Missing client for interface_id %s",
197
+ interface_id,
198
+ )
199
+ return
200
+
201
+ client = self._coordinator_provider.client_coordinator.get_client(interface_id=interface_id)
202
+ device_descriptions: list[DeviceDescription] = []
203
+ for address, device_name in address_names.items():
204
+ if not (dds := self._delayed_device_descriptions.pop(address, None)):
205
+ _LOGGER.error( # i18n-log: ignore
206
+ "ADD_NEW_DEVICES_MANUALLY failed: No device description found for address %s on interface_id %s",
207
+ address,
208
+ interface_id,
209
+ )
210
+ return
211
+ device_descriptions.extend(dds)
212
+
213
+ await client.accept_device_in_inbox(device_address=address)
214
+
215
+ if device_name:
216
+ await self._rename_new_device(
217
+ client=client,
218
+ device_descriptions=tuple(dds),
219
+ device_name=device_name,
220
+ )
221
+
222
+ await self._add_new_devices(
223
+ interface_id=interface_id,
224
+ device_descriptions=tuple(device_descriptions),
225
+ source=SourceOfDeviceCreation.MANUAL,
226
+ )
227
+
228
+ async def check_and_create_devices_from_cache(self) -> None:
229
+ """
230
+ Check for new devices in cache and create them atomically.
231
+
232
+ Race condition prevention:
233
+ This method acquires the device_add_semaphore to ensure it doesn't
234
+ race with _add_new_devices() which is populating the cache from
235
+ newDevices callbacks. Without this synchronization, the startup
236
+ code could try to create devices while descriptions are still
237
+ being added, resulting in devices with missing channels.
238
+
239
+ """
240
+ async with self._device_add_semaphore:
241
+ if new_device_addresses := self.check_for_new_device_addresses():
242
+ await self.create_devices(
243
+ new_device_addresses=new_device_addresses,
244
+ source=SourceOfDeviceCreation.CACHE,
245
+ )
246
+
247
+ def check_for_new_device_addresses(self, *, interface_id: str | None = None) -> Mapping[str, set[str]]:
248
+ """
249
+ Check if there are new devices that need to be created.
250
+
251
+ Algorithm:
252
+ This method identifies device addresses that exist in the cache
253
+ (from device descriptions) but haven't been created as Device objects yet.
254
+
255
+ 1. Get all existing device addresses from device registry (O(1) lookup set)
256
+ 2. For each interface, get cached addresses from device descriptions
257
+ 3. Compute set difference: cached_addresses - existing_addresses
258
+ 4. Non-empty differences indicate devices that need creation
259
+
260
+ Why use a helper function?
261
+ The helper function allows the same logic to work for:
262
+ - Single interface check (when interface_id is provided)
263
+ - All interfaces check (when interface_id is None)
264
+ This avoids code duplication while keeping the interface flexible.
265
+
266
+ Performance note:
267
+ Set difference (addresses - existing_addresses) is O(n) where n is the
268
+ smaller set, making this efficient even for large device counts.
269
+
270
+ Args:
271
+ ----
272
+ interface_id: Optional interface identifier to check
273
+
274
+ Returns:
275
+ -------
276
+ Mapping of interface IDs to sets of new device addresses
277
+
278
+ """
279
+ new_device_addresses: dict[str, set[str]] = {}
280
+
281
+ # Cache existing device addresses once - this set is used for all difference operations
282
+ existing_addresses = self.device_registry.get_device_addresses()
283
+
284
+ def _check_for_new_device_addresses_helper(*, iid: str) -> None:
285
+ """
286
+ Check a single interface for new devices.
287
+
288
+ Encapsulates the per-interface logic to avoid duplication between
289
+ single-interface and all-interfaces code paths.
290
+ """
291
+ # Skip interfaces without paramset descriptions (not fully initialized)
292
+ if not self._coordinator_provider.cache_coordinator.paramset_descriptions.has_interface_id(
293
+ interface_id=iid
294
+ ):
295
+ _LOGGER.debug(
296
+ "CHECK_FOR_NEW_DEVICE_ADDRESSES: Skipping interface %s, missing paramsets",
297
+ iid,
298
+ )
299
+ return
300
+
301
+ # Convert to set once for efficient set difference operation
302
+ addresses = set(
303
+ self._coordinator_provider.cache_coordinator.device_descriptions.get_addresses(interface_id=iid)
304
+ )
305
+
306
+ # Set difference: addresses in cache but not yet created as Device objects
307
+ if new_set := addresses - existing_addresses:
308
+ new_device_addresses[iid] = new_set
309
+
310
+ # Dispatch: single interface or all interfaces
311
+ if interface_id:
312
+ _check_for_new_device_addresses_helper(iid=interface_id)
313
+ else:
314
+ for iid in self._coordinator_provider.client_coordinator.interface_ids:
315
+ _check_for_new_device_addresses_helper(iid=iid)
316
+
317
+ if _LOGGER.isEnabledFor(level=logging.DEBUG):
318
+ count = sum(len(item) for item in new_device_addresses.values())
319
+ _LOGGER.debug(
320
+ "CHECK_FOR_NEW_DEVICE_ADDRESSES: %s: %i.",
321
+ "Found new device addresses" if new_device_addresses else "Did not find any new device addresses",
322
+ count,
323
+ )
324
+
325
+ return new_device_addresses
326
+
327
+ @inspector
328
+ async def create_central_links(self) -> None:
329
+ """Create central links to support press events on all channels with click events."""
330
+ for device in self.devices:
331
+ await device.create_central_links()
332
+
333
+ async def create_devices(
334
+ self, *, new_device_addresses: Mapping[str, set[str]], source: SourceOfDeviceCreation
335
+ ) -> None:
336
+ """
337
+ Trigger creation of the objects that expose the functionality.
338
+
339
+ Args:
340
+ ----
341
+ new_device_addresses: Mapping of interface IDs to device addresses
342
+ source: Source of device creation
343
+
344
+ """
345
+ if not self._coordinator_provider.client_coordinator.has_clients:
346
+ raise AioHomematicException(
347
+ i18n.tr(
348
+ key="exception.central.create_devices.no_clients",
349
+ name=self._central_info.name,
350
+ )
351
+ )
352
+ _LOGGER.debug("CREATE_DEVICES: Starting to create devices for %s", self._central_info.name)
353
+
354
+ new_devices = set[DeviceProtocol]()
355
+
356
+ for interface_id, device_addresses in new_device_addresses.items():
357
+ for device_address in device_addresses:
358
+ # Do we check for duplicates here? For now, we do.
359
+ if self.device_registry.has_device(address=device_address):
360
+ continue
361
+ device: DeviceProtocol | None = None
362
+ try:
363
+ device = Device(
364
+ interface_id=interface_id,
365
+ device_address=device_address,
366
+ central_info=self._central_info,
367
+ channel_lookup=self,
368
+ client_provider=self._client_provider,
369
+ config_provider=self._config_provider,
370
+ data_cache_provider=self._data_cache_provider,
371
+ data_point_provider=self._data_point_provider,
372
+ device_data_refresher=self,
373
+ device_description_provider=self._device_description_provider,
374
+ device_details_provider=self._device_details_provider,
375
+ event_bus_provider=self._event_bus_provider,
376
+ event_publisher=self._event_publisher,
377
+ event_subscription_manager=self._event_subscription_manager,
378
+ file_operations=self._file_operations,
379
+ parameter_visibility_provider=self._parameter_visibility_provider,
380
+ paramset_description_provider=self._paramset_description_provider,
381
+ task_scheduler=self._task_scheduler,
382
+ )
383
+ except Exception as exc:
384
+ _LOGGER.error( # i18n-log: ignore
385
+ "CREATE_DEVICES failed: %s [%s] Unable to create device: %s, %s",
386
+ type(exc).__name__,
387
+ extract_exc_args(exc=exc),
388
+ interface_id,
389
+ device_address,
390
+ )
391
+ try:
392
+ if device:
393
+ create_data_points_and_events(device=device)
394
+ create_custom_data_points(device=device)
395
+ new_devices.add(device)
396
+ await self.device_registry.add_device(device=device)
397
+ except Exception as exc:
398
+ _LOGGER.error( # i18n-log: ignore
399
+ "CREATE_DEVICES failed: %s [%s] Unable to create data points: %s, %s",
400
+ type(exc).__name__,
401
+ extract_exc_args(exc=exc),
402
+ interface_id,
403
+ device_address,
404
+ )
405
+ _LOGGER.debug("CREATE_DEVICES: Finished creating devices for %s", self._central_info.name)
406
+
407
+ if new_devices:
408
+ for device in new_devices:
409
+ await device.finalize_init()
410
+ new_dps: dict[DataPointCategory, Any] = _get_new_data_points(new_devices=new_devices)
411
+ new_dps[DataPointCategory.EVENT] = _get_new_channel_events(new_devices=new_devices)
412
+ self._coordinator_provider.event_coordinator.publish_system_event(
413
+ system_event=SystemEventType.DEVICES_CREATED,
414
+ new_data_points=new_dps,
415
+ source=source,
416
+ )
417
+
418
+ async def delete_device(self, *, interface_id: str, device_address: str) -> None:
419
+ """
420
+ Delete a device from central.
421
+
422
+ Args:
423
+ ----
424
+ interface_id: Interface identifier
425
+ device_address: Device address
426
+
427
+ """
428
+ _LOGGER.debug(
429
+ "DELETE_DEVICE: interface_id = %s, device_address = %s",
430
+ interface_id,
431
+ device_address,
432
+ )
433
+
434
+ if (device := self.device_registry.get_device(address=device_address)) is None:
435
+ return
436
+
437
+ await self.delete_devices(interface_id=interface_id, addresses=(device_address, *tuple(device.channels.keys())))
438
+
439
+ @callback_backend_system(system_event=SystemEventType.DELETE_DEVICES)
440
+ async def delete_devices(self, *, interface_id: str, addresses: tuple[str, ...]) -> None:
441
+ """
442
+ Delete multiple devices from central.
443
+
444
+ Args:
445
+ ----
446
+ interface_id: Interface identifier
447
+ addresses: Tuple of addresses to delete
448
+
449
+ """
450
+ _LOGGER.debug(
451
+ "DELETE_DEVICES: interface_id = %s, addresses = %s",
452
+ interface_id,
453
+ str(addresses),
454
+ )
455
+
456
+ for address in addresses:
457
+ if device := self.device_registry.get_device(address=address):
458
+ await self.remove_device(device=device)
459
+
460
+ await self._coordinator_provider.cache_coordinator.save_all(
461
+ save_device_descriptions=True,
462
+ save_paramset_descriptions=True,
463
+ )
464
+
465
+ def get_channel(self, *, channel_address: str) -> ChannelProtocol | None:
466
+ """
467
+ Return Homematic channel.
468
+
469
+ Args:
470
+ ----
471
+ channel_address: Channel address
472
+
473
+ Returns:
474
+ -------
475
+ Channel instance or None if not found
476
+
477
+ """
478
+ return self.device_registry.get_channel(channel_address=channel_address)
479
+
480
+ def get_device(self, *, address: str) -> DeviceProtocol | None:
481
+ """
482
+ Return Homematic device.
483
+
484
+ Args:
485
+ ----
486
+ address: Device address
487
+
488
+ Returns:
489
+ -------
490
+ Device instance or None if not found
491
+
492
+ """
493
+ return self.device_registry.get_device(address=address)
494
+
495
+ def get_virtual_remotes(self) -> tuple[DeviceProtocol, ...]:
496
+ """Get the virtual remotes for all clients."""
497
+ return self.device_registry.get_virtual_remotes()
498
+
499
+ def identify_channel(self, *, text: str) -> ChannelProtocol | None:
500
+ """
501
+ Identify channel within a text.
502
+
503
+ Args:
504
+ ----
505
+ text: Text to search for channel identification
506
+
507
+ Returns:
508
+ -------
509
+ Channel instance or None if not found
510
+
511
+ """
512
+ return self.device_registry.identify_channel(text=text)
513
+
514
+ @callback_backend_system(system_event=SystemEventType.LIST_DEVICES)
515
+ def list_devices(self, *, interface_id: str) -> list[DeviceDescription]:
516
+ """
517
+ Return already existing devices to the backend.
518
+
519
+ Args:
520
+ ----
521
+ interface_id: Interface identifier
522
+
523
+ Returns:
524
+ -------
525
+ List of device descriptions
526
+
527
+ """
528
+ result = self._coordinator_provider.cache_coordinator.device_descriptions.get_raw_device_descriptions(
529
+ interface_id=interface_id
530
+ )
531
+ _LOGGER.debug("LIST_DEVICES: interface_id = %s, channel_count = %i", interface_id, len(result))
532
+ return result
533
+
534
+ @callback_backend_system(system_event=SystemEventType.RE_ADDED_DEVICE)
535
+ async def readd_device(self, *, interface_id: str, device_addresses: tuple[str, ...]) -> None:
536
+ """
537
+ Handle re-added device after re-pairing in learn mode.
538
+
539
+ This method is called when the CCU sends a readdedDevice callback, which
540
+ occurs when a known device is put into learn-mode while installation mode
541
+ is active (re-pairing). The device parameters may have changed, so we
542
+ refresh the device data.
543
+
544
+ Args:
545
+ ----
546
+ interface_id: Interface identifier
547
+ device_addresses: Addresses of the re-added devices
548
+
549
+ """
550
+ _LOGGER.debug(
551
+ "READD_DEVICE: interface_id = %s, device_addresses = %s",
552
+ interface_id,
553
+ str(device_addresses),
554
+ )
555
+
556
+ if not self._coordinator_provider.client_coordinator.has_client(interface_id=interface_id):
557
+ _LOGGER.error( # i18n-log: ignore
558
+ "READD_DEVICE failed: Missing client for interface_id %s",
559
+ interface_id,
560
+ )
561
+ return
562
+
563
+ client = self._coordinator_provider.client_coordinator.get_client(interface_id=interface_id)
564
+
565
+ for device_address in device_addresses:
566
+ # Get existing device
567
+ if device := self.device_registry.get_device(address=device_address):
568
+ # Remove from caches to force refresh
569
+ self._coordinator_provider.cache_coordinator.device_descriptions.remove_device(device=device)
570
+ self._coordinator_provider.cache_coordinator.paramset_descriptions.remove_device(device=device)
571
+ await self.remove_device(device=device)
572
+
573
+ # Fetch fresh device descriptions and recreate
574
+ await self.refresh_device_descriptions_and_create_missing_devices(
575
+ client=client, refresh_only_existing=False, device_address=device_address
576
+ )
577
+
578
+ # Save updated caches
579
+ await self._coordinator_provider.cache_coordinator.save_all(
580
+ save_device_descriptions=True,
581
+ save_paramset_descriptions=True,
582
+ )
583
+
584
+ async def refresh_device_descriptions_and_create_missing_devices(
585
+ self,
586
+ *,
587
+ client: DeviceDiscoveryWithIdentityProtocol,
588
+ refresh_only_existing: bool,
589
+ device_address: str | None = None,
590
+ ) -> None:
591
+ """
592
+ Refresh device descriptions and create missing devices.
593
+
594
+ Args:
595
+ ----
596
+ client: Client to use for refreshing
597
+ refresh_only_existing: Whether to only refresh existing devices
598
+ device_address: Optional device address to refresh
599
+
600
+ """
601
+ device_descriptions: tuple[DeviceDescription, ...] | None = None
602
+
603
+ if (
604
+ device_address
605
+ and (device_description := await client.get_device_description(address=device_address)) is not None
606
+ ):
607
+ device_descriptions = (device_description,)
608
+ else:
609
+ device_descriptions = await client.list_devices()
610
+
611
+ if (
612
+ device_descriptions
613
+ and refresh_only_existing
614
+ and (
615
+ existing_device_descriptions := tuple(
616
+ dev_desc
617
+ for dev_desc in list(device_descriptions)
618
+ if dev_desc["ADDRESS"]
619
+ in self._coordinator_provider.cache_coordinator.device_descriptions.get_device_descriptions(
620
+ interface_id=client.interface_id
621
+ )
622
+ )
623
+ )
624
+ ):
625
+ device_descriptions = existing_device_descriptions
626
+
627
+ if device_descriptions:
628
+ await self._add_new_devices(
629
+ interface_id=client.interface_id,
630
+ device_descriptions=device_descriptions,
631
+ source=SourceOfDeviceCreation.REFRESH,
632
+ )
633
+
634
+ async def refresh_device_link_peers(self, *, device_address: str) -> None:
635
+ """
636
+ Refresh link peer information for a device after link partner change.
637
+
638
+ This method is called when the CCU sends an updateDevice callback with
639
+ hint=1 (link partner change). It refreshes the link peer addresses for
640
+ all channels of the device.
641
+
642
+ Args:
643
+ ----
644
+ device_address: Device address to refresh link peers for
645
+
646
+ """
647
+ _LOGGER.debug(
648
+ "REFRESH_DEVICE_LINK_PEERS: device_address = %s",
649
+ device_address,
650
+ )
651
+
652
+ if (device := self.device_registry.get_device(address=device_address)) is None:
653
+ _LOGGER.debug(
654
+ "REFRESH_DEVICE_LINK_PEERS: Device %s not found in registry",
655
+ device_address,
656
+ )
657
+ return
658
+
659
+ # Refresh link peers for all channels
660
+ for channel in device.channels.values():
661
+ await channel.init_link_peer()
662
+
663
+ @inspector(re_raise=False)
664
+ async def refresh_firmware_data(self, *, device_address: str | None = None) -> None:
665
+ """
666
+ Refresh device firmware data.
667
+
668
+ Args:
669
+ ----
670
+ device_address: Optional device address to refresh, or None for all devices
671
+
672
+ """
673
+ if device_address and (device := self.get_device(address=device_address)) is not None and device.is_updatable:
674
+ await self.refresh_device_descriptions_and_create_missing_devices(
675
+ client=device.client, refresh_only_existing=True, device_address=device_address
676
+ )
677
+ device.refresh_firmware_data()
678
+ else:
679
+ for client in self._coordinator_provider.client_coordinator.clients:
680
+ await self.refresh_device_descriptions_and_create_missing_devices(
681
+ client=client, refresh_only_existing=True
682
+ )
683
+ for device in self.devices:
684
+ if device.is_updatable:
685
+ device.refresh_firmware_data()
686
+
687
+ @inspector(re_raise=False)
688
+ async def refresh_firmware_data_by_state(self, *, device_firmware_states: tuple[DeviceFirmwareState, ...]) -> None:
689
+ """Refresh firmware by state (internal use - use device_coordinator for external access)."""
690
+ for device in [
691
+ device_in_state
692
+ for device_in_state in self.devices
693
+ if device_in_state.firmware_update_state in device_firmware_states
694
+ ]:
695
+ await self.refresh_firmware_data(device_address=device.address)
696
+
697
+ @inspector
698
+ async def remove_central_links(self) -> None:
699
+ """Remove central links."""
700
+ for device in self.devices:
701
+ await device.remove_central_links()
702
+
703
+ async def remove_device(self, *, device: DeviceProtocol) -> None:
704
+ """
705
+ Remove device from central collections.
706
+
707
+ Emits DeviceRemovedEvent to trigger decoupled cache invalidation.
708
+
709
+ Args:
710
+ ----
711
+ device: Device to remove
712
+
713
+ """
714
+ if not self.device_registry.has_device(address=device.address):
715
+ _LOGGER.debug(
716
+ "REMOVE_DEVICE: device %s not registered in central",
717
+ device.address,
718
+ )
719
+ return
720
+
721
+ # Capture data before removal for event emission
722
+ device_address = device.address
723
+ interface_id = device.interface_id
724
+ channel_addresses = tuple(device.channels.keys())
725
+ identifier = device.identifier
726
+
727
+ device.remove()
728
+
729
+ # Emit event for decoupled cache invalidation
730
+ await self._event_bus_provider.event_bus.publish(
731
+ event=DeviceRemovedEvent(
732
+ timestamp=datetime.now(),
733
+ unique_id=identifier,
734
+ device_address=device_address,
735
+ interface_id=interface_id,
736
+ channel_addresses=channel_addresses,
737
+ )
738
+ )
739
+
740
+ await self.device_registry.remove_device(device_address=device_address)
741
+
742
+ @callback_backend_system(system_event=SystemEventType.REPLACE_DEVICE)
743
+ async def replace_device(self, *, interface_id: str, old_device_address: str, new_device_address: str) -> None:
744
+ """
745
+ Replace an old device with a new device after CCU device replacement.
746
+
747
+ This method is called when the CCU sends a replaceDevice callback, which
748
+ occurs when a user replaces a broken device with a new one using the CCU's
749
+ "Replace device" function. The CCU transfers configuration from the old
750
+ device to the new one.
751
+
752
+ Args:
753
+ ----
754
+ interface_id: Interface identifier
755
+ old_device_address: Address of the device being replaced
756
+ new_device_address: Address of the replacement device
757
+
758
+ """
759
+ _LOGGER.debug(
760
+ "REPLACE_DEVICE: interface_id = %s, old_device_address = %s, new_device_address = %s",
761
+ interface_id,
762
+ old_device_address,
763
+ new_device_address,
764
+ )
765
+
766
+ if not self._coordinator_provider.client_coordinator.has_client(interface_id=interface_id):
767
+ _LOGGER.error( # i18n-log: ignore
768
+ "REPLACE_DEVICE failed: Missing client for interface_id %s",
769
+ interface_id,
770
+ )
771
+ return
772
+
773
+ # Remove old device from registry and caches
774
+ if old_device := self.device_registry.get_device(address=old_device_address):
775
+ self._coordinator_provider.cache_coordinator.device_descriptions.remove_device(device=old_device)
776
+ self._coordinator_provider.cache_coordinator.paramset_descriptions.remove_device(device=old_device)
777
+ await self.remove_device(device=old_device)
778
+
779
+ # Fetch and create new device
780
+ client = self._coordinator_provider.client_coordinator.get_client(interface_id=interface_id)
781
+ await self.refresh_device_descriptions_and_create_missing_devices(
782
+ client=client, refresh_only_existing=False, device_address=new_device_address
783
+ )
784
+
785
+ # Save updated caches
786
+ await self._coordinator_provider.cache_coordinator.save_all(
787
+ save_device_descriptions=True,
788
+ save_paramset_descriptions=True,
789
+ )
790
+
791
+ @callback_backend_system(system_event=SystemEventType.UPDATE_DEVICE)
792
+ async def update_device(self, *, interface_id: str, device_address: str) -> None:
793
+ """
794
+ Update device after firmware update by invalidating cache and reloading.
795
+
796
+ This method is called when the CCU sends an updateDevice callback with
797
+ hint=0 (firmware update). It invalidates the cached device and paramset
798
+ descriptions, fetches fresh data from the backend, and recreates the
799
+ Device object.
800
+
801
+ Args:
802
+ ----
803
+ interface_id: Interface identifier
804
+ device_address: Device address to update
805
+
806
+ """
807
+ _LOGGER.debug(
808
+ "UPDATE_DEVICE: interface_id = %s, device_address = %s",
809
+ interface_id,
810
+ device_address,
811
+ )
812
+
813
+ if not self._coordinator_provider.client_coordinator.has_client(interface_id=interface_id):
814
+ _LOGGER.error( # i18n-log: ignore
815
+ "UPDATE_DEVICE failed: Missing client for interface_id %s",
816
+ interface_id,
817
+ )
818
+ return
819
+
820
+ # Get existing device to collect all channel addresses for cache invalidation
821
+ if device := self.device_registry.get_device(address=device_address):
822
+ # Remove device from caches using the device's channel information
823
+ self._coordinator_provider.cache_coordinator.device_descriptions.remove_device(device=device)
824
+ self._coordinator_provider.cache_coordinator.paramset_descriptions.remove_device(device=device)
825
+ # Remove the Device object from registry
826
+ await self.remove_device(device=device)
827
+
828
+ # Fetch fresh device descriptions from backend
829
+ client = self._coordinator_provider.client_coordinator.get_client(interface_id=interface_id)
830
+ await self.refresh_device_descriptions_and_create_missing_devices(
831
+ client=client, refresh_only_existing=False, device_address=device_address
832
+ )
833
+
834
+ # Save updated caches
835
+ await self._coordinator_provider.cache_coordinator.save_all(
836
+ save_device_descriptions=True,
837
+ save_paramset_descriptions=True,
838
+ )
839
+
840
+ @inspector(measure_performance=True)
841
+ async def _add_new_devices(
842
+ self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...], source: SourceOfDeviceCreation
843
+ ) -> None:
844
+ """
845
+ Add new devices to central unit.
846
+
847
+ Device creation pipeline:
848
+ This is a multi-step orchestration process:
849
+
850
+ 1. Validation: Skip if no descriptions or client missing
851
+ 2. Semaphore: Acquire lock to prevent concurrent device creation
852
+ 3. Filtering: Identify truly new devices (not already known)
853
+ 4. Delay check: Optionally defer creation for user confirmation
854
+ 5. Cache population:
855
+ - Add device descriptions to cache
856
+ - Fetch paramset descriptions from backend
857
+ 6. Persistence: Save updated caches to disk
858
+ 7. Device creation: Create Device objects from cached descriptions
859
+
860
+ Semaphore pattern:
861
+ The _device_add_semaphore ensures only one device addition operation
862
+ runs at a time. This prevents race conditions when multiple interfaces
863
+ report new devices simultaneously.
864
+
865
+ Delayed device creation:
866
+ When delay_new_device_creation is enabled, newly discovered devices
867
+ are stored in _delayed_device_descriptions instead of being created.
868
+ This allows the user to review and approve new devices before they
869
+ appear in Home Assistant.
870
+
871
+ Args:
872
+ ----
873
+ interface_id: Interface identifier
874
+ device_descriptions: Tuple of device descriptions
875
+ source: Source of device creation (STARTUP, NEW, MANUAL)
876
+
877
+ """
878
+ if not device_descriptions:
879
+ _LOGGER.debug(
880
+ "ADD_NEW_DEVICES: Nothing to add for interface_id %s",
881
+ interface_id,
882
+ )
883
+ return
884
+
885
+ _LOGGER.debug(
886
+ "ADD_NEW_DEVICES: interface_id = %s, device_descriptions = %s",
887
+ interface_id,
888
+ len(device_descriptions),
889
+ )
890
+
891
+ if not self._coordinator_provider.client_coordinator.has_client(interface_id=interface_id):
892
+ _LOGGER.error( # i18n-log: ignore
893
+ "ADD_NEW_DEVICES failed: Missing client for interface_id %s",
894
+ interface_id,
895
+ )
896
+ return
897
+
898
+ async with self._device_add_semaphore:
899
+ new_device_descriptions = self._identify_new_device_descriptions(
900
+ device_descriptions=device_descriptions, interface_id=interface_id
901
+ )
902
+
903
+ # For REFRESH operations, we need to update the cache even for existing devices
904
+ # (e.g., firmware data may have changed)
905
+ descriptions_to_cache = (
906
+ device_descriptions if source == SourceOfDeviceCreation.REFRESH else new_device_descriptions
907
+ )
908
+
909
+ if not descriptions_to_cache:
910
+ # Check if there are devices with missing paramset descriptions
911
+ # This can happen when device_descriptions were cached but paramsets weren't
912
+ # (e.g., previous run was interrupted after saving device_descriptions)
913
+ devices_missing_paramsets = self._identify_devices_missing_paramsets(
914
+ interface_id=interface_id, device_descriptions=device_descriptions
915
+ )
916
+ if not devices_missing_paramsets:
917
+ _LOGGER.debug("ADD_NEW_DEVICES: Nothing to add/update for interface_id %s", interface_id)
918
+ return
919
+
920
+ # Fetch missing paramset descriptions
921
+ _LOGGER.debug(
922
+ "ADD_NEW_DEVICES: Fetching missing paramsets for %s devices on interface_id %s",
923
+ len(devices_missing_paramsets),
924
+ interface_id,
925
+ )
926
+ client = self._coordinator_provider.client_coordinator.get_client(interface_id=interface_id)
927
+ for dev_desc in devices_missing_paramsets:
928
+ await client.fetch_paramset_descriptions(device_description=dev_desc)
929
+
930
+ await self._coordinator_provider.cache_coordinator.save_all(save_paramset_descriptions=True)
931
+
932
+ # Now check if we can create devices
933
+ if new_device_addresses := self.check_for_new_device_addresses(interface_id=interface_id):
934
+ await self._coordinator_provider.cache_coordinator.device_details.load()
935
+ await self._coordinator_provider.cache_coordinator.load_data_cache(interface=client.interface)
936
+ await self.create_devices(new_device_addresses=new_device_addresses, source=source)
937
+ return
938
+
939
+ # Here we block the automatic creation of new devices, if required
940
+ if self._config_provider.config.delay_new_device_creation and source == SourceOfDeviceCreation.NEW:
941
+ self._store_delayed_device_descriptions(device_descriptions=new_device_descriptions)
942
+ self._coordinator_provider.event_coordinator.publish_system_event(
943
+ system_event=SystemEventType.DEVICES_DELAYED,
944
+ new_addresses=tuple(self._delayed_device_descriptions.keys()),
945
+ interface_id=interface_id,
946
+ source=source,
947
+ )
948
+ return
949
+
950
+ client = self._coordinator_provider.client_coordinator.get_client(interface_id=interface_id)
951
+ save_descriptions = False
952
+ for dev_desc in descriptions_to_cache:
953
+ try:
954
+ self._coordinator_provider.cache_coordinator.device_descriptions.add_device(
955
+ interface_id=interface_id, device_description=dev_desc
956
+ )
957
+ # Only fetch paramset descriptions for new devices (not needed for refresh)
958
+ if source != SourceOfDeviceCreation.REFRESH or dev_desc in new_device_descriptions:
959
+ await client.fetch_paramset_descriptions(device_description=dev_desc)
960
+ save_descriptions = True
961
+ except Exception as exc: # pragma: no cover
962
+ save_descriptions = False
963
+ _LOGGER.error( # i18n-log: ignore
964
+ "UPDATE_CACHES_WITH_NEW_DEVICES failed: %s [%s]",
965
+ type(exc).__name__,
966
+ extract_exc_args(exc=exc),
967
+ )
968
+
969
+ await self._coordinator_provider.cache_coordinator.save_all(
970
+ save_device_descriptions=save_descriptions,
971
+ save_paramset_descriptions=save_descriptions,
972
+ )
973
+
974
+ # Device creation MUST be inside semaphore to prevent race condition:
975
+ # Without this, startup code can call check_for_new_device_addresses()
976
+ # while callback is still adding descriptions, causing incomplete devices.
977
+ if new_device_addresses := self.check_for_new_device_addresses(interface_id=interface_id):
978
+ await self._coordinator_provider.cache_coordinator.device_details.load()
979
+ await self._coordinator_provider.cache_coordinator.load_data_cache(interface=client.interface)
980
+ await self.create_devices(new_device_addresses=new_device_addresses, source=source)
981
+
982
+ def _identify_devices_missing_paramsets(
983
+ self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]
984
+ ) -> tuple[DeviceDescription, ...]:
985
+ """
986
+ Identify devices that have device_descriptions but missing paramset_descriptions.
987
+
988
+ This handles the case where device_descriptions were persisted but paramset_descriptions
989
+ weren't (e.g., previous run was interrupted, or paramset fetch failed).
990
+
991
+ Synchronization check:
992
+ For each device_description, we verify that ALL expected paramsets (from the
993
+ PARAMSETS field) are present in the paramset_descriptions cache. This ensures
994
+ both caches are synchronized - not just that "some" paramset exists.
995
+
996
+ Args:
997
+ ----
998
+ interface_id: Interface identifier
999
+ device_descriptions: Tuple of device descriptions to check
1000
+
1001
+ Returns:
1002
+ -------
1003
+ Tuple of device descriptions that need paramset fetching
1004
+
1005
+ """
1006
+ paramset_cache = self._coordinator_provider.cache_coordinator.paramset_descriptions
1007
+ missing: list[DeviceDescription] = []
1008
+
1009
+ for dev_desc in device_descriptions:
1010
+ address = dev_desc["ADDRESS"]
1011
+
1012
+ # Skip if no paramsets expected (shouldn't happen, but be safe)
1013
+ if not (expected_paramsets := dev_desc.get("PARAMSETS", [])):
1014
+ continue
1015
+
1016
+ # Get cached paramsets for this address
1017
+ cached_paramsets = paramset_cache.get_channel_paramset_descriptions(
1018
+ interface_id=interface_id, channel_address=address
1019
+ )
1020
+
1021
+ # Check if ALL expected paramsets are present in cache
1022
+ cached_keys = set(cached_paramsets.keys())
1023
+ expected_keys = {ParamsetKey(p) for p in expected_paramsets}
1024
+
1025
+ if not expected_keys.issubset(cached_keys):
1026
+ missing.append(dev_desc)
1027
+
1028
+ return tuple(missing)
1029
+
1030
+ def _identify_new_device_descriptions(
1031
+ self, *, device_descriptions: tuple[DeviceDescription, ...], interface_id: str | None = None
1032
+ ) -> tuple[DeviceDescription, ...]:
1033
+ """
1034
+ Identify devices whose ADDRESS isn't already known on any interface.
1035
+
1036
+ Address resolution with PARENT fallback:
1037
+ Device descriptions come in two forms:
1038
+ - Device entries: ADDRESS is the device address, PARENT is empty/missing
1039
+ - Channel entries: ADDRESS is channel address (e.g., "ABC:1"), PARENT is device address
1040
+
1041
+ When checking if a device is new, we need to check the device address,
1042
+ not the channel address. The expression:
1043
+ dev_desc["ADDRESS"] if not parent_address else parent_address
1044
+ Handles both cases:
1045
+ - For device entries: Use ADDRESS (PARENT is empty)
1046
+ - For channel entries: Use PARENT (the actual device address)
1047
+
1048
+ This ensures we don't treat the same device as "new" multiple times
1049
+ when we receive descriptions for both the device and its channels.
1050
+
1051
+ Args:
1052
+ ----
1053
+ device_descriptions: Tuple of device descriptions
1054
+ interface_id: Optional interface identifier
1055
+
1056
+ Returns:
1057
+ -------
1058
+ Tuple of new device descriptions
1059
+
1060
+ """
1061
+ known_addresses = self._coordinator_provider.cache_coordinator.device_descriptions.get_addresses(
1062
+ interface_id=interface_id
1063
+ )
1064
+ return tuple(
1065
+ dev_desc
1066
+ for dev_desc in device_descriptions
1067
+ # Use PARENT if present (channel entry), else ADDRESS (device entry)
1068
+ if (parent_address if (parent_address := dev_desc.get("PARENT")) else dev_desc["ADDRESS"])
1069
+ not in known_addresses
1070
+ )
1071
+
1072
+ async def _rename_new_device(
1073
+ self,
1074
+ *,
1075
+ client: DeviceDiscoveryAndMetadataProtocol,
1076
+ device_descriptions: tuple[DeviceDescription, ...],
1077
+ device_name: str,
1078
+ ) -> None:
1079
+ """
1080
+ Rename a new device and its channels before adding to the system.
1081
+
1082
+ Args:
1083
+ client: The client to use for renaming.
1084
+ device_descriptions: Tuple of device descriptions (device + channels).
1085
+ device_name: The new name for the device.
1086
+
1087
+ """
1088
+ await client.fetch_device_details()
1089
+ for device_desc in device_descriptions:
1090
+ address = device_desc["ADDRESS"]
1091
+ parent = device_desc.get("PARENT")
1092
+
1093
+ if (rega_id := await client.get_rega_id_by_address(address=address)) is None:
1094
+ _LOGGER.warning( # i18n-log: ignore
1095
+ "RENAME_NEW_DEVICE: Could not get rega_id for address %s",
1096
+ address,
1097
+ )
1098
+ continue
1099
+
1100
+ if not parent:
1101
+ # This is the device itself
1102
+ await client.rename_device(rega_id=rega_id, new_name=device_name)
1103
+ elif (channel_no := address.split(":")[-1] if ":" in address else None) is not None:
1104
+ # This is a channel - extract channel number from address
1105
+ channel_name = f"{device_name}:{channel_no}"
1106
+ await client.rename_channel(rega_id=rega_id, new_name=channel_name)
1107
+
1108
+ await asyncio.sleep(0.1)
1109
+
1110
+ def _store_delayed_device_descriptions(self, *, device_descriptions: tuple[DeviceDescription, ...]) -> None:
1111
+ """Store device descriptions for delayed creation."""
1112
+ for dev_desc in device_descriptions:
1113
+ device_address = dev_desc.get("PARENT") or dev_desc["ADDRESS"]
1114
+ self._delayed_device_descriptions[device_address].append(dev_desc)
1115
+
1116
+
1117
+ def _get_new_channel_events(*, new_devices: set[DeviceProtocol]) -> tuple[tuple[GenericEventProtocolAny, ...], ...]:
1118
+ """
1119
+ Return new channel events.
1120
+
1121
+ Args:
1122
+ ----
1123
+ new_devices: Set of new devices
1124
+
1125
+ Returns:
1126
+ -------
1127
+ Tuple of channel event tuples
1128
+
1129
+ """
1130
+ channel_events: list[tuple[GenericEventProtocolAny, ...]] = []
1131
+
1132
+ for device in new_devices:
1133
+ for event_type in DATA_POINT_EVENTS:
1134
+ if (hm_channel_events := list(device.get_events(event_type=event_type, registered=False).values())) and len(
1135
+ hm_channel_events
1136
+ ) > 0:
1137
+ channel_events.extend(hm_channel_events) # noqa: PERF401
1138
+
1139
+ return tuple(channel_events)
1140
+
1141
+
1142
+ def _get_new_data_points(
1143
+ *,
1144
+ new_devices: set[DeviceProtocol],
1145
+ ) -> dict[DataPointCategory, set[CallbackDataPointProtocol]]:
1146
+ """
1147
+ Return new data points by category.
1148
+
1149
+ Args:
1150
+ ----
1151
+ new_devices: Set of new devices
1152
+
1153
+ Returns:
1154
+ -------
1155
+ Mapping of categories to data points
1156
+
1157
+ """
1158
+ data_points_by_category: dict[DataPointCategory, set[CallbackDataPointProtocol]] = {
1159
+ category: set() for category in CATEGORIES if category != DataPointCategory.EVENT
1160
+ }
1161
+
1162
+ for device in new_devices:
1163
+ for category, data_points in data_points_by_category.items():
1164
+ data_points.update(device.get_data_points(category=category, exclude_no_create=True, registered=False))
1165
+
1166
+ return data_points_by_category