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