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