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,794 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Background scheduler for periodic tasks in aiohomematic.
5
+
6
+ This module provides a modern asyncio-based scheduler that manages periodic
7
+ background tasks such as:
8
+
9
+ - Connection health checks (detection only - emits ConnectionLostEvent)
10
+ - Data refreshes (client data, programs, system variables)
11
+ - Firmware update checks
12
+ - Metrics refresh
13
+
14
+ Connection recovery is handled by ConnectionRecoveryCoordinator which subscribes
15
+ to ConnectionLostEvent emitted by this scheduler.
16
+
17
+ The scheduler runs tasks based on configurable intervals and handles errors
18
+ gracefully without affecting other tasks.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ from collections.abc import Awaitable, Callable
25
+ import contextlib
26
+ from datetime import datetime, timedelta
27
+ import logging
28
+ from typing import Final
29
+
30
+ from aiohomematic import i18n
31
+ from aiohomematic.central.coordinators import ClientCoordinator, EventCoordinator
32
+ from aiohomematic.central.events import (
33
+ ConnectionLostEvent,
34
+ DataRefreshCompletedEvent,
35
+ DataRefreshTriggeredEvent,
36
+ DeviceLifecycleEvent,
37
+ DeviceLifecycleEventType,
38
+ )
39
+ from aiohomematic.const import (
40
+ SCHEDULER_LOOP_SLEEP,
41
+ SCHEDULER_NOT_STARTED_SLEEP,
42
+ CentralState,
43
+ DataRefreshType,
44
+ DeviceFirmwareState,
45
+ )
46
+ from aiohomematic.exceptions import NoConnectionException
47
+ from aiohomematic.interfaces import (
48
+ CentralInfoProtocol,
49
+ CentralUnitStateProviderProtocol,
50
+ ConfigProviderProtocol,
51
+ ConnectionStateProviderProtocol,
52
+ DeviceDataRefresherProtocol,
53
+ EventBusProviderProtocol,
54
+ HubDataFetcherProtocol,
55
+ )
56
+ from aiohomematic.interfaces.central import FirmwareDataRefresherProtocol
57
+ from aiohomematic.property_decorators import DelegatedProperty
58
+ from aiohomematic.support import extract_exc_args
59
+ from aiohomematic.type_aliases import UnsubscribeCallback
60
+
61
+ _LOGGER: Final = logging.getLogger(__name__)
62
+
63
+ # Type alias for async task factory
64
+ _AsyncTaskFactory = Callable[[], Awaitable[None]]
65
+
66
+
67
+ class SchedulerJob:
68
+ """Represents a scheduled job with interval-based execution."""
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ task: _AsyncTaskFactory,
74
+ run_interval: int,
75
+ next_run: datetime | None = None,
76
+ ):
77
+ """
78
+ Initialize a scheduler job.
79
+
80
+ Args:
81
+ ----
82
+ task: Async callable to execute
83
+ run_interval: Interval in seconds between executions
84
+ next_run: When to run next (defaults to now)
85
+
86
+ """
87
+ self._task: Final = task
88
+ self._next_run = next_run or datetime.now()
89
+ self._run_interval: Final = run_interval
90
+
91
+ name: Final = DelegatedProperty[str](path="_task.__name__")
92
+ next_run: Final = DelegatedProperty[datetime](path="_next_run")
93
+
94
+ @property
95
+ def ready(self) -> bool:
96
+ """Return True if the job is ready to execute."""
97
+ return self._next_run < datetime.now()
98
+
99
+ async def run(self) -> None:
100
+ """Execute the job's task."""
101
+ await self._task()
102
+
103
+ def schedule_next_execution(self) -> None:
104
+ """Schedule the next execution based on run_interval."""
105
+ self._next_run += timedelta(seconds=self._run_interval)
106
+
107
+
108
+ class BackgroundScheduler:
109
+ """
110
+ Modern asyncio-based scheduler for periodic background tasks.
111
+
112
+ Manages scheduled tasks such as connection checks, data refreshes, and
113
+ firmware update checks.
114
+
115
+ Features:
116
+ ---------
117
+ - Asyncio-based (no threads)
118
+ - Graceful error handling per task
119
+ - Configurable intervals
120
+ - Start/stop lifecycle management
121
+ - Responsive to central state changes
122
+
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ *,
128
+ central_info: CentralInfoProtocol,
129
+ config_provider: ConfigProviderProtocol,
130
+ client_coordinator: ClientCoordinator,
131
+ connection_state_provider: ConnectionStateProviderProtocol,
132
+ device_data_refresher: DeviceDataRefresherProtocol,
133
+ firmware_data_refresher: FirmwareDataRefresherProtocol,
134
+ event_coordinator: EventCoordinator,
135
+ hub_data_fetcher: HubDataFetcherProtocol,
136
+ event_bus_provider: EventBusProviderProtocol,
137
+ state_provider: CentralUnitStateProviderProtocol,
138
+ ) -> None:
139
+ """
140
+ Initialize the background scheduler.
141
+
142
+ Args:
143
+ ----
144
+ central_info: Provider for central system information
145
+ config_provider: Provider for configuration access
146
+ client_coordinator: Client coordinator for client operations
147
+ connection_state_provider: Provider for connection state access
148
+ device_data_refresher: Provider for device data refresh operations
149
+ firmware_data_refresher: Provider for firmware data refresh operations
150
+ event_coordinator: Event coordinator for event management
151
+ hub_data_fetcher: Provider for hub data fetch operations
152
+ event_bus_provider: Provider for event bus access
153
+ state_provider: Provider for central unit state
154
+
155
+ """
156
+ self._central_info: Final = central_info
157
+ self._config_provider: Final = config_provider
158
+ self._client_coordinator: Final = client_coordinator
159
+ self._connection_state_provider: Final = connection_state_provider
160
+ self._device_data_refresher: Final = device_data_refresher
161
+ self._firmware_data_refresher: Final = firmware_data_refresher
162
+ self._event_coordinator: Final = event_coordinator
163
+ self._hub_data_fetcher: Final = hub_data_fetcher
164
+ self._event_bus_provider: Final = event_bus_provider
165
+ self._state_provider: Final = state_provider
166
+
167
+ # Use asyncio.Event for thread-safe state flags
168
+ self._active_event: Final = asyncio.Event()
169
+ self._devices_created_event: Final = asyncio.Event()
170
+ self._scheduler_task: asyncio.Task[None] | None = None
171
+ self._unsubscribe_callback: UnsubscribeCallback | None = None
172
+
173
+ # Subscribe to DeviceLifecycleEvent for CREATED events
174
+ def _event_handler(*, event: DeviceLifecycleEvent) -> None:
175
+ self._on_device_lifecycle_event(event=event)
176
+
177
+ self._unsubscribe_callback = self._event_bus_provider.event_bus.subscribe(
178
+ event_type=DeviceLifecycleEvent,
179
+ event_key=None,
180
+ handler=_event_handler,
181
+ )
182
+
183
+ # Define scheduled jobs
184
+ self._scheduler_jobs: Final[list[SchedulerJob]] = [
185
+ SchedulerJob(
186
+ task=self._check_connection,
187
+ run_interval=self._config_provider.config.schedule_timer_config.connection_checker_interval,
188
+ ),
189
+ SchedulerJob(
190
+ task=self._refresh_client_data,
191
+ run_interval=self._config_provider.config.schedule_timer_config.periodic_refresh_interval,
192
+ ),
193
+ SchedulerJob(
194
+ task=self._refresh_program_data,
195
+ run_interval=self._config_provider.config.schedule_timer_config.sys_scan_interval,
196
+ ),
197
+ SchedulerJob(
198
+ task=self._refresh_sysvar_data,
199
+ run_interval=self._config_provider.config.schedule_timer_config.sys_scan_interval,
200
+ ),
201
+ SchedulerJob(
202
+ task=self._refresh_inbox_data,
203
+ run_interval=self._config_provider.config.schedule_timer_config.sys_scan_interval,
204
+ ),
205
+ SchedulerJob(
206
+ task=self._refresh_system_update_data,
207
+ run_interval=self._config_provider.config.schedule_timer_config.system_update_check_interval,
208
+ ),
209
+ SchedulerJob(
210
+ task=self._fetch_device_firmware_update_data,
211
+ run_interval=self._config_provider.config.schedule_timer_config.device_firmware_check_interval,
212
+ ),
213
+ SchedulerJob(
214
+ task=self._fetch_device_firmware_update_data_in_delivery,
215
+ run_interval=self._config_provider.config.schedule_timer_config.device_firmware_delivering_check_interval,
216
+ ),
217
+ SchedulerJob(
218
+ task=self._fetch_device_firmware_update_data_in_update,
219
+ run_interval=self._config_provider.config.schedule_timer_config.device_firmware_updating_check_interval,
220
+ ),
221
+ SchedulerJob(
222
+ task=self._refresh_metrics_data,
223
+ run_interval=self._config_provider.config.schedule_timer_config.metrics_refresh_interval,
224
+ ),
225
+ SchedulerJob(
226
+ task=self._refresh_connectivity_data,
227
+ run_interval=self._config_provider.config.schedule_timer_config.metrics_refresh_interval,
228
+ ),
229
+ ]
230
+
231
+ has_connection_issue: Final = DelegatedProperty[bool](
232
+ path="_connection_state_provider.connection_state.has_any_issue"
233
+ )
234
+
235
+ @property
236
+ def _primary_client_avaliable(self) -> bool:
237
+ """Return True if the primary client is available."""
238
+ return self._client_coordinator.primary_client is not None and self._client_coordinator.primary_client.available
239
+
240
+ @property
241
+ def devices_created(self) -> bool:
242
+ """Return True if devices have been created."""
243
+ return self._devices_created_event.is_set()
244
+
245
+ @property
246
+ def is_active(self) -> bool:
247
+ """Return True if the scheduler is active."""
248
+ return self._active_event.is_set()
249
+
250
+ async def start(self) -> None:
251
+ """Start the scheduler and begin running scheduled tasks."""
252
+ if self._active_event.is_set():
253
+ _LOGGER.warning("Scheduler for %s is already running", self._central_info.name) # i18n-log: ignore
254
+ return
255
+
256
+ _LOGGER.debug("Starting scheduler for %s", self._central_info.name)
257
+ self._active_event.set()
258
+ self._scheduler_task = asyncio.create_task(self._run_scheduler_loop())
259
+
260
+ async def stop(self) -> None:
261
+ """Stop the scheduler and cancel all running tasks."""
262
+ if not self._active_event.is_set():
263
+ return
264
+
265
+ _LOGGER.debug("Stopping scheduler for %s", self._central_info.name)
266
+ self._active_event.clear()
267
+
268
+ # Unsubscribe from events
269
+ if self._unsubscribe_callback:
270
+ self._unsubscribe_callback()
271
+ self._unsubscribe_callback = None
272
+
273
+ # Cancel scheduler task
274
+ if self._scheduler_task and not self._scheduler_task.done():
275
+ self._scheduler_task.cancel()
276
+ with contextlib.suppress(asyncio.CancelledError):
277
+ await self._scheduler_task
278
+
279
+ async def _check_connection(self) -> None:
280
+ """
281
+ Check connection health to all clients.
282
+
283
+ Detection only - emits ConnectionLostEvent when connection issues are detected.
284
+ Actual recovery is handled by ConnectionRecoveryCoordinator.
285
+ """
286
+ _LOGGER.debug("CHECK_CONNECTION: Checking connection to server %s", self._central_info.name)
287
+ try:
288
+ if not self._client_coordinator.all_clients_active:
289
+ _LOGGER.error(
290
+ i18n.tr(
291
+ key="log.central.scheduler.check_connection.no_clients",
292
+ name=self._central_info.name,
293
+ )
294
+ )
295
+ # Emit ConnectionLostEvent for each inactive client
296
+ for client in self._client_coordinator.clients:
297
+ if not client.available:
298
+ await self._emit_connection_lost(
299
+ interface_id=client.interface_id,
300
+ reason="client_not_active",
301
+ )
302
+ return
303
+
304
+ # Normal operation - perform client health checks
305
+ for client in self._client_coordinator.clients:
306
+ if client.available is False or not await client.is_connected() or not client.is_callback_alive():
307
+ # Connection loss detected - emit event for ConnectionRecoveryCoordinator
308
+ reason = (
309
+ "not_available"
310
+ if not client.available
311
+ else "not_connected"
312
+ if not await client.is_connected()
313
+ else "callback_not_alive"
314
+ )
315
+ await self._emit_connection_lost(
316
+ interface_id=client.interface_id,
317
+ reason=reason,
318
+ )
319
+ _LOGGER.info(
320
+ i18n.tr(
321
+ key="log.central.scheduler.check_connection.connection_loss_detected",
322
+ name=self._central_info.name,
323
+ )
324
+ )
325
+
326
+ except NoConnectionException as nex:
327
+ _LOGGER.error(
328
+ i18n.tr(
329
+ key="log.central.scheduler.check_connection.no_connection",
330
+ reason=extract_exc_args(exc=nex),
331
+ )
332
+ )
333
+ except Exception as exc:
334
+ _LOGGER.error(
335
+ i18n.tr(
336
+ key="log.central.scheduler.check_connection.failed",
337
+ exc_type=type(exc).__name__,
338
+ reason=extract_exc_args(exc=exc),
339
+ )
340
+ )
341
+
342
+ async def _emit_connection_lost(self, *, interface_id: str, reason: str) -> None:
343
+ """Emit a ConnectionLostEvent for the specified interface."""
344
+ await self._event_bus_provider.event_bus.publish(
345
+ event=ConnectionLostEvent(
346
+ timestamp=datetime.now(),
347
+ interface_id=interface_id,
348
+ reason=reason,
349
+ detected_at=datetime.now(),
350
+ )
351
+ )
352
+
353
+ async def _emit_refresh_completed(
354
+ self,
355
+ *,
356
+ refresh_type: DataRefreshType,
357
+ interface_id: str | None,
358
+ success: bool,
359
+ duration_ms: float,
360
+ items_refreshed: int = 0,
361
+ error_message: str | None = None,
362
+ ) -> None:
363
+ """
364
+ Emit a data refresh completed event.
365
+
366
+ Args:
367
+ ----
368
+ refresh_type: Type of refresh operation
369
+ interface_id: Interface ID or None for hub-level refreshes
370
+ success: True if refresh completed successfully
371
+ duration_ms: Duration of the refresh operation in milliseconds
372
+ items_refreshed: Number of items refreshed
373
+ error_message: Error message if success is False
374
+
375
+ """
376
+ await self._event_bus_provider.event_bus.publish(
377
+ event=DataRefreshCompletedEvent(
378
+ timestamp=datetime.now(),
379
+ refresh_type=refresh_type,
380
+ interface_id=interface_id,
381
+ success=success,
382
+ duration_ms=duration_ms,
383
+ items_refreshed=items_refreshed,
384
+ error_message=error_message,
385
+ )
386
+ )
387
+
388
+ def _emit_refresh_triggered(
389
+ self,
390
+ *,
391
+ refresh_type: DataRefreshType,
392
+ interface_id: str | None,
393
+ scheduled: bool,
394
+ ) -> None:
395
+ """
396
+ Emit a data refresh triggered event.
397
+
398
+ Args:
399
+ ----
400
+ refresh_type: Type of refresh operation
401
+ interface_id: Interface ID or None for hub-level refreshes
402
+ scheduled: True if this is a scheduled refresh
403
+
404
+ """
405
+ self._event_bus_provider.event_bus.publish_sync(
406
+ event=DataRefreshTriggeredEvent(
407
+ timestamp=datetime.now(),
408
+ refresh_type=refresh_type,
409
+ interface_id=interface_id,
410
+ scheduled=scheduled,
411
+ )
412
+ )
413
+
414
+ async def _fetch_device_firmware_update_data(self) -> None:
415
+ """Periodically fetch device firmware update data from backend."""
416
+ if (
417
+ not self._config_provider.config.enable_device_firmware_check
418
+ or not self._central_info.available
419
+ or not self.devices_created
420
+ ):
421
+ return
422
+
423
+ _LOGGER.debug(
424
+ "FETCH_DEVICE_FIRMWARE_UPDATE_DATA: Scheduled fetching for %s",
425
+ self._central_info.name,
426
+ )
427
+ await self._firmware_data_refresher.refresh_firmware_data()
428
+
429
+ async def _fetch_device_firmware_update_data_in_delivery(self) -> None:
430
+ """Fetch firmware update data for devices in delivery state."""
431
+ if (
432
+ not self._config_provider.config.enable_device_firmware_check
433
+ or not self._central_info.available
434
+ or not self.devices_created
435
+ ):
436
+ return
437
+
438
+ _LOGGER.debug(
439
+ "FETCH_DEVICE_FIRMWARE_UPDATE_DATA_IN_DELIVERY: For delivering devices for %s",
440
+ self._central_info.name,
441
+ )
442
+ await self._firmware_data_refresher.refresh_firmware_data_by_state(
443
+ device_firmware_states=(
444
+ DeviceFirmwareState.DELIVER_FIRMWARE_IMAGE,
445
+ DeviceFirmwareState.LIVE_DELIVER_FIRMWARE_IMAGE,
446
+ )
447
+ )
448
+
449
+ async def _fetch_device_firmware_update_data_in_update(self) -> None:
450
+ """Fetch firmware update data for devices in update state."""
451
+ if (
452
+ not self._config_provider.config.enable_device_firmware_check
453
+ or not self._central_info.available
454
+ or not self.devices_created
455
+ ):
456
+ return
457
+
458
+ _LOGGER.debug(
459
+ "FETCH_DEVICE_FIRMWARE_UPDATE_DATA_IN_UPDATE: For updating devices for %s",
460
+ self._central_info.name,
461
+ )
462
+ await self._firmware_data_refresher.refresh_firmware_data_by_state(
463
+ device_firmware_states=(
464
+ DeviceFirmwareState.READY_FOR_UPDATE,
465
+ DeviceFirmwareState.DO_UPDATE_PENDING,
466
+ DeviceFirmwareState.PERFORMING_UPDATE,
467
+ )
468
+ )
469
+
470
+ def _on_device_lifecycle_event(self, *, event: DeviceLifecycleEvent) -> None:
471
+ """
472
+ Handle device lifecycle events.
473
+
474
+ Args:
475
+ ----
476
+ event: DeviceLifecycleEvent instance
477
+
478
+ """
479
+ if event.event_type == DeviceLifecycleEventType.CREATED:
480
+ self._devices_created_event.set()
481
+
482
+ async def _refresh_client_data(self) -> None:
483
+ """Refresh client data for polled interfaces."""
484
+ if not self._central_info.available:
485
+ return
486
+
487
+ if (poll_clients := self._client_coordinator.poll_clients) is not None and len(poll_clients) > 0:
488
+ _LOGGER.debug("REFRESH_CLIENT_DATA: Loading data for %s", self._central_info.name)
489
+ for client in poll_clients:
490
+ start_time = datetime.now()
491
+ self._emit_refresh_triggered(
492
+ refresh_type=DataRefreshType.CLIENT_DATA,
493
+ interface_id=client.interface_id,
494
+ scheduled=True,
495
+ )
496
+ try:
497
+ await self._device_data_refresher.load_and_refresh_data_point_data(interface=client.interface)
498
+ self._event_coordinator.set_last_event_seen_for_interface(interface_id=client.interface_id)
499
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
500
+ await self._emit_refresh_completed(
501
+ refresh_type=DataRefreshType.CLIENT_DATA,
502
+ interface_id=client.interface_id,
503
+ success=True,
504
+ duration_ms=duration_ms,
505
+ )
506
+ except Exception as exc:
507
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
508
+ await self._emit_refresh_completed(
509
+ refresh_type=DataRefreshType.CLIENT_DATA,
510
+ interface_id=client.interface_id,
511
+ success=False,
512
+ duration_ms=duration_ms,
513
+ error_message=str(exc),
514
+ )
515
+ raise
516
+
517
+ async def _refresh_connectivity_data(self) -> None:
518
+ """Refresh connectivity binary sensors."""
519
+ if not self.devices_created:
520
+ return
521
+
522
+ _LOGGER.debug("REFRESH_CONNECTIVITY_DATA: For %s", self._central_info.name)
523
+ start_time = datetime.now()
524
+ self._emit_refresh_triggered(
525
+ refresh_type=DataRefreshType.CONNECTIVITY,
526
+ interface_id=None,
527
+ scheduled=True,
528
+ )
529
+ try:
530
+ self._hub_data_fetcher.fetch_connectivity_data(scheduled=True)
531
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
532
+ await self._emit_refresh_completed(
533
+ refresh_type=DataRefreshType.CONNECTIVITY,
534
+ interface_id=None,
535
+ success=True,
536
+ duration_ms=duration_ms,
537
+ )
538
+ except Exception as exc:
539
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
540
+ await self._emit_refresh_completed(
541
+ refresh_type=DataRefreshType.CONNECTIVITY,
542
+ interface_id=None,
543
+ success=False,
544
+ duration_ms=duration_ms,
545
+ error_message=str(exc),
546
+ )
547
+ raise
548
+
549
+ async def _refresh_inbox_data(self) -> None:
550
+ """Refresh inbox data."""
551
+ # Check primary client availability instead of central availability
552
+ # to allow hub operations when secondary clients (e.g., CUxD) fail
553
+ if not self._primary_client_avaliable or not self.devices_created:
554
+ return
555
+
556
+ _LOGGER.debug("REFRESH_INBOX_DATA: For %s", self._central_info.name)
557
+ start_time = datetime.now()
558
+ self._emit_refresh_triggered(
559
+ refresh_type=DataRefreshType.INBOX,
560
+ interface_id=None,
561
+ scheduled=True,
562
+ )
563
+ try:
564
+ await self._hub_data_fetcher.fetch_inbox_data(scheduled=True)
565
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
566
+ await self._emit_refresh_completed(
567
+ refresh_type=DataRefreshType.INBOX,
568
+ interface_id=None,
569
+ success=True,
570
+ duration_ms=duration_ms,
571
+ )
572
+ except Exception as exc:
573
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
574
+ await self._emit_refresh_completed(
575
+ refresh_type=DataRefreshType.INBOX,
576
+ interface_id=None,
577
+ success=False,
578
+ duration_ms=duration_ms,
579
+ error_message=str(exc),
580
+ )
581
+ raise
582
+
583
+ async def _refresh_metrics_data(self) -> None:
584
+ """Refresh metrics hub sensors."""
585
+ if not self._central_info.available or not self.devices_created:
586
+ return
587
+
588
+ _LOGGER.debug("REFRESH_METRICS_DATA: For %s", self._central_info.name)
589
+ start_time = datetime.now()
590
+ self._emit_refresh_triggered(
591
+ refresh_type=DataRefreshType.METRICS,
592
+ interface_id=None,
593
+ scheduled=True,
594
+ )
595
+ try:
596
+ self._hub_data_fetcher.fetch_metrics_data(scheduled=True)
597
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
598
+ await self._emit_refresh_completed(
599
+ refresh_type=DataRefreshType.METRICS,
600
+ interface_id=None,
601
+ success=True,
602
+ duration_ms=duration_ms,
603
+ )
604
+ except Exception as exc:
605
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
606
+ await self._emit_refresh_completed(
607
+ refresh_type=DataRefreshType.METRICS,
608
+ interface_id=None,
609
+ success=False,
610
+ duration_ms=duration_ms,
611
+ error_message=str(exc),
612
+ )
613
+ raise
614
+
615
+ async def _refresh_program_data(self) -> None:
616
+ """Refresh system programs data."""
617
+ # Check primary client availability instead of central availability
618
+ # to allow hub operations when secondary clients (e.g., CUxD) fail
619
+ if (
620
+ not self._primary_client_avaliable
621
+ or not self._config_provider.config.enable_program_scan
622
+ or not self.devices_created
623
+ ):
624
+ return
625
+
626
+ _LOGGER.debug("REFRESH_PROGRAM_DATA: For %s", self._central_info.name)
627
+ start_time = datetime.now()
628
+ self._emit_refresh_triggered(
629
+ refresh_type=DataRefreshType.PROGRAM,
630
+ interface_id=None,
631
+ scheduled=True,
632
+ )
633
+ try:
634
+ await self._hub_data_fetcher.fetch_program_data(scheduled=True)
635
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
636
+ await self._emit_refresh_completed(
637
+ refresh_type=DataRefreshType.PROGRAM,
638
+ interface_id=None,
639
+ success=True,
640
+ duration_ms=duration_ms,
641
+ )
642
+ except Exception as exc:
643
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
644
+ await self._emit_refresh_completed(
645
+ refresh_type=DataRefreshType.PROGRAM,
646
+ interface_id=None,
647
+ success=False,
648
+ duration_ms=duration_ms,
649
+ error_message=str(exc),
650
+ )
651
+ raise
652
+
653
+ async def _refresh_system_update_data(self) -> None:
654
+ """Refresh system update data."""
655
+ # Check primary client availability instead of central availability
656
+ # to allow hub operations when secondary clients (e.g., CUxD) fail
657
+ if not self._primary_client_avaliable or not self.devices_created:
658
+ return
659
+
660
+ _LOGGER.debug("REFRESH_SYSTEM_UPDATE_DATA: For %s", self._central_info.name)
661
+ start_time = datetime.now()
662
+ self._emit_refresh_triggered(
663
+ refresh_type=DataRefreshType.SYSTEM_UPDATE,
664
+ interface_id=None,
665
+ scheduled=True,
666
+ )
667
+ try:
668
+ await self._hub_data_fetcher.fetch_system_update_data(scheduled=True)
669
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
670
+ await self._emit_refresh_completed(
671
+ refresh_type=DataRefreshType.SYSTEM_UPDATE,
672
+ interface_id=None,
673
+ success=True,
674
+ duration_ms=duration_ms,
675
+ )
676
+ except Exception as exc:
677
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
678
+ await self._emit_refresh_completed(
679
+ refresh_type=DataRefreshType.SYSTEM_UPDATE,
680
+ interface_id=None,
681
+ success=False,
682
+ duration_ms=duration_ms,
683
+ error_message=str(exc),
684
+ )
685
+ raise
686
+
687
+ async def _refresh_sysvar_data(self) -> None:
688
+ """Refresh system variables data."""
689
+ # Check primary client availability instead of central availability
690
+ # to allow hub operations when secondary clients (e.g., CUxD) fail
691
+ if (
692
+ not self._primary_client_avaliable
693
+ or not self._config_provider.config.enable_sysvar_scan
694
+ or not self.devices_created
695
+ ):
696
+ return
697
+
698
+ _LOGGER.debug("REFRESH_SYSVAR_DATA: For %s", self._central_info.name)
699
+ start_time = datetime.now()
700
+ self._emit_refresh_triggered(
701
+ refresh_type=DataRefreshType.SYSVAR,
702
+ interface_id=None,
703
+ scheduled=True,
704
+ )
705
+ try:
706
+ await self._hub_data_fetcher.fetch_sysvar_data(scheduled=True)
707
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
708
+ await self._emit_refresh_completed(
709
+ refresh_type=DataRefreshType.SYSVAR,
710
+ interface_id=None,
711
+ success=True,
712
+ duration_ms=duration_ms,
713
+ )
714
+ except Exception as exc:
715
+ duration_ms = (datetime.now() - start_time).total_seconds() * 1000
716
+ await self._emit_refresh_completed(
717
+ refresh_type=DataRefreshType.SYSVAR,
718
+ interface_id=None,
719
+ success=False,
720
+ duration_ms=duration_ms,
721
+ error_message=str(exc),
722
+ )
723
+ raise
724
+
725
+ async def _run_scheduler_loop(self) -> None:
726
+ """Execute the main scheduler loop that runs jobs based on their schedule."""
727
+ connection_issue_logged = False
728
+ while self.is_active:
729
+ # Wait until central is operational (RUNNING or DEGRADED)
730
+ # DEGRADED means at least one interface is working, so scheduler should run
731
+ if (current_state := self._state_provider.state) not in (CentralState.RUNNING, CentralState.DEGRADED):
732
+ _LOGGER.debug(
733
+ "Scheduler: Waiting until central %s is operational (current: %s)",
734
+ self._central_info.name,
735
+ current_state.value,
736
+ )
737
+ await asyncio.sleep(SCHEDULER_NOT_STARTED_SLEEP)
738
+ continue
739
+
740
+ # Check for connection issues - pause most jobs when connection is down
741
+ # Only _check_connection continues to run to detect reconnection
742
+ has_issue = self.has_connection_issue
743
+ if has_issue and not connection_issue_logged:
744
+ _LOGGER.debug(
745
+ "Scheduler: Pausing jobs due to connection issue for %s (connection check continues)",
746
+ self._central_info.name,
747
+ )
748
+ connection_issue_logged = True
749
+ elif not has_issue and connection_issue_logged:
750
+ _LOGGER.debug(
751
+ "Scheduler: Resuming jobs after connection restored for %s",
752
+ self._central_info.name,
753
+ )
754
+ connection_issue_logged = False
755
+
756
+ # Execute ready jobs
757
+ any_executed = False
758
+ for job in self._scheduler_jobs:
759
+ if not self.is_active or not job.ready:
760
+ continue
761
+
762
+ # Skip non-connection-check jobs when there's a connection issue
763
+ # This prevents unnecessary RPC calls and log spam during CCU restart
764
+ if has_issue and job.name != "_check_connection":
765
+ continue
766
+
767
+ try:
768
+ await job.run()
769
+ except Exception:
770
+ _LOGGER.exception( # i18n-log: ignore
771
+ "SCHEDULER: Job %s failed for %s",
772
+ job.name,
773
+ self._central_info.name,
774
+ )
775
+ job.schedule_next_execution()
776
+ any_executed = True
777
+
778
+ if not self.is_active:
779
+ break # type: ignore[unreachable]
780
+
781
+ # Sleep logic: minimize CPU usage when idle
782
+ if not any_executed:
783
+ now = datetime.now()
784
+ try:
785
+ next_due = min(job.next_run for job in self._scheduler_jobs)
786
+ # Sleep until the next task, capped at 1s for responsiveness
787
+ delay = max(0.0, (next_due - now).total_seconds())
788
+ await asyncio.sleep(min(1.0, delay))
789
+ except ValueError:
790
+ # No jobs configured; use default sleep
791
+ await asyncio.sleep(SCHEDULER_LOOP_SLEEP)
792
+ else:
793
+ # Brief yield after executing jobs
794
+ await asyncio.sleep(SCHEDULER_LOOP_SLEEP)