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