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,391 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Central state machine for orchestrating overall system health.
|
|
5
|
+
|
|
6
|
+
This module provides the CentralStateMachine which manages the overall state
|
|
7
|
+
of the system based on individual client states. It acts as an orchestrator
|
|
8
|
+
above the client-level state machines.
|
|
9
|
+
|
|
10
|
+
Overview
|
|
11
|
+
--------
|
|
12
|
+
The CentralStateMachine provides:
|
|
13
|
+
- Unified view of system health
|
|
14
|
+
- Coordinated state transitions
|
|
15
|
+
- Event emission for state changes
|
|
16
|
+
- Validation of state transitions
|
|
17
|
+
|
|
18
|
+
State Machine
|
|
19
|
+
-------------
|
|
20
|
+
```
|
|
21
|
+
STARTING ──► INITIALIZING ──► RUNNING ◄──► DEGRADED
|
|
22
|
+
│ │ │
|
|
23
|
+
│ ▼ ▼
|
|
24
|
+
│ RECOVERING ◄────┘
|
|
25
|
+
│ │
|
|
26
|
+
│ ├──► RUNNING
|
|
27
|
+
│ ├──► DEGRADED
|
|
28
|
+
│ └──► FAILED
|
|
29
|
+
│
|
|
30
|
+
└──► FAILED
|
|
31
|
+
|
|
32
|
+
STOPPED ◄── (from any state)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The CentralStateMachine observes client state machines and determines the
|
|
36
|
+
overall system state:
|
|
37
|
+
- RUNNING: All clients are CONNECTED
|
|
38
|
+
- DEGRADED: At least one client is not CONNECTED
|
|
39
|
+
- RECOVERING: Recovery is in progress
|
|
40
|
+
- FAILED: Max retries reached, manual intervention required
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from datetime import datetime
|
|
47
|
+
import logging
|
|
48
|
+
from types import MappingProxyType
|
|
49
|
+
from typing import TYPE_CHECKING, Final
|
|
50
|
+
|
|
51
|
+
from aiohomematic.central.events import SystemStatusChangedEvent
|
|
52
|
+
from aiohomematic.central.events.types import CentralStateChangedEvent
|
|
53
|
+
from aiohomematic.const import CentralState, FailureReason
|
|
54
|
+
from aiohomematic.interfaces import CentralStateMachineProtocol
|
|
55
|
+
from aiohomematic.property_decorators import DelegatedProperty
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from aiohomematic.central.events import EventBus
|
|
59
|
+
|
|
60
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
# Valid state transitions define which state changes are allowed.
|
|
63
|
+
# This forms a directed graph where each state maps to its valid successors.
|
|
64
|
+
VALID_CENTRAL_TRANSITIONS: Final[dict[CentralState, frozenset[CentralState]]] = {
|
|
65
|
+
# Initial state - Central is being created
|
|
66
|
+
CentralState.STARTING: frozenset(
|
|
67
|
+
{
|
|
68
|
+
CentralState.INITIALIZING,
|
|
69
|
+
CentralState.STOPPED, # stop() before start()
|
|
70
|
+
}
|
|
71
|
+
),
|
|
72
|
+
# During initialization - clients are being initialized
|
|
73
|
+
CentralState.INITIALIZING: frozenset(
|
|
74
|
+
{
|
|
75
|
+
CentralState.RUNNING, # All clients OK
|
|
76
|
+
CentralState.DEGRADED, # At least one client not OK
|
|
77
|
+
CentralState.FAILED, # Critical init error
|
|
78
|
+
CentralState.STOPPED, # stop() during init
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
# Normal operation - all clients connected
|
|
82
|
+
CentralState.RUNNING: frozenset(
|
|
83
|
+
{
|
|
84
|
+
CentralState.DEGRADED, # Client problem detected
|
|
85
|
+
CentralState.RECOVERING, # Proactive recovery
|
|
86
|
+
CentralState.STOPPED, # Graceful shutdown
|
|
87
|
+
}
|
|
88
|
+
),
|
|
89
|
+
# Limited operation - at least one client not connected
|
|
90
|
+
CentralState.DEGRADED: frozenset(
|
|
91
|
+
{
|
|
92
|
+
CentralState.RUNNING, # All clients recovered
|
|
93
|
+
CentralState.RECOVERING, # Start recovery
|
|
94
|
+
CentralState.FAILED, # Too long degraded
|
|
95
|
+
CentralState.STOPPED, # Shutdown
|
|
96
|
+
}
|
|
97
|
+
),
|
|
98
|
+
# Active recovery in progress
|
|
99
|
+
CentralState.RECOVERING: frozenset(
|
|
100
|
+
{
|
|
101
|
+
CentralState.RUNNING, # Recovery successful
|
|
102
|
+
CentralState.DEGRADED, # Partial recovery
|
|
103
|
+
CentralState.FAILED, # Max retries reached
|
|
104
|
+
CentralState.STOPPED, # Shutdown during recovery
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
# Critical error - manual intervention required
|
|
108
|
+
CentralState.FAILED: frozenset(
|
|
109
|
+
{
|
|
110
|
+
CentralState.RECOVERING, # Manual retry
|
|
111
|
+
CentralState.STOPPED, # Shutdown
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
# Terminal state - no transitions allowed
|
|
115
|
+
CentralState.STOPPED: frozenset(),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class InvalidCentralStateTransitionError(Exception):
|
|
120
|
+
"""Raised when an invalid central state transition is attempted."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, *, current: CentralState, target: CentralState, central_name: str) -> None:
|
|
123
|
+
"""Initialize the error."""
|
|
124
|
+
self.current = current
|
|
125
|
+
self.target = target
|
|
126
|
+
self.central_name = central_name
|
|
127
|
+
super().__init__(f"Invalid central state transition from {current.value} to {target.value} for {central_name}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CentralStateMachine(CentralStateMachineProtocol):
|
|
131
|
+
"""
|
|
132
|
+
State machine for central system health orchestration.
|
|
133
|
+
|
|
134
|
+
This class manages the overall state of the central system based on
|
|
135
|
+
individual client states. It provides:
|
|
136
|
+
- Unified system state (RUNNING, DEGRADED, RECOVERING, FAILED)
|
|
137
|
+
- Validated state transitions
|
|
138
|
+
- Event emission for monitoring via EventBus
|
|
139
|
+
|
|
140
|
+
Thread Safety
|
|
141
|
+
-------------
|
|
142
|
+
This class is NOT thread-safe. All calls should happen from the same
|
|
143
|
+
event loop/thread.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
from aiohomematic.central.events import CentralStateChangedEvent, EventBus
|
|
147
|
+
|
|
148
|
+
def on_state_changed(*, event: CentralStateChangedEvent) -> None:
|
|
149
|
+
print(f"Central state: {event.old_state} -> {event.new_state}")
|
|
150
|
+
|
|
151
|
+
event_bus = EventBus()
|
|
152
|
+
sm = CentralStateMachine(central_name="ccu-main", event_bus=event_bus)
|
|
153
|
+
|
|
154
|
+
# Subscribe to state changes via EventBus
|
|
155
|
+
event_bus.subscribe(
|
|
156
|
+
event_type=CentralStateChangedEvent,
|
|
157
|
+
handler=on_state_changed,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
sm.transition_to(target=CentralState.INITIALIZING, reason="start() called")
|
|
161
|
+
sm.transition_to(target=CentralState.RUNNING, reason="all clients connected")
|
|
162
|
+
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
__slots__ = (
|
|
166
|
+
"_central_name",
|
|
167
|
+
"_degraded_interfaces",
|
|
168
|
+
"_event_bus",
|
|
169
|
+
"_failure_interface_id",
|
|
170
|
+
"_failure_message",
|
|
171
|
+
"_failure_reason",
|
|
172
|
+
"_last_state_change",
|
|
173
|
+
"_state",
|
|
174
|
+
"_state_history",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
central_name: str,
|
|
181
|
+
event_bus: EventBus | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Initialize the central state machine.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
central_name: Name of the central unit for logging
|
|
188
|
+
event_bus: Optional event bus for publishing state change events
|
|
189
|
+
|
|
190
|
+
"""
|
|
191
|
+
self._central_name: Final = central_name
|
|
192
|
+
self._event_bus = event_bus
|
|
193
|
+
self._state: CentralState = CentralState.STARTING
|
|
194
|
+
self._failure_reason: FailureReason = FailureReason.NONE
|
|
195
|
+
self._failure_message: str = ""
|
|
196
|
+
self._failure_interface_id: str | None = None
|
|
197
|
+
self._degraded_interfaces: Mapping[str, FailureReason] = MappingProxyType({})
|
|
198
|
+
self._last_state_change: datetime = datetime.now()
|
|
199
|
+
self._state_history: list[tuple[datetime, CentralState, CentralState, str]] = []
|
|
200
|
+
|
|
201
|
+
degraded_interfaces: Final = DelegatedProperty[Mapping[str, FailureReason]](path="_degraded_interfaces")
|
|
202
|
+
failure_interface_id: Final = DelegatedProperty[str | None](path="_failure_interface_id")
|
|
203
|
+
failure_message: Final = DelegatedProperty[str](path="_failure_message")
|
|
204
|
+
failure_reason: Final = DelegatedProperty[FailureReason](path="_failure_reason")
|
|
205
|
+
last_state_change: Final = DelegatedProperty[datetime](path="_last_state_change")
|
|
206
|
+
state: Final = DelegatedProperty[CentralState](path="_state")
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def is_degraded(self) -> bool:
|
|
210
|
+
"""Return True if system is in degraded state."""
|
|
211
|
+
return self._state == CentralState.DEGRADED
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def is_failed(self) -> bool:
|
|
215
|
+
"""Return True if system is in failed state."""
|
|
216
|
+
return self._state == CentralState.FAILED
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def is_operational(self) -> bool:
|
|
220
|
+
"""Return True if system is operational (RUNNING or DEGRADED)."""
|
|
221
|
+
return self._state in (CentralState.RUNNING, CentralState.DEGRADED)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def is_recovering(self) -> bool:
|
|
225
|
+
"""Return True if recovery is in progress."""
|
|
226
|
+
return self._state == CentralState.RECOVERING
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def is_running(self) -> bool:
|
|
230
|
+
"""Return True if system is fully running."""
|
|
231
|
+
return self._state == CentralState.RUNNING
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def is_stopped(self) -> bool:
|
|
235
|
+
"""Return True if system is stopped."""
|
|
236
|
+
return self._state == CentralState.STOPPED
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def seconds_in_current_state(self) -> float:
|
|
240
|
+
"""Return seconds since last state change."""
|
|
241
|
+
return (datetime.now() - self._last_state_change).total_seconds()
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def state_history(self) -> list[tuple[datetime, CentralState, CentralState, str]]:
|
|
245
|
+
"""Return state transition history (timestamp, old_state, new_state, reason)."""
|
|
246
|
+
return self._state_history.copy()
|
|
247
|
+
|
|
248
|
+
def can_transition_to(self, *, target: CentralState) -> bool:
|
|
249
|
+
"""
|
|
250
|
+
Check if transition to target state is valid.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
target: Target state to check
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if transition is valid, False otherwise
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
return target in VALID_CENTRAL_TRANSITIONS.get(self._state, frozenset())
|
|
260
|
+
|
|
261
|
+
def set_event_bus(self, *, event_bus: EventBus) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Set the event bus for publishing state change events.
|
|
264
|
+
|
|
265
|
+
This is useful when the event bus is created after the state machine.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
event_bus: The event bus to use
|
|
269
|
+
|
|
270
|
+
"""
|
|
271
|
+
self._event_bus = event_bus
|
|
272
|
+
|
|
273
|
+
def transition_to(
|
|
274
|
+
self,
|
|
275
|
+
*,
|
|
276
|
+
target: CentralState,
|
|
277
|
+
reason: str = "",
|
|
278
|
+
force: bool = False,
|
|
279
|
+
failure_reason: FailureReason = FailureReason.NONE,
|
|
280
|
+
failure_interface_id: str | None = None,
|
|
281
|
+
degraded_interfaces: Mapping[str, FailureReason] | None = None,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Transition to a new state.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
target: Target state to transition to
|
|
288
|
+
reason: Human-readable reason for the transition
|
|
289
|
+
force: If True, skip validation (use with caution)
|
|
290
|
+
failure_reason: Categorized failure reason (only used when target is FAILED)
|
|
291
|
+
failure_interface_id: Interface ID that caused the failure (optional)
|
|
292
|
+
degraded_interfaces: Map of interface_id to failure reason (only used when target is DEGRADED)
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
InvalidCentralStateTransitionError: If transition is not valid and force=False
|
|
296
|
+
|
|
297
|
+
"""
|
|
298
|
+
if not force and not self.can_transition_to(target=target):
|
|
299
|
+
raise InvalidCentralStateTransitionError(
|
|
300
|
+
current=self._state,
|
|
301
|
+
target=target,
|
|
302
|
+
central_name=self._central_name,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
old_state = self._state
|
|
306
|
+
self._state = target
|
|
307
|
+
self._last_state_change = datetime.now()
|
|
308
|
+
|
|
309
|
+
# Track failure reason when entering FAILED state
|
|
310
|
+
if target == CentralState.FAILED:
|
|
311
|
+
self._failure_reason = failure_reason
|
|
312
|
+
self._failure_message = reason
|
|
313
|
+
self._failure_interface_id = failure_interface_id
|
|
314
|
+
self._degraded_interfaces = MappingProxyType({})
|
|
315
|
+
elif target == CentralState.DEGRADED:
|
|
316
|
+
# Track degraded interfaces with their reasons
|
|
317
|
+
self._degraded_interfaces = MappingProxyType(dict(degraded_interfaces or {}))
|
|
318
|
+
elif target == CentralState.RUNNING:
|
|
319
|
+
# Clear failure and degraded info on successful state
|
|
320
|
+
self._failure_reason = FailureReason.NONE
|
|
321
|
+
self._failure_message = ""
|
|
322
|
+
self._failure_interface_id = None
|
|
323
|
+
self._degraded_interfaces = MappingProxyType({})
|
|
324
|
+
|
|
325
|
+
# Record in history (keep last 100 transitions)
|
|
326
|
+
self._state_history.append((self._last_state_change, old_state, target, reason))
|
|
327
|
+
if len(self._state_history) > 100:
|
|
328
|
+
self._state_history = self._state_history[-100:]
|
|
329
|
+
|
|
330
|
+
# Log the transition
|
|
331
|
+
if old_state != target:
|
|
332
|
+
extra_info = ""
|
|
333
|
+
if target == CentralState.FAILED:
|
|
334
|
+
extra_info = f" [reason={failure_reason.value}]"
|
|
335
|
+
elif target == CentralState.DEGRADED and degraded_interfaces:
|
|
336
|
+
iface_reasons = ", ".join(f"{k}={v.value}" for k, v in degraded_interfaces.items())
|
|
337
|
+
extra_info = f" [interfaces: {iface_reasons}]"
|
|
338
|
+
_LOGGER.info( # i18n-log: ignore
|
|
339
|
+
"CENTRAL_STATE: %s: %s -> %s (%s)%s",
|
|
340
|
+
self._central_name,
|
|
341
|
+
old_state.value,
|
|
342
|
+
target.value,
|
|
343
|
+
reason or "no reason specified",
|
|
344
|
+
extra_info,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Publish event to event bus
|
|
348
|
+
if self._event_bus is not None:
|
|
349
|
+
self._publish_state_change_event(old_state=old_state, new_state=target, reason=reason)
|
|
350
|
+
|
|
351
|
+
def _publish_state_change_event(self, *, old_state: CentralState, new_state: CentralState, reason: str) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Publish state change event to the event bus.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
old_state: Previous state
|
|
357
|
+
new_state: New state
|
|
358
|
+
reason: Reason for the transition
|
|
359
|
+
|
|
360
|
+
"""
|
|
361
|
+
if self._event_bus is None:
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
# Include failure info when transitioning to FAILED state
|
|
365
|
+
failure_reason = self._failure_reason if new_state == CentralState.FAILED else None
|
|
366
|
+
failure_interface_id = self._failure_interface_id if new_state == CentralState.FAILED else None
|
|
367
|
+
|
|
368
|
+
# Include degraded interfaces when transitioning to DEGRADED state
|
|
369
|
+
degraded_interfaces = self._degraded_interfaces if new_state == CentralState.DEGRADED else None
|
|
370
|
+
|
|
371
|
+
# Emit SystemStatusChangedEvent for integration compatibility
|
|
372
|
+
self._event_bus.publish_sync(
|
|
373
|
+
event=SystemStatusChangedEvent(
|
|
374
|
+
timestamp=self._last_state_change,
|
|
375
|
+
central_state=new_state,
|
|
376
|
+
failure_reason=failure_reason,
|
|
377
|
+
failure_interface_id=failure_interface_id,
|
|
378
|
+
degraded_interfaces=degraded_interfaces,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Emit CentralStateChangedEvent for observability
|
|
383
|
+
self._event_bus.publish_sync(
|
|
384
|
+
event=CentralStateChangedEvent(
|
|
385
|
+
timestamp=self._last_state_change,
|
|
386
|
+
central_name=self._central_name,
|
|
387
|
+
old_state=old_state,
|
|
388
|
+
new_state=new_state,
|
|
389
|
+
trigger=reason or None,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Client adapters for communicating with Homematic CCU and compatible backends.
|
|
5
|
+
|
|
6
|
+
This package provides client implementations that abstract the transport details
|
|
7
|
+
of Homematic backends (CCU via JSON-RPC/XML-RPC or Homegear) and expose a
|
|
8
|
+
consistent API used by the central module.
|
|
9
|
+
|
|
10
|
+
Package structure
|
|
11
|
+
-----------------
|
|
12
|
+
- ccu.py: Client implementations (ClientCCU, ClientJsonCCU, ClientHomegear, ClientConfig)
|
|
13
|
+
- config.py: InterfaceConfig for per-interface connection settings
|
|
14
|
+
- circuit_breaker.py: CircuitBreaker, CircuitBreakerConfig, CircuitState
|
|
15
|
+
- state_machine.py: ClientStateMachine for connection state tracking
|
|
16
|
+
- rpc_proxy.py: BaseRpcProxy, AioXmlRpcProxy for XML-RPC transport
|
|
17
|
+
- json_rpc.py: AioJsonRpcAioHttpClient for JSON-RPC transport
|
|
18
|
+
- request_coalescer.py: RequestCoalescer for deduplicating concurrent requests
|
|
19
|
+
- handlers/: Protocol-specific operation handlers
|
|
20
|
+
- backends/: Backend strategy implementations (CCU, CCU-Jack, Homegear)
|
|
21
|
+
- interface_client.py: InterfaceClient using backend strategy pattern
|
|
22
|
+
|
|
23
|
+
Public API
|
|
24
|
+
----------
|
|
25
|
+
- Clients: ClientCCU, ClientJsonCCU, ClientHomegear, ClientConfig, InterfaceClient
|
|
26
|
+
- Configuration: InterfaceConfig
|
|
27
|
+
- Circuit breaker: CircuitBreaker, CircuitBreakerConfig, CircuitState
|
|
28
|
+
- State machine: ClientStateMachine, InvalidStateTransitionError
|
|
29
|
+
- Transport: BaseRpcProxy, AioJsonRpcAioHttpClient
|
|
30
|
+
- Coalescing: RequestCoalescer, make_coalesce_key
|
|
31
|
+
- Factory functions: create_client, get_client
|
|
32
|
+
|
|
33
|
+
Notes
|
|
34
|
+
-----
|
|
35
|
+
- Most users interact with clients via CentralUnit; direct usage is for advanced scenarios
|
|
36
|
+
- Clients are created via ClientConfig.create_client() or the create_client() function
|
|
37
|
+
- XML-RPC is used for device operations; JSON-RPC for metadata/programs/sysvars (CCU only)
|
|
38
|
+
- InterfaceClient with backends can be enabled via:
|
|
39
|
+
- OptionalSettings.USE_INTERFACE_CLIENT in config, OR
|
|
40
|
+
- Environment variable AIOHOMEMATIC_USE_INTERFACE_CLIENT=1 (for CI testing)
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
from typing import Final
|
|
49
|
+
|
|
50
|
+
from aiohomematic import central as hmcu, i18n
|
|
51
|
+
from aiohomematic.client.backends import create_backend
|
|
52
|
+
from aiohomematic.client.ccu import ClientCCU, ClientConfig, ClientHomegear, ClientJsonCCU
|
|
53
|
+
from aiohomematic.client.circuit_breaker import CircuitBreaker, CircuitBreakerConfig
|
|
54
|
+
from aiohomematic.client.config import InterfaceConfig
|
|
55
|
+
from aiohomematic.client.interface_client import InterfaceClient
|
|
56
|
+
from aiohomematic.client.json_rpc import AioJsonRpcAioHttpClient
|
|
57
|
+
from aiohomematic.client.request_coalescer import RequestCoalescer, make_coalesce_key
|
|
58
|
+
from aiohomematic.client.rpc_proxy import BaseRpcProxy
|
|
59
|
+
from aiohomematic.client.state_machine import ClientStateMachine, InvalidStateTransitionError
|
|
60
|
+
from aiohomematic.const import CircuitState, OptionalSettings
|
|
61
|
+
from aiohomematic.exceptions import NoConnectionException
|
|
62
|
+
from aiohomematic.interfaces.client import ClientDependenciesProtocol, ClientProtocol
|
|
63
|
+
|
|
64
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
# Circuit breaker
|
|
68
|
+
"CircuitBreaker",
|
|
69
|
+
"CircuitBreakerConfig",
|
|
70
|
+
"CircuitState",
|
|
71
|
+
# Clients
|
|
72
|
+
"ClientCCU",
|
|
73
|
+
"ClientConfig",
|
|
74
|
+
"ClientHomegear",
|
|
75
|
+
"ClientJsonCCU",
|
|
76
|
+
"InterfaceClient",
|
|
77
|
+
# Config
|
|
78
|
+
"InterfaceConfig",
|
|
79
|
+
# Factory functions
|
|
80
|
+
"create_client",
|
|
81
|
+
"get_client",
|
|
82
|
+
# JSON RPC
|
|
83
|
+
"AioJsonRpcAioHttpClient",
|
|
84
|
+
# RPC proxy
|
|
85
|
+
"BaseRpcProxy",
|
|
86
|
+
# Request coalescing
|
|
87
|
+
"RequestCoalescer",
|
|
88
|
+
"make_coalesce_key",
|
|
89
|
+
# State machine
|
|
90
|
+
"ClientStateMachine",
|
|
91
|
+
"InvalidStateTransitionError",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_ENV_USE_INTERFACE_CLIENT: Final = "AIOHOMEMATIC_USE_INTERFACE_CLIENT"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _should_use_interface_client(*, client_deps: ClientDependenciesProtocol) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Determine if InterfaceClient should be used.
|
|
101
|
+
|
|
102
|
+
Checks in order:
|
|
103
|
+
1. Environment variable AIOHOMEMATIC_USE_INTERFACE_CLIENT (for CI testing)
|
|
104
|
+
2. OptionalSettings.USE_INTERFACE_CLIENT in config
|
|
105
|
+
"""
|
|
106
|
+
# Environment variable takes precedence (for CI testing)
|
|
107
|
+
if (env_value := os.environ.get(_ENV_USE_INTERFACE_CLIENT)) is not None:
|
|
108
|
+
return env_value.lower() in ("1", "true", "yes")
|
|
109
|
+
|
|
110
|
+
# Check config setting
|
|
111
|
+
return OptionalSettings.USE_INTERFACE_CLIENT in client_deps.config.optional_settings
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def create_client(
|
|
115
|
+
*,
|
|
116
|
+
client_deps: ClientDependenciesProtocol,
|
|
117
|
+
interface_config: InterfaceConfig,
|
|
118
|
+
) -> ClientProtocol:
|
|
119
|
+
"""
|
|
120
|
+
Return a new client for with a given interface_config.
|
|
121
|
+
|
|
122
|
+
Uses InterfaceClient with backend strategy pattern if:
|
|
123
|
+
- Environment variable AIOHOMEMATIC_USE_INTERFACE_CLIENT=1 is set, OR
|
|
124
|
+
- USE_INTERFACE_CLIENT is enabled in optional_settings
|
|
125
|
+
|
|
126
|
+
Otherwise uses legacy ClientCCU family.
|
|
127
|
+
"""
|
|
128
|
+
if _should_use_interface_client(client_deps=client_deps):
|
|
129
|
+
return await _create_interface_client(client_deps=client_deps, interface_config=interface_config)
|
|
130
|
+
|
|
131
|
+
# Legacy path - unchanged
|
|
132
|
+
return await ClientConfig(
|
|
133
|
+
client_deps=client_deps,
|
|
134
|
+
interface_config=interface_config,
|
|
135
|
+
).create_client()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _create_interface_client(
|
|
139
|
+
*,
|
|
140
|
+
client_deps: ClientDependenciesProtocol,
|
|
141
|
+
interface_config: InterfaceConfig,
|
|
142
|
+
) -> ClientProtocol:
|
|
143
|
+
"""Create InterfaceClient using backend strategy pattern."""
|
|
144
|
+
# Get version first (needed for backend selection)
|
|
145
|
+
client_config = ClientConfig(
|
|
146
|
+
client_deps=client_deps,
|
|
147
|
+
interface_config=interface_config,
|
|
148
|
+
)
|
|
149
|
+
version = await client_config._get_version() # noqa: SLF001 # pylint: disable=protected-access
|
|
150
|
+
|
|
151
|
+
# Create appropriate backend
|
|
152
|
+
backend = await create_backend(
|
|
153
|
+
interface=interface_config.interface,
|
|
154
|
+
interface_id=interface_config.interface_id,
|
|
155
|
+
version=version,
|
|
156
|
+
proxy=await client_config.create_rpc_proxy(
|
|
157
|
+
interface=interface_config.interface,
|
|
158
|
+
auth_enabled=True,
|
|
159
|
+
)
|
|
160
|
+
if client_config.has_rpc_callback
|
|
161
|
+
else None,
|
|
162
|
+
proxy_read=await client_config.create_rpc_proxy(
|
|
163
|
+
interface=interface_config.interface,
|
|
164
|
+
auth_enabled=True,
|
|
165
|
+
max_workers=client_config.max_read_workers,
|
|
166
|
+
)
|
|
167
|
+
if client_config.has_rpc_callback
|
|
168
|
+
else None,
|
|
169
|
+
json_rpc=client_deps.json_rpc_client,
|
|
170
|
+
paramset_provider=client_deps.cache_coordinator.paramset_descriptions,
|
|
171
|
+
device_details_provider=client_deps.cache_coordinator.device_details.device_channel_rega_ids,
|
|
172
|
+
has_push_updates=client_config.has_push_updates,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
_LOGGER.debug(
|
|
176
|
+
"CREATE_INTERFACE_CLIENT: Created %s backend for %s",
|
|
177
|
+
backend.model,
|
|
178
|
+
interface_config.interface_id,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Create InterfaceClient
|
|
182
|
+
client = InterfaceClient(
|
|
183
|
+
backend=backend,
|
|
184
|
+
central=client_deps,
|
|
185
|
+
interface_config=interface_config,
|
|
186
|
+
version=version,
|
|
187
|
+
)
|
|
188
|
+
await client.init_client()
|
|
189
|
+
|
|
190
|
+
if await client.check_connection_availability(handle_ping_pong=False):
|
|
191
|
+
return client
|
|
192
|
+
|
|
193
|
+
raise NoConnectionException(
|
|
194
|
+
i18n.tr(key="exception.client.client_config.no_connection", interface_id=interface_config.interface_id)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_client(*, interface_id: str) -> ClientProtocol | None:
|
|
199
|
+
"""Return client by interface_id."""
|
|
200
|
+
for central in hmcu.CENTRAL_INSTANCES.values():
|
|
201
|
+
if central.client_coordinator.has_client(interface_id=interface_id):
|
|
202
|
+
return central.client_coordinator.get_client(interface_id=interface_id)
|
|
203
|
+
return None
|