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,534 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Metrics aggregation from system components.
5
+
6
+ This module provides MetricsAggregator which collects metrics from
7
+ various system components and presents them through a unified interface.
8
+
9
+ Public API
10
+ ----------
11
+ - MetricsAggregator: Main class for aggregating metrics
12
+
13
+ Usage
14
+ -----
15
+ from aiohomematic.metrics import MetricsAggregator
16
+
17
+ aggregator = MetricsAggregator(
18
+ central_name="my-central",
19
+ client_provider=central,
20
+ event_bus=central.event_bus,
21
+ health_tracker=central.health_tracker,
22
+ ...
23
+ )
24
+
25
+ # Get individual metric categories
26
+ rpc_metrics = aggregator.rpc
27
+ event_metrics = aggregator.events
28
+
29
+ # Get full snapshot
30
+ snapshot = aggregator.snapshot()
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from datetime import datetime
36
+ from typing import TYPE_CHECKING, Final
37
+
38
+ from aiohomematic.const import INIT_DATETIME, CircuitState
39
+ from aiohomematic.metrics._protocols import (
40
+ CacheProviderForMetricsProtocol,
41
+ ClientProviderForMetricsProtocol,
42
+ DeviceProviderForMetricsProtocol,
43
+ HubDataPointManagerForMetricsProtocol,
44
+ RecoveryProviderForMetricsProtocol,
45
+ )
46
+ from aiohomematic.metrics.dataclasses import (
47
+ CacheMetrics,
48
+ EventMetrics,
49
+ HealthMetrics,
50
+ MetricsSnapshot,
51
+ ModelMetrics,
52
+ RecoveryMetrics,
53
+ RpcMetrics,
54
+ RpcServerMetrics,
55
+ ServiceMetrics,
56
+ )
57
+ from aiohomematic.metrics.stats import CacheStats, ServiceStats, SizeOnlyStats
58
+
59
+ if TYPE_CHECKING:
60
+ from aiohomematic.central.events import EventBus
61
+ from aiohomematic.central.health import HealthTracker
62
+ from aiohomematic.metrics.observer import MetricsObserver
63
+ from aiohomematic.store.dynamic import CentralDataCache
64
+
65
+
66
+ # =============================================================================
67
+ # Metrics Aggregator
68
+ # =============================================================================
69
+
70
+
71
+ class MetricsAggregator:
72
+ """
73
+ Aggregate metrics from various system components.
74
+
75
+ Provides a unified interface for accessing all system metrics.
76
+ This class collects data from:
77
+ - CircuitBreaker (per client)
78
+ - RequestCoalescer (per client)
79
+ - EventBus
80
+ - HealthTracker
81
+ - RecoveryCoordinator
82
+ - Various caches
83
+ - Device registry
84
+
85
+ Example:
86
+ -------
87
+ ```python
88
+ aggregator = MetricsAggregator(
89
+ central_name="my-central",
90
+ client_provider=central,
91
+ event_bus=central.event_bus,
92
+ health_tracker=central.health_tracker,
93
+ ...
94
+ )
95
+
96
+ # Get individual metric categories
97
+ rpc_metrics = aggregator.rpc
98
+ event_metrics = aggregator.events
99
+
100
+ # Get full snapshot
101
+ snapshot = aggregator.snapshot()
102
+ ```
103
+
104
+ """
105
+
106
+ __slots__ = (
107
+ "_cache_provider",
108
+ "_central_name",
109
+ "_client_provider",
110
+ "_data_cache",
111
+ "_device_provider",
112
+ "_event_bus",
113
+ "_health_tracker",
114
+ "_hub_data_point_manager",
115
+ "_observer",
116
+ "_recovery_provider",
117
+ )
118
+
119
+ def __init__(
120
+ self,
121
+ *,
122
+ central_name: str,
123
+ client_provider: ClientProviderForMetricsProtocol,
124
+ device_provider: DeviceProviderForMetricsProtocol,
125
+ event_bus: EventBus,
126
+ health_tracker: HealthTracker,
127
+ data_cache: CentralDataCache,
128
+ observer: MetricsObserver | None = None,
129
+ hub_data_point_manager: HubDataPointManagerForMetricsProtocol | None = None,
130
+ cache_provider: CacheProviderForMetricsProtocol | None = None,
131
+ recovery_provider: RecoveryProviderForMetricsProtocol | None = None,
132
+ ) -> None:
133
+ """
134
+ Initialize the metrics aggregator.
135
+
136
+ Args:
137
+ central_name: Name of the CentralUnit (for service stats isolation)
138
+ client_provider: Provider for client access
139
+ device_provider: Provider for device access
140
+ event_bus: The EventBus instance
141
+ health_tracker: The HealthTracker instance
142
+ data_cache: The CentralDataCache instance
143
+ observer: Optional MetricsObserver for event-driven metrics
144
+ hub_data_point_manager: Optional hub data point manager
145
+ cache_provider: Optional cache provider for cache statistics
146
+ recovery_provider: Optional recovery provider for recovery statistics
147
+
148
+ """
149
+ self._central_name: Final = central_name
150
+ self._client_provider: Final = client_provider
151
+ self._device_provider: Final = device_provider
152
+ self._event_bus: Final = event_bus
153
+ self._health_tracker: Final = health_tracker
154
+ self._observer: Final = observer
155
+ self._data_cache: Final = data_cache
156
+ self._hub_data_point_manager: Final = hub_data_point_manager
157
+ self._cache_provider: Final = cache_provider
158
+ self._recovery_provider: Final = recovery_provider
159
+
160
+ @property
161
+ def cache(self) -> CacheMetrics:
162
+ """Return cache statistics."""
163
+ # Get data cache statistics directly from cache
164
+ data_stats = self._data_cache.statistics
165
+ data_cache_size = self._data_cache.size
166
+
167
+ # Get cache sizes from provider if available
168
+ visibility_cache_size = 0
169
+ device_descriptions_size = 0
170
+ paramset_descriptions_size = 0
171
+ if self._cache_provider is not None:
172
+ visibility_cache_size = self._cache_provider.visibility_cache_size
173
+ device_descriptions_size = self._cache_provider.device_descriptions_size
174
+ paramset_descriptions_size = self._cache_provider.paramset_descriptions_size
175
+
176
+ # Aggregate command tracker and ping_pong tracker from all clients
177
+ command_tracker_size = 0
178
+ command_tracker_evictions = 0
179
+ ping_pong_tracker_size = 0
180
+ for client in self._client_provider.clients:
181
+ if (cmd_tracker := getattr(client, "last_value_send_tracker", None)) is not None:
182
+ command_tracker_size += cmd_tracker.size
183
+ command_tracker_evictions += cmd_tracker.statistics.evictions
184
+ if (pp_tracker := getattr(client, "ping_pong_tracker", None)) is not None:
185
+ ping_pong_tracker_size += pp_tracker.size
186
+
187
+ return CacheMetrics(
188
+ # Registries (size-only)
189
+ device_descriptions=SizeOnlyStats(
190
+ size=device_descriptions_size,
191
+ ),
192
+ paramset_descriptions=SizeOnlyStats(
193
+ size=paramset_descriptions_size,
194
+ ),
195
+ visibility_registry=SizeOnlyStats(
196
+ size=visibility_cache_size,
197
+ ),
198
+ # Trackers (size-only)
199
+ ping_pong_tracker=SizeOnlyStats(
200
+ size=ping_pong_tracker_size,
201
+ ),
202
+ command_tracker=SizeOnlyStats(
203
+ size=command_tracker_size,
204
+ evictions=command_tracker_evictions,
205
+ ),
206
+ # True caches (with hit/miss semantics)
207
+ data_cache=CacheStats(
208
+ size=data_cache_size,
209
+ hits=data_stats.hits,
210
+ misses=data_stats.misses,
211
+ evictions=data_stats.evictions,
212
+ ),
213
+ )
214
+
215
+ @property
216
+ def events(self) -> EventMetrics:
217
+ """Return EventBus metrics including operational event counts."""
218
+ event_stats = self._event_bus.get_event_stats()
219
+ handler_stats = self._event_bus.get_handler_stats()
220
+
221
+ # Extract operational event counts from event_stats
222
+ circuit_breaker_trips = event_stats.get("CircuitBreakerTrippedEvent", 0)
223
+ client_state_changes = event_stats.get("ClientStateChangedEvent", 0)
224
+ central_state_changes = event_stats.get("CentralStateChangedEvent", 0)
225
+ data_refreshes_triggered = event_stats.get("DataRefreshTriggeredEvent", 0)
226
+ data_refreshes_completed = event_stats.get("DataRefreshCompletedEvent", 0)
227
+ programs_executed = event_stats.get("ProgramExecutedEvent", 0)
228
+ requests_coalesced = event_stats.get("RequestCoalescedEvent", 0)
229
+ health_records = event_stats.get("HealthRecordedEvent", 0)
230
+
231
+ return EventMetrics(
232
+ total_published=sum(event_stats.values()),
233
+ total_subscriptions=self._event_bus.get_total_subscription_count(),
234
+ handlers_executed=handler_stats.total_executions,
235
+ handler_errors=handler_stats.total_errors,
236
+ avg_handler_duration_ms=handler_stats.avg_duration_ms,
237
+ max_handler_duration_ms=handler_stats.max_duration_ms,
238
+ events_by_type=event_stats,
239
+ circuit_breaker_trips=circuit_breaker_trips,
240
+ state_changes=client_state_changes + central_state_changes,
241
+ data_refreshes_triggered=data_refreshes_triggered,
242
+ data_refreshes_completed=data_refreshes_completed,
243
+ programs_executed=programs_executed,
244
+ requests_coalesced=requests_coalesced,
245
+ health_records=health_records,
246
+ )
247
+
248
+ @property
249
+ def health(self) -> HealthMetrics:
250
+ """Return health metrics."""
251
+ health = self._health_tracker.health
252
+ clients_healthy = len(health.healthy_clients)
253
+ clients_degraded = len(health.degraded_clients)
254
+ clients_failed = len(health.failed_clients)
255
+
256
+ # Aggregate metrics across all clients
257
+ last_event_time = INIT_DATETIME
258
+ reconnect_attempts = 0
259
+ for client_health in health.client_health.values():
260
+ if client_health.last_event_received is not None and client_health.last_event_received > last_event_time:
261
+ last_event_time = client_health.last_event_received
262
+ reconnect_attempts += client_health.reconnect_attempts
263
+
264
+ return HealthMetrics(
265
+ overall_score=health.overall_health_score,
266
+ clients_total=clients_healthy + clients_degraded + clients_failed,
267
+ clients_healthy=clients_healthy,
268
+ clients_degraded=clients_degraded,
269
+ clients_failed=clients_failed,
270
+ reconnect_attempts=reconnect_attempts,
271
+ last_event_time=last_event_time,
272
+ )
273
+
274
+ @property
275
+ def model(self) -> ModelMetrics:
276
+ """Return model statistics."""
277
+ devices = self._device_provider.devices
278
+ devices_available = sum(1 for d in devices if d.available)
279
+ channels_total = sum(len(d.channels) for d in devices)
280
+
281
+ generic_count = 0
282
+ custom_count = 0
283
+ calculated_count = 0
284
+ by_category: dict[str, int] = {}
285
+
286
+ for device in devices:
287
+ for channel in device.channels.values():
288
+ for dp in channel.generic_data_points:
289
+ generic_count += 1
290
+ cat_name = dp.category.name
291
+ by_category[cat_name] = by_category.get(cat_name, 0) + 1
292
+
293
+ for dp in channel.calculated_data_points:
294
+ calculated_count += 1
295
+ cat_name = dp.category.name
296
+ by_category[cat_name] = by_category.get(cat_name, 0) + 1
297
+
298
+ if (custom_dp := channel.custom_data_point) is not None:
299
+ custom_count += 1
300
+ cat_name = custom_dp.category.name
301
+ by_category[cat_name] = by_category.get(cat_name, 0) + 1
302
+
303
+ # Subscription counting available via EventBus.get_total_subscription_count()
304
+ subscribed_count = self._event_bus.get_total_subscription_count()
305
+
306
+ programs_total = 0
307
+ sysvars_total = 0
308
+ if self._hub_data_point_manager is not None:
309
+ for dp in self._hub_data_point_manager.program_data_points:
310
+ programs_total += 1
311
+ cat_name = dp.category.name
312
+ by_category[cat_name] = by_category.get(cat_name, 0) + 1
313
+
314
+ for dp in self._hub_data_point_manager.sysvar_data_points:
315
+ sysvars_total += 1
316
+ cat_name = dp.category.name
317
+ by_category[cat_name] = by_category.get(cat_name, 0) + 1
318
+
319
+ return ModelMetrics(
320
+ devices_total=len(devices),
321
+ devices_available=devices_available,
322
+ channels_total=channels_total,
323
+ data_points_generic=generic_count,
324
+ data_points_custom=custom_count,
325
+ data_points_calculated=calculated_count,
326
+ data_points_subscribed=subscribed_count,
327
+ data_points_by_category=dict(sorted(by_category.items())),
328
+ programs_total=programs_total,
329
+ sysvars_total=sysvars_total,
330
+ )
331
+
332
+ @property
333
+ def recovery(self) -> RecoveryMetrics:
334
+ """Return recovery metrics."""
335
+ if self._recovery_provider is None:
336
+ return RecoveryMetrics()
337
+
338
+ if not (recovery_states := self._recovery_provider.recovery_states):
339
+ return RecoveryMetrics(
340
+ in_progress=self._recovery_provider.in_recovery,
341
+ )
342
+
343
+ # Aggregate metrics across all interface recovery states
344
+ attempts_total = 0
345
+ successes = 0
346
+ failures = 0
347
+ max_retries_reached = 0
348
+ last_recovery_time: datetime | None = None
349
+
350
+ for state in recovery_states.values():
351
+ attempts_total += state.attempt_count
352
+ failures += state.consecutive_failures
353
+ # Count successes as attempts minus current consecutive failures
354
+ if state.attempt_count > state.consecutive_failures:
355
+ successes += state.attempt_count - state.consecutive_failures
356
+ # Check if max retries reached (can_retry is False when at limit)
357
+ if not state.can_retry:
358
+ max_retries_reached += 1
359
+ # Track most recent recovery attempt
360
+ if state.last_attempt is not None and (
361
+ last_recovery_time is None or state.last_attempt > last_recovery_time
362
+ ):
363
+ last_recovery_time = state.last_attempt
364
+
365
+ return RecoveryMetrics(
366
+ attempts_total=attempts_total,
367
+ successes=successes,
368
+ failures=failures,
369
+ max_retries_reached=max_retries_reached,
370
+ in_progress=self._recovery_provider.in_recovery,
371
+ last_recovery_time=last_recovery_time,
372
+ )
373
+
374
+ @property
375
+ def rpc(self) -> RpcMetrics:
376
+ """Return aggregated RPC metrics from all clients."""
377
+ # Get significant event counters from observer (failures, rejections, transitions, coalesced)
378
+ failed_requests = 0
379
+ rejected_requests = 0
380
+ coalesced_requests = 0
381
+ state_transitions = 0
382
+ total_latency_ms = 0.0
383
+ max_latency_ms = 0.0
384
+ latency_count = 0
385
+
386
+ if self._observer is not None:
387
+ # Circuit breaker metrics from observer (only significant events)
388
+ failed_requests = self._observer.get_aggregated_counter(pattern="circuit.failure.")
389
+ rejected_requests = self._observer.get_aggregated_counter(pattern="circuit.rejection.")
390
+ state_transitions = self._observer.get_aggregated_counter(pattern="circuit.state_transition.")
391
+
392
+ # Coalescer metrics from observer (only significant events)
393
+ coalesced_requests = self._observer.get_aggregated_counter(pattern="coalescer.coalesced.")
394
+
395
+ # Latency metrics from observer
396
+ latency_tracker = self._observer.get_aggregated_latency(pattern="ping_pong.rtt")
397
+ if latency_tracker.count > 0:
398
+ total_latency_ms = latency_tracker.total_ms
399
+ latency_count = latency_tracker.count
400
+ max_latency_ms = latency_tracker.max_ms
401
+
402
+ # Read local counters directly from circuit breakers and coalescers
403
+ # These are high-frequency metrics that don't emit events
404
+ total_requests = 0
405
+ executed_requests = 0
406
+ pending_requests = 0
407
+ circuit_breakers_open = 0
408
+ circuit_breakers_half_open = 0
409
+ last_failure_time: datetime | None = None
410
+
411
+ for client in self._client_provider.clients:
412
+ # Circuit breaker state and local counters
413
+ if (cb := getattr(client, "circuit_breaker", None)) is not None:
414
+ # Total requests from local counter (no event emission)
415
+ total_requests += cb.total_requests
416
+
417
+ if cb.state == CircuitState.OPEN:
418
+ circuit_breakers_open += 1
419
+ elif cb.state == CircuitState.HALF_OPEN:
420
+ circuit_breakers_half_open += 1
421
+
422
+ if cb.last_failure_time is not None and (
423
+ last_failure_time is None or cb.last_failure_time > last_failure_time
424
+ ):
425
+ last_failure_time = cb.last_failure_time
426
+
427
+ # Coalescer local counters
428
+ if (coalescer := getattr(client, "request_coalescer", None)) is not None:
429
+ pending_requests += coalescer.pending_count
430
+ executed_requests += coalescer.executed_requests
431
+
432
+ # Calculate successful requests from total minus failures and rejections
433
+ successful_requests = total_requests - failed_requests - rejected_requests
434
+ avg_latency_ms = total_latency_ms / latency_count if latency_count > 0 else 0.0
435
+
436
+ return RpcMetrics(
437
+ total_requests=total_requests,
438
+ successful_requests=successful_requests,
439
+ failed_requests=failed_requests,
440
+ rejected_requests=rejected_requests,
441
+ coalesced_requests=coalesced_requests,
442
+ executed_requests=executed_requests,
443
+ pending_requests=pending_requests,
444
+ circuit_breakers_open=circuit_breakers_open,
445
+ circuit_breakers_half_open=circuit_breakers_half_open,
446
+ state_transitions=state_transitions,
447
+ avg_latency_ms=avg_latency_ms,
448
+ max_latency_ms=max_latency_ms,
449
+ last_failure_time=last_failure_time,
450
+ )
451
+
452
+ @property
453
+ def rpc_server(self) -> RpcServerMetrics:
454
+ """Return RPC server metrics (incoming requests from CCU)."""
455
+ if self._observer is None:
456
+ return RpcServerMetrics()
457
+
458
+ total_requests = self._observer.get_counter(key="rpc_server.request")
459
+ total_errors = self._observer.get_counter(key="rpc_server.error")
460
+ active_tasks = int(self._observer.get_gauge(key="rpc_server.active_tasks"))
461
+
462
+ # Get latency metrics
463
+ latency = self._observer.get_latency(key="rpc_server.latency")
464
+ avg_latency_ms = 0.0
465
+ max_latency_ms = 0.0
466
+ if latency is not None and latency.count > 0:
467
+ avg_latency_ms = latency.total_ms / latency.count
468
+ max_latency_ms = latency.max_ms
469
+
470
+ return RpcServerMetrics(
471
+ total_requests=total_requests,
472
+ total_errors=total_errors,
473
+ active_tasks=active_tasks,
474
+ avg_latency_ms=avg_latency_ms,
475
+ max_latency_ms=max_latency_ms,
476
+ )
477
+
478
+ @property
479
+ def services(self) -> ServiceMetrics:
480
+ """Return service call metrics from MetricsObserver."""
481
+ if self._observer is None:
482
+ return ServiceMetrics()
483
+
484
+ # Build stats by method from observer data
485
+ stats_by_method: dict[str, ServiceStats] = {}
486
+
487
+ # Get all latency keys for service calls (pattern: service.call.{method})
488
+ for key in self._observer.get_keys_by_prefix(prefix="service.call."):
489
+ # Extract method name from key (service.call.method_name -> method_name)
490
+ parts = key.split(".")
491
+ if len(parts) >= 3:
492
+ method_name = parts[2]
493
+ if (latency := self._observer.get_latency(key=key)) is None:
494
+ continue
495
+ error_count = self._observer.get_counter(key=f"service.error.{method_name}")
496
+
497
+ stats_by_method[method_name] = ServiceStats(
498
+ call_count=latency.count,
499
+ error_count=error_count,
500
+ total_duration_ms=latency.total_ms,
501
+ max_duration_ms=latency.max_ms,
502
+ )
503
+
504
+ if not stats_by_method:
505
+ return ServiceMetrics()
506
+
507
+ total_calls = sum(s.call_count for s in stats_by_method.values())
508
+ total_errors = sum(s.error_count for s in stats_by_method.values())
509
+ total_duration = sum(s.total_duration_ms for s in stats_by_method.values())
510
+ max_duration = max((s.max_duration_ms for s in stats_by_method.values()), default=0.0)
511
+
512
+ avg_duration = total_duration / total_calls if total_calls > 0 else 0.0
513
+
514
+ return ServiceMetrics(
515
+ total_calls=total_calls,
516
+ total_errors=total_errors,
517
+ avg_duration_ms=avg_duration,
518
+ max_duration_ms=max_duration,
519
+ by_method=stats_by_method,
520
+ )
521
+
522
+ def snapshot(self) -> MetricsSnapshot:
523
+ """Return point-in-time snapshot of all metrics."""
524
+ return MetricsSnapshot(
525
+ timestamp=datetime.now(),
526
+ rpc=self.rpc,
527
+ rpc_server=self.rpc_server,
528
+ events=self.events,
529
+ cache=self.cache,
530
+ health=self.health,
531
+ recovery=self.recovery,
532
+ model=self.model,
533
+ services=self.services,
534
+ )