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,563 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Central observer for event-driven metrics aggregation.
5
+
6
+ This module provides MetricsObserver which subscribes to metric events
7
+ on the EventBus and maintains aggregated statistics. It replaces the
8
+ polling-based approach with event-driven collection.
9
+
10
+ Public API
11
+ ----------
12
+ - MetricsObserver: Central aggregator for all metric events
13
+ - ObserverSnapshot: Point-in-time snapshot of all collected metrics
14
+ - LatencyTracker: Tracks latency statistics for a single metric key
15
+ - HealthState: Tracks health state for a component
16
+
17
+ Usage
18
+ -----
19
+ from aiohomematic.metrics import MetricsObserver
20
+
21
+ # Create observer (typically done by CentralUnit)
22
+ observer = MetricsObserver(event_bus=central.event_bus)
23
+
24
+ # Get snapshot of all metrics
25
+ snapshot = observer.snapshot()
26
+ print(snapshot.latency["ping_pong:HmIP-RF:round_trip"].avg_ms)
27
+
28
+ # Get aggregated latency
29
+ latency = observer.get_aggregated_latency(pattern="ping_pong")
30
+ print(latency.avg_ms)
31
+
32
+ # Get overall health score
33
+ health_score = observer.get_overall_health_score()
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections import defaultdict
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime
41
+ import logging
42
+ import math
43
+ from typing import TYPE_CHECKING, Final
44
+
45
+ from aiohomematic import i18n
46
+ from aiohomematic.central.events.types import EventPriority
47
+ from aiohomematic.metrics.events import (
48
+ CounterMetricEvent,
49
+ GaugeMetricEvent,
50
+ HealthMetricEvent,
51
+ LatencyMetricEvent,
52
+ MetricType,
53
+ )
54
+ from aiohomematic.metrics.keys import MetricKey
55
+ from aiohomematic.metrics.stats import LatencyStats
56
+
57
+ if TYPE_CHECKING:
58
+ from collections.abc import Callable
59
+
60
+ from aiohomematic.central.events import EventBus
61
+
62
+ _LOGGER: Final = logging.getLogger(__name__)
63
+
64
+ # Maximum number of unique metric keys to prevent unbounded growth
65
+ MAX_METRIC_KEYS: Final = 10_000
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class LatencyTracker:
70
+ """Tracks latency statistics for a single metric key."""
71
+
72
+ count: int = 0
73
+ total_ms: float = 0.0
74
+ min_ms: float = math.inf
75
+ max_ms: float = 0.0
76
+
77
+ @property
78
+ def avg_ms(self) -> float:
79
+ """Return average latency in milliseconds."""
80
+ if self.count == 0:
81
+ return 0.0
82
+ return self.total_ms / self.count
83
+
84
+ def copy(self) -> LatencyTracker:
85
+ """Return a copy of this tracker."""
86
+ return LatencyTracker(
87
+ count=self.count,
88
+ total_ms=self.total_ms,
89
+ min_ms=self.min_ms,
90
+ max_ms=self.max_ms,
91
+ )
92
+
93
+ def record(self, *, duration_ms: float) -> None:
94
+ """Record a latency sample."""
95
+ self.count += 1
96
+ self.total_ms += duration_ms
97
+ self.min_ms = min(self.min_ms, duration_ms)
98
+ self.max_ms = max(self.max_ms, duration_ms)
99
+
100
+ def reset(self) -> None:
101
+ """Reset all statistics."""
102
+ self.count = 0
103
+ self.total_ms = 0.0
104
+ self.min_ms = math.inf
105
+ self.max_ms = 0.0
106
+
107
+ def to_stats(self) -> LatencyStats:
108
+ """
109
+ Convert to LatencyStats for external consumption.
110
+
111
+ Returns:
112
+ LatencyStats snapshot of current state.
113
+
114
+ """
115
+ return LatencyStats(
116
+ count=self.count,
117
+ total_ms=self.total_ms,
118
+ min_ms=self.min_ms,
119
+ max_ms=self.max_ms,
120
+ )
121
+
122
+
123
+ # Type alias for key parameter
124
+ KeyType = MetricKey | str
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class HealthState:
129
+ """Tracks health state for a component."""
130
+
131
+ healthy: bool = True
132
+ reason: str | None = None
133
+ last_change: datetime = field(default_factory=datetime.now)
134
+
135
+ def update(self, *, healthy: bool, reason: str | None = None) -> None:
136
+ """Update health state."""
137
+ if self.healthy != healthy:
138
+ self.last_change = datetime.now()
139
+ self.healthy = healthy
140
+ self.reason = reason
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class ObserverSnapshot:
145
+ """
146
+ Point-in-time snapshot of all collected metrics.
147
+
148
+ Provides a consistent view of metrics at a specific moment.
149
+ """
150
+
151
+ timestamp: datetime
152
+ """When the snapshot was taken."""
153
+
154
+ latency: dict[str, LatencyTracker]
155
+ """Latency metrics by full key."""
156
+
157
+ counters: dict[str, int]
158
+ """Counter metrics by full key."""
159
+
160
+ gauges: dict[str, float]
161
+ """Gauge metrics by full key."""
162
+
163
+ health: dict[str, HealthState]
164
+ """Health states by component key."""
165
+
166
+ def aggregate_counters(self, *, pattern: str) -> int:
167
+ """
168
+ Aggregate counter metrics matching a pattern.
169
+
170
+ Args:
171
+ pattern: Key prefix to match
172
+
173
+ Returns:
174
+ Sum of matching counters
175
+
176
+ """
177
+ total = 0
178
+ for key, value in self.counters.items():
179
+ if key.startswith(pattern):
180
+ total += value
181
+ return total
182
+
183
+ def aggregate_latency(self, *, pattern: str) -> LatencyTracker:
184
+ """
185
+ Aggregate latency metrics matching a pattern.
186
+
187
+ Args:
188
+ pattern: Key prefix to match (e.g., "ping_pong" matches all ping_pong:* keys)
189
+
190
+ Returns:
191
+ Aggregated LatencyTracker
192
+
193
+ """
194
+ result = LatencyTracker()
195
+ for key, tracker in self.latency.items():
196
+ if key.startswith(pattern):
197
+ result.count += tracker.count
198
+ result.total_ms += tracker.total_ms
199
+ result.min_ms = min(result.min_ms, tracker.min_ms)
200
+ result.max_ms = max(result.max_ms, tracker.max_ms)
201
+ return result
202
+
203
+ def get_counter(self, *, key: str, default: int = 0) -> int:
204
+ """Get counter value for a key."""
205
+ return self.counters.get(key, default)
206
+
207
+ def get_gauge(self, *, key: str, default: float = 0.0) -> float:
208
+ """Get gauge value for a key."""
209
+ return self.gauges.get(key, default)
210
+
211
+ def get_latency(self, *, key: str, default: float = 0.0) -> float:
212
+ """Get average latency for a key."""
213
+ if tracker := self.latency.get(key):
214
+ return tracker.avg_ms
215
+ return default
216
+
217
+ def get_rate(self, *, hit_key: str, miss_key: str) -> float:
218
+ """Calculate hit rate from hit and miss counters."""
219
+ hits = self.counters.get(hit_key, 0)
220
+ misses = self.counters.get(miss_key, 0)
221
+ if (total := hits + misses) == 0:
222
+ return 100.0
223
+ return (hits / total) * 100
224
+
225
+
226
+ class MetricsObserver:
227
+ """
228
+ Central observer that subscribes to metric events and maintains aggregated statistics.
229
+
230
+ This class replaces the polling-based approach of MetricsAggregator with
231
+ event-driven collection. Components emit metric events to the EventBus,
232
+ and this observer aggregates them into queryable statistics.
233
+
234
+ Features:
235
+ - Subscribes to all metric event types with LOW priority
236
+ - Maintains rolling statistics without blocking productive code
237
+ - Provides thread-safe snapshot export
238
+ - Limits metric key count to prevent unbounded growth
239
+ - Computes derived metrics (overall health score, last event age)
240
+ """
241
+
242
+ __slots__ = (
243
+ "_counters",
244
+ "_event_bus",
245
+ "_gauges",
246
+ "_health",
247
+ "_last_event_time",
248
+ "_latency",
249
+ "_unsubscribers",
250
+ )
251
+
252
+ def __init__(self, *, event_bus: EventBus) -> None:
253
+ """
254
+ Initialize the metrics observer.
255
+
256
+ Args:
257
+ event_bus: EventBus to subscribe to for metric events
258
+
259
+ """
260
+ self._event_bus: Final = event_bus
261
+ self._latency: dict[str, LatencyTracker] = defaultdict(LatencyTracker)
262
+ self._counters: dict[str, int] = defaultdict(int)
263
+ self._gauges: dict[str, float] = {}
264
+ self._health: dict[str, HealthState] = defaultdict(HealthState)
265
+ self._last_event_time: datetime | None = None
266
+ self._unsubscribers: list[Callable[[], None]] = []
267
+
268
+ self._subscribe_to_events()
269
+
270
+ @property
271
+ def counter_keys(self) -> list[str]:
272
+ """Return all counter metric keys."""
273
+ return list(self._counters.keys())
274
+
275
+ @property
276
+ def gauge_keys(self) -> list[str]:
277
+ """Return all gauge metric keys."""
278
+ return list(self._gauges.keys())
279
+
280
+ @property
281
+ def health_keys(self) -> list[str]:
282
+ """Return all health metric keys."""
283
+ return list(self._health.keys())
284
+
285
+ @property
286
+ def latency_keys(self) -> list[str]:
287
+ """Return all latency metric keys."""
288
+ return list(self._latency.keys())
289
+
290
+ def clear(self) -> None:
291
+ """Clear all collected metrics."""
292
+ self._latency.clear()
293
+ self._counters.clear()
294
+ self._gauges.clear()
295
+ self._health.clear()
296
+ self._last_event_time = None
297
+ _LOGGER.debug("METRICS OBSERVER: Cleared all metrics")
298
+
299
+ def get_aggregated_counter(self, *, pattern: str) -> int:
300
+ """
301
+ Get sum of counters for all keys matching a pattern.
302
+
303
+ Args:
304
+ pattern: Key prefix to match
305
+
306
+ Returns:
307
+ Sum of matching counters
308
+
309
+ """
310
+ total = 0
311
+ for key, value in self._counters.items():
312
+ if key.startswith(pattern):
313
+ total += value
314
+ return total
315
+
316
+ def get_aggregated_latency(self, *, pattern: str) -> LatencyTracker:
317
+ """
318
+ Get aggregated latency for all keys matching a pattern.
319
+
320
+ Args:
321
+ pattern: Key prefix to match
322
+
323
+ Returns:
324
+ Aggregated LatencyTracker
325
+
326
+ """
327
+ result = LatencyTracker()
328
+ for key, tracker in self._latency.items():
329
+ if key.startswith(pattern):
330
+ result.count += tracker.count
331
+ result.total_ms += tracker.total_ms
332
+ if tracker.min_ms != math.inf:
333
+ result.min_ms = min(result.min_ms, tracker.min_ms)
334
+ result.max_ms = max(result.max_ms, tracker.max_ms)
335
+ return result
336
+
337
+ def get_counter(self, *, key: KeyType, default: int = 0) -> int:
338
+ """
339
+ Get counter value for a key.
340
+
341
+ Args:
342
+ key: Metric key (MetricKey instance or string).
343
+ default: Default value if key not found.
344
+
345
+ Returns:
346
+ Counter value.
347
+
348
+ """
349
+ return self._counters.get(str(key), default)
350
+
351
+ def get_gauge(self, *, key: KeyType, default: float = 0.0) -> float:
352
+ """
353
+ Get gauge value for a key.
354
+
355
+ Args:
356
+ key: Metric key (MetricKey instance or string).
357
+ default: Default value if key not found.
358
+
359
+ Returns:
360
+ Gauge value.
361
+
362
+ """
363
+ return self._gauges.get(str(key), default)
364
+
365
+ def get_health(self, *, key: KeyType) -> HealthState | None:
366
+ """
367
+ Get health state for a key.
368
+
369
+ Args:
370
+ key: Metric key (MetricKey instance or string).
371
+
372
+ Returns:
373
+ HealthState or None if not found.
374
+
375
+ """
376
+ return self._health.get(str(key))
377
+
378
+ def get_keys_by_prefix(self, *, prefix: str) -> list[str]:
379
+ """
380
+ Get all metric keys matching a prefix.
381
+
382
+ Args:
383
+ prefix: Key prefix to match (e.g., "handler.execution").
384
+
385
+ Returns:
386
+ List of matching keys.
387
+
388
+ """
389
+ # Collect all keys from all metric types, deduplicated
390
+ all_keys: set[str] = set()
391
+ all_keys.update(self._latency.keys())
392
+ all_keys.update(self._counters.keys())
393
+ all_keys.update(self._gauges.keys())
394
+ all_keys.update(self._health.keys())
395
+ return [key for key in all_keys if key.startswith(prefix)]
396
+
397
+ def get_last_event_age_seconds(self) -> float:
398
+ """
399
+ Get seconds since last metric event was received.
400
+
401
+ Returns:
402
+ Seconds since last event, or -1.0 if no events received yet
403
+
404
+ """
405
+ if self._last_event_time is None:
406
+ return -1.0
407
+ return (datetime.now() - self._last_event_time).total_seconds()
408
+
409
+ def get_last_event_time(self) -> datetime | None:
410
+ """Return the timestamp of the last received event."""
411
+ return self._last_event_time
412
+
413
+ def get_latency(self, *, key: KeyType) -> LatencyTracker | None:
414
+ """
415
+ Get latency tracker for a key.
416
+
417
+ Args:
418
+ key: Metric key (MetricKey instance or string).
419
+
420
+ Returns:
421
+ LatencyTracker or None if not found.
422
+
423
+ """
424
+ return self._latency.get(str(key))
425
+
426
+ def get_metric(self, *, key: KeyType, metric_type: MetricType) -> float:
427
+ """
428
+ Get a single metric value by key and type.
429
+
430
+ Args:
431
+ key: Metric key (MetricKey instance or string).
432
+ metric_type: Type of metric to retrieve.
433
+
434
+ Returns:
435
+ The metric value (float).
436
+
437
+ """
438
+ key_str = str(key)
439
+ if metric_type == MetricType.LATENCY:
440
+ if tracker := self._latency.get(key_str):
441
+ return tracker.avg_ms
442
+ return 0.0
443
+ if metric_type == MetricType.COUNTER:
444
+ return float(self._counters.get(key_str, 0))
445
+ if metric_type == MetricType.GAUGE:
446
+ return self._gauges.get(key_str, 0.0)
447
+ # MetricType.HEALTH
448
+ if health := self._health.get(key_str):
449
+ return 100.0 if health.healthy else 0.0
450
+ return 0.0 # No health data yet
451
+
452
+ def get_overall_health_score(self) -> float:
453
+ """
454
+ Compute overall health score from all tracked health states.
455
+
456
+ Returns:
457
+ Health score as a value between 0.0 and 1.0 (0.0 if no health data yet)
458
+
459
+ """
460
+ if not self._health:
461
+ return 0.0 # No health data yet - report 0% until connections are established
462
+
463
+ healthy_count = sum(1 for h in self._health.values() if h.healthy)
464
+ return healthy_count / len(self._health)
465
+
466
+ def record_event_received(self) -> None:
467
+ """Record that an event was received (for last_event_time tracking)."""
468
+ self._last_event_time = datetime.now()
469
+
470
+ def snapshot(self) -> ObserverSnapshot:
471
+ """
472
+ Export a consistent snapshot of all metrics.
473
+
474
+ Returns:
475
+ ObserverSnapshot with copies of all metric data
476
+
477
+ """
478
+ return ObserverSnapshot(
479
+ timestamp=datetime.now(),
480
+ latency={k: v.copy() for k, v in self._latency.items()},
481
+ counters=dict(self._counters),
482
+ gauges=dict(self._gauges),
483
+ health={
484
+ k: HealthState(healthy=v.healthy, reason=v.reason, last_change=v.last_change)
485
+ for k, v in self._health.items()
486
+ },
487
+ )
488
+
489
+ def stop(self) -> None:
490
+ """Unsubscribe from all events."""
491
+ for unsub in self._unsubscribers:
492
+ unsub()
493
+ self._unsubscribers.clear()
494
+ _LOGGER.debug("METRICS OBSERVER: Unsubscribed from all events")
495
+
496
+ async def _handle_counter(self, *, event: CounterMetricEvent) -> None:
497
+ """Handle counter metric event."""
498
+ if len(self._counters) >= MAX_METRIC_KEYS:
499
+ _LOGGER.warning(i18n.tr(key="log.metrics.observer.counter_key_limit", metric_key=event.metric_key))
500
+ return
501
+ self._counters[event.metric_key] += event.delta
502
+ self._last_event_time = event.timestamp
503
+
504
+ async def _handle_gauge(self, *, event: GaugeMetricEvent) -> None:
505
+ """Handle gauge metric event."""
506
+ if len(self._gauges) >= MAX_METRIC_KEYS:
507
+ _LOGGER.warning(i18n.tr(key="log.metrics.observer.gauge_key_limit", metric_key=event.metric_key))
508
+ return
509
+ self._gauges[event.metric_key] = event.value
510
+ self._last_event_time = event.timestamp
511
+
512
+ async def _handle_health(self, *, event: HealthMetricEvent) -> None:
513
+ """Handle health metric event."""
514
+ self._health[event.metric_key].update(healthy=event.healthy, reason=event.reason)
515
+ self._last_event_time = event.timestamp
516
+
517
+ async def _handle_latency(self, *, event: LatencyMetricEvent) -> None:
518
+ """Handle latency metric event."""
519
+ if len(self._latency) >= MAX_METRIC_KEYS:
520
+ _LOGGER.warning(i18n.tr(key="log.metrics.observer.latency_key_limit", metric_key=event.metric_key))
521
+ return
522
+ self._latency[event.metric_key].record(duration_ms=event.duration_ms)
523
+ self._last_event_time = event.timestamp
524
+
525
+ def _subscribe_to_events(self) -> None:
526
+ """Subscribe to all metric event types with LOW priority."""
527
+ # Latency events
528
+ unsub = self._event_bus.subscribe(
529
+ event_type=LatencyMetricEvent,
530
+ event_key=None,
531
+ handler=self._handle_latency,
532
+ priority=EventPriority.LOW,
533
+ )
534
+ self._unsubscribers.append(unsub)
535
+
536
+ # Counter events
537
+ unsub = self._event_bus.subscribe(
538
+ event_type=CounterMetricEvent,
539
+ event_key=None,
540
+ handler=self._handle_counter,
541
+ priority=EventPriority.LOW,
542
+ )
543
+ self._unsubscribers.append(unsub)
544
+
545
+ # Gauge events
546
+ unsub = self._event_bus.subscribe(
547
+ event_type=GaugeMetricEvent,
548
+ event_key=None,
549
+ handler=self._handle_gauge,
550
+ priority=EventPriority.LOW,
551
+ )
552
+ self._unsubscribers.append(unsub)
553
+
554
+ # Health events
555
+ unsub = self._event_bus.subscribe(
556
+ event_type=HealthMetricEvent,
557
+ event_key=None,
558
+ handler=self._handle_health,
559
+ priority=EventPriority.LOW,
560
+ )
561
+ self._unsubscribers.append(unsub)
562
+
563
+ _LOGGER.debug("METRICS OBSERVER: Subscribed to all metric event types")