aiohomematic-test-support 2025.12.13__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,565 @@
1
+ """
2
+ Mock implementations for RPC clients with session playback.
3
+
4
+ This module provides mock RPC proxy implementations that replay pre-recorded
5
+ backend responses from session data files. This enables deterministic, fast
6
+ testing without live Homematic backend dependencies.
7
+
8
+ Key Classes
9
+ -----------
10
+ - **SessionPlayer**: Loads and plays back recorded RPC session data from ZIP archives.
11
+ - **get_mock**: Creates mock instances of data points and devices with configurable
12
+ method/property exclusions.
13
+ - **get_xml_rpc_proxy**: Returns mock XML-RPC proxy with session playback.
14
+ - **get_client_session**: Returns mock aiohttp ClientSession for JSON-RPC tests.
15
+
16
+ Session Playback
17
+ ----------------
18
+ Session data is stored in ZIP archives containing JSON files with recorded
19
+ RPC method calls and responses. The SessionPlayer replays these responses
20
+ when tests invoke RPC methods:
21
+
22
+ player = SessionPlayer(session_data_path="tests/data/ccu_full.zip")
23
+ proxy = get_xml_rpc_proxy(player=player, interface="BidCos-RF")
24
+
25
+ # Calls return pre-recorded responses
26
+ devices = await proxy.listDevices()
27
+
28
+ This approach provides:
29
+ - Fast test execution (no network I/O)
30
+ - Reproducible results (same responses every time)
31
+ - Offline testing (no backend required)
32
+
33
+ Public API of this module is defined by __all__.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ from collections import defaultdict
40
+ import json
41
+ import logging
42
+ import os
43
+ from typing import Any, cast
44
+ from unittest.mock import MagicMock, Mock
45
+ import zipfile
46
+
47
+ from aiohttp import ClientSession
48
+ import orjson
49
+
50
+ from aiohomematic.central import CentralUnit
51
+ from aiohomematic.client import BaseRpcProxy
52
+ from aiohomematic.client.json_rpc import _JsonKey, _JsonRpcMethod
53
+ from aiohomematic.client.rpc_proxy import _RpcMethod
54
+ from aiohomematic.const import UTF_8, DataOperationResult, Parameter, ParamsetKey, RPCType
55
+ from aiohomematic.store.persistent import _freeze_params, _unfreeze_params
56
+ from aiohomematic_test_support import const
57
+
58
+ _LOGGER = logging.getLogger(__name__)
59
+
60
+ # pylint: disable=protected-access
61
+
62
+
63
+ def _get_not_mockable_method_names(*, instance: Any, exclude_methods: set[str]) -> set[str]:
64
+ """Return all relevant method names for mocking."""
65
+ methods: set[str] = set(_get_properties(data_object=instance, decorator=property))
66
+
67
+ for method in dir(instance):
68
+ if method in exclude_methods:
69
+ methods.add(method)
70
+ return methods
71
+
72
+
73
+ def _get_properties(*, data_object: Any, decorator: Any) -> set[str]:
74
+ """Return the object attributes by decorator."""
75
+ cls = data_object.__class__
76
+
77
+ # Resolve function-based decorators to their underlying property class, if provided
78
+ resolved_decorator: Any = decorator
79
+ if not isinstance(decorator, type):
80
+ resolved_decorator = getattr(decorator, "__property_class__", decorator)
81
+
82
+ return {y for y in dir(cls) if isinstance(getattr(cls, y), resolved_decorator)}
83
+
84
+
85
+ def get_client_session( # noqa: C901
86
+ *,
87
+ player: SessionPlayer,
88
+ address_device_translation: set[str] | None = None,
89
+ ignore_devices_on_create: list[str] | None = None,
90
+ ) -> ClientSession:
91
+ """
92
+ Provide a ClientSession-like fixture that answers via SessionPlayer(JSON-RPC).
93
+
94
+ Any POST request will be answered by looking up the latest recorded
95
+ JSON-RPC response in the session player using the provided method and params.
96
+ """
97
+
98
+ class _MockResponse:
99
+ def __init__(self, *, json_data: dict[str, Any] | None) -> None:
100
+ # If no match is found, emulate backend error payload
101
+ self._json: dict[str, Any] = json_data or {
102
+ _JsonKey.RESULT: None,
103
+ _JsonKey.ERROR: {"name": "-1", "code": -1, "message": "Not found in session player"},
104
+ _JsonKey.ID: 0,
105
+ }
106
+ self.status = 200
107
+
108
+ async def json(self, *, encoding: str | None = None) -> dict[str, Any]: # mimic aiohttp API
109
+ return self._json
110
+
111
+ async def read(self) -> bytes:
112
+ return orjson.dumps(self._json)
113
+
114
+ class _MockClientSession:
115
+ def __init__(self) -> None:
116
+ """Initialize the mock client session."""
117
+ self._central: CentralUnit | None = None
118
+
119
+ async def close(self) -> None: # compatibility
120
+ return None
121
+
122
+ async def post(
123
+ self,
124
+ *,
125
+ url: str,
126
+ data: bytes | bytearray | str | None = None,
127
+ headers: Any = None,
128
+ timeout: Any = None, # noqa: ASYNC109
129
+ ssl: Any = None,
130
+ ) -> _MockResponse:
131
+ # Payload is produced by AioJsonRpcAioHttpClient via orjson.dumps
132
+ if isinstance(data, (bytes, bytearray)):
133
+ payload = orjson.loads(data)
134
+ elif isinstance(data, str):
135
+ payload = orjson.loads(data.encode(UTF_8))
136
+ else:
137
+ payload = {}
138
+
139
+ method = payload.get("method")
140
+ params = payload.get("params")
141
+
142
+ if self._central:
143
+ if method in (
144
+ _JsonRpcMethod.PROGRAM_EXECUTE,
145
+ _JsonRpcMethod.SYSVAR_SET_BOOL,
146
+ _JsonRpcMethod.SYSVAR_SET_FLOAT,
147
+ _JsonRpcMethod.SESSION_LOGOUT,
148
+ ):
149
+ return _MockResponse(json_data={_JsonKey.ID: 0, _JsonKey.RESULT: "200", _JsonKey.ERROR: None})
150
+ if method == _JsonRpcMethod.SYSVAR_GET_ALL:
151
+ return _MockResponse(
152
+ json_data={_JsonKey.ID: 0, _JsonKey.RESULT: const.SYSVAR_DATA_JSON, _JsonKey.ERROR: None}
153
+ )
154
+ if method == _JsonRpcMethod.PROGRAM_GET_ALL:
155
+ return _MockResponse(
156
+ json_data={_JsonKey.ID: 0, _JsonKey.RESULT: const.PROGRAM_DATA_JSON, _JsonKey.ERROR: None}
157
+ )
158
+ if method == _JsonRpcMethod.REGA_RUN_SCRIPT:
159
+ if "get_program_descriptions" in params[_JsonKey.SCRIPT]:
160
+ return _MockResponse(
161
+ json_data={
162
+ _JsonKey.ID: 0,
163
+ _JsonKey.RESULT: const.PROGRAM_DATA_JSON_DESCRIPTION,
164
+ _JsonKey.ERROR: None,
165
+ }
166
+ )
167
+
168
+ if "get_system_variable_descriptions" in params[_JsonKey.SCRIPT]:
169
+ return _MockResponse(
170
+ json_data={
171
+ _JsonKey.ID: 0,
172
+ _JsonKey.RESULT: const.SYSVAR_DATA_JSON_DESCRIPTION,
173
+ _JsonKey.ERROR: None,
174
+ }
175
+ )
176
+
177
+ if "get_backend_info" in params[_JsonKey.SCRIPT]:
178
+ return _MockResponse(
179
+ json_data={
180
+ _JsonKey.ID: 0,
181
+ _JsonKey.RESULT: const.BACKEND_INFO_JSON,
182
+ _JsonKey.ERROR: None,
183
+ }
184
+ )
185
+
186
+ if method == _JsonRpcMethod.INTERFACE_SET_VALUE:
187
+ await self._central.data_point_event(
188
+ interface_id=params[_JsonKey.INTERFACE],
189
+ channel_address=params[_JsonKey.ADDRESS],
190
+ parameter=params[_JsonKey.VALUE_KEY],
191
+ value=params[_JsonKey.VALUE],
192
+ )
193
+ return _MockResponse(json_data={_JsonKey.ID: 0, _JsonKey.RESULT: "200", _JsonKey.ERROR: None})
194
+ if method == _JsonRpcMethod.INTERFACE_PUT_PARAMSET:
195
+ if params[_JsonKey.PARAMSET_KEY] == ParamsetKey.VALUES:
196
+ interface_id = params[_JsonKey.INTERFACE]
197
+ channel_address = params[_JsonKey.ADDRESS]
198
+ values = params[_JsonKey.SET]
199
+ for param, value in values.items():
200
+ await self._central.data_point_event(
201
+ interface_id=interface_id,
202
+ channel_address=channel_address,
203
+ parameter=param,
204
+ value=value,
205
+ )
206
+ return _MockResponse(json_data={_JsonKey.RESULT: "200", _JsonKey.ERROR: None})
207
+
208
+ json_data = player.get_latest_response_by_params(
209
+ rpc_type=RPCType.JSON_RPC,
210
+ method=str(method) if method is not None else "",
211
+ params=params,
212
+ )
213
+ if method == _JsonRpcMethod.INTERFACE_LIST_DEVICES and (
214
+ ignore_devices_on_create is not None or address_device_translation is not None
215
+ ):
216
+ new_devices = []
217
+ for dd in json_data[_JsonKey.RESULT]:
218
+ if ignore_devices_on_create is not None and (
219
+ dd["address"] in ignore_devices_on_create or dd["parent"] in ignore_devices_on_create
220
+ ):
221
+ continue
222
+ if address_device_translation is not None:
223
+ if dd["address"] in address_device_translation or dd["parent"] in address_device_translation:
224
+ new_devices.append(dd)
225
+ else:
226
+ new_devices.append(dd)
227
+
228
+ json_data[_JsonKey.RESULT] = new_devices
229
+ return _MockResponse(json_data=json_data)
230
+
231
+ def set_central(self, *, central: CentralUnit) -> None:
232
+ """Set the central."""
233
+ self._central = central
234
+
235
+ return cast(ClientSession, _MockClientSession())
236
+
237
+
238
+ def get_xml_rpc_proxy( # noqa: C901
239
+ *,
240
+ player: SessionPlayer,
241
+ address_device_translation: set[str] | None = None,
242
+ ignore_devices_on_create: list[str] | None = None,
243
+ ) -> BaseRpcProxy:
244
+ """
245
+ Provide an BaseRpcProxy-like fixture that answers via SessionPlayer (XML-RPC).
246
+
247
+ Any method call like: await proxy.system.listMethods(...)
248
+ will be answered by looking up the latest recorded XML-RPC response
249
+ in the session player using the provided method and positional params.
250
+ """
251
+
252
+ class _Method:
253
+ def __init__(self, full_name: str, caller: Any) -> None:
254
+ self._name = full_name
255
+ self._caller = caller
256
+
257
+ async def __call__(self, *args: Any) -> Any:
258
+ # Forward to caller with collected method name and positional params
259
+ return await self._caller(self._name, *args)
260
+
261
+ def __getattr__(self, sub: str) -> _Method:
262
+ # Allow chaining like proxy.system.listMethods
263
+ return _Method(f"{self._name}.{sub}", self._caller)
264
+
265
+ class _AioXmlRpcProxyFromSession:
266
+ def __init__(self) -> None:
267
+ self._player = player
268
+ self._supported_methods: tuple[str, ...] = ()
269
+ self._central: CentralUnit | None = None
270
+
271
+ def __getattr__(self, name: str) -> Any:
272
+ # Start of method chain
273
+ return _Method(name, self._invoke)
274
+
275
+ @property
276
+ def supported_methods(self) -> tuple[str, ...]:
277
+ """Return the supported methods."""
278
+ return self._supported_methods
279
+
280
+ async def clientServerInitialized(self, interface_id: str) -> None:
281
+ """Answer clientServerInitialized with pong."""
282
+ await self.ping(callerId=interface_id)
283
+
284
+ async def do_init(self) -> None:
285
+ """Init the xml rpc proxy."""
286
+ if supported_methods := await self.system.listMethods():
287
+ # ping is missing in VirtualDevices interface but can be used.
288
+ supported_methods.append(_RpcMethod.PING)
289
+ self._supported_methods = tuple(supported_methods)
290
+
291
+ async def getAllSystemVariables(self) -> dict[str, Any]:
292
+ """Return all system variables."""
293
+ return const.SYSVAR_DATA_XML
294
+
295
+ async def getParamset(self, channel_address: str, paramset: str) -> Any:
296
+ """Set a value."""
297
+ if self._central:
298
+ result = self._player.get_latest_response_by_params(
299
+ rpc_type=RPCType.XML_RPC,
300
+ method="getParamset",
301
+ params=(channel_address, paramset),
302
+ )
303
+ return result if result else {}
304
+
305
+ async def listDevices(self) -> list[Any]:
306
+ """Return a list of devices."""
307
+ devices = self._player.get_latest_response_by_params(
308
+ rpc_type=RPCType.XML_RPC,
309
+ method="listDevices",
310
+ params="()",
311
+ )
312
+
313
+ new_devices = []
314
+ if ignore_devices_on_create is None and address_device_translation is None:
315
+ return cast(list[Any], devices)
316
+
317
+ for dd in devices:
318
+ if ignore_devices_on_create is not None and (
319
+ dd["ADDRESS"] in ignore_devices_on_create or dd["PARENT"] in ignore_devices_on_create
320
+ ):
321
+ continue
322
+ if address_device_translation is not None:
323
+ if dd["ADDRESS"] in address_device_translation or dd["PARENT"] in address_device_translation:
324
+ new_devices.append(dd)
325
+ else:
326
+ new_devices.append(dd)
327
+
328
+ return new_devices
329
+
330
+ async def ping(self, callerId: str) -> None:
331
+ """Answer ping with pong."""
332
+ if self._central:
333
+ await self._central.data_point_event(
334
+ interface_id=callerId,
335
+ channel_address="",
336
+ parameter=Parameter.PONG,
337
+ value=callerId,
338
+ )
339
+
340
+ async def putParamset(
341
+ self, channel_address: str, paramset_key: str, values: Any, rx_mode: Any | None = None
342
+ ) -> None:
343
+ """Set a paramset."""
344
+ if self._central and paramset_key == ParamsetKey.VALUES:
345
+ interface_id = self._central.primary_client.interface_id # type: ignore[union-attr]
346
+ for param, value in values.items():
347
+ await self._central.data_point_event(
348
+ interface_id=interface_id, channel_address=channel_address, parameter=param, value=value
349
+ )
350
+
351
+ async def setValue(self, channel_address: str, parameter: str, value: Any, rx_mode: Any | None = None) -> None:
352
+ """Set a value."""
353
+ if self._central:
354
+ await self._central.data_point_event(
355
+ interface_id=self._central.primary_client.interface_id, # type: ignore[union-attr]
356
+ channel_address=channel_address,
357
+ parameter=parameter,
358
+ value=value,
359
+ )
360
+
361
+ def set_central(self, *, central: CentralUnit) -> None:
362
+ """Set the central."""
363
+ self._central = central
364
+
365
+ async def stop(self) -> None: # compatibility with AioXmlRpcProxy.stop
366
+ return None
367
+
368
+ async def _invoke(self, method: str, *args: Any) -> Any:
369
+ params = tuple(args)
370
+ return self._player.get_latest_response_by_params(
371
+ rpc_type=RPCType.XML_RPC,
372
+ method=method,
373
+ params=params,
374
+ )
375
+
376
+ return cast(BaseRpcProxy, _AioXmlRpcProxyFromSession())
377
+
378
+
379
+ def get_mock(
380
+ *, instance: Any, exclude_methods: set[str] | None = None, include_properties: set[str] | None = None, **kwargs: Any
381
+ ) -> Any:
382
+ """Create a mock and copy instance attributes over mock."""
383
+ if exclude_methods is None:
384
+ exclude_methods = set()
385
+ if include_properties is None:
386
+ include_properties = set()
387
+
388
+ if isinstance(instance, Mock):
389
+ instance.__dict__.update(instance._mock_wraps.__dict__)
390
+ return instance
391
+ mock = MagicMock(spec=instance, wraps=instance, **kwargs)
392
+ mock.__dict__.update(instance.__dict__)
393
+ try:
394
+ for method_name in [
395
+ prop
396
+ 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
398
+ ]:
399
+ setattr(mock, method_name, getattr(instance, method_name))
400
+ except Exception:
401
+ pass
402
+
403
+ return mock
404
+
405
+
406
+ async def get_session_player(*, file_name: str) -> SessionPlayer:
407
+ """Provide a SessionPlayer preloaded from the randomized full session JSON file."""
408
+ player = SessionPlayer(file_id=file_name)
409
+ if player.supports_file_id(file_id=file_name):
410
+ return player
411
+
412
+ for load_fn in const.ALL_SESSION_FILES:
413
+ file_path = os.path.join(os.path.dirname(__file__), "data", load_fn)
414
+ await player.load(file_path=file_path, file_id=load_fn)
415
+ return player
416
+
417
+
418
+ class SessionPlayer:
419
+ """Player for sessions."""
420
+
421
+ _store: dict[str, dict[str, dict[str, dict[str, dict[int, Any]]]]] = defaultdict(
422
+ lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(dict))))
423
+ )
424
+
425
+ def __init__(self, *, file_id: str) -> None:
426
+ """Initialize the session player."""
427
+ self._file_id = file_id
428
+
429
+ @property
430
+ def _secondary_file_ids(self) -> list[str]:
431
+ """Return the secondary store for the given file_id."""
432
+ return [fid for fid in self._store if fid != self._file_id]
433
+
434
+ def get_latest_response_by_method(self, *, rpc_type: str, method: str) -> list[tuple[Any, Any]]:
435
+ """Return latest non-expired responses for a given (rpc_type, method)."""
436
+ if pri_result := self.get_latest_response_by_method_for_file_id(
437
+ file_id=self._file_id,
438
+ rpc_type=rpc_type,
439
+ method=method,
440
+ ):
441
+ return pri_result
442
+
443
+ for secondary_file_id in self._secondary_file_ids:
444
+ if sec_result := self.get_latest_response_by_method_for_file_id(
445
+ file_id=secondary_file_id,
446
+ rpc_type=rpc_type,
447
+ method=method,
448
+ ):
449
+ return sec_result
450
+ return pri_result
451
+
452
+ def get_latest_response_by_method_for_file_id(
453
+ self, *, file_id: str, rpc_type: str, method: str
454
+ ) -> list[tuple[Any, Any]]:
455
+ """Return latest non-expired responses for a given (rpc_type, method)."""
456
+ result: list[Any] = []
457
+ # Access store safely to avoid side effects from creating buckets.
458
+ if not (bucket_by_method := self._store[file_id].get(rpc_type)):
459
+ return result
460
+ if not (bucket_by_parameter := bucket_by_method.get(method)):
461
+ return result
462
+ # For each parameter, choose the response at the latest timestamp.
463
+ for frozen_params, bucket_by_ts in bucket_by_parameter.items():
464
+ if not bucket_by_ts:
465
+ continue
466
+ try:
467
+ latest_ts = max(bucket_by_ts.keys())
468
+ except ValueError:
469
+ continue
470
+ resp = bucket_by_ts[latest_ts]
471
+ params = _unfreeze_params(frozen_params=frozen_params)
472
+
473
+ result.append((params, resp))
474
+ return result
475
+
476
+ def get_latest_response_by_params(
477
+ self,
478
+ *,
479
+ rpc_type: str,
480
+ method: str,
481
+ params: Any,
482
+ ) -> Any:
483
+ """Return latest non-expired responses for a given (rpc_type, method, params)."""
484
+ if pri_result := self.get_latest_response_by_params_for_file_id(
485
+ file_id=self._file_id,
486
+ rpc_type=rpc_type,
487
+ method=method,
488
+ params=params,
489
+ ):
490
+ return pri_result
491
+
492
+ for secondary_file_id in self._secondary_file_ids:
493
+ if sec_result := self.get_latest_response_by_params_for_file_id(
494
+ file_id=secondary_file_id,
495
+ rpc_type=rpc_type,
496
+ method=method,
497
+ params=params,
498
+ ):
499
+ return sec_result
500
+ return pri_result
501
+
502
+ def get_latest_response_by_params_for_file_id(
503
+ self,
504
+ *,
505
+ file_id: str,
506
+ rpc_type: str,
507
+ method: str,
508
+ params: Any,
509
+ ) -> Any:
510
+ """Return latest non-expired responses for a given (rpc_type, method, params)."""
511
+ # Access store safely to avoid side effects from creating buckets.
512
+ if not (bucket_by_method := self._store[file_id].get(rpc_type)):
513
+ return None
514
+ if not (bucket_by_parameter := bucket_by_method.get(method)):
515
+ return None
516
+ frozen_params = _freeze_params(params=params)
517
+
518
+ # For each parameter, choose the response at the latest timestamp.
519
+ if (bucket_by_ts := bucket_by_parameter.get(frozen_params)) is None:
520
+ return None
521
+
522
+ try:
523
+ latest_ts = max(bucket_by_ts.keys())
524
+ return bucket_by_ts[latest_ts]
525
+ except ValueError:
526
+ return None
527
+
528
+ async def load(self, *, file_path: str, file_id: str) -> DataOperationResult:
529
+ """
530
+ Load data from disk into the dictionary.
531
+
532
+ Supports plain JSON files and ZIP archives containing a JSON file.
533
+ When a ZIP archive is provided, the first JSON member inside the archive
534
+ will be loaded.
535
+ """
536
+ if self.supports_file_id(file_id=file_id):
537
+ return DataOperationResult.NO_LOAD
538
+
539
+ if not os.path.exists(file_path):
540
+ return DataOperationResult.NO_LOAD
541
+
542
+ def _perform_load() -> DataOperationResult:
543
+ try:
544
+ if zipfile.is_zipfile(file_path):
545
+ with zipfile.ZipFile(file_path, mode="r") as zf:
546
+ # Prefer json files; pick the first .json entry if available
547
+ if not (json_members := [n for n in zf.namelist() if n.lower().endswith(".json")]):
548
+ return DataOperationResult.LOAD_FAIL
549
+ raw = zf.read(json_members[0]).decode(UTF_8)
550
+ data = json.loads(raw)
551
+ else:
552
+ with open(file=file_path, encoding=UTF_8) as file_pointer:
553
+ data = json.loads(file_pointer.read())
554
+
555
+ self._store[file_id] = data
556
+ except (json.JSONDecodeError, zipfile.BadZipFile, UnicodeDecodeError, OSError):
557
+ return DataOperationResult.LOAD_FAIL
558
+ return DataOperationResult.LOAD_SUCCESS
559
+
560
+ loop = asyncio.get_running_loop()
561
+ return await loop.run_in_executor(None, _perform_load)
562
+
563
+ def supports_file_id(self, *, file_id: str) -> bool:
564
+ """Return whether the session player supports the given file_id."""
565
+ return file_id in self._store
File without changes
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiohomematic-test-support
3
+ Version: 2025.12.13
4
+ Summary: Support-only package for AioHomematic (tests/dev). Not part of production builds.
5
+ Author-email: SukramJ <sukramj@icloud.com>
6
+ Project-URL: Homepage, https://github.com/SukramJ/aiohomematic
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Python: >=3.13
@@ -0,0 +1,12 @@
1
+ aiohomematic_test_support/__init__.py,sha256=QZ6fA_Lk1iukixJUU6h8a4XcgF3OhzQaiVhUYc9VtTA,1623
2
+ aiohomematic_test_support/const.py,sha256=YXN3FvZCqjxOaqCs0E7KTz2PUa1zONzS9-W_6YqJrPI,19755
3
+ aiohomematic_test_support/factory.py,sha256=BT0bsplFO5nPqAJo2zwyI5QmNVDu32JcrgTCQbpB2dU,10767
4
+ aiohomematic_test_support/helper.py,sha256=Ue2tfy10_fiuMjYsc1jYPvo5sEtMF2WVKjvLnTZ0TzU,1360
5
+ aiohomematic_test_support/mock.py,sha256=z91_mjLMF09B5FprCnVeXwXpWHM1ZWe4h2nn97pGXR8,22713
6
+ aiohomematic_test_support/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ aiohomematic_test_support/data/full_session_randomized_ccu.zip,sha256=oN7g0CB_0kQX3qk-RSu-Rt28yW7BcplVqPYZCDqr0EU,734626
8
+ aiohomematic_test_support/data/full_session_randomized_pydevccu.zip,sha256=_QFWSP03dkiMFdD_w-R98DS6ur4PYDQXw-DCkbJEGg4,1293240
9
+ aiohomematic_test_support-2025.12.13.dist-info/METADATA,sha256=BbOorwMcviBXtN2mBdNtdJVpuQQ9SCJLLyyMMgvIzl0,536
10
+ aiohomematic_test_support-2025.12.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ aiohomematic_test_support-2025.12.13.dist-info/top_level.txt,sha256=KmK-OiDDbrmawIsIgPWNAkpkDfWQnOoumYd9MXAiTHc,26
12
+ aiohomematic_test_support-2025.12.13.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aiohomematic_test_support