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,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
|
+
)
|