aiohomematic 2026.1.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
@@ -0,0 +1,391 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Central state machine for orchestrating overall system health.
5
+
6
+ This module provides the CentralStateMachine which manages the overall state
7
+ of the system based on individual client states. It acts as an orchestrator
8
+ above the client-level state machines.
9
+
10
+ Overview
11
+ --------
12
+ The CentralStateMachine provides:
13
+ - Unified view of system health
14
+ - Coordinated state transitions
15
+ - Event emission for state changes
16
+ - Validation of state transitions
17
+
18
+ State Machine
19
+ -------------
20
+ ```
21
+ STARTING ──► INITIALIZING ──► RUNNING ◄──► DEGRADED
22
+ │ │ │
23
+ │ ▼ ▼
24
+ │ RECOVERING ◄────┘
25
+ │ │
26
+ │ ├──► RUNNING
27
+ │ ├──► DEGRADED
28
+ │ └──► FAILED
29
+
30
+ └──► FAILED
31
+
32
+ STOPPED ◄── (from any state)
33
+ ```
34
+
35
+ The CentralStateMachine observes client state machines and determines the
36
+ overall system state:
37
+ - RUNNING: All clients are CONNECTED
38
+ - DEGRADED: At least one client is not CONNECTED
39
+ - RECOVERING: Recovery is in progress
40
+ - FAILED: Max retries reached, manual intervention required
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from collections.abc import Mapping
46
+ from datetime import datetime
47
+ import logging
48
+ from types import MappingProxyType
49
+ from typing import TYPE_CHECKING, Final
50
+
51
+ from aiohomematic.central.events import SystemStatusChangedEvent
52
+ from aiohomematic.central.events.types import CentralStateChangedEvent
53
+ from aiohomematic.const import CentralState, FailureReason
54
+ from aiohomematic.interfaces import CentralStateMachineProtocol
55
+ from aiohomematic.property_decorators import DelegatedProperty
56
+
57
+ if TYPE_CHECKING:
58
+ from aiohomematic.central.events import EventBus
59
+
60
+ _LOGGER: Final = logging.getLogger(__name__)
61
+
62
+ # Valid state transitions define which state changes are allowed.
63
+ # This forms a directed graph where each state maps to its valid successors.
64
+ VALID_CENTRAL_TRANSITIONS: Final[dict[CentralState, frozenset[CentralState]]] = {
65
+ # Initial state - Central is being created
66
+ CentralState.STARTING: frozenset(
67
+ {
68
+ CentralState.INITIALIZING,
69
+ CentralState.STOPPED, # stop() before start()
70
+ }
71
+ ),
72
+ # During initialization - clients are being initialized
73
+ CentralState.INITIALIZING: frozenset(
74
+ {
75
+ CentralState.RUNNING, # All clients OK
76
+ CentralState.DEGRADED, # At least one client not OK
77
+ CentralState.FAILED, # Critical init error
78
+ CentralState.STOPPED, # stop() during init
79
+ }
80
+ ),
81
+ # Normal operation - all clients connected
82
+ CentralState.RUNNING: frozenset(
83
+ {
84
+ CentralState.DEGRADED, # Client problem detected
85
+ CentralState.RECOVERING, # Proactive recovery
86
+ CentralState.STOPPED, # Graceful shutdown
87
+ }
88
+ ),
89
+ # Limited operation - at least one client not connected
90
+ CentralState.DEGRADED: frozenset(
91
+ {
92
+ CentralState.RUNNING, # All clients recovered
93
+ CentralState.RECOVERING, # Start recovery
94
+ CentralState.FAILED, # Too long degraded
95
+ CentralState.STOPPED, # Shutdown
96
+ }
97
+ ),
98
+ # Active recovery in progress
99
+ CentralState.RECOVERING: frozenset(
100
+ {
101
+ CentralState.RUNNING, # Recovery successful
102
+ CentralState.DEGRADED, # Partial recovery
103
+ CentralState.FAILED, # Max retries reached
104
+ CentralState.STOPPED, # Shutdown during recovery
105
+ }
106
+ ),
107
+ # Critical error - manual intervention required
108
+ CentralState.FAILED: frozenset(
109
+ {
110
+ CentralState.RECOVERING, # Manual retry
111
+ CentralState.STOPPED, # Shutdown
112
+ }
113
+ ),
114
+ # Terminal state - no transitions allowed
115
+ CentralState.STOPPED: frozenset(),
116
+ }
117
+
118
+
119
+ class InvalidCentralStateTransitionError(Exception):
120
+ """Raised when an invalid central state transition is attempted."""
121
+
122
+ def __init__(self, *, current: CentralState, target: CentralState, central_name: str) -> None:
123
+ """Initialize the error."""
124
+ self.current = current
125
+ self.target = target
126
+ self.central_name = central_name
127
+ super().__init__(f"Invalid central state transition from {current.value} to {target.value} for {central_name}")
128
+
129
+
130
+ class CentralStateMachine(CentralStateMachineProtocol):
131
+ """
132
+ State machine for central system health orchestration.
133
+
134
+ This class manages the overall state of the central system based on
135
+ individual client states. It provides:
136
+ - Unified system state (RUNNING, DEGRADED, RECOVERING, FAILED)
137
+ - Validated state transitions
138
+ - Event emission for monitoring via EventBus
139
+
140
+ Thread Safety
141
+ -------------
142
+ This class is NOT thread-safe. All calls should happen from the same
143
+ event loop/thread.
144
+
145
+ Example:
146
+ from aiohomematic.central.events import CentralStateChangedEvent, EventBus
147
+
148
+ def on_state_changed(*, event: CentralStateChangedEvent) -> None:
149
+ print(f"Central state: {event.old_state} -> {event.new_state}")
150
+
151
+ event_bus = EventBus()
152
+ sm = CentralStateMachine(central_name="ccu-main", event_bus=event_bus)
153
+
154
+ # Subscribe to state changes via EventBus
155
+ event_bus.subscribe(
156
+ event_type=CentralStateChangedEvent,
157
+ handler=on_state_changed,
158
+ )
159
+
160
+ sm.transition_to(target=CentralState.INITIALIZING, reason="start() called")
161
+ sm.transition_to(target=CentralState.RUNNING, reason="all clients connected")
162
+
163
+ """
164
+
165
+ __slots__ = (
166
+ "_central_name",
167
+ "_degraded_interfaces",
168
+ "_event_bus",
169
+ "_failure_interface_id",
170
+ "_failure_message",
171
+ "_failure_reason",
172
+ "_last_state_change",
173
+ "_state",
174
+ "_state_history",
175
+ )
176
+
177
+ def __init__(
178
+ self,
179
+ *,
180
+ central_name: str,
181
+ event_bus: EventBus | None = None,
182
+ ) -> None:
183
+ """
184
+ Initialize the central state machine.
185
+
186
+ Args:
187
+ central_name: Name of the central unit for logging
188
+ event_bus: Optional event bus for publishing state change events
189
+
190
+ """
191
+ self._central_name: Final = central_name
192
+ self._event_bus = event_bus
193
+ self._state: CentralState = CentralState.STARTING
194
+ self._failure_reason: FailureReason = FailureReason.NONE
195
+ self._failure_message: str = ""
196
+ self._failure_interface_id: str | None = None
197
+ self._degraded_interfaces: Mapping[str, FailureReason] = MappingProxyType({})
198
+ self._last_state_change: datetime = datetime.now()
199
+ self._state_history: list[tuple[datetime, CentralState, CentralState, str]] = []
200
+
201
+ degraded_interfaces: Final = DelegatedProperty[Mapping[str, FailureReason]](path="_degraded_interfaces")
202
+ failure_interface_id: Final = DelegatedProperty[str | None](path="_failure_interface_id")
203
+ failure_message: Final = DelegatedProperty[str](path="_failure_message")
204
+ failure_reason: Final = DelegatedProperty[FailureReason](path="_failure_reason")
205
+ last_state_change: Final = DelegatedProperty[datetime](path="_last_state_change")
206
+ state: Final = DelegatedProperty[CentralState](path="_state")
207
+
208
+ @property
209
+ def is_degraded(self) -> bool:
210
+ """Return True if system is in degraded state."""
211
+ return self._state == CentralState.DEGRADED
212
+
213
+ @property
214
+ def is_failed(self) -> bool:
215
+ """Return True if system is in failed state."""
216
+ return self._state == CentralState.FAILED
217
+
218
+ @property
219
+ def is_operational(self) -> bool:
220
+ """Return True if system is operational (RUNNING or DEGRADED)."""
221
+ return self._state in (CentralState.RUNNING, CentralState.DEGRADED)
222
+
223
+ @property
224
+ def is_recovering(self) -> bool:
225
+ """Return True if recovery is in progress."""
226
+ return self._state == CentralState.RECOVERING
227
+
228
+ @property
229
+ def is_running(self) -> bool:
230
+ """Return True if system is fully running."""
231
+ return self._state == CentralState.RUNNING
232
+
233
+ @property
234
+ def is_stopped(self) -> bool:
235
+ """Return True if system is stopped."""
236
+ return self._state == CentralState.STOPPED
237
+
238
+ @property
239
+ def seconds_in_current_state(self) -> float:
240
+ """Return seconds since last state change."""
241
+ return (datetime.now() - self._last_state_change).total_seconds()
242
+
243
+ @property
244
+ def state_history(self) -> list[tuple[datetime, CentralState, CentralState, str]]:
245
+ """Return state transition history (timestamp, old_state, new_state, reason)."""
246
+ return self._state_history.copy()
247
+
248
+ def can_transition_to(self, *, target: CentralState) -> bool:
249
+ """
250
+ Check if transition to target state is valid.
251
+
252
+ Args:
253
+ target: Target state to check
254
+
255
+ Returns:
256
+ True if transition is valid, False otherwise
257
+
258
+ """
259
+ return target in VALID_CENTRAL_TRANSITIONS.get(self._state, frozenset())
260
+
261
+ def set_event_bus(self, *, event_bus: EventBus) -> None:
262
+ """
263
+ Set the event bus for publishing state change events.
264
+
265
+ This is useful when the event bus is created after the state machine.
266
+
267
+ Args:
268
+ event_bus: The event bus to use
269
+
270
+ """
271
+ self._event_bus = event_bus
272
+
273
+ def transition_to(
274
+ self,
275
+ *,
276
+ target: CentralState,
277
+ reason: str = "",
278
+ force: bool = False,
279
+ failure_reason: FailureReason = FailureReason.NONE,
280
+ failure_interface_id: str | None = None,
281
+ degraded_interfaces: Mapping[str, FailureReason] | None = None,
282
+ ) -> None:
283
+ """
284
+ Transition to a new state.
285
+
286
+ Args:
287
+ target: Target state to transition to
288
+ reason: Human-readable reason for the transition
289
+ force: If True, skip validation (use with caution)
290
+ failure_reason: Categorized failure reason (only used when target is FAILED)
291
+ failure_interface_id: Interface ID that caused the failure (optional)
292
+ degraded_interfaces: Map of interface_id to failure reason (only used when target is DEGRADED)
293
+
294
+ Raises:
295
+ InvalidCentralStateTransitionError: If transition is not valid and force=False
296
+
297
+ """
298
+ if not force and not self.can_transition_to(target=target):
299
+ raise InvalidCentralStateTransitionError(
300
+ current=self._state,
301
+ target=target,
302
+ central_name=self._central_name,
303
+ )
304
+
305
+ old_state = self._state
306
+ self._state = target
307
+ self._last_state_change = datetime.now()
308
+
309
+ # Track failure reason when entering FAILED state
310
+ if target == CentralState.FAILED:
311
+ self._failure_reason = failure_reason
312
+ self._failure_message = reason
313
+ self._failure_interface_id = failure_interface_id
314
+ self._degraded_interfaces = MappingProxyType({})
315
+ elif target == CentralState.DEGRADED:
316
+ # Track degraded interfaces with their reasons
317
+ self._degraded_interfaces = MappingProxyType(dict(degraded_interfaces or {}))
318
+ elif target == CentralState.RUNNING:
319
+ # Clear failure and degraded info on successful state
320
+ self._failure_reason = FailureReason.NONE
321
+ self._failure_message = ""
322
+ self._failure_interface_id = None
323
+ self._degraded_interfaces = MappingProxyType({})
324
+
325
+ # Record in history (keep last 100 transitions)
326
+ self._state_history.append((self._last_state_change, old_state, target, reason))
327
+ if len(self._state_history) > 100:
328
+ self._state_history = self._state_history[-100:]
329
+
330
+ # Log the transition
331
+ if old_state != target:
332
+ extra_info = ""
333
+ if target == CentralState.FAILED:
334
+ extra_info = f" [reason={failure_reason.value}]"
335
+ elif target == CentralState.DEGRADED and degraded_interfaces:
336
+ iface_reasons = ", ".join(f"{k}={v.value}" for k, v in degraded_interfaces.items())
337
+ extra_info = f" [interfaces: {iface_reasons}]"
338
+ _LOGGER.info( # i18n-log: ignore
339
+ "CENTRAL_STATE: %s: %s -> %s (%s)%s",
340
+ self._central_name,
341
+ old_state.value,
342
+ target.value,
343
+ reason or "no reason specified",
344
+ extra_info,
345
+ )
346
+
347
+ # Publish event to event bus
348
+ if self._event_bus is not None:
349
+ self._publish_state_change_event(old_state=old_state, new_state=target, reason=reason)
350
+
351
+ def _publish_state_change_event(self, *, old_state: CentralState, new_state: CentralState, reason: str) -> None:
352
+ """
353
+ Publish state change event to the event bus.
354
+
355
+ Args:
356
+ old_state: Previous state
357
+ new_state: New state
358
+ reason: Reason for the transition
359
+
360
+ """
361
+ if self._event_bus is None:
362
+ return
363
+
364
+ # Include failure info when transitioning to FAILED state
365
+ failure_reason = self._failure_reason if new_state == CentralState.FAILED else None
366
+ failure_interface_id = self._failure_interface_id if new_state == CentralState.FAILED else None
367
+
368
+ # Include degraded interfaces when transitioning to DEGRADED state
369
+ degraded_interfaces = self._degraded_interfaces if new_state == CentralState.DEGRADED else None
370
+
371
+ # Emit SystemStatusChangedEvent for integration compatibility
372
+ self._event_bus.publish_sync(
373
+ event=SystemStatusChangedEvent(
374
+ timestamp=self._last_state_change,
375
+ central_state=new_state,
376
+ failure_reason=failure_reason,
377
+ failure_interface_id=failure_interface_id,
378
+ degraded_interfaces=degraded_interfaces,
379
+ )
380
+ )
381
+
382
+ # Emit CentralStateChangedEvent for observability
383
+ self._event_bus.publish_sync(
384
+ event=CentralStateChangedEvent(
385
+ timestamp=self._last_state_change,
386
+ central_name=self._central_name,
387
+ old_state=old_state,
388
+ new_state=new_state,
389
+ trigger=reason or None,
390
+ )
391
+ )
@@ -0,0 +1,203 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Client adapters for communicating with Homematic CCU and compatible backends.
5
+
6
+ This package provides client implementations that abstract the transport details
7
+ of Homematic backends (CCU via JSON-RPC/XML-RPC or Homegear) and expose a
8
+ consistent API used by the central module.
9
+
10
+ Package structure
11
+ -----------------
12
+ - ccu.py: Client implementations (ClientCCU, ClientJsonCCU, ClientHomegear, ClientConfig)
13
+ - config.py: InterfaceConfig for per-interface connection settings
14
+ - circuit_breaker.py: CircuitBreaker, CircuitBreakerConfig, CircuitState
15
+ - state_machine.py: ClientStateMachine for connection state tracking
16
+ - rpc_proxy.py: BaseRpcProxy, AioXmlRpcProxy for XML-RPC transport
17
+ - json_rpc.py: AioJsonRpcAioHttpClient for JSON-RPC transport
18
+ - request_coalescer.py: RequestCoalescer for deduplicating concurrent requests
19
+ - handlers/: Protocol-specific operation handlers
20
+ - backends/: Backend strategy implementations (CCU, CCU-Jack, Homegear)
21
+ - interface_client.py: InterfaceClient using backend strategy pattern
22
+
23
+ Public API
24
+ ----------
25
+ - Clients: ClientCCU, ClientJsonCCU, ClientHomegear, ClientConfig, InterfaceClient
26
+ - Configuration: InterfaceConfig
27
+ - Circuit breaker: CircuitBreaker, CircuitBreakerConfig, CircuitState
28
+ - State machine: ClientStateMachine, InvalidStateTransitionError
29
+ - Transport: BaseRpcProxy, AioJsonRpcAioHttpClient
30
+ - Coalescing: RequestCoalescer, make_coalesce_key
31
+ - Factory functions: create_client, get_client
32
+
33
+ Notes
34
+ -----
35
+ - Most users interact with clients via CentralUnit; direct usage is for advanced scenarios
36
+ - Clients are created via ClientConfig.create_client() or the create_client() function
37
+ - XML-RPC is used for device operations; JSON-RPC for metadata/programs/sysvars (CCU only)
38
+ - InterfaceClient with backends can be enabled via:
39
+ - OptionalSettings.USE_INTERFACE_CLIENT in config, OR
40
+ - Environment variable AIOHOMEMATIC_USE_INTERFACE_CLIENT=1 (for CI testing)
41
+
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import logging
47
+ import os
48
+ from typing import Final
49
+
50
+ from aiohomematic import central as hmcu, i18n
51
+ from aiohomematic.client.backends import create_backend
52
+ from aiohomematic.client.ccu import ClientCCU, ClientConfig, ClientHomegear, ClientJsonCCU
53
+ from aiohomematic.client.circuit_breaker import CircuitBreaker, CircuitBreakerConfig
54
+ from aiohomematic.client.config import InterfaceConfig
55
+ from aiohomematic.client.interface_client import InterfaceClient
56
+ from aiohomematic.client.json_rpc import AioJsonRpcAioHttpClient
57
+ from aiohomematic.client.request_coalescer import RequestCoalescer, make_coalesce_key
58
+ from aiohomematic.client.rpc_proxy import BaseRpcProxy
59
+ from aiohomematic.client.state_machine import ClientStateMachine, InvalidStateTransitionError
60
+ from aiohomematic.const import CircuitState, OptionalSettings
61
+ from aiohomematic.exceptions import NoConnectionException
62
+ from aiohomematic.interfaces.client import ClientDependenciesProtocol, ClientProtocol
63
+
64
+ _LOGGER: Final = logging.getLogger(__name__)
65
+
66
+ __all__ = [
67
+ # Circuit breaker
68
+ "CircuitBreaker",
69
+ "CircuitBreakerConfig",
70
+ "CircuitState",
71
+ # Clients
72
+ "ClientCCU",
73
+ "ClientConfig",
74
+ "ClientHomegear",
75
+ "ClientJsonCCU",
76
+ "InterfaceClient",
77
+ # Config
78
+ "InterfaceConfig",
79
+ # Factory functions
80
+ "create_client",
81
+ "get_client",
82
+ # JSON RPC
83
+ "AioJsonRpcAioHttpClient",
84
+ # RPC proxy
85
+ "BaseRpcProxy",
86
+ # Request coalescing
87
+ "RequestCoalescer",
88
+ "make_coalesce_key",
89
+ # State machine
90
+ "ClientStateMachine",
91
+ "InvalidStateTransitionError",
92
+ ]
93
+
94
+
95
+ _ENV_USE_INTERFACE_CLIENT: Final = "AIOHOMEMATIC_USE_INTERFACE_CLIENT"
96
+
97
+
98
+ def _should_use_interface_client(*, client_deps: ClientDependenciesProtocol) -> bool:
99
+ """
100
+ Determine if InterfaceClient should be used.
101
+
102
+ Checks in order:
103
+ 1. Environment variable AIOHOMEMATIC_USE_INTERFACE_CLIENT (for CI testing)
104
+ 2. OptionalSettings.USE_INTERFACE_CLIENT in config
105
+ """
106
+ # Environment variable takes precedence (for CI testing)
107
+ if (env_value := os.environ.get(_ENV_USE_INTERFACE_CLIENT)) is not None:
108
+ return env_value.lower() in ("1", "true", "yes")
109
+
110
+ # Check config setting
111
+ return OptionalSettings.USE_INTERFACE_CLIENT in client_deps.config.optional_settings
112
+
113
+
114
+ async def create_client(
115
+ *,
116
+ client_deps: ClientDependenciesProtocol,
117
+ interface_config: InterfaceConfig,
118
+ ) -> ClientProtocol:
119
+ """
120
+ Return a new client for with a given interface_config.
121
+
122
+ Uses InterfaceClient with backend strategy pattern if:
123
+ - Environment variable AIOHOMEMATIC_USE_INTERFACE_CLIENT=1 is set, OR
124
+ - USE_INTERFACE_CLIENT is enabled in optional_settings
125
+
126
+ Otherwise uses legacy ClientCCU family.
127
+ """
128
+ if _should_use_interface_client(client_deps=client_deps):
129
+ return await _create_interface_client(client_deps=client_deps, interface_config=interface_config)
130
+
131
+ # Legacy path - unchanged
132
+ return await ClientConfig(
133
+ client_deps=client_deps,
134
+ interface_config=interface_config,
135
+ ).create_client()
136
+
137
+
138
+ async def _create_interface_client(
139
+ *,
140
+ client_deps: ClientDependenciesProtocol,
141
+ interface_config: InterfaceConfig,
142
+ ) -> ClientProtocol:
143
+ """Create InterfaceClient using backend strategy pattern."""
144
+ # Get version first (needed for backend selection)
145
+ client_config = ClientConfig(
146
+ client_deps=client_deps,
147
+ interface_config=interface_config,
148
+ )
149
+ version = await client_config._get_version() # noqa: SLF001 # pylint: disable=protected-access
150
+
151
+ # Create appropriate backend
152
+ backend = await create_backend(
153
+ interface=interface_config.interface,
154
+ interface_id=interface_config.interface_id,
155
+ version=version,
156
+ proxy=await client_config.create_rpc_proxy(
157
+ interface=interface_config.interface,
158
+ auth_enabled=True,
159
+ )
160
+ if client_config.has_rpc_callback
161
+ else None,
162
+ proxy_read=await client_config.create_rpc_proxy(
163
+ interface=interface_config.interface,
164
+ auth_enabled=True,
165
+ max_workers=client_config.max_read_workers,
166
+ )
167
+ if client_config.has_rpc_callback
168
+ else None,
169
+ json_rpc=client_deps.json_rpc_client,
170
+ paramset_provider=client_deps.cache_coordinator.paramset_descriptions,
171
+ device_details_provider=client_deps.cache_coordinator.device_details.device_channel_rega_ids,
172
+ has_push_updates=client_config.has_push_updates,
173
+ )
174
+
175
+ _LOGGER.debug(
176
+ "CREATE_INTERFACE_CLIENT: Created %s backend for %s",
177
+ backend.model,
178
+ interface_config.interface_id,
179
+ )
180
+
181
+ # Create InterfaceClient
182
+ client = InterfaceClient(
183
+ backend=backend,
184
+ central=client_deps,
185
+ interface_config=interface_config,
186
+ version=version,
187
+ )
188
+ await client.init_client()
189
+
190
+ if await client.check_connection_availability(handle_ping_pong=False):
191
+ return client
192
+
193
+ raise NoConnectionException(
194
+ i18n.tr(key="exception.client.client_config.no_connection", interface_id=interface_config.interface_id)
195
+ )
196
+
197
+
198
+ def get_client(*, interface_id: str) -> ClientProtocol | None:
199
+ """Return client by interface_id."""
200
+ for central in hmcu.CENTRAL_INSTANCES.values():
201
+ if central.client_coordinator.has_client(interface_id=interface_id):
202
+ return central.client_coordinator.get_client(interface_id=interface_id)
203
+ return None