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