aiohomematic-test-support 2025.11.3__tar.gz → 2025.12.3__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.
- {aiohomematic_test_support-2025.11.3/aiohomematic_test_support.egg-info → aiohomematic_test_support-2025.12.3}/PKG-INFO +1 -1
- aiohomematic_test_support-2025.12.3/__init__.py +48 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3/aiohomematic_test_support.egg-info}/PKG-INFO +1 -1
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/const.py +7 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/factory.py +119 -65
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/helper.py +5 -3
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/mock.py +170 -129
- aiohomematic_test_support-2025.11.3/__init__.py +0 -2
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/MANIFEST.in +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/README.md +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/aiohomematic_test_support.egg-info/SOURCES.txt +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/aiohomematic_test_support.egg-info/dependency_links.txt +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/aiohomematic_test_support.egg-info/top_level.txt +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/data/full_session_randomized_ccu.zip +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/data/full_session_randomized_pydevccu.zip +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/py.typed +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/pyproject.toml +0 -0
- {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic-test-support
|
|
3
|
-
Version: 2025.
|
|
3
|
+
Version: 2025.12.3
|
|
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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test support infrastructure for aiohomematic.
|
|
3
|
+
|
|
4
|
+
Overview
|
|
5
|
+
--------
|
|
6
|
+
This package provides reusable test utilities for testing aiohomematic functionality
|
|
7
|
+
without requiring a live Homematic backend. It includes factories for creating test
|
|
8
|
+
instances, mock implementations for RPC communication, and session playback capabilities
|
|
9
|
+
for reproducible testing.
|
|
10
|
+
|
|
11
|
+
Key Components
|
|
12
|
+
--------------
|
|
13
|
+
- **factory**: Factory classes for creating CentralUnit and Client instances with
|
|
14
|
+
pre-recorded or mocked backend responses.
|
|
15
|
+
- **mock**: Mock implementations for XML-RPC and JSON-RPC clients with session
|
|
16
|
+
playback support for deterministic testing.
|
|
17
|
+
- **helper**: Test helper utilities for common testing operations.
|
|
18
|
+
- **const**: Test-specific constants and configuration values.
|
|
19
|
+
|
|
20
|
+
Usage Example
|
|
21
|
+
-------------
|
|
22
|
+
Using the factory to create a test central with session playback:
|
|
23
|
+
|
|
24
|
+
from aiohomematic_test_support.factory import FactoryWithClient
|
|
25
|
+
from aiohomematic_test_support.mock import SessionPlayer
|
|
26
|
+
|
|
27
|
+
player = SessionPlayer(session_data_path="tests/data/ccu_session.zip")
|
|
28
|
+
factory = FactoryWithClient(player=player)
|
|
29
|
+
central, client = await factory()
|
|
30
|
+
|
|
31
|
+
# Test operations
|
|
32
|
+
await central.start()
|
|
33
|
+
device = central.get_device_by_address("VCU0000001")
|
|
34
|
+
await central.stop()
|
|
35
|
+
|
|
36
|
+
The session player replays pre-recorded backend responses, enabling fast and
|
|
37
|
+
reproducible tests without backend dependencies.
|
|
38
|
+
|
|
39
|
+
Notes
|
|
40
|
+
-----
|
|
41
|
+
This is a separate package from the main aiohomematic library. Install with
|
|
42
|
+
test dependencies to access test support functionality.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
__version__ = "2025.12.3"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohomematic-test-support
|
|
3
|
-
Version: 2025.
|
|
3
|
+
Version: 2025.12.3
|
|
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
|
|
@@ -13,6 +13,13 @@ CCU_PORT = 2002
|
|
|
13
13
|
CCU_MINI_PORT = 2003
|
|
14
14
|
INTERFACE_ID = f"{CENTRAL_NAME}-{Interface.BIDCOS_RF}"
|
|
15
15
|
|
|
16
|
+
# Backend info response for get_backend_info.fn script
|
|
17
|
+
BACKEND_INFO_JSON = {
|
|
18
|
+
"version": "3.75.6.20240316",
|
|
19
|
+
"product": "CCU3",
|
|
20
|
+
"hostname": "ccu-test",
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
FULL_SESSION_RANDOMIZED_PYDEVCCU = "full_session_randomized_pydevccu.zip"
|
|
17
24
|
FULL_SESSION_RANDOMIZED_CCU = "full_session_randomized_ccu.zip"
|
|
18
25
|
|
|
@@ -1,26 +1,56 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""
|
|
2
|
+
Test factories for creating CentralUnit and Client instances.
|
|
3
|
+
|
|
4
|
+
This module provides factory classes that simplify the creation of test instances
|
|
5
|
+
with pre-configured mocks and session playback. Factories handle the complexity of
|
|
6
|
+
setting up CentralConfig, InterfaceConfig, and mock RPC clients.
|
|
7
|
+
|
|
8
|
+
Key Classes
|
|
9
|
+
-----------
|
|
10
|
+
- **FactoryWithClient**: Factory for creating a CentralUnit with a single mocked client.
|
|
11
|
+
- **CentralClientFactory**: Factory for creating central with multiple clients.
|
|
12
|
+
|
|
13
|
+
Usage Pattern
|
|
14
|
+
-------------
|
|
15
|
+
Factories use the builder pattern with fluent configuration:
|
|
16
|
+
|
|
17
|
+
factory = FactoryWithClient(
|
|
18
|
+
player=session_player,
|
|
19
|
+
do_mock_client=True,
|
|
20
|
+
ignore_devices_on_create=["VCU0000099"],
|
|
21
|
+
)
|
|
22
|
+
central, client = await factory()
|
|
23
|
+
|
|
24
|
+
# Use central and client for testing
|
|
25
|
+
await central.start()
|
|
26
|
+
# ... test operations ...
|
|
27
|
+
await central.stop()
|
|
28
|
+
|
|
29
|
+
Public API of this module is defined by __all__.
|
|
30
|
+
"""
|
|
2
31
|
|
|
3
32
|
from __future__ import annotations
|
|
4
33
|
|
|
5
34
|
import asyncio
|
|
6
|
-
from collections.abc import AsyncGenerator
|
|
35
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
36
|
import contextlib
|
|
8
37
|
import logging
|
|
9
|
-
from typing import
|
|
38
|
+
from typing import Self, cast
|
|
10
39
|
from unittest.mock import MagicMock, Mock, patch
|
|
11
40
|
|
|
12
41
|
from aiohttp import ClientSession
|
|
13
42
|
|
|
14
43
|
from aiohomematic.central import CentralConfig, CentralUnit
|
|
15
|
-
from aiohomematic.
|
|
44
|
+
from aiohomematic.central.event_bus import BackendSystemEventData, HomematicEvent
|
|
45
|
+
from aiohomematic.client import ClientConfig, InterfaceConfig
|
|
16
46
|
from aiohomematic.const import LOCAL_HOST, BackendSystemEvent, Interface, OptionalSettings
|
|
47
|
+
from aiohomematic.interfaces import ClientProtocol
|
|
17
48
|
from aiohomematic_test_support import const
|
|
18
49
|
from aiohomematic_test_support.mock import SessionPlayer, get_client_session, get_mock, get_xml_rpc_proxy
|
|
19
50
|
|
|
20
51
|
_LOGGER = logging.getLogger(__name__)
|
|
21
52
|
|
|
22
53
|
|
|
23
|
-
# pylint: disable=protected-access
|
|
24
54
|
class FactoryWithClient:
|
|
25
55
|
"""Factory for a central with one local client."""
|
|
26
56
|
|
|
@@ -51,6 +81,84 @@ class FactoryWithClient:
|
|
|
51
81
|
)
|
|
52
82
|
self.system_event_mock = MagicMock()
|
|
53
83
|
self.ha_event_mock = MagicMock()
|
|
84
|
+
self._event_bus_unsubscribe_handlers: list[Callable[[], None]] = []
|
|
85
|
+
|
|
86
|
+
def cleanup_event_bus_subscriptions(self) -> None:
|
|
87
|
+
"""Clean up all event bus subscriptions."""
|
|
88
|
+
for unsubscribe in self._event_bus_unsubscribe_handlers:
|
|
89
|
+
unsubscribe()
|
|
90
|
+
self._event_bus_unsubscribe_handlers.clear()
|
|
91
|
+
|
|
92
|
+
async def get_default_central(self, *, start: bool = True) -> CentralUnit:
|
|
93
|
+
"""Return a central based on give address_device_translation."""
|
|
94
|
+
central = await self.get_raw_central()
|
|
95
|
+
|
|
96
|
+
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()
|
|
99
|
+
|
|
100
|
+
# Optionally patch client creation to return a mocked client
|
|
101
|
+
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)
|
|
106
|
+
return cast(
|
|
107
|
+
Mock,
|
|
108
|
+
get_mock(
|
|
109
|
+
instance=real_client,
|
|
110
|
+
exclude_methods=self._exclude_methods_from_mocks,
|
|
111
|
+
include_properties=self._include_properties_in_mocks,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
patch("aiohomematic.client.ClientConfig.create_client", _mocked_create_client).start()
|
|
116
|
+
|
|
117
|
+
if start:
|
|
118
|
+
await central.start()
|
|
119
|
+
await central.hub_coordinator.init_hub()
|
|
120
|
+
assert central
|
|
121
|
+
return central
|
|
122
|
+
|
|
123
|
+
async def get_raw_central(self) -> CentralUnit:
|
|
124
|
+
"""Return a central based on give address_device_translation."""
|
|
125
|
+
interface_configs = self._interface_configs if self._interface_configs else set()
|
|
126
|
+
central = CentralConfig(
|
|
127
|
+
name=const.CENTRAL_NAME,
|
|
128
|
+
host=const.CCU_HOST,
|
|
129
|
+
username=const.CCU_USERNAME,
|
|
130
|
+
password=const.CCU_PASSWORD,
|
|
131
|
+
central_id="test1234",
|
|
132
|
+
interface_configs=interface_configs,
|
|
133
|
+
client_session=self._client_session,
|
|
134
|
+
un_ignore_list=self._un_ignore_list,
|
|
135
|
+
ignore_custom_device_definition_models=frozenset(self._ignore_custom_device_definition_models or []),
|
|
136
|
+
start_direct=True,
|
|
137
|
+
optional_settings=(OptionalSettings.ENABLE_LINKED_ENTITY_CLIMATE_ACTIVITY,),
|
|
138
|
+
).create_central()
|
|
139
|
+
|
|
140
|
+
# Subscribe to events via event bus
|
|
141
|
+
def _system_event_handler(event: BackendSystemEventData) -> None:
|
|
142
|
+
"""Handle backend system events."""
|
|
143
|
+
self.system_event_mock(event)
|
|
144
|
+
|
|
145
|
+
def _ha_event_handler(event: HomematicEvent) -> None:
|
|
146
|
+
"""Handle homematic events."""
|
|
147
|
+
self.ha_event_mock(event)
|
|
148
|
+
|
|
149
|
+
self._event_bus_unsubscribe_handlers.append(
|
|
150
|
+
central.event_bus.subscribe(
|
|
151
|
+
event_type=BackendSystemEventData, event_key=None, handler=_system_event_handler
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
self._event_bus_unsubscribe_handlers.append(
|
|
155
|
+
central.event_bus.subscribe(event_type=HomematicEvent, event_key=None, handler=_ha_event_handler)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert central
|
|
159
|
+
self._client_session.set_central(central=central) # type: ignore[attr-defined]
|
|
160
|
+
self._xml_proxy.set_central(central=central)
|
|
161
|
+
return central
|
|
54
162
|
|
|
55
163
|
def init(
|
|
56
164
|
self,
|
|
@@ -95,62 +203,6 @@ class FactoryWithClient:
|
|
|
95
203
|
)
|
|
96
204
|
return self
|
|
97
205
|
|
|
98
|
-
async def get_raw_central(self) -> CentralUnit:
|
|
99
|
-
"""Return a central based on give address_device_translation."""
|
|
100
|
-
interface_configs = self._interface_configs if self._interface_configs else set()
|
|
101
|
-
central = CentralConfig(
|
|
102
|
-
name=const.CENTRAL_NAME,
|
|
103
|
-
host=const.CCU_HOST,
|
|
104
|
-
username=const.CCU_USERNAME,
|
|
105
|
-
password=const.CCU_PASSWORD,
|
|
106
|
-
central_id="test1234",
|
|
107
|
-
interface_configs=interface_configs,
|
|
108
|
-
client_session=self._client_session,
|
|
109
|
-
un_ignore_list=self._un_ignore_list,
|
|
110
|
-
ignore_custom_device_definition_models=frozenset(self._ignore_custom_device_definition_models or []),
|
|
111
|
-
start_direct=True,
|
|
112
|
-
optional_settings=(OptionalSettings.ENABLE_LINKED_ENTITY_CLIMATE_ACTIVITY,),
|
|
113
|
-
).create_central()
|
|
114
|
-
|
|
115
|
-
central.register_backend_system_callback(cb=self.system_event_mock)
|
|
116
|
-
central.register_homematic_callback(cb=self.ha_event_mock)
|
|
117
|
-
|
|
118
|
-
assert central
|
|
119
|
-
self._client_session.set_central(central=central) # type: ignore[attr-defined]
|
|
120
|
-
self._xml_proxy.set_central(central=central)
|
|
121
|
-
return central
|
|
122
|
-
|
|
123
|
-
async def get_default_central(self, *, start: bool = True) -> CentralUnit:
|
|
124
|
-
"""Return a central based on give address_device_translation."""
|
|
125
|
-
central = await self.get_raw_central()
|
|
126
|
-
|
|
127
|
-
await self._xml_proxy.do_init()
|
|
128
|
-
patch("aiohomematic.client.ClientConfig._create_xml_rpc_proxy", return_value=self._xml_proxy).start()
|
|
129
|
-
patch("aiohomematic.central.CentralUnit._identify_ip_addr", return_value=LOCAL_HOST).start()
|
|
130
|
-
|
|
131
|
-
# Optionally patch client creation to return a mocked client
|
|
132
|
-
if self._do_mock_client:
|
|
133
|
-
_orig_create_client = ClientConfig.create_client
|
|
134
|
-
|
|
135
|
-
async def _mocked_create_client(config: ClientConfig) -> Client | Mock:
|
|
136
|
-
real_client = await _orig_create_client(config)
|
|
137
|
-
return cast(
|
|
138
|
-
Mock,
|
|
139
|
-
get_mock(
|
|
140
|
-
instance=real_client,
|
|
141
|
-
exclude_methods=self._exclude_methods_from_mocks,
|
|
142
|
-
include_properties=self._include_properties_in_mocks,
|
|
143
|
-
),
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
patch("aiohomematic.client.ClientConfig.create_client", _mocked_create_client).start()
|
|
147
|
-
|
|
148
|
-
if start:
|
|
149
|
-
await central.start()
|
|
150
|
-
await central._init_hub()
|
|
151
|
-
assert central
|
|
152
|
-
return central
|
|
153
|
-
|
|
154
206
|
|
|
155
207
|
async def get_central_client_factory(
|
|
156
208
|
*,
|
|
@@ -160,7 +212,7 @@ async def get_central_client_factory(
|
|
|
160
212
|
ignore_devices_on_create: list[str] | None,
|
|
161
213
|
ignore_custom_device_definition_models: list[str] | None,
|
|
162
214
|
un_ignore_list: list[str] | None,
|
|
163
|
-
) -> AsyncGenerator[tuple[CentralUnit,
|
|
215
|
+
) -> AsyncGenerator[tuple[CentralUnit, ClientProtocol | Mock, FactoryWithClient]]:
|
|
164
216
|
"""Return central factory."""
|
|
165
217
|
factory = FactoryWithClient(
|
|
166
218
|
player=player,
|
|
@@ -176,6 +228,7 @@ async def get_central_client_factory(
|
|
|
176
228
|
try:
|
|
177
229
|
yield central, client, factory
|
|
178
230
|
finally:
|
|
231
|
+
factory.cleanup_event_bus_subscriptions()
|
|
179
232
|
await central.stop()
|
|
180
233
|
await central.clear_files()
|
|
181
234
|
|
|
@@ -188,8 +241,9 @@ async def get_pydev_ccu_central_unit_full(
|
|
|
188
241
|
"""Create and yield central, after all devices have been created."""
|
|
189
242
|
device_event = asyncio.Event()
|
|
190
243
|
|
|
191
|
-
def
|
|
192
|
-
|
|
244
|
+
def system_event_handler(event: BackendSystemEventData) -> None:
|
|
245
|
+
"""Handle backend system events."""
|
|
246
|
+
if event.system_event == BackendSystemEvent.DEVICES_CREATED:
|
|
193
247
|
device_event.set()
|
|
194
248
|
|
|
195
249
|
interface_configs = {
|
|
@@ -211,7 +265,7 @@ async def get_pydev_ccu_central_unit_full(
|
|
|
211
265
|
program_markers=(),
|
|
212
266
|
sysvar_markers=(),
|
|
213
267
|
).create_central()
|
|
214
|
-
central.
|
|
268
|
+
central.event_bus.subscribe(event_type=BackendSystemEventData, event_key=None, handler=system_event_handler)
|
|
215
269
|
await central.start()
|
|
216
270
|
|
|
217
271
|
# 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.
|
|
14
|
+
from aiohomematic.interfaces import CustomDataPointProtocol
|
|
15
15
|
|
|
16
16
|
_LOGGER = logging.getLogger(__name__)
|
|
17
17
|
|
|
@@ -29,10 +29,12 @@ def _load_json_file(anchor: str, resource: str, file_name: str) -> Any | None:
|
|
|
29
29
|
return orjson.loads(fptr.read())
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def get_prepared_custom_data_point(
|
|
32
|
+
def get_prepared_custom_data_point(
|
|
33
|
+
central: CentralUnit, address: str, channel_no: int
|
|
34
|
+
) -> CustomDataPointProtocol | None:
|
|
33
35
|
"""Return the hm custom_data_point."""
|
|
34
36
|
if cdp := central.get_custom_data_point(address=address, channel_no=channel_no):
|
|
35
|
-
for dp in cdp._data_points.values():
|
|
37
|
+
for dp in cdp._data_points.values(): # type: ignore[attr-defined]
|
|
36
38
|
dp._state_uncertain = False
|
|
37
39
|
return cdp
|
|
38
40
|
return None
|
|
@@ -1,4 +1,37 @@
|
|
|
1
|
-
"""
|
|
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
|
+
"""
|
|
2
35
|
|
|
3
36
|
from __future__ import annotations
|
|
4
37
|
|
|
@@ -63,9 +96,9 @@ def get_client_session( # noqa: C901
|
|
|
63
96
|
"""
|
|
64
97
|
|
|
65
98
|
class _MockResponse:
|
|
66
|
-
def __init__(self, *, json_data: dict | None) -> None:
|
|
99
|
+
def __init__(self, *, json_data: dict[str, Any] | None) -> None:
|
|
67
100
|
# If no match is found, emulate backend error payload
|
|
68
|
-
self._json = json_data or {
|
|
101
|
+
self._json: dict[str, Any] = json_data or {
|
|
69
102
|
_JsonKey.RESULT: None,
|
|
70
103
|
_JsonKey.ERROR: {"name": "-1", "code": -1, "message": "Not found in session player"},
|
|
71
104
|
_JsonKey.ID: 0,
|
|
@@ -83,9 +116,8 @@ def get_client_session( # noqa: C901
|
|
|
83
116
|
"""Initialize the mock client session."""
|
|
84
117
|
self._central: CentralUnit | None = None
|
|
85
118
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
self._central = central
|
|
119
|
+
async def close(self) -> None: # compatibility
|
|
120
|
+
return None
|
|
89
121
|
|
|
90
122
|
async def post(
|
|
91
123
|
self,
|
|
@@ -142,6 +174,15 @@ def get_client_session( # noqa: C901
|
|
|
142
174
|
}
|
|
143
175
|
)
|
|
144
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
|
+
|
|
145
186
|
if method == _JsonRpcMethod.INTERFACE_SET_VALUE:
|
|
146
187
|
await self._central.data_point_event(
|
|
147
188
|
interface_id=params[_JsonKey.INTERFACE],
|
|
@@ -187,8 +228,9 @@ def get_client_session( # noqa: C901
|
|
|
187
228
|
json_data[_JsonKey.RESULT] = new_devices
|
|
188
229
|
return _MockResponse(json_data=json_data)
|
|
189
230
|
|
|
190
|
-
|
|
191
|
-
|
|
231
|
+
def set_central(self, *, central: CentralUnit) -> None:
|
|
232
|
+
"""Set the central."""
|
|
233
|
+
self._central = central
|
|
192
234
|
|
|
193
235
|
return cast(ClientSession, _MockClientSession())
|
|
194
236
|
|
|
@@ -212,29 +254,40 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
212
254
|
self._name = full_name
|
|
213
255
|
self._caller = caller
|
|
214
256
|
|
|
215
|
-
def __getattr__(self, sub: str) -> _Method:
|
|
216
|
-
# Allow chaining like proxy.system.listMethods
|
|
217
|
-
return _Method(f"{self._name}.{sub}", self._caller)
|
|
218
|
-
|
|
219
257
|
async def __call__(self, *args: Any) -> Any:
|
|
220
258
|
# Forward to caller with collected method name and positional params
|
|
221
259
|
return await self._caller(self._name, *args)
|
|
222
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
|
+
|
|
223
265
|
class _AioXmlRpcProxyFromSession:
|
|
224
266
|
def __init__(self) -> None:
|
|
225
267
|
self._player = player
|
|
226
268
|
self._supported_methods: tuple[str, ...] = ()
|
|
227
269
|
self._central: CentralUnit | None = None
|
|
228
270
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
self.
|
|
271
|
+
def __getattr__(self, name: str) -> Any:
|
|
272
|
+
# Start of method chain
|
|
273
|
+
return _Method(name, self._invoke)
|
|
232
274
|
|
|
233
275
|
@property
|
|
234
276
|
def supported_methods(self) -> tuple[str, ...]:
|
|
235
277
|
"""Return the supported methods."""
|
|
236
278
|
return self._supported_methods
|
|
237
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
|
+
|
|
238
291
|
async def getAllSystemVariables(self) -> dict[str, Any]:
|
|
239
292
|
"""Return all system variables."""
|
|
240
293
|
return const.SYSVAR_DATA_XML
|
|
@@ -249,41 +302,6 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
249
302
|
)
|
|
250
303
|
return result if result else {}
|
|
251
304
|
|
|
252
|
-
async def setValue(self, channel_address: str, parameter: str, value: Any, rx_mode: Any | None = None) -> None:
|
|
253
|
-
"""Set a value."""
|
|
254
|
-
if self._central:
|
|
255
|
-
await self._central.data_point_event(
|
|
256
|
-
interface_id=self._central.primary_client.interface_id, # type: ignore[union-attr]
|
|
257
|
-
channel_address=channel_address,
|
|
258
|
-
parameter=parameter,
|
|
259
|
-
value=value,
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
async def putParamset(
|
|
263
|
-
self, channel_address: str, paramset_key: str, values: Any, rx_mode: Any | None = None
|
|
264
|
-
) -> None:
|
|
265
|
-
"""Set a paramset."""
|
|
266
|
-
if self._central and paramset_key == ParamsetKey.VALUES:
|
|
267
|
-
interface_id = self._central.primary_client.interface_id # type: ignore[union-attr]
|
|
268
|
-
for param, value in values.items():
|
|
269
|
-
await self._central.data_point_event(
|
|
270
|
-
interface_id=interface_id, channel_address=channel_address, parameter=param, value=value
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
async def ping(self, callerId: str) -> None:
|
|
274
|
-
"""Answer ping with pong."""
|
|
275
|
-
if self._central:
|
|
276
|
-
await self._central.data_point_event(
|
|
277
|
-
interface_id=callerId,
|
|
278
|
-
channel_address="",
|
|
279
|
-
parameter=Parameter.PONG,
|
|
280
|
-
value=callerId,
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
async def clientServerInitialized(self, interface_id: str) -> None:
|
|
284
|
-
"""Answer clientServerInitialized with pong."""
|
|
285
|
-
await self.ping(callerId=interface_id)
|
|
286
|
-
|
|
287
305
|
async def listDevices(self) -> list[Any]:
|
|
288
306
|
"""Return a list of devices."""
|
|
289
307
|
devices = self._player.get_latest_response_by_params(
|
|
@@ -309,9 +327,43 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
309
327
|
|
|
310
328
|
return new_devices
|
|
311
329
|
|
|
312
|
-
def
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
315
367
|
|
|
316
368
|
async def _invoke(self, method: str, *args: Any) -> Any:
|
|
317
369
|
params = tuple(args)
|
|
@@ -321,16 +373,6 @@ def get_xml_rpc_proxy( # noqa: C901
|
|
|
321
373
|
params=params,
|
|
322
374
|
)
|
|
323
375
|
|
|
324
|
-
async def stop(self) -> None: # compatibility with AioXmlRpcProxy.stop
|
|
325
|
-
return None
|
|
326
|
-
|
|
327
|
-
async def do_init(self) -> None:
|
|
328
|
-
"""Init the xml rpc proxy."""
|
|
329
|
-
if supported_methods := await self.system.listMethods():
|
|
330
|
-
# ping is missing in VirtualDevices interface but can be used.
|
|
331
|
-
supported_methods.append(_RpcMethod.PING)
|
|
332
|
-
self._supported_methods = tuple(supported_methods)
|
|
333
|
-
|
|
334
376
|
return cast(BaseRpcProxy, _AioXmlRpcProxyFromSession())
|
|
335
377
|
|
|
336
378
|
|
|
@@ -389,45 +431,23 @@ class SessionPlayer:
|
|
|
389
431
|
"""Return the secondary store for the given file_id."""
|
|
390
432
|
return [fid for fid in self._store if fid != self._file_id]
|
|
391
433
|
|
|
392
|
-
def
|
|
393
|
-
"""Return
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
Supports plain JSON files and ZIP archives containing a JSON file.
|
|
401
|
-
When a ZIP archive is provided, the first JSON member inside the archive
|
|
402
|
-
will be loaded.
|
|
403
|
-
"""
|
|
404
|
-
|
|
405
|
-
if self.supports_file_id(file_id=file_id):
|
|
406
|
-
return DataOperationResult.NO_LOAD
|
|
407
|
-
|
|
408
|
-
if not os.path.exists(file_path):
|
|
409
|
-
return DataOperationResult.NO_LOAD
|
|
410
|
-
|
|
411
|
-
def _perform_load() -> DataOperationResult:
|
|
412
|
-
try:
|
|
413
|
-
if zipfile.is_zipfile(file_path):
|
|
414
|
-
with zipfile.ZipFile(file_path, mode="r") as zf:
|
|
415
|
-
# Prefer json files; pick the first .json entry if available
|
|
416
|
-
if not (json_members := [n for n in zf.namelist() if n.lower().endswith(".json")]):
|
|
417
|
-
return DataOperationResult.LOAD_FAIL
|
|
418
|
-
raw = zf.read(json_members[0]).decode(UTF_8)
|
|
419
|
-
data = json.loads(raw)
|
|
420
|
-
else:
|
|
421
|
-
with open(file=file_path, encoding=UTF_8) as file_pointer:
|
|
422
|
-
data = json.loads(file_pointer.read())
|
|
423
|
-
|
|
424
|
-
self._store[file_id] = data
|
|
425
|
-
except (json.JSONDecodeError, zipfile.BadZipFile, UnicodeDecodeError, OSError):
|
|
426
|
-
return DataOperationResult.LOAD_FAIL
|
|
427
|
-
return DataOperationResult.LOAD_SUCCESS
|
|
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
|
|
428
442
|
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
431
451
|
|
|
432
452
|
def get_latest_response_by_method_for_file_id(
|
|
433
453
|
self, *, file_id: str, rpc_type: str, method: str
|
|
@@ -453,20 +473,28 @@ class SessionPlayer:
|
|
|
453
473
|
result.append((params, resp))
|
|
454
474
|
return result
|
|
455
475
|
|
|
456
|
-
def
|
|
457
|
-
|
|
458
|
-
|
|
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(
|
|
459
485
|
file_id=self._file_id,
|
|
460
486
|
rpc_type=rpc_type,
|
|
461
487
|
method=method,
|
|
488
|
+
params=params,
|
|
462
489
|
):
|
|
463
490
|
return pri_result
|
|
464
491
|
|
|
465
492
|
for secondary_file_id in self._secondary_file_ids:
|
|
466
|
-
if sec_result := self.
|
|
493
|
+
if sec_result := self.get_latest_response_by_params_for_file_id(
|
|
467
494
|
file_id=secondary_file_id,
|
|
468
495
|
rpc_type=rpc_type,
|
|
469
496
|
method=method,
|
|
497
|
+
params=params,
|
|
470
498
|
):
|
|
471
499
|
return sec_result
|
|
472
500
|
return pri_result
|
|
@@ -497,28 +525,41 @@ class SessionPlayer:
|
|
|
497
525
|
except ValueError:
|
|
498
526
|
return None
|
|
499
527
|
|
|
500
|
-
def
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
rpc_type: str,
|
|
504
|
-
method: str,
|
|
505
|
-
params: Any,
|
|
506
|
-
) -> Any:
|
|
507
|
-
"""Return latest non-expired responses for a given (rpc_type, method, params)."""
|
|
508
|
-
if pri_result := self.get_latest_response_by_params_for_file_id(
|
|
509
|
-
file_id=self._file_id,
|
|
510
|
-
rpc_type=rpc_type,
|
|
511
|
-
method=method,
|
|
512
|
-
params=params,
|
|
513
|
-
):
|
|
514
|
-
return pri_result
|
|
528
|
+
async def load(self, *, file_path: str, file_id: str) -> DataOperationResult:
|
|
529
|
+
"""
|
|
530
|
+
Load data from disk into the dictionary.
|
|
515
531
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|