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,762 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Connection health tracking for unified availability determination.
5
+
6
+ This module provides unified health tracking that replaces the three overlapping
7
+ availability systems (state machine, circuit breaker, forced availability) with
8
+ a single source of truth.
9
+
10
+ Overview
11
+ --------
12
+ The health system provides:
13
+ - ConnectionHealth: Per-client health status
14
+ - CentralHealth: Aggregated system health
15
+ - Unified availability determination
16
+ - Health scoring for weighted decisions
17
+
18
+ Key Classes
19
+ -----------
20
+ - ConnectionHealth: Tracks health of a single client connection
21
+ - CentralHealth: Aggregates health across all clients
22
+
23
+ The health system observes:
24
+ - Client state machine status
25
+ - Circuit breaker states
26
+ - Communication metrics (last request, last event)
27
+ - Recovery tracking
28
+
29
+ Example:
30
+ # Get health for a specific client
31
+ health = central.health.get_client_health("ccu-main-HmIP-RF")
32
+ if health.is_available:
33
+ # Client is fully operational
34
+ ...
35
+ elif health.is_degraded:
36
+ # Client has issues but may work
37
+ ...
38
+
39
+ # Check overall system health
40
+ if central.health.all_clients_healthy:
41
+ # All clients are good
42
+ ...
43
+
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from collections.abc import Mapping
49
+ from dataclasses import dataclass, field, fields, is_dataclass
50
+ from datetime import datetime
51
+ from enum import Enum
52
+ from typing import TYPE_CHECKING, Any, Final
53
+
54
+ from aiohomematic.client import CircuitState
55
+ from aiohomematic.const import CentralState, ClientState, Interface
56
+ from aiohomematic.interfaces import CentralHealthProtocol, ConnectionHealthProtocol, HealthTrackerProtocol
57
+ from aiohomematic.metrics import MetricKeys, emit_health
58
+ from aiohomematic.property_decorators import DelegatedProperty
59
+
60
+ if TYPE_CHECKING:
61
+ from aiohomematic.central.events import EventBus
62
+ from aiohomematic.central.state_machine import CentralStateMachine
63
+
64
+
65
+ def _convert_value(*, value: Any) -> Any:
66
+ """
67
+ Convert a value to a JSON-serializable format.
68
+
69
+ Handles:
70
+ - datetime → ISO format string
71
+ - float → rounded to 2 decimal places
72
+ - Enum → name string
73
+ - Mapping → dict with converted values
74
+ - dataclass → dict with fields and properties
75
+ - list/tuple → list with converted items
76
+ - None, int, str, bool → pass through
77
+ """
78
+ if value is None or isinstance(value, (bool, int, str)):
79
+ return value
80
+ if isinstance(value, datetime):
81
+ return value.isoformat()
82
+ if isinstance(value, float):
83
+ return round(value, 2)
84
+ if isinstance(value, Enum):
85
+ return value.name
86
+ if is_dataclass(value) and not isinstance(value, type):
87
+ return _dataclass_to_dict(obj=value)
88
+ if isinstance(value, Mapping):
89
+ return {k: _convert_value(value=v) for k, v in value.items()}
90
+ if isinstance(value, (list, tuple)):
91
+ return [_convert_value(value=item) for item in value]
92
+ # Fallback for unknown types
93
+ return str(value)
94
+
95
+
96
+ def _dataclass_to_dict(*, obj: Any) -> dict[str, Any]:
97
+ """
98
+ Convert a dataclass instance to a dictionary.
99
+
100
+ Includes both dataclass fields and @property computed values.
101
+ """
102
+ result: dict[str, Any] = {}
103
+
104
+ # Add dataclass fields
105
+ for f in fields(obj):
106
+ attr_value = getattr(obj, f.name)
107
+ result[f.name] = _convert_value(value=attr_value)
108
+
109
+ # Add @property computed values
110
+ for name in dir(type(obj)):
111
+ if name.startswith("_"):
112
+ continue
113
+ attr = getattr(type(obj), name, None)
114
+ if isinstance(attr, property):
115
+ attr_value = getattr(obj, name)
116
+ result[name] = _convert_value(value=attr_value)
117
+
118
+ return result
119
+
120
+
121
+ # Threshold for considering events as "recent" (5 minutes)
122
+ EVENT_STALENESS_THRESHOLD: Final = 300.0
123
+
124
+ # Health score weights
125
+ _WEIGHT_STATE_MACHINE: Final = 0.4
126
+ _WEIGHT_CIRCUIT_BREAKERS: Final = 0.3
127
+ _WEIGHT_RECENT_ACTIVITY: Final = 0.3
128
+
129
+
130
+ @dataclass(slots=True)
131
+ class ConnectionHealth(ConnectionHealthProtocol):
132
+ """
133
+ Unified health status for a single client connection.
134
+
135
+ This class replaces the three overlapping availability systems:
136
+ - state_machine.is_available
137
+ - circuit_breaker.is_available
138
+ - forced_availability
139
+
140
+ It provides a single source of truth for connection health with
141
+ detailed metrics for monitoring and debugging.
142
+
143
+ Attributes
144
+ ----------
145
+ interface_id : str
146
+ Unique identifier for the interface (e.g., "ccu-main-HmIP-RF")
147
+ interface : Interface
148
+ The interface type (e.g., Interface.HMIP_RF)
149
+ client_state : ClientState
150
+ Current state from the client state machine
151
+ xml_rpc_circuit : CircuitState
152
+ State of the XML-RPC circuit breaker
153
+ json_rpc_circuit : CircuitState | None
154
+ State of the JSON-RPC circuit breaker (None for non-CCU clients)
155
+ last_successful_request : datetime | None
156
+ Timestamp of last successful RPC request
157
+ last_failed_request : datetime | None
158
+ Timestamp of last failed RPC request
159
+ last_event_received : datetime | None
160
+ Timestamp of last event received from backend
161
+ consecutive_failures : int
162
+ Number of consecutive failed operations
163
+ reconnect_attempts : int
164
+ Number of reconnection attempts
165
+ last_reconnect_attempt : datetime | None
166
+ Timestamp of last reconnection attempt
167
+ in_recovery : bool
168
+ True if recovery is in progress for this client
169
+
170
+ """
171
+
172
+ interface_id: str
173
+ interface: Interface
174
+ client_state: ClientState = ClientState.CREATED
175
+ xml_rpc_circuit: CircuitState = CircuitState.CLOSED
176
+ json_rpc_circuit: CircuitState | None = None
177
+ last_successful_request: datetime | None = None
178
+ last_failed_request: datetime | None = None
179
+ last_event_received: datetime | None = None
180
+ consecutive_failures: int = 0
181
+ reconnect_attempts: int = 0
182
+ last_reconnect_attempt: datetime | None = None
183
+ in_recovery: bool = False
184
+
185
+ @property
186
+ def can_receive_events(self) -> bool:
187
+ """
188
+ Check if client can receive events from the backend.
189
+
190
+ Returns True if connected and has received events recently.
191
+ """
192
+ if not self.is_connected:
193
+ return False
194
+ if self.last_event_received is None:
195
+ return False
196
+ age = (datetime.now() - self.last_event_received).total_seconds()
197
+ return age < EVENT_STALENESS_THRESHOLD
198
+
199
+ @property
200
+ def health_score(self) -> float:
201
+ """
202
+ Calculate a numeric health score (0.0 - 1.0).
203
+
204
+ The score is weighted:
205
+ - 40% State Machine status
206
+ - 30% Circuit Breaker status
207
+ - 30% Recent Activity
208
+
209
+ Returns:
210
+ Health score between 0.0 (unhealthy) and 1.0 (fully healthy)
211
+
212
+ """
213
+ score = 0.0
214
+
215
+ # State Machine (40%)
216
+ if self.client_state == ClientState.CONNECTED:
217
+ score += _WEIGHT_STATE_MACHINE
218
+ elif self.client_state == ClientState.RECONNECTING:
219
+ score += _WEIGHT_STATE_MACHINE * 0.5
220
+
221
+ # Circuit Breakers (30% total - 15% each)
222
+ xml_weight = _WEIGHT_CIRCUIT_BREAKERS / 2
223
+ json_weight = _WEIGHT_CIRCUIT_BREAKERS / 2
224
+
225
+ if self.xml_rpc_circuit == CircuitState.CLOSED:
226
+ score += xml_weight
227
+ elif self.xml_rpc_circuit == CircuitState.HALF_OPEN:
228
+ score += xml_weight * 0.33
229
+
230
+ if self.json_rpc_circuit is None:
231
+ # No JSON-RPC circuit - give full credit
232
+ score += json_weight
233
+ elif self.json_rpc_circuit == CircuitState.CLOSED:
234
+ score += json_weight
235
+ elif self.json_rpc_circuit == CircuitState.HALF_OPEN:
236
+ score += json_weight * 0.33
237
+
238
+ # Recent Activity (30% total - 15% each for request and event)
239
+ activity_weight = _WEIGHT_RECENT_ACTIVITY / 2
240
+
241
+ if self.last_successful_request:
242
+ age = (datetime.now() - self.last_successful_request).total_seconds()
243
+ if age < 60:
244
+ score += activity_weight
245
+ elif age < 300:
246
+ score += activity_weight * 0.66
247
+ elif age < 600:
248
+ score += activity_weight * 0.33
249
+
250
+ if self.last_event_received:
251
+ age = (datetime.now() - self.last_event_received).total_seconds()
252
+ if age < 60:
253
+ score += activity_weight
254
+ elif age < 300:
255
+ score += activity_weight * 0.66
256
+ elif age < 600:
257
+ score += activity_weight * 0.33
258
+
259
+ return min(score, 1.0)
260
+
261
+ @property
262
+ def is_available(self) -> bool:
263
+ """
264
+ Check if client is available for operations.
265
+
266
+ Returns True if:
267
+ - Client state is CONNECTED
268
+ - All circuit breakers are CLOSED
269
+ """
270
+ return (
271
+ self.client_state == ClientState.CONNECTED
272
+ and self.xml_rpc_circuit == CircuitState.CLOSED
273
+ and (self.json_rpc_circuit is None or self.json_rpc_circuit == CircuitState.CLOSED)
274
+ )
275
+
276
+ @property
277
+ def is_connected(self) -> bool:
278
+ """Check if client is in connected state."""
279
+ return self.client_state == ClientState.CONNECTED
280
+
281
+ @property
282
+ def is_degraded(self) -> bool:
283
+ """
284
+ Check if client is in degraded state.
285
+
286
+ Returns True if connected/reconnecting but circuit breakers have issues.
287
+ """
288
+ if self.client_state not in (ClientState.CONNECTED, ClientState.RECONNECTING):
289
+ return False
290
+ return self.xml_rpc_circuit != CircuitState.CLOSED or (
291
+ self.json_rpc_circuit is not None and self.json_rpc_circuit != CircuitState.CLOSED
292
+ )
293
+
294
+ @property
295
+ def is_failed(self) -> bool:
296
+ """Check if client is in failed or disconnected state."""
297
+ return self.client_state in (ClientState.FAILED, ClientState.DISCONNECTED)
298
+
299
+ def record_event_received(self) -> None:
300
+ """Record that an event was received from the backend."""
301
+ self.last_event_received = datetime.now()
302
+
303
+ def record_failed_request(self) -> None:
304
+ """Record a failed RPC request."""
305
+ self.last_failed_request = datetime.now()
306
+ self.consecutive_failures += 1
307
+
308
+ def record_reconnect_attempt(self) -> None:
309
+ """Record a reconnection attempt."""
310
+ self.reconnect_attempts += 1
311
+ self.last_reconnect_attempt = datetime.now()
312
+
313
+ def record_successful_request(self) -> None:
314
+ """Record a successful RPC request."""
315
+ self.last_successful_request = datetime.now()
316
+ self.consecutive_failures = 0
317
+
318
+ def reset_reconnect_counter(self) -> None:
319
+ """Reset the reconnect attempt counter (called on successful recovery)."""
320
+ self.reconnect_attempts = 0
321
+
322
+ def to_dict(self) -> dict[str, Any]:
323
+ """
324
+ Convert to a JSON-serializable dictionary.
325
+
326
+ Automatically converts all fields and computed properties.
327
+
328
+ Returns:
329
+ Dictionary representation of connection health.
330
+
331
+ """
332
+ return _dataclass_to_dict(obj=self)
333
+
334
+ def update_from_client(self, *, client: Any) -> None:
335
+ """
336
+ Update health from client state.
337
+
338
+ Args:
339
+ client: The client to read state from (ClientCCU or similar)
340
+
341
+ Note:
342
+ This method uses hasattr checks because the client's internal
343
+ attributes (_state_machine, _proxy, _json_rpc_client) are not
344
+ part of the ClientProtocol interface. A proper protocol will
345
+ be added in Phase 1.4.
346
+
347
+ """
348
+ # Update client state from state machine
349
+ # pylint: disable=protected-access
350
+ if hasattr(client, "_state_machine"):
351
+ self.client_state = client._state_machine.state
352
+
353
+ # Update circuit breaker states
354
+ if hasattr(client, "_proxy") and hasattr(client._proxy, "_circuit_breaker"):
355
+ self.xml_rpc_circuit = client._proxy._circuit_breaker.state
356
+
357
+ if (
358
+ hasattr(client, "_json_rpc_client")
359
+ and client._json_rpc_client is not None
360
+ and hasattr(client._json_rpc_client, "_circuit_breaker")
361
+ ):
362
+ self.json_rpc_circuit = client._json_rpc_client._circuit_breaker.state
363
+
364
+
365
+ @dataclass(slots=True)
366
+ class CentralHealth(CentralHealthProtocol):
367
+ """
368
+ Aggregated health status for the entire central system.
369
+
370
+ This class provides a unified view of system health by aggregating
371
+ health from all connected clients.
372
+
373
+ Attributes
374
+ ----------
375
+ central_state : CentralState
376
+ Current state of the central state machine
377
+ client_health : dict[str, ConnectionHealth]
378
+ Health status for each client (interface_id -> health)
379
+ primary_interface : Interface | None
380
+ The primary interface type for determining primary_client_healthy
381
+
382
+ """
383
+
384
+ central_state: CentralState = CentralState.STARTING
385
+ client_health: dict[str, ConnectionHealth] = field(default_factory=dict)
386
+ primary_interface: Interface | None = None
387
+
388
+ @property
389
+ def all_clients_healthy(self) -> bool:
390
+ """Check if all clients are fully healthy."""
391
+ if not self.client_health:
392
+ return False
393
+ return all(h.is_available for h in self.client_health.values())
394
+
395
+ @property
396
+ def any_client_healthy(self) -> bool:
397
+ """Check if at least one client is healthy."""
398
+ return any(h.is_available for h in self.client_health.values())
399
+
400
+ @property
401
+ def degraded_clients(self) -> list[str]:
402
+ """Return list of interface IDs with degraded health."""
403
+ return [iid for iid, h in self.client_health.items() if h.is_degraded]
404
+
405
+ @property
406
+ def failed_clients(self) -> list[str]:
407
+ """Return list of interface IDs that have failed."""
408
+ return [iid for iid, h in self.client_health.items() if h.is_failed]
409
+
410
+ @property
411
+ def healthy_clients(self) -> list[str]:
412
+ """Return list of healthy interface IDs."""
413
+ return [iid for iid, h in self.client_health.items() if h.is_available]
414
+
415
+ @property
416
+ def overall_health_score(self) -> float:
417
+ """
418
+ Calculate weighted average health score across all clients.
419
+
420
+ Returns 0.0 if no clients are registered.
421
+ """
422
+ if not self.client_health:
423
+ return 0.0
424
+ scores = [h.health_score for h in self.client_health.values()]
425
+ return sum(scores) / len(scores)
426
+
427
+ @property
428
+ def primary_client_healthy(self) -> bool:
429
+ """
430
+ Check if the primary client (for JSON-RPC) is healthy.
431
+
432
+ The primary client is determined by:
433
+ 1. If primary_interface is set, find client with that interface
434
+ 2. Otherwise, prefer HmIP-RF, then first available
435
+ """
436
+ if not self.client_health:
437
+ return False
438
+
439
+ # Find primary client
440
+ primary_health: ConnectionHealth | None = None
441
+
442
+ if self.primary_interface:
443
+ for health in self.client_health.values():
444
+ if health.interface == self.primary_interface:
445
+ primary_health = health
446
+ break
447
+
448
+ if primary_health is None:
449
+ # Fallback: prefer HmIP-RF
450
+ for health in self.client_health.values():
451
+ if health.interface == Interface.HMIP_RF:
452
+ primary_health = health
453
+ break
454
+
455
+ if primary_health is None:
456
+ # Last resort: first client
457
+ primary_health = next(iter(self.client_health.values()), None)
458
+
459
+ return primary_health.is_available if primary_health else False
460
+
461
+ @property
462
+ def state(self) -> CentralState:
463
+ """Return current central state."""
464
+ return self.central_state
465
+
466
+ def get_client_health(self, *, interface_id: str) -> ConnectionHealth | None:
467
+ """
468
+ Get health for a specific client.
469
+
470
+ Args:
471
+ interface_id: The interface ID to look up
472
+
473
+ Returns:
474
+ ConnectionHealth for the client, or None if not found
475
+
476
+ """
477
+ return self.client_health.get(interface_id)
478
+
479
+ def register_client(
480
+ self,
481
+ *,
482
+ interface_id: str,
483
+ interface: Interface,
484
+ ) -> ConnectionHealth:
485
+ """
486
+ Register a new client and create its health tracker.
487
+
488
+ Args:
489
+ interface_id: Unique identifier for the interface
490
+ interface: The interface type
491
+
492
+ Returns:
493
+ The created ConnectionHealth instance
494
+
495
+ """
496
+ health = ConnectionHealth(interface_id=interface_id, interface=interface)
497
+ self.client_health[interface_id] = health
498
+ return health
499
+
500
+ def should_be_degraded(self) -> bool:
501
+ """
502
+ Determine if central should be in DEGRADED state.
503
+
504
+ Returns True if at least one client is healthy but not all.
505
+ """
506
+ return self.any_client_healthy and not self.all_clients_healthy
507
+
508
+ def should_be_running(self) -> bool:
509
+ """
510
+ Determine if central should be in RUNNING state.
511
+
512
+ Based on user's choice: ALL clients must be CONNECTED.
513
+ """
514
+ return self.all_clients_healthy
515
+
516
+ def to_dict(self) -> dict[str, Any]:
517
+ """
518
+ Convert to a JSON-serializable dictionary.
519
+
520
+ Automatically converts all fields and computed properties.
521
+ Client health entries are keyed by interface_id.
522
+
523
+ Returns:
524
+ Dictionary representation of central health.
525
+
526
+ """
527
+ return _dataclass_to_dict(obj=self)
528
+
529
+ def unregister_client(self, *, interface_id: str) -> None:
530
+ """
531
+ Remove a client from health tracking.
532
+
533
+ Args:
534
+ interface_id: The interface ID to remove
535
+
536
+ """
537
+ self.client_health.pop(interface_id, None)
538
+
539
+ def update_central_state(self, *, state: CentralState) -> None:
540
+ """
541
+ Update the cached central state.
542
+
543
+ Args:
544
+ state: The new central state
545
+
546
+ """
547
+ self.central_state = state
548
+
549
+
550
+ class HealthTracker(HealthTrackerProtocol):
551
+ """
552
+ Central health tracking coordinator.
553
+
554
+ This class manages health tracking for all clients and provides
555
+ methods to query and update health status.
556
+ """
557
+
558
+ __slots__ = (
559
+ "_central_health",
560
+ "_central_name",
561
+ "_event_bus",
562
+ "_state_machine",
563
+ )
564
+
565
+ def __init__(
566
+ self,
567
+ *,
568
+ central_name: str,
569
+ state_machine: CentralStateMachine | None = None,
570
+ event_bus: EventBus | None = None,
571
+ ) -> None:
572
+ """
573
+ Initialize the health tracker.
574
+
575
+ Args:
576
+ central_name: Name of the central unit
577
+ state_machine: Optional reference to the central state machine
578
+ event_bus: Optional event bus for emitting health metric events
579
+
580
+ """
581
+ self._central_name: Final = central_name
582
+ self._state_machine = state_machine
583
+ self._event_bus = event_bus
584
+ self._central_health: Final = CentralHealth()
585
+
586
+ health: Final = DelegatedProperty[CentralHealth](path="_central_health")
587
+
588
+ def get_client_health(self, *, interface_id: str) -> ConnectionHealth | None:
589
+ """
590
+ Get health for a specific client.
591
+
592
+ Args:
593
+ interface_id: The interface ID to look up
594
+
595
+ Returns:
596
+ ConnectionHealth for the client, or None if not found
597
+
598
+ """
599
+ return self._central_health.get_client_health(interface_id=interface_id)
600
+
601
+ def record_event_received(self, *, interface_id: str) -> None:
602
+ """
603
+ Record that an event was received for an interface.
604
+
605
+ Args:
606
+ interface_id: The interface ID that received the event
607
+
608
+ """
609
+ if (health := self._central_health.get_client_health(interface_id=interface_id)) is not None:
610
+ health.record_event_received()
611
+
612
+ def record_failed_request(self, *, interface_id: str) -> None:
613
+ """
614
+ Record a failed RPC request for an interface.
615
+
616
+ Args:
617
+ interface_id: The interface ID where the request failed
618
+
619
+ """
620
+ if (health := self._central_health.get_client_health(interface_id=interface_id)) is not None:
621
+ health.record_failed_request()
622
+
623
+ def record_successful_request(self, *, interface_id: str) -> None:
624
+ """
625
+ Record a successful RPC request for an interface.
626
+
627
+ Args:
628
+ interface_id: The interface ID where the request succeeded
629
+
630
+ """
631
+ if (health := self._central_health.get_client_health(interface_id=interface_id)) is not None:
632
+ health.record_successful_request()
633
+
634
+ def register_client(
635
+ self,
636
+ *,
637
+ interface_id: str,
638
+ interface: Interface,
639
+ ) -> ConnectionHealth:
640
+ """
641
+ Register a new client for health tracking.
642
+
643
+ Args:
644
+ interface_id: Unique identifier for the interface
645
+ interface: The interface type
646
+
647
+ Returns:
648
+ The created ConnectionHealth instance
649
+
650
+ """
651
+ return self._central_health.register_client(
652
+ interface_id=interface_id,
653
+ interface=interface,
654
+ )
655
+
656
+ def set_primary_interface(self, *, interface: Interface) -> None:
657
+ """
658
+ Set the primary interface type.
659
+
660
+ Args:
661
+ interface: The primary interface type
662
+
663
+ """
664
+ self._central_health.primary_interface = interface
665
+
666
+ def set_state_machine(self, *, state_machine: CentralStateMachine) -> None:
667
+ """
668
+ Set the central state machine reference.
669
+
670
+ Args:
671
+ state_machine: The central state machine
672
+
673
+ """
674
+ self._state_machine = state_machine
675
+
676
+ def unregister_client(self, *, interface_id: str) -> None:
677
+ """
678
+ Remove a client from health tracking.
679
+
680
+ Args:
681
+ interface_id: The interface ID to remove
682
+
683
+ """
684
+ self._central_health.unregister_client(interface_id=interface_id)
685
+
686
+ def update_all_from_clients(self, *, clients: dict[str, Any]) -> None:
687
+ """
688
+ Update health for all clients.
689
+
690
+ Args:
691
+ clients: Dictionary of interface_id -> client
692
+
693
+ """
694
+ for interface_id, client in clients.items():
695
+ if (health := self._central_health.get_client_health(interface_id=interface_id)) is not None:
696
+ health.update_from_client(client=client)
697
+
698
+ # Update central state in health
699
+ if self._state_machine is not None:
700
+ self._central_health.update_central_state(state=self._state_machine.state)
701
+
702
+ def update_client_health(
703
+ self,
704
+ *,
705
+ interface_id: str,
706
+ old_state: ClientState,
707
+ new_state: ClientState,
708
+ ) -> None:
709
+ """
710
+ Update health for a specific client based on state change.
711
+
712
+ Args:
713
+ interface_id: The interface ID that changed
714
+ old_state: Previous client state
715
+ new_state: New client state
716
+
717
+ """
718
+ if (health := self._central_health.get_client_health(interface_id=interface_id)) is not None:
719
+ health.client_state = new_state
720
+
721
+ # Track reconnection attempts
722
+ if new_state == ClientState.RECONNECTING and old_state != ClientState.RECONNECTING:
723
+ health.record_reconnect_attempt()
724
+
725
+ # Reset reconnect counter on successful connection
726
+ if new_state == ClientState.CONNECTED:
727
+ health.reset_reconnect_counter()
728
+
729
+ # Emit health metric event for event-driven metrics
730
+ self._emit_health_event(interface_id=interface_id, health=health)
731
+
732
+ # Update central state in health
733
+ if self._state_machine is not None:
734
+ self._central_health.update_central_state(state=self._state_machine.state)
735
+
736
+ def _emit_health_event(self, *, interface_id: str, health: ConnectionHealth) -> None:
737
+ """
738
+ Emit a health metric event for a client.
739
+
740
+ Args:
741
+ interface_id: The interface ID
742
+ health: The connection health state
743
+
744
+ """
745
+ if self._event_bus is None:
746
+ return
747
+
748
+ # Determine health status and reason
749
+ is_healthy = health.is_available
750
+ reason: str | None = None
751
+ if not is_healthy:
752
+ if health.is_failed:
753
+ reason = f"Client state: {health.client_state.name}"
754
+ elif health.is_degraded:
755
+ reason = "Degraded"
756
+
757
+ emit_health(
758
+ event_bus=self._event_bus,
759
+ key=MetricKeys.client_health(interface_id=interface_id),
760
+ healthy=is_healthy,
761
+ reason=reason,
762
+ )