aiohomematic-test-support 2025.10.14__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.
Potentially problematic release.
This version of aiohomematic-test-support might be problematic. Click here for more details.
- aiohomematic_test_support/__init__.py +2 -0
- aiohomematic_test_support/client_local.py +361 -0
- aiohomematic_test_support/const.py +549 -0
- aiohomematic_test_support/data/full_session_randomized_ccu.zip +0 -0
- aiohomematic_test_support/data/full_session_randomized_pydevccu.zip +0 -0
- aiohomematic_test_support/py.typed +0 -0
- aiohomematic_test_support/support.py +742 -0
- aiohomematic_test_support-2025.10.14.dist-info/METADATA +11 -0
- aiohomematic_test_support-2025.10.14.dist-info/RECORD +11 -0
- aiohomematic_test_support-2025.10.14.dist-info/WHEEL +5 -0
- aiohomematic_test_support-2025.10.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""Helpers for tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
import contextlib
|
|
9
|
+
import importlib.resources
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Final, cast
|
|
14
|
+
from unittest.mock import MagicMock, Mock, patch
|
|
15
|
+
import zipfile
|
|
16
|
+
|
|
17
|
+
from aiohttp import ClientSession
|
|
18
|
+
import orjson
|
|
19
|
+
|
|
20
|
+
from aiohomematic.central import CentralConfig, CentralUnit
|
|
21
|
+
from aiohomematic.client import BaseRpcProxy, Client, ClientConfig, InterfaceConfig
|
|
22
|
+
from aiohomematic.client.json_rpc import _JsonKey, _JsonRpcMethod
|
|
23
|
+
from aiohomematic.client.rpc_proxy import _RpcMethod
|
|
24
|
+
from aiohomematic.const import (
|
|
25
|
+
LOCAL_HOST,
|
|
26
|
+
UTF_8,
|
|
27
|
+
BackendSystemEvent,
|
|
28
|
+
DataOperationResult,
|
|
29
|
+
Interface,
|
|
30
|
+
Parameter,
|
|
31
|
+
ParamsetKey,
|
|
32
|
+
RPCType,
|
|
33
|
+
SourceOfDeviceCreation,
|
|
34
|
+
)
|
|
35
|
+
from aiohomematic.model.custom import CustomDataPoint
|
|
36
|
+
from aiohomematic.store.persistent import _freeze_params, _unfreeze_params
|
|
37
|
+
from aiohomematic_test_support import const
|
|
38
|
+
from aiohomematic_test_support.client_local import ClientLocal, LocalRessources
|
|
39
|
+
|
|
40
|
+
_LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
EXCLUDE_METHODS_FROM_MOCKS: Final[list[str]] = []
|
|
43
|
+
INCLUDE_PROPERTIES_IN_MOCKS: Final[list[str]] = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# pylint: disable=protected-access
|
|
47
|
+
class FactoryWithLocalClient:
|
|
48
|
+
"""Factory for a central with one local client."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *, client_session: ClientSession | None = None):
|
|
51
|
+
"""Init the central factory."""
|
|
52
|
+
self._client_session = client_session
|
|
53
|
+
self.system_event_mock = MagicMock()
|
|
54
|
+
self.ha_event_mock = MagicMock()
|
|
55
|
+
|
|
56
|
+
async def get_raw_central(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
interface_config: InterfaceConfig | None,
|
|
60
|
+
un_ignore_list: list[str] | None = None,
|
|
61
|
+
ignore_custom_device_definition_models: list[str] | None = None,
|
|
62
|
+
) -> CentralUnit:
|
|
63
|
+
"""Return a central based on give address_device_translation."""
|
|
64
|
+
interface_configs = {interface_config} if interface_config else set()
|
|
65
|
+
central = CentralConfig(
|
|
66
|
+
name=const.CENTRAL_NAME,
|
|
67
|
+
host=const.CCU_HOST,
|
|
68
|
+
username=const.CCU_USERNAME,
|
|
69
|
+
password=const.CCU_PASSWORD,
|
|
70
|
+
central_id="test1234",
|
|
71
|
+
interface_configs=interface_configs,
|
|
72
|
+
client_session=self._client_session,
|
|
73
|
+
un_ignore_list=frozenset(un_ignore_list or []),
|
|
74
|
+
ignore_custom_device_definition_models=frozenset(ignore_custom_device_definition_models or []),
|
|
75
|
+
start_direct=True,
|
|
76
|
+
).create_central()
|
|
77
|
+
|
|
78
|
+
central.register_backend_system_callback(cb=self.system_event_mock)
|
|
79
|
+
central.register_homematic_callback(cb=self.ha_event_mock)
|
|
80
|
+
|
|
81
|
+
return central
|
|
82
|
+
|
|
83
|
+
async def get_unpatched_default_central(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
port: int,
|
|
87
|
+
address_device_translation: dict[str, str],
|
|
88
|
+
do_mock_client: bool = True,
|
|
89
|
+
ignore_devices_on_create: list[str] | None = None,
|
|
90
|
+
un_ignore_list: list[str] | None = None,
|
|
91
|
+
ignore_custom_device_definition_models: list[str] | None = None,
|
|
92
|
+
) -> tuple[CentralUnit, Client | Mock]:
|
|
93
|
+
"""Return a central based on give address_device_translation."""
|
|
94
|
+
interface_config = InterfaceConfig(
|
|
95
|
+
central_name=const.CENTRAL_NAME,
|
|
96
|
+
interface=Interface.BIDCOS_RF,
|
|
97
|
+
port=port,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
central = await self.get_raw_central(
|
|
101
|
+
interface_config=interface_config,
|
|
102
|
+
un_ignore_list=un_ignore_list,
|
|
103
|
+
ignore_custom_device_definition_models=ignore_custom_device_definition_models,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_client = ClientLocal(
|
|
107
|
+
client_config=ClientConfig(
|
|
108
|
+
central=central,
|
|
109
|
+
interface_config=interface_config,
|
|
110
|
+
),
|
|
111
|
+
local_resources=LocalRessources(
|
|
112
|
+
address_device_translation=address_device_translation,
|
|
113
|
+
ignore_devices_on_create=ignore_devices_on_create if ignore_devices_on_create else [],
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
await _client.init_client()
|
|
117
|
+
client = get_mock(_client) if do_mock_client else _client
|
|
118
|
+
|
|
119
|
+
assert central
|
|
120
|
+
assert client
|
|
121
|
+
return central, client
|
|
122
|
+
|
|
123
|
+
async def get_default_central(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
port: int = const.CCU_MINI_PORT,
|
|
127
|
+
address_device_translation: dict[str, str],
|
|
128
|
+
do_mock_client: bool = True,
|
|
129
|
+
add_sysvars: bool = False,
|
|
130
|
+
add_programs: bool = False,
|
|
131
|
+
ignore_devices_on_create: list[str] | None = None,
|
|
132
|
+
un_ignore_list: list[str] | None = None,
|
|
133
|
+
ignore_custom_device_definition_models: list[str] | None = None,
|
|
134
|
+
) -> tuple[CentralUnit, Client | Mock]:
|
|
135
|
+
"""Return a central based on give address_device_translation."""
|
|
136
|
+
central, client = await self.get_unpatched_default_central(
|
|
137
|
+
port=port,
|
|
138
|
+
address_device_translation=address_device_translation,
|
|
139
|
+
do_mock_client=True,
|
|
140
|
+
ignore_devices_on_create=ignore_devices_on_create,
|
|
141
|
+
un_ignore_list=un_ignore_list,
|
|
142
|
+
ignore_custom_device_definition_models=ignore_custom_device_definition_models,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
patch("aiohomematic.central.CentralUnit._get_primary_client", return_value=client).start()
|
|
146
|
+
patch("aiohomematic.client.ClientConfig.create_client", return_value=client).start()
|
|
147
|
+
patch(
|
|
148
|
+
"aiohomematic_test_support.client_local.ClientLocal.get_all_system_variables",
|
|
149
|
+
return_value=const.SYSVAR_DATA if add_sysvars else [],
|
|
150
|
+
).start()
|
|
151
|
+
patch(
|
|
152
|
+
"aiohomematic_test_support.client_local.ClientLocal.get_all_programs",
|
|
153
|
+
return_value=const.PROGRAM_DATA if add_programs else [],
|
|
154
|
+
).start()
|
|
155
|
+
patch("aiohomematic.central.CentralUnit._identify_ip_addr", return_value=LOCAL_HOST).start()
|
|
156
|
+
|
|
157
|
+
await central.start()
|
|
158
|
+
if new_device_addresses := central._check_for_new_device_addresses():
|
|
159
|
+
await central._create_devices(new_device_addresses=new_device_addresses, source=SourceOfDeviceCreation.INIT)
|
|
160
|
+
await central._init_hub()
|
|
161
|
+
|
|
162
|
+
assert central
|
|
163
|
+
assert client
|
|
164
|
+
return central, client
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class FactoryWithClient:
|
|
168
|
+
"""Factory for a central with one local client."""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
recorder: SessionPlayer,
|
|
174
|
+
address_device_translation: dict[str, str] | None = None,
|
|
175
|
+
ignore_devices_on_create: list[str] | None = None,
|
|
176
|
+
):
|
|
177
|
+
"""Init the central factory."""
|
|
178
|
+
self._client_session = _get_client_session(
|
|
179
|
+
recorder=recorder,
|
|
180
|
+
address_device_translation=address_device_translation,
|
|
181
|
+
ignore_devices_on_create=ignore_devices_on_create,
|
|
182
|
+
)
|
|
183
|
+
self._xml_proxy = _get_xml_rpc_proxy(
|
|
184
|
+
recorder=recorder,
|
|
185
|
+
address_device_translation=address_device_translation,
|
|
186
|
+
ignore_devices_on_create=ignore_devices_on_create,
|
|
187
|
+
)
|
|
188
|
+
self.system_event_mock = MagicMock()
|
|
189
|
+
self.ha_event_mock = MagicMock()
|
|
190
|
+
|
|
191
|
+
async def get_raw_central(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
interface_config: InterfaceConfig | None,
|
|
195
|
+
un_ignore_list: list[str] | None = None,
|
|
196
|
+
ignore_custom_device_definition_models: list[str] | None = None,
|
|
197
|
+
) -> CentralUnit:
|
|
198
|
+
"""Return a central based on give address_device_translation."""
|
|
199
|
+
interface_configs = {interface_config} if interface_config else set()
|
|
200
|
+
central = CentralConfig(
|
|
201
|
+
name=const.CENTRAL_NAME,
|
|
202
|
+
host=const.CCU_HOST,
|
|
203
|
+
username=const.CCU_USERNAME,
|
|
204
|
+
password=const.CCU_PASSWORD,
|
|
205
|
+
central_id="test1234",
|
|
206
|
+
interface_configs=interface_configs,
|
|
207
|
+
client_session=self._client_session,
|
|
208
|
+
un_ignore_list=frozenset(un_ignore_list or []),
|
|
209
|
+
ignore_custom_device_definition_models=frozenset(ignore_custom_device_definition_models or []),
|
|
210
|
+
start_direct=True,
|
|
211
|
+
).create_central()
|
|
212
|
+
|
|
213
|
+
central.register_backend_system_callback(cb=self.system_event_mock)
|
|
214
|
+
central.register_homematic_callback(cb=self.ha_event_mock)
|
|
215
|
+
|
|
216
|
+
return central
|
|
217
|
+
|
|
218
|
+
async def get_unpatched_default_central(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
un_ignore_list: list[str] | None = None,
|
|
222
|
+
ignore_custom_device_definition_models: list[str] | None = None,
|
|
223
|
+
) -> CentralUnit:
|
|
224
|
+
"""Return a central based on give address_device_translation."""
|
|
225
|
+
interface_config = InterfaceConfig(
|
|
226
|
+
central_name=const.CENTRAL_NAME,
|
|
227
|
+
interface=Interface.BIDCOS_RF,
|
|
228
|
+
port=2001,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
central = await self.get_raw_central(
|
|
232
|
+
interface_config=interface_config,
|
|
233
|
+
un_ignore_list=un_ignore_list,
|
|
234
|
+
ignore_custom_device_definition_models=ignore_custom_device_definition_models,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert central
|
|
238
|
+
self._client_session.set_central(central=central) # type: ignore[attr-defined]
|
|
239
|
+
self._xml_proxy.set_central(central=central)
|
|
240
|
+
return central
|
|
241
|
+
|
|
242
|
+
async def get_default_central(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
do_mock_client: bool = True,
|
|
246
|
+
un_ignore_list: list[str] | None = None,
|
|
247
|
+
ignore_custom_device_definition_models: list[str] | None = None,
|
|
248
|
+
) -> tuple[CentralUnit, Client | Mock]:
|
|
249
|
+
"""Return a central based on give address_device_translation."""
|
|
250
|
+
central = await self.get_unpatched_default_central(
|
|
251
|
+
un_ignore_list=un_ignore_list,
|
|
252
|
+
ignore_custom_device_definition_models=ignore_custom_device_definition_models,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
await self._xml_proxy.do_init()
|
|
256
|
+
patch("aiohomematic.client.ClientConfig._create_xml_rpc_proxy", return_value=self._xml_proxy).start()
|
|
257
|
+
patch("aiohomematic.central.CentralUnit._identify_ip_addr", return_value=LOCAL_HOST).start()
|
|
258
|
+
|
|
259
|
+
# Optionally patch client creation to return a mocked client
|
|
260
|
+
if do_mock_client:
|
|
261
|
+
_orig_create_client = ClientConfig.create_client
|
|
262
|
+
|
|
263
|
+
async def _mocked_create_client(self: ClientConfig) -> Client | Mock:
|
|
264
|
+
real_client = await _orig_create_client(self)
|
|
265
|
+
return cast(Mock, get_mock(real_client))
|
|
266
|
+
|
|
267
|
+
patch("aiohomematic.client.ClientConfig.create_client", _mocked_create_client).start()
|
|
268
|
+
|
|
269
|
+
await central.start()
|
|
270
|
+
|
|
271
|
+
client = central.primary_client
|
|
272
|
+
assert central
|
|
273
|
+
assert client
|
|
274
|
+
return central, client
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _get_not_mockable_method_names(instance: Any) -> set[str]:
|
|
278
|
+
"""Return all relevant method names for mocking."""
|
|
279
|
+
methods: set[str] = set(_get_properties(data_object=instance, decorator=property))
|
|
280
|
+
|
|
281
|
+
for method in dir(instance):
|
|
282
|
+
if method in EXCLUDE_METHODS_FROM_MOCKS:
|
|
283
|
+
methods.add(method)
|
|
284
|
+
return methods
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _get_properties(data_object: Any, decorator: Any) -> set[str]:
|
|
288
|
+
"""Return the object attributes by decorator."""
|
|
289
|
+
cls = data_object.__class__
|
|
290
|
+
|
|
291
|
+
# Resolve function-based decorators to their underlying property class, if provided
|
|
292
|
+
resolved_decorator: Any = decorator
|
|
293
|
+
if not isinstance(decorator, type):
|
|
294
|
+
resolved_decorator = getattr(decorator, "__property_class__", decorator)
|
|
295
|
+
|
|
296
|
+
return {y for y in dir(cls) if isinstance(getattr(cls, y), resolved_decorator)}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None:
|
|
300
|
+
"""Load json file from disk into dict."""
|
|
301
|
+
package_path = str(importlib.resources.files(anchor))
|
|
302
|
+
with open(
|
|
303
|
+
file=os.path.join(package_path, resource, file_name),
|
|
304
|
+
encoding=UTF_8,
|
|
305
|
+
) as fptr:
|
|
306
|
+
return orjson.loads(fptr.read())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _get_client_session(
|
|
310
|
+
*,
|
|
311
|
+
recorder: SessionPlayer,
|
|
312
|
+
address_device_translation: dict[str, str] | None = None,
|
|
313
|
+
ignore_devices_on_create: list[str] | None = None,
|
|
314
|
+
) -> ClientSession:
|
|
315
|
+
"""
|
|
316
|
+
Provide a ClientSession-like fixture that answers via SimpleSessionRecorder (JSON-RPC).
|
|
317
|
+
|
|
318
|
+
Any POST request will be answered by looking up the latest recorded
|
|
319
|
+
JSON-RPC response in the session recorder using the provided method and params.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
class _MockResponse:
|
|
323
|
+
def __init__(self, json_data: dict | None) -> None:
|
|
324
|
+
# If no match is found, emulate backend error payload
|
|
325
|
+
self._json = json_data or {
|
|
326
|
+
"result": None,
|
|
327
|
+
"error": {"name": "-1", "code": -1, "message": "Not found in session recorder"},
|
|
328
|
+
"id": 0,
|
|
329
|
+
}
|
|
330
|
+
self.status = 200
|
|
331
|
+
|
|
332
|
+
async def json(self, encoding: str | None = None) -> dict[str, Any]: # mimic aiohttp API
|
|
333
|
+
return self._json
|
|
334
|
+
|
|
335
|
+
async def read(self) -> bytes:
|
|
336
|
+
return orjson.dumps(self._json)
|
|
337
|
+
|
|
338
|
+
class _MockClientSession:
|
|
339
|
+
def __init__(self) -> None:
|
|
340
|
+
"""Initialize the mock client session."""
|
|
341
|
+
self._central: CentralUnit | None = None
|
|
342
|
+
|
|
343
|
+
def set_central(self, *, central: CentralUnit) -> None:
|
|
344
|
+
"""Set the central."""
|
|
345
|
+
self._central = central
|
|
346
|
+
|
|
347
|
+
async def post(
|
|
348
|
+
self,
|
|
349
|
+
*,
|
|
350
|
+
url: str,
|
|
351
|
+
data: bytes | bytearray | str | None = None,
|
|
352
|
+
headers: Any = None,
|
|
353
|
+
timeout: Any = None, # noqa: ASYNC109
|
|
354
|
+
ssl: Any = None,
|
|
355
|
+
) -> _MockResponse:
|
|
356
|
+
# Payload is produced by AioJsonRpcAioHttpClient via orjson.dumps
|
|
357
|
+
if isinstance(data, (bytes, bytearray)):
|
|
358
|
+
payload = orjson.loads(data)
|
|
359
|
+
elif isinstance(data, str):
|
|
360
|
+
payload = orjson.loads(data.encode(UTF_8))
|
|
361
|
+
else:
|
|
362
|
+
payload = {}
|
|
363
|
+
|
|
364
|
+
method = payload.get("method")
|
|
365
|
+
params = payload.get("params")
|
|
366
|
+
|
|
367
|
+
if self._central:
|
|
368
|
+
if method == _JsonRpcMethod.INTERFACE_SET_VALUE:
|
|
369
|
+
await self._central.data_point_event(
|
|
370
|
+
interface_id=params[_JsonKey.INTERFACE],
|
|
371
|
+
channel_address=params[_JsonKey.ADDRESS],
|
|
372
|
+
parameter=params[_JsonKey.VALUE_KEY],
|
|
373
|
+
value=params[_JsonKey.VALUE],
|
|
374
|
+
)
|
|
375
|
+
return _MockResponse({"result": "200"})
|
|
376
|
+
if method == _JsonRpcMethod.INTERFACE_PUT_PARAMSET:
|
|
377
|
+
if params[_JsonKey.PARAMSET_KEY] == ParamsetKey.VALUES:
|
|
378
|
+
interface_id = params[_JsonKey.INTERFACE]
|
|
379
|
+
channel_address = params[_JsonKey.ADDRESS]
|
|
380
|
+
values = params[_JsonKey.SET]
|
|
381
|
+
for param, value in values.items():
|
|
382
|
+
await self._central.data_point_event(
|
|
383
|
+
interface_id=interface_id,
|
|
384
|
+
channel_address=channel_address,
|
|
385
|
+
parameter=param,
|
|
386
|
+
value=value,
|
|
387
|
+
)
|
|
388
|
+
return _MockResponse({"result": "200"})
|
|
389
|
+
|
|
390
|
+
json_data = recorder.get_latest_response_by_params(
|
|
391
|
+
rpc_type=RPCType.JSON_RPC,
|
|
392
|
+
method=str(method) if method is not None else "",
|
|
393
|
+
params=params,
|
|
394
|
+
)
|
|
395
|
+
# if method == _JsonRpcMethod.INTERFACE_LIST_DEVICES:
|
|
396
|
+
# if address_device_translation:
|
|
397
|
+
# devices: dict[str, Any] = {}
|
|
398
|
+
# for address, device in json_data["result"].items():
|
|
399
|
+
# if address in address_device_translation:
|
|
400
|
+
# device["ADDRESS"] = address_device_translation[address]
|
|
401
|
+
return _MockResponse(json_data)
|
|
402
|
+
|
|
403
|
+
async def close(self) -> None: # compatibility
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
return cast(ClientSession, _MockClientSession())
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _get_xml_rpc_proxy( # noqa: C901
|
|
410
|
+
*,
|
|
411
|
+
recorder: SessionPlayer,
|
|
412
|
+
address_device_translation: dict[str, str] | None = None,
|
|
413
|
+
ignore_devices_on_create: list[str] | None = None,
|
|
414
|
+
) -> BaseRpcProxy:
|
|
415
|
+
"""
|
|
416
|
+
Provide an BaseRpcProxy-like fixture that answers via SimpleSessionRecorder (XML-RPC).
|
|
417
|
+
|
|
418
|
+
Any method call like: await proxy.system.listMethods(...)
|
|
419
|
+
will be answered by looking up the latest recorded XML-RPC response
|
|
420
|
+
in the session recorder using the provided method and positional params.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
class _Method:
|
|
424
|
+
def __init__(self, full_name: str, caller: Any) -> None:
|
|
425
|
+
self._name = full_name
|
|
426
|
+
self._caller = caller
|
|
427
|
+
|
|
428
|
+
def __getattr__(self, sub: str) -> _Method:
|
|
429
|
+
# Allow chaining like proxy.system.listMethods
|
|
430
|
+
return _Method(f"{self._name}.{sub}", self._caller)
|
|
431
|
+
|
|
432
|
+
async def __call__(self, *args: Any) -> Any:
|
|
433
|
+
# Forward to caller with collected method name and positional params
|
|
434
|
+
return await self._caller(self._name, *args)
|
|
435
|
+
|
|
436
|
+
class _AioXmlRpcProxyFromRecorder:
|
|
437
|
+
def __init__(self) -> None:
|
|
438
|
+
self._recorder = recorder
|
|
439
|
+
self._supported_methods: tuple[str, ...] = ()
|
|
440
|
+
self._central: CentralUnit | None = None
|
|
441
|
+
|
|
442
|
+
def set_central(self, *, central: CentralUnit) -> None:
|
|
443
|
+
"""Set the central."""
|
|
444
|
+
self._central = central
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def supported_methods(self) -> tuple[str, ...]:
|
|
448
|
+
"""Return the supported methods."""
|
|
449
|
+
return self._supported_methods
|
|
450
|
+
|
|
451
|
+
async def setValue(self, channel_address: str, parameter: str, value: Any, rx_mode: Any | None = None) -> None:
|
|
452
|
+
"""Set a value."""
|
|
453
|
+
if self._central:
|
|
454
|
+
await self._central.data_point_event(
|
|
455
|
+
interface_id=self._central.primary_client.interface_id, # type: ignore[union-attr]
|
|
456
|
+
channel_address=channel_address,
|
|
457
|
+
parameter=parameter,
|
|
458
|
+
value=value,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
async def putParamset(
|
|
462
|
+
self, channel_address: str, paramset_key: str, values: Any, rx_mode: Any | None = None
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Set a paramset."""
|
|
465
|
+
if self._central and paramset_key == ParamsetKey.VALUES:
|
|
466
|
+
interface_id = self._central.primary_client.interface_id # type: ignore[union-attr]
|
|
467
|
+
for param, value in values.items():
|
|
468
|
+
await self._central.data_point_event(
|
|
469
|
+
interface_id=interface_id, channel_address=channel_address, parameter=param, value=value
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
async def ping(self, callerId: str) -> None:
|
|
473
|
+
"""Answer ping with pong."""
|
|
474
|
+
if self._central:
|
|
475
|
+
await self._central.data_point_event(
|
|
476
|
+
interface_id=callerId,
|
|
477
|
+
channel_address="",
|
|
478
|
+
parameter=Parameter.PONG,
|
|
479
|
+
value=callerId,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
async def clientServerInitialized(self, interface_id: str) -> None:
|
|
483
|
+
"""Answer clientServerInitialized with pong."""
|
|
484
|
+
await self.ping(callerId=interface_id)
|
|
485
|
+
|
|
486
|
+
async def listDevices(self) -> list[Any]:
|
|
487
|
+
"""Return a list of devices."""
|
|
488
|
+
devices = self._recorder.get_latest_response_by_params(
|
|
489
|
+
rpc_type=RPCType.XML_RPC,
|
|
490
|
+
method="listDevices",
|
|
491
|
+
params="()",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
new_devices = []
|
|
495
|
+
if ignore_devices_on_create is None and address_device_translation is None:
|
|
496
|
+
return cast(list[Any], devices)
|
|
497
|
+
|
|
498
|
+
for device in devices:
|
|
499
|
+
if ignore_devices_on_create is not None and (
|
|
500
|
+
device["ADDRESS"] in ignore_devices_on_create or device["PARENT"] in ignore_devices_on_create
|
|
501
|
+
):
|
|
502
|
+
continue
|
|
503
|
+
if address_device_translation is not None:
|
|
504
|
+
if (
|
|
505
|
+
device["ADDRESS"] in address_device_translation
|
|
506
|
+
or device["PARENT"] in address_device_translation
|
|
507
|
+
):
|
|
508
|
+
new_devices.append(device)
|
|
509
|
+
else:
|
|
510
|
+
new_devices.append(device)
|
|
511
|
+
|
|
512
|
+
return new_devices
|
|
513
|
+
|
|
514
|
+
def __getattr__(self, name: str) -> Any:
|
|
515
|
+
# Start of method chain
|
|
516
|
+
return _Method(name, self._invoke)
|
|
517
|
+
|
|
518
|
+
async def _invoke(self, method: str, *args: Any) -> Any:
|
|
519
|
+
params = tuple(args)
|
|
520
|
+
return self._recorder.get_latest_response_by_params(
|
|
521
|
+
rpc_type=RPCType.XML_RPC,
|
|
522
|
+
method=method,
|
|
523
|
+
params=params,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
async def stop(self) -> None: # compatibility with AioXmlRpcProxy.stop
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
async def do_init(self) -> None:
|
|
530
|
+
"""Init the xml rpc proxy."""
|
|
531
|
+
if supported_methods := await self.system.listMethods():
|
|
532
|
+
# ping is missing in VirtualDevices interface but can be used.
|
|
533
|
+
supported_methods.append(_RpcMethod.PING)
|
|
534
|
+
self._supported_methods = tuple(supported_methods)
|
|
535
|
+
|
|
536
|
+
return cast(BaseRpcProxy, _AioXmlRpcProxyFromRecorder())
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
async def get_central_client_factory(
|
|
540
|
+
recorder: SessionPlayer,
|
|
541
|
+
address_device_translation: dict[str, str],
|
|
542
|
+
do_mock_client: bool,
|
|
543
|
+
ignore_devices_on_create: list[str] | None,
|
|
544
|
+
ignore_custom_device_definition_models: list[str] | None,
|
|
545
|
+
un_ignore_list: list[str] | None,
|
|
546
|
+
) -> AsyncGenerator[tuple[CentralUnit, Client | Mock, FactoryWithClient]]:
|
|
547
|
+
"""Return central factory."""
|
|
548
|
+
factory = FactoryWithClient(
|
|
549
|
+
recorder=recorder,
|
|
550
|
+
address_device_translation=address_device_translation,
|
|
551
|
+
ignore_devices_on_create=ignore_devices_on_create,
|
|
552
|
+
)
|
|
553
|
+
central_client = await factory.get_default_central(
|
|
554
|
+
do_mock_client=do_mock_client,
|
|
555
|
+
ignore_custom_device_definition_models=ignore_custom_device_definition_models,
|
|
556
|
+
un_ignore_list=un_ignore_list,
|
|
557
|
+
)
|
|
558
|
+
central, client = central_client
|
|
559
|
+
try:
|
|
560
|
+
yield central, client, factory
|
|
561
|
+
finally:
|
|
562
|
+
await central.stop()
|
|
563
|
+
await central.clear_files()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def get_mock(instance: Any, **kwargs: Any) -> Any:
|
|
567
|
+
"""Create a mock and copy instance attributes over mock."""
|
|
568
|
+
if isinstance(instance, Mock):
|
|
569
|
+
instance.__dict__.update(instance._mock_wraps.__dict__)
|
|
570
|
+
return instance
|
|
571
|
+
mock = MagicMock(spec=instance, wraps=instance, **kwargs)
|
|
572
|
+
mock.__dict__.update(instance.__dict__)
|
|
573
|
+
try:
|
|
574
|
+
for method_name in [
|
|
575
|
+
prop
|
|
576
|
+
for prop in _get_not_mockable_method_names(instance)
|
|
577
|
+
if prop not in INCLUDE_PROPERTIES_IN_MOCKS and prop not in kwargs
|
|
578
|
+
]:
|
|
579
|
+
setattr(mock, method_name, getattr(instance, method_name))
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
|
|
583
|
+
return mock
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def get_prepared_custom_data_point(central: CentralUnit, address: str, channel_no: int) -> CustomDataPoint | None:
|
|
587
|
+
"""Return the hm custom_data_point."""
|
|
588
|
+
if cdp := central.get_custom_data_point(address=address, channel_no=channel_no):
|
|
589
|
+
for dp in cdp._data_points.values():
|
|
590
|
+
dp._state_uncertain = False
|
|
591
|
+
return cdp
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
async def get_pydev_ccu_central_unit_full(
|
|
596
|
+
port: int,
|
|
597
|
+
client_session: ClientSession | None = None,
|
|
598
|
+
) -> CentralUnit:
|
|
599
|
+
"""Create and yield central, after all devices have been created."""
|
|
600
|
+
device_event = asyncio.Event()
|
|
601
|
+
|
|
602
|
+
def systemcallback(system_event: Any, *args: Any, **kwargs: Any) -> None:
|
|
603
|
+
if system_event == BackendSystemEvent.DEVICES_CREATED:
|
|
604
|
+
device_event.set()
|
|
605
|
+
|
|
606
|
+
interface_configs = {
|
|
607
|
+
InterfaceConfig(
|
|
608
|
+
central_name=const.CENTRAL_NAME,
|
|
609
|
+
interface=Interface.BIDCOS_RF,
|
|
610
|
+
port=port,
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
central = CentralConfig(
|
|
615
|
+
name=const.CENTRAL_NAME,
|
|
616
|
+
host=const.CCU_HOST,
|
|
617
|
+
username=const.CCU_USERNAME,
|
|
618
|
+
password=const.CCU_PASSWORD,
|
|
619
|
+
central_id="test1234",
|
|
620
|
+
interface_configs=interface_configs,
|
|
621
|
+
client_session=client_session,
|
|
622
|
+
program_markers=(),
|
|
623
|
+
sysvar_markers=(),
|
|
624
|
+
).create_central()
|
|
625
|
+
central.register_backend_system_callback(cb=systemcallback)
|
|
626
|
+
await central.start()
|
|
627
|
+
|
|
628
|
+
# Wait up to 60 seconds for the DEVICES_CREATED event which signals that all devices are available
|
|
629
|
+
with contextlib.suppress(TimeoutError):
|
|
630
|
+
await asyncio.wait_for(device_event.wait(), timeout=60)
|
|
631
|
+
|
|
632
|
+
return central
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
async def get_session_player(*, file_name: str) -> SessionPlayer:
|
|
636
|
+
"""Provide a SessionPlayer preloaded from the randomized full session JSON file."""
|
|
637
|
+
player = SessionPlayer(file_id=file_name)
|
|
638
|
+
file_path = os.path.join(os.path.dirname(__file__), "data", file_name)
|
|
639
|
+
await player.load(file_path=file_path)
|
|
640
|
+
return player
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def load_device_description(file_name: str) -> Any:
|
|
644
|
+
"""Load device description."""
|
|
645
|
+
dev_desc = _load_json_file(anchor="pydevccu", resource="device_descriptions", file_name=file_name)
|
|
646
|
+
assert dev_desc
|
|
647
|
+
return dev_desc
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
class SessionPlayer:
|
|
651
|
+
"""Player for sessions."""
|
|
652
|
+
|
|
653
|
+
_store: dict[str, dict[str, dict[str, dict[str, dict[int, Any]]]]] = defaultdict(
|
|
654
|
+
lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(dict))))
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def __init__(self, *, file_id: str) -> None:
|
|
658
|
+
"""Initialize the session recorder."""
|
|
659
|
+
self._file_id = file_id
|
|
660
|
+
|
|
661
|
+
async def load(self, *, file_path: str) -> DataOperationResult:
|
|
662
|
+
"""
|
|
663
|
+
Load data from disk into the dictionary.
|
|
664
|
+
|
|
665
|
+
Supports plain JSON files and ZIP archives containing a JSON file.
|
|
666
|
+
When a ZIP archive is provided, the first JSON member inside the archive
|
|
667
|
+
will be loaded.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
if self._store[self._file_id]:
|
|
671
|
+
return DataOperationResult.NO_LOAD
|
|
672
|
+
|
|
673
|
+
if not os.path.exists(file_path):
|
|
674
|
+
return DataOperationResult.NO_LOAD
|
|
675
|
+
|
|
676
|
+
def _perform_load() -> DataOperationResult:
|
|
677
|
+
try:
|
|
678
|
+
if zipfile.is_zipfile(file_path):
|
|
679
|
+
with zipfile.ZipFile(file_path, mode="r") as zf:
|
|
680
|
+
# Prefer json files; pick the first .json entry if available
|
|
681
|
+
if not (json_members := [n for n in zf.namelist() if n.lower().endswith(".json")]):
|
|
682
|
+
return DataOperationResult.LOAD_FAIL
|
|
683
|
+
raw = zf.read(json_members[0]).decode(UTF_8)
|
|
684
|
+
data = json.loads(raw)
|
|
685
|
+
else:
|
|
686
|
+
with open(file=file_path, encoding=UTF_8) as file_pointer:
|
|
687
|
+
data = json.loads(file_pointer.read())
|
|
688
|
+
|
|
689
|
+
self._store[self._file_id] = data
|
|
690
|
+
except (json.JSONDecodeError, zipfile.BadZipFile, UnicodeDecodeError, OSError):
|
|
691
|
+
return DataOperationResult.LOAD_FAIL
|
|
692
|
+
return DataOperationResult.LOAD_SUCCESS
|
|
693
|
+
|
|
694
|
+
loop = asyncio.get_running_loop()
|
|
695
|
+
return await loop.run_in_executor(None, _perform_load)
|
|
696
|
+
|
|
697
|
+
def get_latest_response_by_method(self, *, rpc_type: str, method: str) -> list[tuple[Any, Any]]:
|
|
698
|
+
"""Return latest non-expired responses for a given (rpc_type, method)."""
|
|
699
|
+
result: list[Any] = []
|
|
700
|
+
# Access store safely to avoid side effects from creating buckets.
|
|
701
|
+
if not (bucket_by_method := self._store[self._file_id].get(rpc_type)):
|
|
702
|
+
return result
|
|
703
|
+
if not (bucket_by_parameter := bucket_by_method.get(method)):
|
|
704
|
+
return result
|
|
705
|
+
# For each parameter, choose the response at the latest timestamp.
|
|
706
|
+
for frozen_params, bucket_by_ts in bucket_by_parameter.items():
|
|
707
|
+
if not bucket_by_ts:
|
|
708
|
+
continue
|
|
709
|
+
try:
|
|
710
|
+
latest_ts = max(bucket_by_ts.keys())
|
|
711
|
+
except ValueError:
|
|
712
|
+
continue
|
|
713
|
+
resp = bucket_by_ts[latest_ts]
|
|
714
|
+
params = _unfreeze_params(frozen_params=frozen_params)
|
|
715
|
+
|
|
716
|
+
result.append((params, resp))
|
|
717
|
+
return result
|
|
718
|
+
|
|
719
|
+
def get_latest_response_by_params(
|
|
720
|
+
self,
|
|
721
|
+
*,
|
|
722
|
+
rpc_type: str,
|
|
723
|
+
method: str,
|
|
724
|
+
params: Any,
|
|
725
|
+
) -> Any:
|
|
726
|
+
"""Return latest non-expired responses for a given (rpc_type, method, params)."""
|
|
727
|
+
# Access store safely to avoid side effects from creating buckets.
|
|
728
|
+
if not (bucket_by_method := self._store[self._file_id].get(rpc_type)):
|
|
729
|
+
return None
|
|
730
|
+
if not (bucket_by_parameter := bucket_by_method.get(method)):
|
|
731
|
+
return None
|
|
732
|
+
frozen_params = _freeze_params(params=params)
|
|
733
|
+
|
|
734
|
+
# For each parameter, choose the response at the latest timestamp.
|
|
735
|
+
if (bucket_by_ts := bucket_by_parameter.get(frozen_params)) is None:
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
latest_ts = max(bucket_by_ts.keys())
|
|
740
|
+
return bucket_by_ts[latest_ts]
|
|
741
|
+
except ValueError:
|
|
742
|
+
return None
|