aiohomematic-test-support 2025.12.8__tar.gz → 2025.12.26__tar.gz

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 (18) hide show
  1. {aiohomematic_test_support-2025.12.8/aiohomematic_test_support.egg-info → aiohomematic_test_support-2025.12.26}/PKG-INFO +1 -1
  2. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/__init__.py +2 -2
  3. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26/aiohomematic_test_support.egg-info}/PKG-INFO +1 -1
  4. aiohomematic_test_support-2025.12.26/data/full_session_randomized_ccu.zip +0 -0
  5. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/factory.py +46 -20
  6. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/helper.py +1 -1
  7. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/mock.py +169 -14
  8. aiohomematic_test_support-2025.12.8/data/full_session_randomized_ccu.zip +0 -0
  9. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/MANIFEST.in +0 -0
  10. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/README.md +0 -0
  11. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/aiohomematic_test_support.egg-info/SOURCES.txt +0 -0
  12. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/aiohomematic_test_support.egg-info/dependency_links.txt +0 -0
  13. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/aiohomematic_test_support.egg-info/top_level.txt +0 -0
  14. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/const.py +0 -0
  15. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/data/full_session_randomized_pydevccu.zip +0 -0
  16. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/py.typed +0 -0
  17. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/pyproject.toml +0 -0
  18. {aiohomematic_test_support-2025.12.8 → aiohomematic_test_support-2025.12.26}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic-test-support
3
- Version: 2025.12.8
3
+ Version: 2025.12.26
4
4
  Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
5
5
  Author-email: SukramJ <sukramj@icloud.com>
6
6
  Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
@@ -30,7 +30,7 @@ Using the factory to create a test central with session playback:
30
30
 
31
31
  # Test operations
32
32
  await central.start()
33
- device = central.get_device_by_address("VCU0000001")
33
+ device = central.device_coordinator.get_device_by_address("VCU0000001")
34
34
  await central.stop()
35
35
 
36
36
  The session player replays pre-recorded backend responses, enabling fast and
@@ -45,4 +45,4 @@ test dependencies to access test support functionality.
45
45
 
46
46
  from __future__ import annotations
47
47
 
48
- __version__ = "2025.12.8"
48
+ __version__ = "2025.12.26"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic-test-support
3
- Version: 2025.12.8
3
+ Version: 2025.12.26
4
4
  Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
5
5
  Author-email: SukramJ <sukramj@icloud.com>
6
6
  Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
@@ -41,10 +41,16 @@ from unittest.mock import MagicMock, Mock, patch
41
41
  from aiohttp import ClientSession
42
42
 
43
43
  from aiohomematic.central import CentralConfig, CentralUnit
44
- from aiohomematic.central.event_bus import BackendSystemEventData, HomematicEvent
44
+ from aiohomematic.central.integration_events import (
45
+ DataPointsCreatedEvent,
46
+ DeviceLifecycleEvent,
47
+ DeviceLifecycleEventType,
48
+ DeviceTriggerEvent,
49
+ SystemStatusEvent,
50
+ )
45
51
  from aiohomematic.client import ClientConfig, InterfaceConfig
46
- from aiohomematic.const import LOCAL_HOST, BackendSystemEvent, Interface, OptionalSettings
47
- from aiohomematic.interfaces import ClientProtocol
52
+ from aiohomematic.const import LOCAL_HOST, Interface, OptionalSettings
53
+ from aiohomematic.interfaces.client import ClientProtocol
48
54
  from aiohomematic_test_support import const
49
55
  from aiohomematic_test_support.mock import SessionPlayer, get_client_session, get_mock, get_xml_rpc_proxy
50
56
 
@@ -81,13 +87,13 @@ class FactoryWithClient:
81
87
  )
82
88
  self.system_event_mock = MagicMock()
83
89
  self.ha_event_mock = MagicMock()
84
- self._event_bus_unsubscribe_handlers: list[Callable[[], None]] = []
90
+ self._event_bus_unsubscribe_callbacks: list[Callable[[], None]] = []
85
91
 
86
92
  def cleanup_event_bus_subscriptions(self) -> None:
87
93
  """Clean up all event bus subscriptions."""
88
- for unsubscribe in self._event_bus_unsubscribe_handlers:
94
+ for unsubscribe in self._event_bus_unsubscribe_callbacks:
89
95
  unsubscribe()
90
- self._event_bus_unsubscribe_handlers.clear()
96
+ self._event_bus_unsubscribe_callbacks.clear()
91
97
 
92
98
  async def get_default_central(self, *, start: bool = True) -> CentralUnit:
