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,526 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Typed data structures for store caches.
|
|
5
|
+
|
|
6
|
+
This module provides typed cache entries and type aliases used across
|
|
7
|
+
the persistent and dynamic store implementations.
|
|
8
|
+
|
|
9
|
+
Type Aliases
|
|
10
|
+
------------
|
|
11
|
+
- ParameterMap: Parameter name to ParameterData mapping
|
|
12
|
+
- ParamsetMap: ParamsetKey to ParameterMap mapping
|
|
13
|
+
- ChannelParamsetMap: Channel address to ParamsetMap mapping
|
|
14
|
+
- InterfaceParamsetMap: Interface ID to ChannelParamsetMap mapping
|
|
15
|
+
|
|
16
|
+
Cache Entry Types
|
|
17
|
+
-----------------
|
|
18
|
+
- CachedCommand: Command cache entry with value and timestamp
|
|
19
|
+
- PongTracker: Ping/pong tracking entry with token and seen time
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from enum import StrEnum
|
|
27
|
+
import time
|
|
28
|
+
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from aiohomematic.const import ParameterData, ParamsetKey
|
|
32
|
+
|
|
33
|
+
# =============================================================================
|
|
34
|
+
# Type Aliases for Paramset Description Cache
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# These aliases describe the nested structure of paramset descriptions:
|
|
37
|
+
# InterfaceParamsetMap[interface_id][channel_address][paramset_key][parameter] = ParameterData
|
|
38
|
+
|
|
39
|
+
ParameterMap: TypeAlias = dict[str, "ParameterData"]
|
|
40
|
+
ParamsetMap: TypeAlias = dict["ParamsetKey", ParameterMap]
|
|
41
|
+
ChannelParamsetMap: TypeAlias = dict[str, ParamsetMap]
|
|
42
|
+
InterfaceParamsetMap: TypeAlias = dict[str, ChannelParamsetMap]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# Cache Name Enum
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CacheName(StrEnum):
|
|
51
|
+
"""Enumeration of cache names for identification."""
|
|
52
|
+
|
|
53
|
+
DATA = "data"
|
|
54
|
+
"""Central data cache for device/channel values."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# Cache Statistics
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class CacheStatistics:
|
|
64
|
+
"""
|
|
65
|
+
Lightweight statistics container for cache performance tracking.
|
|
66
|
+
|
|
67
|
+
Provides local counters for hits, misses, and evictions instead of
|
|
68
|
+
event-based tracking to reduce EventBus overhead. MetricsAggregator
|
|
69
|
+
reads these counters directly for reporting.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
hits: Number of successful cache lookups.
|
|
73
|
+
misses: Number of failed cache lookups.
|
|
74
|
+
evictions: Number of entries evicted from cache.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
hits: int = 0
|
|
79
|
+
misses: int = 0
|
|
80
|
+
evictions: int = 0
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def hit_rate(self) -> float:
|
|
84
|
+
"""Return cache hit rate as percentage (0-100)."""
|
|
85
|
+
if (total := self.hits + self.misses) == 0:
|
|
86
|
+
return 100.0
|
|
87
|
+
return (self.hits / total) * 100
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def total_lookups(self) -> int:
|
|
91
|
+
"""Return total number of cache lookups."""
|
|
92
|
+
return self.hits + self.misses
|
|
93
|
+
|
|
94
|
+
def record_eviction(self, *, count: int = 1) -> None:
|
|
95
|
+
"""Record cache eviction(s)."""
|
|
96
|
+
self.evictions += count
|
|
97
|
+
|
|
98
|
+
def record_hit(self) -> None:
|
|
99
|
+
"""Record a cache hit."""
|
|
100
|
+
self.hits += 1
|
|
101
|
+
|
|
102
|
+
def record_miss(self) -> None:
|
|
103
|
+
"""Record a cache miss."""
|
|
104
|
+
self.misses += 1
|
|
105
|
+
|
|
106
|
+
def reset(self) -> None:
|
|
107
|
+
"""Reset all counters to zero."""
|
|
108
|
+
self.hits = 0
|
|
109
|
+
self.misses = 0
|
|
110
|
+
self.evictions = 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# =============================================================================
|
|
114
|
+
# Tracker Statistics
|
|
115
|
+
# =============================================================================
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(slots=True)
|
|
119
|
+
class TrackerStatistics:
|
|
120
|
+
"""
|
|
121
|
+
Lightweight statistics container for tracker memory management.
|
|
122
|
+
|
|
123
|
+
Unlike CacheStatistics, trackers don't have hit/miss semantics.
|
|
124
|
+
They only track evictions for memory management monitoring.
|
|
125
|
+
|
|
126
|
+
Attributes:
|
|
127
|
+
evictions: Number of entries evicted from tracker.
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
evictions: int = 0
|
|
132
|
+
|
|
133
|
+
def record_eviction(self, *, count: int = 1) -> None:
|
|
134
|
+
"""Record tracker eviction(s)."""
|
|
135
|
+
self.evictions += count
|
|
136
|
+
|
|
137
|
+
def reset(self) -> None:
|
|
138
|
+
"""Reset all counters to zero."""
|
|
139
|
+
self.evictions = 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# =============================================================================
|
|
143
|
+
# Cache Entry Dataclasses
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class CachedCommand:
|
|
149
|
+
"""
|
|
150
|
+
Cached command entry for tracking sent commands.
|
|
151
|
+
|
|
152
|
+
Attributes:
|
|
153
|
+
value: The value that was sent with the command.
|
|
154
|
+
sent_at: Timestamp when the command was sent.
|
|
155
|
+
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
value: Any
|
|
159
|
+
sent_at: datetime
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(slots=True)
|
|
163
|
+
class PongTracker:
|
|
164
|
+
"""
|
|
165
|
+
Tracker for pending or unknown pong tokens.
|
|
166
|
+
|
|
167
|
+
Used by PingPongTracker to track ping/pong events with timestamps
|
|
168
|
+
for TTL expiry and size limit enforcement.
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
tokens: Set of pong tokens being tracked.
|
|
172
|
+
seen_at: Mapping of token to monotonic timestamp when it was seen.
|
|
173
|
+
logged: Whether a warning has been logged for this tracker.
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
tokens: set[str]
|
|
178
|
+
seen_at: dict[str, float]
|
|
179
|
+
logged: bool = False
|
|
180
|
+
|
|
181
|
+
def __len__(self) -> int:
|
|
182
|
+
"""Return the number of tracked tokens."""
|
|
183
|
+
return len(self.tokens)
|
|
184
|
+
|
|
185
|
+
def add(self, *, token: str, timestamp: float) -> None:
|
|
186
|
+
"""Add a token with its timestamp."""
|
|
187
|
+
self.tokens.add(token)
|
|
188
|
+
self.seen_at[token] = timestamp
|
|
189
|
+
|
|
190
|
+
def clear(self) -> None:
|
|
191
|
+
"""Clear all tokens and timestamps."""
|
|
192
|
+
self.tokens.clear()
|
|
193
|
+
self.seen_at.clear()
|
|
194
|
+
self.logged = False
|
|
195
|
+
|
|
196
|
+
def contains(self, *, token: str) -> bool:
|
|
197
|
+
"""Check if a token is being tracked."""
|
|
198
|
+
return token in self.tokens
|
|
199
|
+
|
|
200
|
+
def remove(self, *, token: str) -> None:
|
|
201
|
+
"""Remove a token and its timestamp."""
|
|
202
|
+
self.tokens.discard(token)
|
|
203
|
+
self.seen_at.pop(token, None)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# =============================================================================
|
|
207
|
+
# PingPong Journal Types
|
|
208
|
+
# =============================================================================
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PingPongEventType(StrEnum):
|
|
212
|
+
"""Types of events recorded in the PingPong journal."""
|
|
213
|
+
|
|
214
|
+
PING_SENT = "PING_SENT"
|
|
215
|
+
"""A PING was sent to the backend."""
|
|
216
|
+
|
|
217
|
+
PONG_RECEIVED = "PONG_RECEIVED"
|
|
218
|
+
"""A matching PONG was received (success)."""
|
|
219
|
+
|
|
220
|
+
PONG_UNKNOWN = "PONG_UNKNOWN"
|
|
221
|
+
"""A PONG was received without a matching PING."""
|
|
222
|
+
|
|
223
|
+
PONG_EXPIRED = "PONG_EXPIRED"
|
|
224
|
+
"""A PING expired without receiving a PONG (TTL exceeded)."""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True, slots=True)
|
|
228
|
+
class PingPongJournalEvent:
|
|
229
|
+
"""
|
|
230
|
+
Single event in the PingPong diagnostic journal.
|
|
231
|
+
|
|
232
|
+
Immutable record of a ping/pong event for diagnostic purposes.
|
|
233
|
+
Events are stored in a ring buffer and can be exported for analysis.
|
|
234
|
+
|
|
235
|
+
Attributes:
|
|
236
|
+
timestamp: Monotonic timestamp for age calculation and ordering.
|
|
237
|
+
timestamp_iso: ISO format timestamp for human-readable display.
|
|
238
|
+
event_type: Type of event (PING_SENT, PONG_RECEIVED, etc.).
|
|
239
|
+
token: The ping/pong token (truncated for display).
|
|
240
|
+
rtt_ms: Round-trip time in milliseconds (only for PONG_RECEIVED).
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
timestamp: float
|
|
245
|
+
timestamp_iso: str
|
|
246
|
+
event_type: PingPongEventType
|
|
247
|
+
token: str
|
|
248
|
+
rtt_ms: float | None = None
|
|
249
|
+
|
|
250
|
+
def to_dict(self) -> dict[str, Any]:
|
|
251
|
+
"""Convert to dictionary for JSON serialization."""
|
|
252
|
+
result: dict[str, Any] = {
|
|
253
|
+
"time": self.timestamp_iso,
|
|
254
|
+
"type": self.event_type.value,
|
|
255
|
+
"token": self.token,
|
|
256
|
+
}
|
|
257
|
+
if self.rtt_ms is not None:
|
|
258
|
+
result["rtt_ms"] = round(self.rtt_ms, 2)
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(slots=True)
|
|
263
|
+
class PingPongJournal:
|
|
264
|
+
"""
|
|
265
|
+
Ring buffer for PingPong diagnostic events.
|
|
266
|
+
|
|
267
|
+
Provides diagnostic history for HA Diagnostics without log parsing.
|
|
268
|
+
Events are stored in a fixed-size ring buffer with optional time-based eviction.
|
|
269
|
+
|
|
270
|
+
Features:
|
|
271
|
+
- Fixed-size ring buffer (default 100 entries)
|
|
272
|
+
- Time-based eviction (default 30 minutes)
|
|
273
|
+
- RTT statistics aggregation (avg/min/max)
|
|
274
|
+
- JSON-serializable for HA Diagnostics
|
|
275
|
+
|
|
276
|
+
Attributes:
|
|
277
|
+
max_entries: Maximum number of events to store.
|
|
278
|
+
max_age_seconds: Maximum age of events before eviction.
|
|
279
|
+
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
max_entries: int = 100
|
|
283
|
+
max_age_seconds: float = 1800.0 # 30 minutes
|
|
284
|
+
_events: list[PingPongJournalEvent] | None = None
|
|
285
|
+
_rtt_samples: list[float] | None = None
|
|
286
|
+
|
|
287
|
+
def __post_init__(self) -> None:
|
|
288
|
+
"""Initialize internal collections."""
|
|
289
|
+
if self._events is None:
|
|
290
|
+
self._events = []
|
|
291
|
+
if self._rtt_samples is None:
|
|
292
|
+
self._rtt_samples = []
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def events(self) -> list[PingPongJournalEvent]:
|
|
296
|
+
"""Return the events list."""
|
|
297
|
+
if self._events is None:
|
|
298
|
+
self._events = []
|
|
299
|
+
return self._events
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def rtt_samples(self) -> list[float]:
|
|
303
|
+
"""Return the RTT samples list."""
|
|
304
|
+
if self._rtt_samples is None:
|
|
305
|
+
self._rtt_samples = []
|
|
306
|
+
return self._rtt_samples
|
|
307
|
+
|
|
308
|
+
def clear(self) -> None:
|
|
309
|
+
"""Clear all events and statistics."""
|
|
310
|
+
self.events.clear()
|
|
311
|
+
self.rtt_samples.clear()
|
|
312
|
+
|
|
313
|
+
def count_events_by_type(self, *, event_type: PingPongEventType, minutes: int = 5) -> int:
|
|
314
|
+
"""Count events of a specific type within the last N minutes."""
|
|
315
|
+
cutoff = time.monotonic() - (minutes * 60)
|
|
316
|
+
return sum(1 for e in self.events if e.event_type == event_type and e.timestamp >= cutoff)
|
|
317
|
+
|
|
318
|
+
def get_diagnostics(self) -> dict[str, Any]:
|
|
319
|
+
"""Return full diagnostics data for HA Diagnostics."""
|
|
320
|
+
return {
|
|
321
|
+
"total_events": len(self.events),
|
|
322
|
+
"max_entries": self.max_entries,
|
|
323
|
+
"max_age_seconds": self.max_age_seconds,
|
|
324
|
+
"rtt_statistics": self.get_rtt_statistics(),
|
|
325
|
+
"recent_events": self.get_recent_events(limit=20),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
def get_recent_events(self, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
329
|
+
"""Return recent events as list of dicts."""
|
|
330
|
+
return [e.to_dict() for e in self.events[-limit:]]
|
|
331
|
+
|
|
332
|
+
def get_rtt_statistics(self) -> dict[str, Any]:
|
|
333
|
+
"""Return RTT statistics from collected samples."""
|
|
334
|
+
if not self.rtt_samples:
|
|
335
|
+
return {
|
|
336
|
+
"samples": 0,
|
|
337
|
+
"avg_ms": None,
|
|
338
|
+
"min_ms": None,
|
|
339
|
+
"max_ms": None,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
"samples": len(self.rtt_samples),
|
|
344
|
+
"avg_ms": round(sum(self.rtt_samples) / len(self.rtt_samples), 2),
|
|
345
|
+
"min_ms": round(min(self.rtt_samples), 2),
|
|
346
|
+
"max_ms": round(max(self.rtt_samples), 2),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
def get_success_rate(self, *, minutes: int = 5) -> float:
|
|
350
|
+
"""Calculate success rate (PONGs received / PINGs sent) over last N minutes."""
|
|
351
|
+
pings = self.count_events_by_type(event_type=PingPongEventType.PING_SENT, minutes=minutes)
|
|
352
|
+
pongs = self.count_events_by_type(event_type=PingPongEventType.PONG_RECEIVED, minutes=minutes)
|
|
353
|
+
|
|
354
|
+
if pings == 0:
|
|
355
|
+
return 1.0 # No pings = 100% success (nothing to fail)
|
|
356
|
+
return pongs / pings
|
|
357
|
+
|
|
358
|
+
def record_ping_sent(self, *, token: str) -> None:
|
|
359
|
+
"""Record a PING being sent."""
|
|
360
|
+
self._add_event(
|
|
361
|
+
event_type=PingPongEventType.PING_SENT,
|
|
362
|
+
token=token,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def record_pong_expired(self, *, token: str) -> None:
|
|
366
|
+
"""Record a PING that expired without PONG."""
|
|
367
|
+
self._add_event(
|
|
368
|
+
event_type=PingPongEventType.PONG_EXPIRED,
|
|
369
|
+
token=token,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def record_pong_received(self, *, token: str, rtt_ms: float) -> None:
|
|
373
|
+
"""Record a matching PONG received with RTT."""
|
|
374
|
+
self._add_event(
|
|
375
|
+
event_type=PingPongEventType.PONG_RECEIVED,
|
|
376
|
+
token=token,
|
|
377
|
+
rtt_ms=rtt_ms,
|
|
378
|
+
)
|
|
379
|
+
# Keep last 50 RTT samples for statistics
|
|
380
|
+
self.rtt_samples.append(rtt_ms)
|
|
381
|
+
if len(self.rtt_samples) > 50:
|
|
382
|
+
self.rtt_samples.pop(0)
|
|
383
|
+
|
|
384
|
+
def record_pong_unknown(self, *, token: str) -> None:
|
|
385
|
+
"""Record an unknown PONG (no matching PING)."""
|
|
386
|
+
self._add_event(
|
|
387
|
+
event_type=PingPongEventType.PONG_UNKNOWN,
|
|
388
|
+
token=token,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _add_event(
|
|
392
|
+
self,
|
|
393
|
+
*,
|
|
394
|
+
event_type: PingPongEventType,
|
|
395
|
+
token: str,
|
|
396
|
+
rtt_ms: float | None = None,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Add event to journal with automatic eviction."""
|
|
399
|
+
now = time.monotonic()
|
|
400
|
+
|
|
401
|
+
# Time-based eviction
|
|
402
|
+
while self.events and (now - self.events[0].timestamp) > self.max_age_seconds:
|
|
403
|
+
self.events.pop(0)
|
|
404
|
+
|
|
405
|
+
# Size-based eviction
|
|
406
|
+
while len(self.events) >= self.max_entries:
|
|
407
|
+
self.events.pop(0)
|
|
408
|
+
|
|
409
|
+
# Truncate token for display (keep last 20 chars)
|
|
410
|
+
display_token = token[-20:] if len(token) > 20 else token
|
|
411
|
+
|
|
412
|
+
self.events.append(
|
|
413
|
+
PingPongJournalEvent(
|
|
414
|
+
timestamp=now,
|
|
415
|
+
timestamp_iso=datetime.now().isoformat(timespec="milliseconds"),
|
|
416
|
+
event_type=event_type,
|
|
417
|
+
token=display_token,
|
|
418
|
+
rtt_ms=rtt_ms,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# =============================================================================
|
|
424
|
+
# Incident Store Types
|
|
425
|
+
# =============================================================================
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class IncidentType(StrEnum):
|
|
429
|
+
"""Types of incidents that can be recorded for diagnostics."""
|
|
430
|
+
|
|
431
|
+
PING_PONG_MISMATCH_HIGH = "PING_PONG_MISMATCH_HIGH"
|
|
432
|
+
"""PingPong pending count exceeded threshold."""
|
|
433
|
+
|
|
434
|
+
PING_PONG_UNKNOWN_HIGH = "PING_PONG_UNKNOWN_HIGH"
|
|
435
|
+
"""PingPong unknown PONG count exceeded threshold."""
|
|
436
|
+
|
|
437
|
+
CONNECTION_LOST = "CONNECTION_LOST"
|
|
438
|
+
"""Connection to backend was lost."""
|
|
439
|
+
|
|
440
|
+
CONNECTION_RESTORED = "CONNECTION_RESTORED"
|
|
441
|
+
"""Connection to backend was restored."""
|
|
442
|
+
|
|
443
|
+
RPC_ERROR = "RPC_ERROR"
|
|
444
|
+
"""RPC call failed with error."""
|
|
445
|
+
|
|
446
|
+
CALLBACK_TIMEOUT = "CALLBACK_TIMEOUT"
|
|
447
|
+
"""Callback from backend timed out."""
|
|
448
|
+
|
|
449
|
+
CIRCUIT_BREAKER_TRIPPED = "CIRCUIT_BREAKER_TRIPPED"
|
|
450
|
+
"""Circuit breaker opened due to excessive failures."""
|
|
451
|
+
|
|
452
|
+
CIRCUIT_BREAKER_RECOVERED = "CIRCUIT_BREAKER_RECOVERED"
|
|
453
|
+
"""Circuit breaker recovered after successful test requests."""
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class IncidentSeverity(StrEnum):
|
|
457
|
+
"""Severity levels for incidents."""
|
|
458
|
+
|
|
459
|
+
INFO = "info"
|
|
460
|
+
"""Informational incident (e.g., connection restored)."""
|
|
461
|
+
|
|
462
|
+
WARNING = "warning"
|
|
463
|
+
"""Warning incident (e.g., threshold approached)."""
|
|
464
|
+
|
|
465
|
+
ERROR = "error"
|
|
466
|
+
"""Error incident (e.g., connection lost)."""
|
|
467
|
+
|
|
468
|
+
CRITICAL = "critical"
|
|
469
|
+
"""Critical incident (e.g., repeated failures)."""
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@dataclass(frozen=True, slots=True)
|
|
473
|
+
class IncidentSnapshot:
|
|
474
|
+
"""
|
|
475
|
+
Immutable snapshot of an incident for diagnostic analysis.
|
|
476
|
+
|
|
477
|
+
Unlike Journal events which expire after TTL, incidents are preserved
|
|
478
|
+
indefinitely (up to max count) for post-mortem analysis.
|
|
479
|
+
|
|
480
|
+
Attributes:
|
|
481
|
+
incident_id: Unique identifier for this incident.
|
|
482
|
+
timestamp_iso: ISO format timestamp for human-readable display.
|
|
483
|
+
incident_type: Type of incident that occurred.
|
|
484
|
+
severity: Severity level of the incident.
|
|
485
|
+
interface_id: Interface where incident occurred (if applicable).
|
|
486
|
+
message: Human-readable description of the incident.
|
|
487
|
+
context: Additional context data for debugging.
|
|
488
|
+
journal_excerpt: Journal events around the time of incident.
|
|
489
|
+
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
incident_id: str
|
|
493
|
+
timestamp_iso: str
|
|
494
|
+
incident_type: IncidentType
|
|
495
|
+
severity: IncidentSeverity
|
|
496
|
+
interface_id: str | None
|
|
497
|
+
message: str
|
|
498
|
+
context: dict[str, Any]
|
|
499
|
+
journal_excerpt: list[dict[str, Any]]
|
|
500
|
+
|
|
501
|
+
@classmethod
|
|
502
|
+
def from_dict(cls, *, data: dict[str, Any]) -> IncidentSnapshot:
|
|
503
|
+
"""Create IncidentSnapshot from dictionary."""
|
|
504
|
+
return cls(
|
|
505
|
+
incident_id=data["incident_id"],
|
|
506
|
+
timestamp_iso=data["timestamp"],
|
|
507
|
+
incident_type=IncidentType(data["type"]),
|
|
508
|
+
severity=IncidentSeverity(data["severity"]),
|
|
509
|
+
interface_id=data.get("interface_id"),
|
|
510
|
+
message=data["message"],
|
|
511
|
+
context=data.get("context", {}),
|
|
512
|
+
journal_excerpt=data.get("journal_excerpt", []),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def to_dict(self) -> dict[str, Any]:
|
|
516
|
+
"""Convert to dictionary for JSON serialization."""
|
|
517
|
+
return {
|
|
518
|
+
"incident_id": self.incident_id,
|
|
519
|
+
"timestamp": self.timestamp_iso,
|
|
520
|
+
"type": self.incident_type.value,
|
|
521
|
+
"severity": self.severity.value,
|
|
522
|
+
"interface_id": self.interface_id,
|
|
523
|
+
"message": self.message,
|
|
524
|
+
"context": self.context,
|
|
525
|
+
"journal_excerpt": self.journal_excerpt,
|
|
526
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Parameter visibility rules and registry for Homematic data points.
|
|
5
|
+
|
|
6
|
+
This package determines which parameters should be created, shown, hidden,
|
|
7
|
+
ignored, or un-ignored for channels and devices. It centralizes the rules
|
|
8
|
+
that influence the visibility of data points exposed by the library.
|
|
9
|
+
|
|
10
|
+
Package structure
|
|
11
|
+
-----------------
|
|
12
|
+
- rules: Static visibility rules (constants, mappings, patterns)
|
|
13
|
+
- parser: Un-ignore configuration line parsing
|
|
14
|
+
- registry: ParameterVisibilityRegistry implementation
|
|
15
|
+
|
|
16
|
+
Public API
|
|
17
|
+
----------
|
|
18
|
+
- ParameterVisibilityRegistry: Main visibility decision registry
|
|
19
|
+
- check_ignore_parameters_is_clean: Validation helper
|
|
20
|
+
|
|
21
|
+
Key concepts
|
|
22
|
+
------------
|
|
23
|
+
- Relevant MASTER parameters: Certain MASTER paramset entries are promoted to
|
|
24
|
+
data points for selected models/channels (e.g. climate related settings), but
|
|
25
|
+
they may still be hidden by default for UI purposes.
|
|
26
|
+
- Ignored vs un-ignored: Parameters can be broadly ignored, with exceptions
|
|
27
|
+
defined via explicit un-ignore rules that match model/channel/paramset keys.
|
|
28
|
+
- Event suppression: For selected devices, button click events are suppressed
|
|
29
|
+
to avoid noise in event streams.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from aiohomematic.store.visibility.registry import ParameterVisibilityRegistry, check_ignore_parameters_is_clean
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Visibility
|
|
38
|
+
"ParameterVisibilityRegistry",
|
|
39
|
+
"check_ignore_parameters_is_clean",
|
|
40
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Un-ignore configuration line parser.
|
|
5
|
+
|
|
6
|
+
This module provides parsing functionality for un-ignore configuration entries
|
|
7
|
+
that allow users to override default parameter visibility rules.
|
|
8
|
+
|
|
9
|
+
Supported formats:
|
|
10
|
+
- Simple: "PARAMETER_NAME" (applies to all VALUES paramsets)
|
|
11
|
+
- Complex: "PARAMETER:PARAMSET_KEY@MODEL:CHANNEL_NO"
|
|
12
|
+
|
|
13
|
+
Example complex entries:
|
|
14
|
+
- "TEMPERATURE_OFFSET:MASTER@HmIP-eTRV:1"
|
|
15
|
+
- "LEVEL:VALUES@HmIP-BROLL:3"
|
|
16
|
+
- "STATE:VALUES@*:*" (wildcard for all models/channels)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
import re
|
|
23
|
+
from typing import Final
|
|
24
|
+
|
|
25
|
+
from aiohomematic.const import UN_IGNORE_WILDCARD, ParamsetKey
|
|
26
|
+
from aiohomematic.store.visibility.rules import ChannelNo, ModelName, ParameterName
|
|
27
|
+
|
|
28
|
+
# Type alias for channel numbers in un-ignore entries (can include wildcard string)
|
|
29
|
+
UnIgnoreChannelNo = ChannelNo | str
|
|
30
|
+
|
|
31
|
+
# Regex pattern for parsing un-ignore configuration lines.
|
|
32
|
+
# Format: PARAMETER:PARAMSET_KEY@MODEL:CHANNEL_NO
|
|
33
|
+
_UN_IGNORE_LINE_PATTERN: Final = re.compile(
|
|
34
|
+
r"^(?P<parameter>[^:@]+):(?P<paramset_key>[^@]+)@(?P<model>[^:]+):(?P<channel_no>.*)$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class UnIgnoreEntry:
|
|
40
|
+
"""Parsed un-ignore configuration entry."""
|
|
41
|
+
|
|
42
|
+
model: ModelName
|
|
43
|
+
channel_no: UnIgnoreChannelNo
|
|
44
|
+
paramset_key: ParamsetKey
|
|
45
|
+
parameter: ParameterName
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ParsedUnIgnoreLine:
|
|
50
|
+
"""Result of parsing an un-ignore configuration line."""
|
|
51
|
+
|
|
52
|
+
entry: UnIgnoreEntry | None = None
|
|
53
|
+
simple_parameter: ParameterName | None = None
|
|
54
|
+
error: str | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_complex(self) -> bool:
|
|
58
|
+
"""Return True if this is a complex model/channel/paramset un-ignore."""
|
|
59
|
+
return self.entry is not None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_error(self) -> bool:
|
|
63
|
+
"""Return True if parsing failed."""
|
|
64
|
+
return self.error is not None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_simple(self) -> bool:
|
|
68
|
+
"""Return True if this is a simple VALUES parameter un-ignore."""
|
|
69
|
+
return self.simple_parameter is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_un_ignore_line(*, line: str) -> ParsedUnIgnoreLine:
|
|
73
|
+
"""
|
|
74
|
+
Parse an un-ignore configuration line.
|
|
75
|
+
|
|
76
|
+
Supported formats:
|
|
77
|
+
- Simple: "PARAMETER_NAME" (applies to all VALUES paramsets)
|
|
78
|
+
- Complex: "PARAMETER:PARAMSET_KEY@MODEL:CHANNEL_NO"
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
line: The configuration line to parse.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
ParsedUnIgnoreLine with either entry, simple_parameter, or error set.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
if not (line := line.strip()):
|
|
88
|
+
return ParsedUnIgnoreLine(error="Empty line")
|
|
89
|
+
|
|
90
|
+
# Check for complex format with @ separator
|
|
91
|
+
if "@" not in line:
|
|
92
|
+
# Simple format - just a parameter name (no : allowed)
|
|
93
|
+
if ":" in line:
|
|
94
|
+
return ParsedUnIgnoreLine(error=f"Invalid format: ':' without '@' in '{line}'")
|
|
95
|
+
return ParsedUnIgnoreLine(simple_parameter=line)
|
|
96
|
+
|
|
97
|
+
# Complex format - parse with regex
|
|
98
|
+
if not (match := _UN_IGNORE_LINE_PATTERN.match(line)):
|
|
99
|
+
return ParsedUnIgnoreLine(
|
|
100
|
+
error=f"Invalid complex format: '{line}'. Expected 'PARAMETER:PARAMSET@MODEL:CHANNEL'"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
parameter = match.group("parameter")
|
|
104
|
+
paramset_key_str = match.group("paramset_key")
|
|
105
|
+
model = match.group("model").lower()
|
|
106
|
+
channel_no_str = match.group("channel_no")
|
|
107
|
+
|
|
108
|
+
# Validate paramset key
|
|
109
|
+
try:
|
|
110
|
+
paramset_key = ParamsetKey(paramset_key_str)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return ParsedUnIgnoreLine(error=f"Invalid paramset key '{paramset_key_str}' in '{line}'")
|
|
113
|
+
|
|
114
|
+
# Parse channel number
|
|
115
|
+
channel_no: UnIgnoreChannelNo
|
|
116
|
+
if channel_no_str == "":
|
|
117
|
+
channel_no = None
|
|
118
|
+
elif channel_no_str.isnumeric():
|
|
119
|
+
channel_no = int(channel_no_str)
|
|
120
|
+
else:
|
|
121
|
+
channel_no = channel_no_str # Could be wildcard "*"
|
|
122
|
+
|
|
123
|
+
# Check for simple wildcard case (all models, all channels, VALUES)
|
|
124
|
+
if model == UN_IGNORE_WILDCARD and channel_no == UN_IGNORE_WILDCARD and paramset_key == ParamsetKey.VALUES:
|
|
125
|
+
return ParsedUnIgnoreLine(simple_parameter=parameter)
|
|
126
|
+
|
|
127
|
+
# Validate MASTER paramset constraints
|
|
128
|
+
if paramset_key == ParamsetKey.MASTER:
|
|
129
|
+
if not isinstance(channel_no, int) and channel_no is not None:
|
|
130
|
+
return ParsedUnIgnoreLine(error=f"Channel must be numeric or empty for MASTER paramset in '{line}'")
|
|
131
|
+
if model == UN_IGNORE_WILDCARD:
|
|
132
|
+
return ParsedUnIgnoreLine(error=f"Model must be specified for MASTER paramset in '{line}'")
|
|
133
|
+
|
|
134
|
+
return ParsedUnIgnoreLine(
|
|
135
|
+
entry=UnIgnoreEntry(
|
|
136
|
+
model=model,
|
|
137
|
+
channel_no=channel_no,
|
|
138
|
+
paramset_key=paramset_key,
|
|
139
|
+
parameter=parameter,
|
|
140
|
+
)
|
|
141
|
+
)
|