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,459 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Circuit Breaker pattern implementation for RPC calls.
5
+
6
+ Overview
7
+ --------
8
+ The Circuit Breaker prevents retry-storms when backends are unavailable by
9
+ tracking failures and temporarily blocking requests when a failure threshold
10
+ is reached. This protects both the client (from wasting resources on doomed
11
+ requests) and the backend (from being overwhelmed during recovery).
12
+
13
+ State Machine
14
+ -------------
15
+ The circuit breaker has three states:
16
+
17
+ CLOSED (normal operation)
18
+
19
+ │ failure_threshold failures
20
+
21
+ OPEN (fast-fail all requests)
22
+
23
+ │ recovery_timeout elapsed
24
+
25
+ HALF_OPEN (test one request)
26
+
27
+ ├── success_threshold successes → CLOSED
28
+ └── failure → OPEN
29
+
30
+ Example Usage
31
+ -------------
32
+ from aiohomematic.async_support import Looper
33
+ from aiohomematic.client import (
34
+ CircuitBreaker,
35
+ CircuitBreakerConfig,
36
+ )
37
+
38
+ looper = Looper()
39
+ breaker = CircuitBreaker(
40
+ config=CircuitBreakerConfig(
41
+ failure_threshold=5,
42
+ recovery_timeout=30.0,
43
+ success_threshold=2,
44
+ ),
45
+ interface_id="BidCos-RF",
46
+ task_scheduler=looper,
47
+ )
48
+
49
+ # In request handler:
50
+ if not breaker.is_available:
51
+ raise NoConnectionException("Circuit breaker is open")
52
+
53
+ try:
54
+ result = await do_request()
55
+ breaker.record_success()
56
+ return result
57
+ except Exception:
58
+ breaker.record_failure()
59
+ raise
60
+
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ from dataclasses import dataclass
66
+ from datetime import datetime
67
+ import logging
68
+ from typing import TYPE_CHECKING, Any, Final
69
+
70
+ from aiohomematic import i18n
71
+ from aiohomematic.central.events.types import CircuitBreakerStateChangedEvent, CircuitBreakerTrippedEvent
72
+ from aiohomematic.const import CircuitState
73
+ from aiohomematic.metrics import MetricKeys, emit_counter
74
+ from aiohomematic.property_decorators import DelegatedProperty
75
+ from aiohomematic.store.types import IncidentSeverity, IncidentType
76
+
77
+ if TYPE_CHECKING:
78
+ from aiohomematic.central import CentralConnectionState
79
+ from aiohomematic.central.events import EventBus
80
+ from aiohomematic.interfaces import IncidentRecorderProtocol, TaskSchedulerProtocol
81
+
82
+
83
+ _LOGGER: Final = logging.getLogger(__name__)
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class CircuitBreakerConfig:
88
+ """Configuration for CircuitBreaker behavior."""
89
+
90
+ failure_threshold: int = 5
91
+ """Number of consecutive failures before opening the circuit."""
92
+
93
+ recovery_timeout: float = 30.0
94
+ """Seconds to wait in OPEN state before transitioning to HALF_OPEN."""
95
+
96
+ success_threshold: int = 2
97
+ """Number of consecutive successes in HALF_OPEN before closing the circuit."""
98
+
99
+
100
+ class CircuitBreaker:
101
+ """
102
+ Circuit breaker for RPC calls to prevent retry-storms.
103
+
104
+ The circuit breaker monitors request success/failure rates and
105
+ temporarily blocks requests when too many failures occur. This
106
+ prevents overwhelming a failing backend and allows time for recovery.
107
+
108
+ Thread Safety
109
+ -------------
110
+ This class is designed for single-threaded asyncio use.
111
+ State changes are not thread-safe.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ *,
117
+ config: CircuitBreakerConfig | None = None,
118
+ interface_id: str,
119
+ connection_state: CentralConnectionState | None = None,
120
+ issuer: Any = None,
121
+ event_bus: EventBus | None = None,
122
+ incident_recorder: IncidentRecorderProtocol | None = None,
123
+ task_scheduler: TaskSchedulerProtocol,
124
+ ) -> None:
125
+ """
126
+ Initialize the circuit breaker.
127
+
128
+ Args:
129
+ ----
130
+ config: Configuration for thresholds and timeouts
131
+ interface_id: Interface identifier for logging and CentralConnectionState
132
+ connection_state: Optional CentralConnectionState for integration
133
+ issuer: Optional issuer object for CentralConnectionState
134
+ event_bus: Optional EventBus for emitting events (metrics and health records)
135
+ incident_recorder: Optional IncidentRecorderProtocol for recording diagnostic incidents
136
+ task_scheduler: TaskSchedulerProtocol for scheduling async incident recording
137
+
138
+ """
139
+ self._config: Final = config or CircuitBreakerConfig()
140
+ self._interface_id: Final = interface_id
141
+ self._connection_state: Final = connection_state
142
+ self._issuer: Final = issuer
143
+ self._event_bus: Final = event_bus
144
+ self._incident_recorder: Final = incident_recorder
145
+ self._task_scheduler: Final = task_scheduler
146
+
147
+ self._state: CircuitState = CircuitState.CLOSED
148
+ self._failure_count: int = 0
149
+ self._success_count: int = 0
150
+ self._total_requests: int = 0
151
+ self._last_failure_time: datetime | None = None
152
+
153
+ state: Final = DelegatedProperty[CircuitState](path="_state")
154
+ total_requests: Final = DelegatedProperty[int](path="_total_requests")
155
+
156
+ @property
157
+ def is_available(self) -> bool:
158
+ """
159
+ Check if requests should be allowed through.
160
+
161
+ Returns True if:
162
+ - State is CLOSED (normal operation)
163
+ - State is HALF_OPEN (testing recovery)
164
+ - State is OPEN but recovery_timeout has elapsed (transitions to HALF_OPEN)
165
+ """
166
+ if self._state == CircuitState.CLOSED:
167
+ return True
168
+
169
+ if self._state == CircuitState.OPEN:
170
+ # Check if recovery timeout has elapsed
171
+ if self._last_failure_time:
172
+ elapsed = (datetime.now() - self._last_failure_time).total_seconds()
173
+ if elapsed >= self._config.recovery_timeout:
174
+ self._transition_to(new_state=CircuitState.HALF_OPEN)
175
+ return True
176
+ return False
177
+
178
+ # HALF_OPEN - allow one request through
179
+ return True
180
+
181
+ @property
182
+ def last_failure_time(self) -> datetime | None:
183
+ """Return the timestamp of the last failure."""
184
+ return self._last_failure_time
185
+
186
+ def record_failure(self) -> None:
187
+ """
188
+ Record a failed request.
189
+
190
+ In CLOSED state: increments failure count and may open circuit.
191
+ In HALF_OPEN state: immediately opens circuit.
192
+ """
193
+ self._failure_count += 1
194
+ self._total_requests += 1
195
+ self._last_failure_time = datetime.now()
196
+
197
+ if self._state == CircuitState.CLOSED:
198
+ if self._failure_count >= self._config.failure_threshold:
199
+ self._transition_to(new_state=CircuitState.OPEN)
200
+ elif self._state == CircuitState.HALF_OPEN:
201
+ # Any failure in HALF_OPEN goes back to OPEN
202
+ self._transition_to(new_state=CircuitState.OPEN)
203
+
204
+ # Emit failure counter (failures are significant events worth tracking)
205
+ self._emit_counter(metric="failure")
206
+
207
+ def record_rejection(self) -> None:
208
+ """Record a rejected request (circuit is open)."""
209
+ self._emit_counter(metric="rejection")
210
+
211
+ def record_success(self) -> None:
212
+ """
213
+ Record a successful request.
214
+
215
+ In CLOSED state: resets failure count.
216
+ In HALF_OPEN state: increments success count and may close circuit.
217
+
218
+ Note: Success is not emitted as an event (high frequency, low signal).
219
+ Use total_requests property for request counting.
220
+ """
221
+ self._total_requests += 1
222
+
223
+ if self._state == CircuitState.CLOSED:
224
+ self._failure_count = 0
225
+ elif self._state == CircuitState.HALF_OPEN:
226
+ self._success_count += 1
227
+ if self._success_count >= self._config.success_threshold:
228
+ self._transition_to(new_state=CircuitState.CLOSED)
229
+
230
+ def reset(self) -> None:
231
+ """Reset the circuit breaker to initial state."""
232
+ self._state = CircuitState.CLOSED
233
+ self._failure_count = 0
234
+ self._success_count = 0
235
+ self._total_requests = 0
236
+ self._last_failure_time = None
237
+ _LOGGER.debug(
238
+ "CIRCUIT_BREAKER: Reset to CLOSED for %s",
239
+ self._interface_id,
240
+ )
241
+
242
+ def _emit_counter(self, *, metric: str) -> None:
243
+ """
244
+ Emit a counter metric event for significant events only.
245
+
246
+ Uses lazy import to avoid circular dependency:
247
+ circuit_breaker → metrics → aggregator → circuit_breaker.
248
+
249
+ Args:
250
+ ----
251
+ metric: The metric type ("failure", "rejection")
252
+
253
+ Note:
254
+ ----
255
+ Success is not emitted as an event (high frequency, low signal).
256
+ Only failures and rejections are tracked via events.
257
+
258
+ """
259
+ if self._event_bus is None:
260
+ return
261
+
262
+ if metric == "failure":
263
+ key = MetricKeys.circuit_failure(interface_id=self._interface_id)
264
+ elif metric == "rejection":
265
+ key = MetricKeys.circuit_rejection(interface_id=self._interface_id)
266
+ else:
267
+ return
268
+
269
+ emit_counter(event_bus=self._event_bus, key=key)
270
+
271
+ def _emit_state_change_event(
272
+ self,
273
+ *,
274
+ old_state: CircuitState,
275
+ new_state: CircuitState,
276
+ ) -> None:
277
+ """Emit a circuit breaker state change event."""
278
+ if self._event_bus is None:
279
+ return
280
+
281
+ self._event_bus.publish_sync(
282
+ event=CircuitBreakerStateChangedEvent(
283
+ timestamp=datetime.now(),
284
+ interface_id=self._interface_id,
285
+ old_state=old_state,
286
+ new_state=new_state,
287
+ failure_count=self._failure_count,
288
+ success_count=self._success_count,
289
+ last_failure_time=self._last_failure_time,
290
+ )
291
+ )
292
+
293
+ def _emit_state_transition_counter(self) -> None:
294
+ """Emit a counter for state transitions."""
295
+ if self._event_bus is None:
296
+ return
297
+
298
+ emit_counter(
299
+ event_bus=self._event_bus,
300
+ key=MetricKeys.circuit_state_transition(interface_id=self._interface_id),
301
+ )
302
+
303
+ def _emit_tripped_event(self) -> None:
304
+ """Emit a circuit breaker tripped event."""
305
+ if self._event_bus is None:
306
+ return
307
+
308
+ self._event_bus.publish_sync(
309
+ event=CircuitBreakerTrippedEvent(
310
+ timestamp=datetime.now(),
311
+ interface_id=self._interface_id,
312
+ failure_count=self._failure_count,
313
+ last_failure_reason=None, # Could be enhanced in future
314
+ cooldown_seconds=self._config.recovery_timeout,
315
+ )
316
+ )
317
+
318
+ def _record_recovered_incident(self) -> None:
319
+ """Record an incident when circuit breaker recovers."""
320
+ if (incident_recorder := self._incident_recorder) is None:
321
+ return
322
+
323
+ # Capture values for the async closure
324
+ interface_id = self._interface_id
325
+ success_count = self._success_count
326
+ success_threshold = self._config.success_threshold
327
+
328
+ async def _record() -> None:
329
+ try:
330
+ await incident_recorder.record_incident(
331
+ incident_type=IncidentType.CIRCUIT_BREAKER_RECOVERED,
332
+ severity=IncidentSeverity.INFO,
333
+ message=f"Circuit breaker recovered for {interface_id} after {success_count} successful requests",
334
+ interface_id=interface_id,
335
+ context={
336
+ "success_count": success_count,
337
+ "success_threshold": success_threshold,
338
+ },
339
+ )
340
+ except Exception as err: # pragma: no cover
341
+ _LOGGER.debug(
342
+ "CIRCUIT_BREAKER: Failed to record recovered incident for %s: %s",
343
+ interface_id,
344
+ err,
345
+ )
346
+
347
+ # Schedule the async recording via task scheduler
348
+ self._task_scheduler.create_task(
349
+ target=_record(),
350
+ name=f"record_circuit_breaker_recovered_incident_{interface_id}",
351
+ )
352
+
353
+ def _record_tripped_incident(self, *, old_state: CircuitState) -> None:
354
+ """Record an incident when circuit breaker opens."""
355
+ if (incident_recorder := self._incident_recorder) is None:
356
+ return
357
+
358
+ # Capture values for the async closure
359
+ interface_id = self._interface_id
360
+ failure_count = self._failure_count
361
+ failure_threshold = self._config.failure_threshold
362
+ recovery_timeout = self._config.recovery_timeout
363
+ last_failure_time = self._last_failure_time.isoformat() if self._last_failure_time else None
364
+ total_requests = self._total_requests
365
+
366
+ async def _record() -> None:
367
+ try:
368
+ await incident_recorder.record_incident(
369
+ incident_type=IncidentType.CIRCUIT_BREAKER_TRIPPED,
370
+ severity=IncidentSeverity.ERROR,
371
+ message=f"Circuit breaker opened for {interface_id} after {failure_count} failures",
372
+ interface_id=interface_id,
373
+ context={
374
+ "old_state": str(old_state),
375
+ "failure_count": failure_count,
376
+ "failure_threshold": failure_threshold,
377
+ "recovery_timeout": recovery_timeout,
378
+ "last_failure_time": last_failure_time,
379
+ "total_requests": total_requests,
380
+ },
381
+ )
382
+ except Exception as err: # pragma: no cover
383
+ _LOGGER.debug(
384
+ "CIRCUIT_BREAKER: Failed to record tripped incident for %s: %s",
385
+ interface_id,
386
+ err,
387
+ )
388
+
389
+ # Schedule the async recording via task scheduler
390
+ self._task_scheduler.create_task(
391
+ target=_record(),
392
+ name=f"record_circuit_breaker_tripped_incident_{interface_id}",
393
+ )
394
+
395
+ def _transition_to(self, *, new_state: CircuitState) -> None:
396
+ """
397
+ Handle state transition with logging and CentralConnectionState notification.
398
+
399
+ Args:
400
+ ----
401
+ new_state: The target state to transition to
402
+
403
+ """
404
+ if (old_state := self._state) == new_state:
405
+ return
406
+
407
+ self._state = new_state
408
+ self._emit_state_transition_counter()
409
+
410
+ # Use DEBUG for expected recovery transitions, INFO for issues and recovery attempts
411
+ if old_state == CircuitState.HALF_OPEN and new_state == CircuitState.CLOSED:
412
+ # Recovery successful - expected behavior during reconnection (DEBUG is allowed without i18n)
413
+ _LOGGER.debug(
414
+ "CIRCUIT_BREAKER: %s → %s for %s (failures=%d, successes=%d)",
415
+ old_state,
416
+ new_state,
417
+ self._interface_id,
418
+ self._failure_count,
419
+ self._success_count,
420
+ )
421
+ else:
422
+ # Problem detected (CLOSED→OPEN) or testing recovery (OPEN→HALF_OPEN)
423
+ _LOGGER.info(
424
+ i18n.tr(
425
+ key="log.client.circuit_breaker.state_transition",
426
+ old_state=old_state,
427
+ new_state=new_state,
428
+ interface_id=self._interface_id,
429
+ failure_count=self._failure_count,
430
+ success_count=self._success_count,
431
+ )
432
+ )
433
+
434
+ # Emit state change event
435
+ self._emit_state_change_event(old_state=old_state, new_state=new_state)
436
+
437
+ # Emit tripped event and record incident when circuit opens
438
+ if new_state == CircuitState.OPEN:
439
+ self._emit_tripped_event()
440
+ self._record_tripped_incident(old_state=old_state)
441
+
442
+ # Record recovery incident when circuit recovers from HALF_OPEN
443
+ if old_state == CircuitState.HALF_OPEN and new_state == CircuitState.CLOSED:
444
+ self._record_recovered_incident()
445
+
446
+ # Reset counters based on new state
447
+ if new_state == CircuitState.CLOSED:
448
+ self._failure_count = 0
449
+ self._success_count = 0
450
+ # Notify CentralConnectionState that connection is restored
451
+ if self._connection_state and self._issuer:
452
+ self._connection_state.remove_issue(issuer=self._issuer, iid=self._interface_id)
453
+ elif new_state == CircuitState.OPEN:
454
+ self._success_count = 0
455
+ # Notify CentralConnectionState about the issue
456
+ if self._connection_state and self._issuer:
457
+ self._connection_state.add_issue(issuer=self._issuer, iid=self._interface_id)
458
+ elif new_state == CircuitState.HALF_OPEN:
459
+ self._success_count = 0
@@ -0,0 +1,64 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Interface configuration for Homematic client connections.
5
+
6
+ This module provides configuration for individual Homematic interface
7
+ connections (e.g., BidCos-RF, HmIP-RF, VirtualDevices).
8
+
9
+ Public API
10
+ ----------
11
+ - InterfaceConfig: Configuration for a single interface connection including
12
+ port, remote path, and RPC server type.
13
+
14
+ Each InterfaceConfig represents one communication channel to the backend,
15
+ identified by a unique interface_id derived from the central name and
16
+ interface type.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Final
22
+
23
+ from aiohomematic import i18n
24
+ from aiohomematic.const import INTERFACE_RPC_SERVER_TYPE, INTERFACES_SUPPORTING_RPC_CALLBACK, Interface, RpcServerType
25
+ from aiohomematic.exceptions import ClientException
26
+ from aiohomematic.property_decorators import DelegatedProperty
27
+
28
+
29
+ class InterfaceConfig:
30
+ """Configuration for a single Homematic interface connection."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ central_name: str,
36
+ interface: Interface,
37
+ port: int,
38
+ remote_path: str | None = None,
39
+ ) -> None:
40
+ """Initialize the interface configuration."""
41
+ self.interface: Final[Interface] = interface
42
+
43
+ self.rpc_server: Final[RpcServerType] = INTERFACE_RPC_SERVER_TYPE[interface]
44
+ self.interface_id: Final[str] = f"{central_name}-{self.interface}"
45
+ self.port: Final = port
46
+ self.remote_path: Final = remote_path
47
+ self._init_validate()
48
+ self._enabled: bool = True
49
+
50
+ enabled: Final = DelegatedProperty[bool](path="_enabled")
51
+
52
+ def disable(self) -> None:
53
+ """Disable the interface config."""
54
+ self._enabled = False
55
+
56
+ def _init_validate(self) -> None:
57
+ """Validate the client_config."""
58
+ if not self.port and self.interface in INTERFACES_SUPPORTING_RPC_CALLBACK:
59
+ raise ClientException(
60
+ i18n.tr(
61
+ key="exception.client.interface_config.port_required",
62
+ interface=self.interface,
63
+ )
64
+ )
@@ -0,0 +1,40 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Handler classes for ClientCCU operations.
5
+
6
+ This package provides specialized handler classes that encapsulate specific
7
+ domains of client operations. Each handler focuses on a single responsibility,
8
+ reducing the complexity of the main client classes.
9
+
10
+ Handler classes
11
+ ---------------
12
+ - DeviceHandler: Value read/write, paramset operations
13
+ - LinkHandler: Device linking operations
14
+ - FirmwareHandler: Device and system firmware updates
15
+ - SystemVariableHandler: System variables CRUD
16
+ - ProgramHandler: Program execution and state management
17
+ - BackupHandler: Backup creation and download
18
+ - MetadataHandler: Metadata, renaming, rooms, functions, install mode
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from aiohomematic.client.handlers.backup import BackupHandler
24
+ from aiohomematic.client.handlers.device_ops import DeviceHandler, _wait_for_state_change_or_timeout
25
+ from aiohomematic.client.handlers.firmware import FirmwareHandler
26
+ from aiohomematic.client.handlers.link_mgmt import LinkHandler
27
+ from aiohomematic.client.handlers.metadata import MetadataHandler
28
+ from aiohomematic.client.handlers.programs import ProgramHandler
29
+ from aiohomematic.client.handlers.sysvars import SystemVariableHandler
30
+
31
+ __all__ = [
32
+ "BackupHandler",
33
+ "DeviceHandler",
34
+ "FirmwareHandler",
35
+ "LinkHandler",
36
+ "MetadataHandler",
37
+ "ProgramHandler",
38
+ "SystemVariableHandler",
39
+ "_wait_for_state_change_or_timeout",
40
+ ]