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.
Files changed (18) hide show
  1. {aiohomematic_test_support-2025.11.3/aiohomematic_test_support.egg-info → aiohomematic_test_support-2025.12.3}/PKG-INFO +1 -1
  2. aiohomematic_test_support-2025.12.3/__init__.py +48 -0
  3. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3/aiohomematic_test_support.egg-info}/PKG-INFO +1 -1
  4. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/const.py +7 -0
  5. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/factory.py +119 -65
  6. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/helper.py +5 -3
  7. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/mock.py +170 -129
  8. aiohomematic_test_support-2025.11.3/__init__.py +0 -2
  9. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/MANIFEST.in +0 -0
  10. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/README.md +0 -0
  11. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/aiohomematic_test_support.egg-info/SOURCES.txt +0 -0
  12. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/aiohomematic_test_support.egg-info/dependency_links.txt +0 -0
  13. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/aiohomematic_test_support.egg-info/top_level.txt +0 -0
  14. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/data/full_session_randomized_ccu.zip +0 -0
  15. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/data/full_session_randomized_pydevccu.zip +0 -0
  16. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/py.typed +0 -0
  17. {aiohomematic_test_support-2025.11.3 → aiohomematic_test_support-2025.12.3}/pyproject.toml +0 -0
  18. {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.11.3
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.11.3
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
- """Factories for tests."""
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 Any, Self, cast
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.client import Client, ClientConfig, InterfaceConfig
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, Client | Mock, FactoryWithClient]]:
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 systemcallback(system_event: Any, *args: Any, **kwargs: Any) -> None:
192
- if system_event == BackendSystemEvent.DEVICES_CREATED:
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.register_backend_system_callback(cb=systemcallback)
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.model.custom import CustomDataPoint
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(central: CentralUnit, address: str, channel_no: int) -> CustomDataPoint | None:
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
- """Mocks for tests."""
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 set_central(self, *, central: CentralUnit) -> None:
87
- """Set the central."""
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
- async def close(self) -> None: # compatibility
191
- return None
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 set_central(self, *, central: CentralUnit) -> None:
230
- """Set the central."""
231
- self._central = central
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 __getattr__(self, name: str) -> Any:
313
- # Start of method chain
314
- return _Method(name, self._invoke)
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 supports_file_id(self, *, file_id: str) -> bool:
393
- """Return whether the session player supports the given file_id."""
394
- return file_id in self._store
395
-
396
- async def load(self, *, file_path: str, file_id: str) -> DataOperationResult:
397
- """
398
- Load data from disk into the dictionary.
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
- loop = asyncio.get_running_loop()
430
- return await loop.run_in_executor(None, _perform_load)
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 get_latest_response_by_method(self, *, rpc_type: str, method: str) -> list[tuple[Any, Any]]:
457
- """Return latest non-expired responses for a given (rpc_type, method)."""
458
- if pri_result := self.get_latest_response_by_method_for_file_id(
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.get_latest_response_by_method_for_file_id(
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 get_latest_response_by_params(
501
- self,
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
- for secondary_file_id in self._secondary_file_ids:
517
- if sec_result := self.get_latest_response_by_params_for_file_id(
518
- file_id=secondary_file_id,
519
- rpc_type=rpc_type,
520
- method=method,
521
- params=params,
522
- ):
523
- return sec_result
524
- return pri_result
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
@@ -1,2 +0,0 @@
1
- __version__ = "2025.11.3"
2
- """Module to support aiohomematic testing with a local client."""