93
99
  """Return a central based on give address_device_translation."""
@@ -138,21 +144,41 @@ class FactoryWithClient:
138
144
  ).create_central()
139
145
 
140
146
  # Subscribe to events via event bus
141
- def _system_event_handler(event: BackendSystemEventData) -> None:
142
- """Handle backend system events."""
147
+ def _device_lifecycle_event_handler(event: DeviceLifecycleEvent) -> None:
148
+ """Handle device lifecycle events."""
143
149
  self.system_event_mock(event)
144
150
 
145
- def _ha_event_handler(event: HomematicEvent) -> None:
146
- """Handle homematic events."""
151
+ def _data_points_created_event_handler(event: DataPointsCreatedEvent) -> None:
152
+ """Handle data points created events."""
153
+ self.system_event_mock(event)
154
+
155
+ def _device_trigger_event_handler(event: DeviceTriggerEvent) -> None:
156
+ """Handle device trigger events."""
157
+ self.ha_event_mock(event)
158
+
159
+ def _system_status_event_handler(event: SystemStatusEvent) -> None:
160
+ """Handle system status events (issues, state changes)."""
147
161
  self.ha_event_mock(event)
148
162
 
149
- self._event_bus_unsubscribe_handlers.append(
163
+ self._event_bus_unsubscribe_callbacks.append(
164
+ central.event_bus.subscribe(
165
+ event_type=DeviceLifecycleEvent, event_key=None, handler=_device_lifecycle_event_handler
166
+ )
167
+ )
168
+ self._event_bus_unsubscribe_callbacks.append(
169
+ central.event_bus.subscribe(
170
+ event_type=DataPointsCreatedEvent, event_key=None, handler=_data_points_created_event_handler
171
+ )
172
+ )
173
+ self._event_bus_unsubscribe_callbacks.append(
150
174
  central.event_bus.subscribe(
151
- event_type=BackendSystemEventData, event_key=None, handler=_system_event_handler
175
+ event_type=DeviceTriggerEvent, event_key=None, handler=_device_trigger_event_handler
152
176
  )
153
177
  )
154
- self._event_bus_unsubscribe_handlers.append(
155
- central.event_bus.subscribe(event_type=HomematicEvent, event_key=None, handler=_ha_event_handler)
178
+ self._event_bus_unsubscribe_callbacks.append(
179
+ central.event_bus.subscribe(
180
+ event_type=SystemStatusEvent, event_key=None, handler=_system_status_event_handler
181
+ )
156
182
  )
157
183
 
158
184
  assert central
@@ -223,14 +249,14 @@ async def get_central_client_factory(
223
249
  un_ignore_list=un_ignore_list,
224
250
  )
225
251
  central = await factory.get_default_central()
226
- client = central.primary_client
252
+ client = central.client_coordinator.primary_client
227
253
  assert client
228
254
  try:
229
255
  yield central, client, factory
230
256
  finally:
231
257
  factory.cleanup_event_bus_subscriptions()
232
258
  await central.stop()
233
- await central.clear_files()
259
+ await central.cache_coordinator.clear_all()
234
260
 
235
261
 
236
262
  async def get_pydev_ccu_central_unit_full(
@@ -241,9 +267,9 @@ async def get_pydev_ccu_central_unit_full(
241
267
  """Create and yield central, after all devices have been created."""
242
268
  device_event = asyncio.Event()
243
269
 
244
- def system_event_handler(event: BackendSystemEventData) -> None:
245
- """Handle backend system events."""
246
- if event.system_event == BackendSystemEvent.DEVICES_CREATED:
270
+ def device_lifecycle_event_handler(event: DeviceLifecycleEvent) -> None:
271
+ """Handle device lifecycle events."""
272
+ if event.event_type == DeviceLifecycleEventType.CREATED:
247
273
  device_event.set()
248
274
 
249
275
  interface_configs = {
@@ -265,7 +291,7 @@ async def get_pydev_ccu_central_unit_full(
265
291
  program_markers=(),
266
292
  sysvar_markers=(),
267
293
  ).create_central()
268
- central.event_bus.subscribe(event_type=BackendSystemEventData, event_key=None, handler=system_event_handler)
294
+ central.event_bus.subscribe(event_type=DeviceLifecycleEvent, event_key=None, handler=device_lifecycle_event_handler)
269
295
  await central.start()
270
296
 
271
297
  # Wait up to 60 seconds for the DEVICES_CREATED event which signals that all devices are available
@@ -11,7 +11,7 @@ import orjson
11
11
 
12
12
  from aiohomematic.central import CentralUnit
13
13
  from aiohomematic.const import UTF_8
14
- from aiohomematic.interfaces import CustomDataPointProtocol
14
+ from aiohomematic.interfaces.model import CustomDataPointProtocol
15
15
 
16
16
  _LOGGER = logging.getLogger(__name__)
17
17
 
@@ -37,6 +37,8 @@ from __future__ import annotations
37
37
 
38
38
  import asyncio
39
39
  from collections import defaultdict
40
+ from collections.abc import Callable
41
+ import contextlib
40
42
  import json
41
43
  import logging
42
44
  import os
@@ -52,7 +54,7 @@ from aiohomematic.client import BaseRpcProxy
52
54
  from aiohomematic.client.json_rpc import _JsonKey, _JsonRpcMethod
53
55
  from aiohomematic.client.rpc_proxy import _RpcMethod
54
56
  from aiohomematic.const import UTF_8, DataOperationResult, Parameter, ParamsetKey, RPCType
55
- from aiohomematic.store.persistent import _freeze_params, _unfreeze_params
57
+ from aiohomematic.store.persistent import _cleanup_params_for_session, _freeze_params, _unfreeze_params
56
58
  from aiohomematic_test_support import const
57
59
 
58
60
  _LOGGER = logging.getLogger(__name__)
@@ -184,7 +186,7 @@ def get_client_session( # noqa: C901
184
186
  )
185
187
 
186
188
  if method == _JsonRpcMethod.INTERFACE_SET_VALUE:
187
- await self._central.data_point_event(
189
+ await self._central.event_coordinator.data_point_event(
188
190
  interface_id=params[_JsonKey.INTERFACE],
189
191
  channel_address=params[_JsonKey.ADDRESS],
190
192
  parameter=params[_JsonKey.VALUE_KEY],
@@ -197,7 +199,7 @@ def get_client_session( # noqa: C901
197
199
  channel_address = params[_JsonKey.ADDRESS]
198
200
  values = params[_JsonKey.SET]
199
201
  for param, value in values.items():
200
- await self._central.data_point_event(
202
+ await self._central.event_coordinator.data_point_event(
201
203
  interface_id=interface_id,
202
204
  channel_address=channel_address,
203
205
  parameter=param,
@@ -277,6 +279,9 @@ def get_xml_rpc_proxy( # noqa: C901
277
279
  """Return the supported methods."""
278
280
  return self._supported_methods
279
281
 
282
+ def clear_connection_issue(self) -> None:
283
+ """Clear connection issue (no-op for test mock)."""
284
+
280
285
  async def clientServerInitialized(self, interface_id: str) -> None:
281
286
  """Answer clientServerInitialized with pong."""
282
287
  await self.ping(callerId=interface_id)
@@ -330,7 +335,7 @@ def get_xml_rpc_proxy( # noqa: C901
330
335
  async def ping(self, callerId: str) -> None:
331
336
  """Answer ping with pong."""
332
337
  if self._central:
333
- await self._central.data_point_event(
338
+ await self._central.event_coordinator.data_point_event(
334
339
  interface_id=callerId,
335
340
  channel_address="",
336
341
  parameter=Parameter.PONG,
@@ -342,17 +347,17 @@ def get_xml_rpc_proxy( # noqa: C901
342
347
  ) -> None:
343
348
  """Set a paramset."""
344
349
  if self._central and paramset_key == ParamsetKey.VALUES:
345
- interface_id = self._central.primary_client.interface_id # type: ignore[union-attr]
350
+ interface_id = self._central.client_coordinator.primary_client.interface_id # type: ignore[union-attr]
346
351
  for param, value in values.items():
347
- await self._central.data_point_event(
352
+ await self._central.event_coordinator.data_point_event(
348
353
  interface_id=interface_id, channel_address=channel_address, parameter=param, value=value
349
354
  )
350
355
 
351
356
  async def setValue(self, channel_address: str, parameter: str, value: Any, rx_mode: Any | None = None) -> None:
352
357
  """Set a value."""
353
358
  if self._central:
354
- await self._central.data_point_event(
355
- interface_id=self._central.primary_client.interface_id, # type: ignore[union-attr]
359
+ await self._central.event_coordinator.data_point_event(
360
+ interface_id=self._central.client_coordinator.primary_client.interface_id, # type: ignore[union-attr]
356
361
  channel_address=channel_address,
357
362
  parameter=parameter,
358
363
  value=value,
@@ -376,25 +381,175 @@ def get_xml_rpc_proxy( # noqa: C901
376
381
  return cast(BaseRpcProxy, _AioXmlRpcProxyFromSession())
377
382
 
378
383
 
384
+ def _get_instance_attributes(instance: Any) -> set[str]:
385
+ """
386
+ Get all instance attribute names, supporting both __dict__ and __slots__.
387
+
388
+ For classes with __slots__, iterates through the class hierarchy to collect
389
+ all slot names. For classes with __dict__, returns the keys of __dict__.
390
+ Handles hybrid classes that have both __slots__ and __dict__.
391
+
392
+ Why this is needed:
393
+ Python classes can store instance attributes in two ways:
394
+ 1. __dict__: A dictionary attached to each instance (default behavior)
395
+ 2. __slots__: Pre-declared attribute names stored more efficiently
396
+
397
+ When copying attributes to a mock, we can't just use instance.__dict__
398
+ because __slots__-based classes don't have __dict__ (or have a limited one).
399
+ We must inspect the class hierarchy to find all declared slots.
400
+
401
+ Algorithm:
402
+ 1. Walk the Method Resolution Order (MRO) to find all classes in hierarchy
403
+ 2. For each class with __slots__, collect slot names (skip internal ones)
404
+ 3. Verify each slot actually has a value on this instance (getattr check)
405
+ 4. Also collect any __dict__ attributes if the instance has __dict__
406
+ 5. Return the union of all found attribute names
407
+ """
408
+ attrs: set[str] = set()
409
+
410
+ # Walk the class hierarchy via MRO (Method Resolution Order).
411
+ # __slots__ are inherited but each class defines its own slots separately,
412
+ # so we must check every class in the hierarchy.
413
+ for cls in type(instance).__mro__:
414
+ if hasattr(cls, "__slots__"):
415
+ for slot in cls.__slots__:
416
+ # Skip internal slots like __dict__ and __weakref__ which are
417
+ # automatically added by Python when a class uses __slots__
418
+ if not slot.startswith("__"):
419
+ try:
420
+ # Only include if the attribute actually exists on the instance.
421
+ # Slots can be declared but unset (raises AttributeError).
422
+ getattr(instance, slot)
423
+ attrs.add(slot)
424
+ except AttributeError:
425
+ # Slot is declared but not initialized on this instance
426
+ pass
427
+
428
+ # Also include __dict__ attributes if the instance has __dict__.
429
+ # Some classes have both __slots__ and __dict__ (e.g., if a parent class
430
+ # doesn't use __slots__, or if __dict__ is explicitly in __slots__).
431
+ if hasattr(instance, "__dict__"):
432
+ attrs.update(instance.__dict__.keys())
433
+
434
+ return attrs
435
+
436
+
379
437
  def get_mock(
380
438
  *, instance: Any, exclude_methods: set[str] | None = None, include_properties: set[str] | None = None, **kwargs: Any
381
439
  ) -> Any:
382
- """Create a mock and copy instance attributes over mock."""
440
+ """
441
+ Create a mock that wraps an instance with proper property delegation.
442
+
443
+ Supports both __dict__-based and __slots__-based classes. Properties are
444
+ delegated dynamically to the wrapped instance to ensure current values
445
+ are always returned.
446
+
447
+ Problem solved:
448
+ MagicMock(wraps=instance) only delegates method calls, not property access.
449
+ When you access mock.some_property, MagicMock returns the value that was
450
+ captured at mock creation time, not the current value on the wrapped instance.
451
+ This causes test failures when the wrapped instance's state changes after
452
+ the mock is created (e.g., client.available changes from False to True
453
+ after initialize_proxy() is called).
454
+
455
+ Solution:
456
+ Create a dynamic MagicMock subclass with property descriptors that delegate
457
+ to the wrapped instance on every access. This ensures properties always
458
+ return current values.
459
+
460
+ Algorithm:
461
+ 1. If already a Mock, just sync attributes from wrapped instance
462
+ 2. Identify all properties on the instance's class
463
+ 3. Create a MagicMock subclass with delegating property descriptors
464
+ 4. Create mock instance with spec and wraps
465
+ 5. Copy instance attributes (supports both __slots__ and __dict__)
466
+ 6. Copy non-mockable methods directly to mock
467
+ """
383
468
  if exclude_methods is None:
384
469
  exclude_methods = set()
385
470
  if include_properties is None:
386
471
  include_properties = set()
387
472
 
473
+ # Early return: if already a Mock, just refresh attributes from wrapped instance
388
474
  if isinstance(instance, Mock):
389
- instance.__dict__.update(instance._mock_wraps.__dict__)
475
+ if hasattr(instance, "_mock_wraps") and instance._mock_wraps is not None:
476
+ for attr in _get_instance_attributes(instance._mock_wraps):
477
+ with contextlib.suppress(AttributeError, TypeError):
478
+ setattr(instance, attr, getattr(instance._mock_wraps, attr))
390
479
  return instance
391
- mock = MagicMock(spec=instance, wraps=instance, **kwargs)
392
- mock.__dict__.update(instance.__dict__)
480
+
481
+ # Step 1: Identify all @property decorated attributes on the class
482
+ # These need special handling because MagicMock doesn't delegate property access
483
+ property_names = _get_properties(data_object=instance, decorator=property)
484
+
485
+ # Step 2: Create a dynamic MagicMock subclass
486
+ # We add property descriptors to this subclass that delegate to _mock_wraps.
487
+ # This is the key technique: property descriptors on the class take precedence
488
+ # over MagicMock's attribute access, allowing us to intercept property reads.
489
+ class _DynamicMock(MagicMock):
490
+ pass
491
+
492
+ # Helper factory functions to create closures with correct name binding.
493
+ # Using a factory function ensures each property gets its own 'name' variable,
494
+ # avoiding the classic lambda closure bug where all properties would share
495
+ # the last loop value.
496
+ def _make_getter(name: str) -> Callable[[Any], Any]:
497
+ """Create a getter that delegates to the wrapped instance."""
498
+
499
+ def getter(self: Any) -> Any:
500
+ # Access _mock_wraps which holds the original instance
501
+ return getattr(self._mock_wraps, name)
502
+
503
+ return getter
504
+
505
+ def _make_setter(name: str) -> Callable[[Any, Any], None]:
506
+ """Create a setter that delegates to the wrapped instance."""
507
+
508
+ def setter(self: Any, value: Any) -> None:
509
+ setattr(self._mock_wraps, name, value)
510
+
511
+ return setter
512
+
513
+ # Step 3: Add property descriptors to the dynamic subclass
514
+ for prop_name in property_names:
515
+ # Skip properties that should be mocked or overridden via kwargs
516
+ if prop_name not in include_properties and prop_name not in kwargs:
517
+ # Check if the original property has a setter (is writable)
518
+ prop_descriptor = getattr(type(instance), prop_name, None)
519
+ if prop_descriptor is not None and getattr(prop_descriptor, "fset", None) is not None:
520
+ # Writable property: create descriptor with both getter and setter
521
+ setattr(
522
+ _DynamicMock,
523
+ prop_name,
524
+ property(_make_getter(prop_name), _make_setter(prop_name)),
525
+ )
526
+ else:
527
+ # Read-only property: create descriptor with getter only
528
+ setattr(
529
+ _DynamicMock,
530
+ prop_name,
531
+ property(_make_getter(prop_name)),
532
+ )
533
+
534
+ # Step 4: Create the mock instance
535
+ # spec=instance: ensures mock only allows access to attributes that exist on instance
536
+ # wraps=instance: delegates method calls to the real instance
537
+ mock = _DynamicMock(spec=instance, wraps=instance, **kwargs)
538
+
539
+ # Step 5: Copy instance attributes to mock
540
+ # This handles both __slots__ and __dict__ based classes via _get_instance_attributes()
541
+ for attr in _get_instance_attributes(instance):
542
+ with contextlib.suppress(AttributeError, TypeError):
543
+ setattr(mock, attr, getattr(instance, attr))
544
+
545
+ # Step 6: Copy non-mockable methods directly
546
+ # Some methods (like bound methods or special attributes) need to be copied
547
+ # directly rather than being mocked
393
548
  try:
394
549
  for method_name in [
395
550
  prop
396
551
  for prop in _get_not_mockable_method_names(instance=instance, exclude_methods=exclude_methods)
397
- if prop not in include_properties and prop not in kwargs
552
+ if prop not in include_properties and prop not in kwargs and prop not in property_names
398
553
  ]:
399
554
  setattr(mock, method_name, getattr(instance, method_name))
400
555
  except Exception:
@@ -513,7 +668,7 @@ class SessionPlayer:
513
668
  return None
514
669
  if not (bucket_by_parameter := bucket_by_method.get(method)):
515
670
  return None
516
- frozen_params = _freeze_params(params=params)
671
+ frozen_params = _freeze_params(params=_cleanup_params_for_session(params=params))
517
672
 
518
673
  # For each parameter, choose the response at the latest timestamp.
519
674
  if (bucket_by_ts := bucket_by_parameter.get(frozen_params)) is None: