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.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- 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)
|