aiohomematic-test-support 2025.12.23__py3-none-any.whl → 2026.1.23__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.
@@ -0,0 +1,300 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Event-driven mock server for test triggers.
5
+
6
+ Overview
7
+ --------
8
+ This module provides a mock server that responds to events by injecting
9
+ data or triggering actions. This enables event-based test orchestration
10
+ where test behavior is triggered by events rather than explicit calls.
11
+
12
+ The EventDrivenMockServer allows tests to:
13
+ - Configure responses to specific event types
14
+ - Inject mock data when certain events are published
15
+ - Build complex test scenarios with event-driven behavior
16
+
17
+ Public API
18
+ ----------
19
+ - EventDrivenMockServer: Main mock server class
20
+ - ResponseBuilder: Fluent builder for configuring responses
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ if TYPE_CHECKING:
30
+ from aiohomematic.central.events import Event, EventBus
31
+
32
+
33
+ @dataclass
34
+ class MockAction:
35
+ """
36
+ An action to perform when an event is received.
37
+
38
+ Attributes:
39
+ ----------
40
+ handler: The callback to invoke when the event is received
41
+ filter_fn: Optional filter function to match specific events
42
+ one_shot: If True, the action is removed after first invocation
43
+
44
+ """
45
+
46
+ handler: Callable[[Any], Any]
47
+ filter_fn: Callable[[Any], bool] | None = None
48
+ one_shot: bool = False
49
+
50
+
51
+ @dataclass
52
+ class EventDrivenMockServer:
53
+ """
54
+ Mock server that responds to events.
55
+
56
+ Provides a fluent API for configuring responses to specific event types.
57
+ When an event is published that matches a configured response, the
58
+ corresponding action is executed.
59
+
60
+ Example Usage
61
+ -------------
62
+ mock_server = EventDrivenMockServer(event_bus=central.event_bus)
63
+
64
+ # Configure response to inject data when refresh is triggered
65
+ mock_server.when(DataRefreshTriggeredEvent).then_call(
66
+ lambda event: inject_mock_data()
67
+ )
68
+
69
+ # Configure one-shot response
70
+ mock_server.when(CircuitBreakerTrippedEvent).once().then_call(
71
+ lambda event: trigger_recovery()
72
+ )
73
+
74
+ # Later, cleanup:
75
+ mock_server.cleanup()
76
+
77
+ """
78
+
79
+ _event_bus: EventBus = field(repr=False)
80
+ """The EventBus to subscribe to."""
81
+
82
+ _responses: dict[type[Event], list[MockAction]] = field(default_factory=dict)
83
+ """Mapping of event types to their configured actions."""
84
+
85
+ _unsubscribers: list[Callable[[], None]] = field(default_factory=list)
86
+ """Unsubscribe callbacks for cleanup."""
87
+
88
+ _invocation_count: dict[type[Event], int] = field(default_factory=dict)
89
+ """Count of invocations per event type."""
90
+
91
+ def cleanup(self) -> None:
92
+ """Unsubscribe from all events and clear responses."""
93
+ for unsub in self._unsubscribers:
94
+ unsub()
95
+ self._unsubscribers.clear()
96
+ self._responses.clear()
97
+ self._invocation_count.clear()
98
+
99
+ def get_invocation_count(self, *, event_type: type[Event]) -> int:
100
+ """
101
+ Return the number of times handlers were invoked for an event type.
102
+
103
+ Args:
104
+ ----
105
+ event_type: The event type to query
106
+
107
+ Returns:
108
+ -------
109
+ Number of handler invocations
110
+
111
+ """
112
+ return self._invocation_count.get(event_type, 0)
113
+
114
+ def when[T: Event](self, *, event_type: type[T]) -> ResponseBuilder[T]:
115
+ """
116
+ Start configuring a response for an event type.
117
+
118
+ Args:
119
+ ----
120
+ event_type: The event type to respond to
121
+
122
+ Returns:
123
+ -------
124
+ A ResponseBuilder for configuring the response
125
+
126
+ """
127
+ return ResponseBuilder(mock_server=self, event_type=event_type)
128
+
129
+ def _add_response(
130
+ self,
131
+ *,
132
+ event_type: type[Event],
133
+ action: MockAction,
134
+ ) -> None:
135
+ """
136
+ Add a response for an event type.
137
+
138
+ If this is the first response for this event type, subscribe to it.
139
+
140
+ Args:
141
+ ----
142
+ event_type: The event type to respond to
143
+ action: The action to perform
144
+
145
+ """
146
+ if event_type not in self._responses:
147
+ self._responses[event_type] = []
148
+ # Subscribe to this event type
149
+ unsub = self._event_bus.subscribe(
150
+ event_type=event_type,
151
+ event_key=None,
152
+ handler=lambda event, et=event_type: self._on_event(event=event, event_type=et),
153
+ )
154
+ self._unsubscribers.append(unsub)
155
+
156
+ self._responses[event_type].append(action)
157
+
158
+ def _on_event(self, *, event: Event, event_type: type[Event]) -> None:
159
+ """
160
+ Handle an event by invoking matching responses.
161
+
162
+ Args:
163
+ ----
164
+ event: The event that was published
165
+ event_type: The event type (for lookup)
166
+
167
+ """
168
+ if event_type not in self._responses:
169
+ return
170
+
171
+ # Track invocations
172
+ self._invocation_count[event_type] = self._invocation_count.get(event_type, 0) + 1
173
+
174
+ # Process actions (copy list to allow modification during iteration)
175
+ actions_to_remove: list[MockAction] = []
176
+ for action in list(self._responses[event_type]):
177
+ # Check filter if present
178
+ if action.filter_fn is not None and not action.filter_fn(event):
179
+ continue
180
+
181
+ # Invoke handler
182
+ action.handler(event)
183
+
184
+ # Mark one-shot actions for removal
185
+ if action.one_shot:
186
+ actions_to_remove.append(action)
187
+
188
+ # Remove one-shot actions
189
+ for action in actions_to_remove:
190
+ self._responses[event_type].remove(action)
191
+
192
+
193
+ @dataclass
194
+ class ResponseBuilder[T: "Event"]:
195
+ """
196
+ Fluent builder for configuring mock responses.
197
+
198
+ Provides a chainable API for configuring how the mock server should
199
+ respond to specific events.
200
+
201
+ Example:
202
+ -------
203
+ mock_server.when(SomeEvent)
204
+ .matching(lambda e: e.interface_id == "test")
205
+ .once()
206
+ .then_call(handler_fn)
207
+
208
+ """
209
+
210
+ mock_server: EventDrivenMockServer
211
+ """The mock server to configure."""
212
+
213
+ event_type: type[T]
214
+ """The event type being configured."""
215
+
216
+ _filter_fn: Callable[[T], bool] | None = None
217
+ """Optional filter function."""
218
+
219
+ _one_shot: bool = False
220
+ """Whether this is a one-shot response."""
221
+
222
+ def matching(self, *, filter_fn: Callable[[T], bool]) -> ResponseBuilder[T]:
223
+ """
224
+ Add a filter to match specific events.
225
+
226
+ Args:
227
+ ----
228
+ filter_fn: Function that returns True for matching events
229
+
230
+ Returns:
231
+ -------
232
+ Self for chaining
233
+
234
+ """
235
+ self._filter_fn = filter_fn
236
+ return self
237
+
238
+ def once(self) -> ResponseBuilder[T]:
239
+ """
240
+ Make this a one-shot response (removed after first invocation).
241
+
242
+ Returns
243
+ -------
244
+ Self for chaining
245
+
246
+ """
247
+ self._one_shot = True
248
+ return self
249
+
250
+ def then_call(self, *, handler: Callable[[T], Any]) -> None:
251
+ """
252
+ Configure the handler to call when the event is received.
253
+
254
+ Args:
255
+ ----
256
+ handler: Function to call with the event
257
+
258
+ """
259
+ self.mock_server._add_response( # noqa: SLF001 # pylint: disable=protected-access
260
+ event_type=self.event_type,
261
+ action=MockAction(
262
+ handler=handler,
263
+ filter_fn=self._filter_fn,
264
+ one_shot=self._one_shot,
265
+ ),
266
+ )
267
+
268
+ def then_publish(self, *, event_factory: Callable[[T], Event]) -> None:
269
+ """
270
+ Configure the mock to publish another event when this event is received.
271
+
272
+ Args:
273
+ ----
274
+ event_factory: Function that creates the event to publish
275
+
276
+ """
277
+
278
+ def publish_handler(event: T) -> None:
279
+ new_event = event_factory(event)
280
+ self.mock_server._event_bus.publish_sync(event=new_event) # noqa: SLF001 # pylint: disable=protected-access
281
+
282
+ self.then_call(handler=publish_handler)
283
+
284
+
285
+ def create_event_mock_server(*, event_bus: EventBus) -> EventDrivenMockServer:
286
+ """
287
+ Create an EventDrivenMockServer instance.
288
+
289
+ Factory function for creating an event-driven mock server.
290
+
291
+ Args:
292
+ ----
293
+ event_bus: The EventBus to subscribe to
294
+
295
+ Returns:
296
+ -------
297
+ Configured EventDrivenMockServer instance
298
+
299
+ """
300
+ return EventDrivenMockServer(_event_bus=event_bus)
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """
2
4
  Test factories for creating CentralUnit and Client instances.
3
5
 
@@ -35,16 +37,22 @@ import asyncio
35
37
  from collections.abc import AsyncGenerator, Callable
36
38
  import contextlib
37
39
  import logging
38
- from typing import Self, cast
40
+ from typing import Any, Self, cast
39
41
  from unittest.mock import MagicMock, Mock, patch
40
42
 
41
43
  from aiohttp import ClientSession
42
44
 
43
45
  from aiohomematic.central import CentralConfig, CentralUnit
44
- from aiohomematic.central.event_bus import BackendSystemEventData, HomematicEvent
45
- from aiohomematic.client import ClientConfig, InterfaceConfig
46
- from aiohomematic.const import LOCAL_HOST, BackendSystemEvent, Interface, OptionalSettings
47
- from aiohomematic.interfaces.client import ClientProtocol
46
+ from aiohomematic.central.events import (
47
+ DataPointsCreatedEvent,
48
+ DeviceLifecycleEvent,
49
+ DeviceLifecycleEventType,
50
+ DeviceTriggerEvent,
51
+ SystemStatusChangedEvent,
52
+ )
53
+ from aiohomematic.client import InterfaceConfig, create_client as create_client_func
54
+ from aiohomematic.const import LOCAL_HOST, Interface, OptionalSettings
55
+ from aiohomematic.interfaces import ClientProtocol
48
56
  from aiohomematic_test_support import const
49
57
  from aiohomematic_test_support.mock import SessionPlayer, get_client_session, get_mock, get_xml_rpc_proxy
50
58
 
@@ -82,27 +90,45 @@ class FactoryWithClient:
82
90
  self.system_event_mock = MagicMock()
83
91
  self.ha_event_mock = MagicMock()
84
92
  self._event_bus_unsubscribe_callbacks: list[Callable[[], None]] = []
93
+ self._patches: list[Any] = []
85
94
 
86
- def cleanup_event_bus_subscriptions(self) -> None:
87
- """Clean up all event bus subscriptions."""
95
+ def cleanup(self) -> None:
96
+ """Clean up all patches and event bus subscriptions."""
88
97
  for unsubscribe in self._event_bus_unsubscribe_callbacks:
89
98
  unsubscribe()
90
99
  self._event_bus_unsubscribe_callbacks.clear()
100
+ for p in self._patches:
101
+ p.stop()
102
+ self._patches.clear()
103
+
104
+ def cleanup_event_bus_subscriptions(self) -> None:
105
+ """Clean up all event bus subscriptions. Deprecated: use cleanup() instead."""
106
+ self.cleanup()
91
107
 
92
108
  async def get_default_central(self, *, start: bool = True) -> CentralUnit:
93
109
  """Return a central based on give address_device_translation."""
94
110
  central = await self.get_raw_central()
95
111
 
96
112
  await self._xml_proxy.do_init()
97
- patch("aiohomematic.client.ClientConfig._create_xml_rpc_proxy", return_value=self._xml_proxy).start()
98
- patch("aiohomematic.central.CentralUnit._identify_ip_addr", return_value=LOCAL_HOST).start()
113
+ p1 = patch("aiohomematic.client.ClientConfig._create_xml_rpc_proxy", return_value=self._xml_proxy)
114
+ p2 = patch("aiohomematic.central.CentralUnit._identify_ip_addr", return_value=LOCAL_HOST)
115
+ p1.start()
116
+ p2.start()
117
+ self._patches.extend([p1, p2])
99
118
 
100
119
  # Optionally patch client creation to return a mocked client
101
120
  if self._do_mock_client:
102
- _orig_create_client = ClientConfig.create_client
103
-
104
- async def _mocked_create_client(config: ClientConfig) -> ClientProtocol | Mock:
105
- real_client = await _orig_create_client(config)
121
+ _orig_create_client = create_client_func
122
+
123
+ async def _mocked_create_client(
124
+ *,
125
+ client_deps: Any,
126
+ interface_config: InterfaceConfig,
127
+ ) -> ClientProtocol | Mock:
128
+ real_client = await _orig_create_client(
129
+ client_deps=client_deps,
130
+ interface_config=interface_config,
131
+ )
106
132
  return cast(
107
133
  Mock,
108
134
  get_mock(
@@ -112,7 +138,9 @@ class FactoryWithClient:
112
138
  ),
113
139
  )
114
140
 
115
- patch("aiohomematic.client.ClientConfig.create_client", _mocked_create_client).start()
141
+ p3 = patch("aiohomematic.client.create_client", _mocked_create_client)
142
+ p3.start()
143
+ self._patches.append(p3)
116
144
 
117
145
  if start:
118
146
  await central.start()
@@ -123,7 +151,7 @@ class FactoryWithClient:
123
151
  async def get_raw_central(self) -> CentralUnit:
124
152
  """Return a central based on give address_device_translation."""
125
153
  interface_configs = self._interface_configs if self._interface_configs else set()
126
- central = CentralConfig(
154
+ central = await CentralConfig(
127
155
  name=const.CENTRAL_NAME,
128
156
  host=const.CCU_HOST,
129
157
  username=const.CCU_USERNAME,
@@ -138,21 +166,41 @@ class FactoryWithClient:
138
166
  ).create_central()
139
167
 
140
168
  # Subscribe to events via event bus
141
- def _system_event_handler(event: BackendSystemEventData) -> None:
142
- """Handle backend system events."""
169
+ def _device_lifecycle_event_handler(event: DeviceLifecycleEvent) -> None:
170
+ """Handle device lifecycle events."""
143
171
  self.system_event_mock(event)
144
172
 
145
- def _ha_event_handler(event: HomematicEvent) -> None:
146
- """Handle homematic events."""
173
+ def _data_points_created_event_handler(event: DataPointsCreatedEvent) -> None:
174
+ """Handle data points created events."""
175
+ self.system_event_mock(event)
176
+
177
+ def _device_trigger_event_handler(event: DeviceTriggerEvent) -> None:
178
+ """Handle device trigger events."""
179
+ self.ha_event_mock(event)
180
+
181
+ def _system_status_event_handler(event: SystemStatusChangedEvent) -> None:
182
+ """Handle system status events (issues, state changes)."""
147
183
  self.ha_event_mock(event)
148
184
 
149
185
  self._event_bus_unsubscribe_callbacks.append(
150
186
  central.event_bus.subscribe(
151
- event_type=BackendSystemEventData, event_key=None, handler=_system_event_handler
187
+ event_type=DeviceLifecycleEvent, event_key=None, handler=_device_lifecycle_event_handler
152
188
  )
153
189
  )
154
190
  self._event_bus_unsubscribe_callbacks.append(
155
- central.event_bus.subscribe(event_type=HomematicEvent, event_key=None, handler=_ha_event_handler)
191
+ central.event_bus.subscribe(
192
+ event_type=DataPointsCreatedEvent, event_key=None, handler=_data_points_created_event_handler
193
+ )
194
+ )
195
+ self._event_bus_unsubscribe_callbacks.append(
196
+ central.event_bus.subscribe(
197
+ event_type=DeviceTriggerEvent, event_key=None, handler=_device_trigger_event_handler
198
+ )
199
+ )
200
+ self._event_bus_unsubscribe_callbacks.append(
201
+ central.event_bus.subscribe(
202
+ event_type=SystemStatusChangedEvent, event_key=None, handler=_system_status_event_handler
203
+ )
156
204
  )
157
205
 
158
206
  assert central
@@ -223,14 +271,14 @@ async def get_central_client_factory(
223
271
  un_ignore_list=un_ignore_list,
224
272
  )
225
273
  central = await factory.get_default_central()
226
- client = central.primary_client
274
+ client = central.client_coordinator.primary_client
227
275
  assert client
228
276
  try:
229
277
  yield central, client, factory
230
278
  finally:
231
- factory.cleanup_event_bus_subscriptions()
279
+ factory.cleanup()
232
280
  await central.stop()
233
- await central.clear_files()
281
+ await central.cache_coordinator.clear_all()
234
282
 
235
283
 
236
284
  async def get_pydev_ccu_central_unit_full(
@@ -241,9 +289,9 @@ async def get_pydev_ccu_central_unit_full(
241
289
  """Create and yield central, after all devices have been created."""
242
290
  device_event = asyncio.Event()
243
291
 
244
- def system_event_handler(event: BackendSystemEventData) -> None:
245
- """Handle backend system events."""
246
- if event.system_event == BackendSystemEvent.DEVICES_CREATED:
292
+ def device_lifecycle_event_handler(event: DeviceLifecycleEvent) -> None:
293
+ """Handle device lifecycle events."""
294
+ if event.event_type == DeviceLifecycleEventType.CREATED:
247
295
  device_event.set()
248
296
 
249
297
  interface_configs = {
@@ -254,7 +302,7 @@ async def get_pydev_ccu_central_unit_full(
254
302
  )
255
303
  }
256
304
 
257
- central = CentralConfig(
305
+ central = await CentralConfig(
258
306
  name=const.CENTRAL_NAME,
259
307
  host=const.CCU_HOST,
260
308
  username=const.CCU_USERNAME,
@@ -265,7 +313,7 @@ async def get_pydev_ccu_central_unit_full(
265
313
  program_markers=(),
266
314
  sysvar_markers=(),
267
315
  ).create_central()
268
- central.event_bus.subscribe(event_type=BackendSystemEventData, event_key=None, handler=system_event_handler)
316
+ central.event_bus.subscribe(event_type=DeviceLifecycleEvent, event_key=None, handler=device_lifecycle_event_handler)
269
317
  await central.start()
270
318
 
271
319
  # Wait up to 60 seconds for the DEVICES_CREATED event which signals that all devices are available
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
1
3
  """Helpers for tests."""
2
4
 
3
5
  from __future__ import annotations
@@ -11,7 +13,7 @@ import orjson
11
13
 
12
14
  from aiohomematic.central import CentralUnit
13
15
  from aiohomematic.const import UTF_8
14
- from aiohomematic.interfaces.model import CustomDataPointProtocol
16
+ from aiohomematic.interfaces import CustomDataPointProtocol
15
17
 
16
18
  _LOGGER = logging.getLogger(__name__)
17
19
 
@@ -19,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
19
21
  # pylint: disable=protected-access
20
22
 
21
23
 
22
- def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None:
24
+ def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None: # kwonly: disable
23
25
  """Load json file from disk into dict."""
24
26
  package_path = str(importlib.resources.files(anchor))
25
27
  with open(
@@ -29,7 +31,7 @@ def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None:
29
31
  return orjson.loads(fptr.read())
30
32
 
31
33
 
32
- def get_prepared_custom_data_point(
34
+ def get_prepared_custom_data_point( # kwonly: disable
33
35
  central: CentralUnit, address: str, channel_no: int
34
36
  ) -> CustomDataPointProtocol | None:
35
37
  """Return the hm custom_data_point."""
@@ -40,7 +42,7 @@ def get_prepared_custom_data_point(
40
42
  return None
41
43
 
42
44
 
43
- def load_device_description(file_name: str) -> Any:
45
+ def load_device_description(file_name: str) -> Any: # kwonly: disable
44
46
  """Load device description."""
45
47
  dev_desc = _load_json_file(anchor="pydevccu", resource="device_descriptions", file_name=file_name)
46
48
  assert dev_desc