aiohomematic 2025.10.7__py3-none-any.whl → 2025.10.9__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 might be problematic. Click here for more details.
- aiohomematic/__init__.py +3 -3
- aiohomematic/async_support.py +1 -1
- aiohomematic/central/__init__.py +59 -31
- aiohomematic/central/decorators.py +1 -1
- aiohomematic/central/rpc_server.py +1 -1
- aiohomematic/client/__init__.py +19 -13
- aiohomematic/client/_rpc_errors.py +1 -1
- aiohomematic/client/json_rpc.py +29 -3
- aiohomematic/client/rpc_proxy.py +20 -2
- aiohomematic/const.py +25 -6
- aiohomematic/context.py +1 -1
- aiohomematic/converter.py +1 -1
- aiohomematic/decorators.py +1 -1
- aiohomematic/exceptions.py +1 -1
- aiohomematic/hmcli.py +1 -1
- aiohomematic/model/__init__.py +1 -1
- aiohomematic/model/calculated/__init__.py +21 -4
- aiohomematic/model/calculated/climate.py +59 -1
- aiohomematic/model/calculated/data_point.py +1 -1
- aiohomematic/model/calculated/operating_voltage_level.py +1 -1
- aiohomematic/model/calculated/support.py +41 -3
- aiohomematic/model/custom/__init__.py +1 -1
- aiohomematic/model/custom/climate.py +7 -4
- aiohomematic/model/custom/const.py +1 -1
- aiohomematic/model/custom/cover.py +1 -1
- aiohomematic/model/custom/data_point.py +1 -1
- aiohomematic/model/custom/definition.py +1 -1
- aiohomematic/model/custom/light.py +1 -1
- aiohomematic/model/custom/lock.py +1 -1
- aiohomematic/model/custom/siren.py +1 -1
- aiohomematic/model/custom/support.py +1 -1
- aiohomematic/model/custom/switch.py +1 -1
- aiohomematic/model/custom/valve.py +1 -1
- aiohomematic/model/data_point.py +3 -2
- aiohomematic/model/device.py +10 -13
- aiohomematic/model/event.py +1 -1
- aiohomematic/model/generic/__init__.py +1 -1
- aiohomematic/model/generic/action.py +1 -1
- aiohomematic/model/generic/binary_sensor.py +1 -1
- aiohomematic/model/generic/button.py +1 -1
- aiohomematic/model/generic/data_point.py +1 -1
- aiohomematic/model/generic/number.py +1 -1
- aiohomematic/model/generic/select.py +1 -1
- aiohomematic/model/generic/sensor.py +1 -1
- aiohomematic/model/generic/switch.py +1 -1
- aiohomematic/model/generic/text.py +1 -1
- aiohomematic/model/hub/__init__.py +1 -1
- aiohomematic/model/hub/binary_sensor.py +1 -1
- aiohomematic/model/hub/button.py +1 -1
- aiohomematic/model/hub/data_point.py +1 -1
- aiohomematic/model/hub/number.py +1 -1
- aiohomematic/model/hub/select.py +1 -1
- aiohomematic/model/hub/sensor.py +1 -1
- aiohomematic/model/hub/switch.py +1 -1
- aiohomematic/model/hub/text.py +1 -1
- aiohomematic/model/support.py +1 -1
- aiohomematic/model/update.py +1 -1
- aiohomematic/property_decorators.py +2 -2
- aiohomematic/store/__init__.py +34 -0
- aiohomematic/{caches → store}/dynamic.py +4 -4
- aiohomematic/store/persistent.py +933 -0
- aiohomematic/{caches → store}/visibility.py +4 -4
- aiohomematic/support.py +20 -17
- aiohomematic/validator.py +1 -1
- {aiohomematic-2025.10.7.dist-info → aiohomematic-2025.10.9.dist-info}/METADATA +1 -1
- aiohomematic-2025.10.9.dist-info/RECORD +78 -0
- aiohomematic_support/client_local.py +2 -2
- aiohomematic/caches/__init__.py +0 -12
- aiohomematic/caches/persistent.py +0 -478
- aiohomematic-2025.10.7.dist-info/RECORD +0 -78
- {aiohomematic-2025.10.7.dist-info → aiohomematic-2025.10.9.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.10.7.dist-info → aiohomematic-2025.10.9.dist-info}/licenses/LICENSE +0 -0
- {aiohomematic-2025.10.7.dist-info → aiohomematic-2025.10.9.dist-info}/top_level.txt +0 -0
aiohomematic/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-2025
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
3
|
"""
|
|
4
4
|
AioHomematic: a Python 3 library to interact with Homematic and HomematicIP backends.
|
|
5
5
|
|
|
@@ -9,10 +9,10 @@ This package provides a high-level API to discover devices and channels, read an
|
|
|
9
9
|
parameters (data points), receive events, and manage programs and system variables.
|
|
10
10
|
|
|
11
11
|
Key layers and responsibilities:
|
|
12
|
-
- aiohomematic.central: Orchestrates clients,
|
|
12
|
+
- aiohomematic.central: Orchestrates clients, store, device creation and events.
|
|
13
13
|
- aiohomematic.client: Interface-specific clients (JSON-RPC/XML-RPC, Homegear) handling IO.
|
|
14
14
|
- aiohomematic.model: Data point abstraction for generic, hub, and calculated entities.
|
|
15
|
-
- aiohomematic.
|
|
15
|
+
- aiohomematic.store: Persistent and runtime store for descriptions, values, and metadata.
|
|
16
16
|
|
|
17
17
|
Typical usage is to construct a CentralConfig, create a CentralUnit and start it, then
|
|
18
18
|
consume data points and events or issue write commands via the exposed API.
|
aiohomematic/async_support.py
CHANGED
aiohomematic/central/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-2025
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
3
|
"""
|
|
4
4
|
Central unit and core orchestration for Homematic CCU and compatible backends.
|
|
5
5
|
|
|
@@ -9,8 +9,8 @@ This package provides the central coordination layer for aiohomematic. It models
|
|
|
9
9
|
Homematic CCU (or compatible backend such as Homegear) and orchestrates
|
|
10
10
|
interfaces, devices, channels, data points, events, and background jobs.
|
|
11
11
|
|
|
12
|
-
The central unit ties together the various submodules:
|
|
13
|
-
(JSON-RPC/XML-RPC), device and data point models, and visibility/description
|
|
12
|
+
The central unit ties together the various submodules: store, client adapters
|
|
13
|
+
(JSON-RPC/XML-RPC), device and data point models, and visibility/description store.
|
|
14
14
|
It exposes high-level APIs to query and manipulate the backend state while
|
|
15
15
|
encapsulating transport and scheduling details.
|
|
16
16
|
|
|
@@ -52,7 +52,7 @@ Example (simplified):
|
|
|
52
52
|
)
|
|
53
53
|
|
|
54
54
|
central = cfg.create_central()
|
|
55
|
-
central.start() # start XML-RPC server, create/init clients, load
|
|
55
|
+
central.start() # start XML-RPC server, create/init clients, load store
|
|
56
56
|
# ... interact with devices / data points via central ...
|
|
57
57
|
central.stop()
|
|
58
58
|
|
|
@@ -79,13 +79,9 @@ import voluptuous as vol
|
|
|
79
79
|
|
|
80
80
|
from aiohomematic import client as hmcl
|
|
81
81
|
from aiohomematic.async_support import Looper, loop_check
|
|
82
|
-
from aiohomematic.caches.dynamic import CentralDataCache, DeviceDetailsCache
|
|
83
|
-
from aiohomematic.caches.persistent import DeviceDescriptionCache, ParamsetDescriptionCache
|
|
84
|
-
from aiohomematic.caches.visibility import ParameterVisibilityCache
|
|
85
82
|
from aiohomematic.central import rpc_server as rpc
|
|
86
83
|
from aiohomematic.central.decorators import callback_backend_system, callback_event
|
|
87
|
-
from aiohomematic.client
|
|
88
|
-
from aiohomematic.client.rpc_proxy import AioXmlRpcProxy
|
|
84
|
+
from aiohomematic.client import AioJsonRpcAioHttpClient, BaseRpcProxy
|
|
89
85
|
from aiohomematic.const import (
|
|
90
86
|
CALLBACK_TYPE,
|
|
91
87
|
CATEGORIES,
|
|
@@ -102,7 +98,7 @@ from aiohomematic.const import (
|
|
|
102
98
|
DEFAULT_MAX_READ_WORKERS,
|
|
103
99
|
DEFAULT_PERIODIC_REFRESH_INTERVAL,
|
|
104
100
|
DEFAULT_PROGRAM_MARKERS,
|
|
105
|
-
|
|
101
|
+
DEFAULT_STORAGE_DIRECTORY,
|
|
106
102
|
DEFAULT_SYS_SCAN_INTERVAL,
|
|
107
103
|
DEFAULT_SYSVAR_MARKERS,
|
|
108
104
|
DEFAULT_TLS,
|
|
@@ -163,6 +159,14 @@ from aiohomematic.model.hub import (
|
|
|
163
159
|
ProgramDpType,
|
|
164
160
|
)
|
|
165
161
|
from aiohomematic.property_decorators import info_property
|
|
162
|
+
from aiohomematic.store import (
|
|
163
|
+
CentralDataCache,
|
|
164
|
+
DeviceDescriptionCache,
|
|
165
|
+
DeviceDetailsCache,
|
|
166
|
+
ParameterVisibilityCache,
|
|
167
|
+
ParamsetDescriptionCache,
|
|
168
|
+
SessionRecorder,
|
|
169
|
+
)
|
|
166
170
|
from aiohomematic.support import (
|
|
167
171
|
LogContextMixin,
|
|
168
172
|
PayloadMixin,
|
|
@@ -181,7 +185,7 @@ _LOGGER_EVENT: Final = logging.getLogger(f"{__package__}.event")
|
|
|
181
185
|
|
|
182
186
|
# {central_name, central}
|
|
183
187
|
CENTRAL_INSTANCES: Final[dict[str, CentralUnit]] = {}
|
|
184
|
-
ConnectionProblemIssuer = AioJsonRpcAioHttpClient |
|
|
188
|
+
ConnectionProblemIssuer = AioJsonRpcAioHttpClient | BaseRpcProxy
|
|
185
189
|
|
|
186
190
|
INTERFACE_EVENT_SCHEMA = vol.Schema(
|
|
187
191
|
{
|
|
@@ -218,7 +222,9 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
218
222
|
self._device_descriptions: Final = DeviceDescriptionCache(central=self)
|
|
219
223
|
self._paramset_descriptions: Final = ParamsetDescriptionCache(central=self)
|
|
220
224
|
self._parameter_visibility: Final = ParameterVisibilityCache(central=self)
|
|
221
|
-
|
|
225
|
+
self._recorder: Final = SessionRecorder(
|
|
226
|
+
central=self, default_ttl_seconds=600, active=central_config.start_recorder
|
|
227
|
+
)
|
|
222
228
|
self._primary_client: hmcl.Client | None = None
|
|
223
229
|
# {interface_id, client}
|
|
224
230
|
self._clients: Final[dict[str, hmcl.Client]] = {}
|
|
@@ -344,6 +350,11 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
344
350
|
"""Return parameter_visibility cache."""
|
|
345
351
|
return self._parameter_visibility
|
|
346
352
|
|
|
353
|
+
@property
|
|
354
|
+
def recorder(self) -> SessionRecorder:
|
|
355
|
+
"""Return the session recorder."""
|
|
356
|
+
return self._recorder
|
|
357
|
+
|
|
347
358
|
@property
|
|
348
359
|
def poll_clients(self) -> tuple[hmcl.Client, ...]:
|
|
349
360
|
"""Return clients that need to poll data."""
|
|
@@ -458,10 +469,13 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
458
469
|
return channel
|
|
459
470
|
return None
|
|
460
471
|
|
|
461
|
-
async def
|
|
462
|
-
self,
|
|
472
|
+
async def save_files(
|
|
473
|
+
self,
|
|
474
|
+
*,
|
|
475
|
+
save_device_descriptions: bool = False,
|
|
476
|
+
save_paramset_descriptions: bool = False,
|
|
463
477
|
) -> None:
|
|
464
|
-
"""Save persistent
|
|
478
|
+
"""Save persistent files to disk."""
|
|
465
479
|
if save_device_descriptions:
|
|
466
480
|
await self._device_descriptions.save()
|
|
467
481
|
if save_paramset_descriptions:
|
|
@@ -479,6 +493,15 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
479
493
|
_LOGGER.debug("START: Central %s already started", self.name)
|
|
480
494
|
return
|
|
481
495
|
|
|
496
|
+
if self._config.start_recorder:
|
|
497
|
+
await self._recorder.deactivate(
|
|
498
|
+
delay=self._config.start_recorder_for_minutes * 60,
|
|
499
|
+
auto_save=True,
|
|
500
|
+
randomize_output=True,
|
|
501
|
+
use_ts_in_filename=False,
|
|
502
|
+
)
|
|
503
|
+
_LOGGER.debug("START: Starting Recorder for %s minutes", self._config.start_recorder_for_minutes)
|
|
504
|
+
|
|
482
505
|
self._state = CentralUnitState.INITIALIZING
|
|
483
506
|
_LOGGER.debug("START: Initializing Central %s", self.name)
|
|
484
507
|
if self._config.enabled_interface_configs and (
|
|
@@ -536,7 +559,7 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
536
559
|
self._state = CentralUnitState.STOPPING
|
|
537
560
|
_LOGGER.debug("STOP: Stopping Central %s", self.name)
|
|
538
561
|
|
|
539
|
-
await self.
|
|
562
|
+
await self.save_files(save_device_descriptions=True, save_paramset_descriptions=True)
|
|
540
563
|
self._stop_scheduler()
|
|
541
564
|
await self._stop_clients()
|
|
542
565
|
if self._json_rpc_client and self._json_rpc_client.is_activated:
|
|
@@ -952,13 +975,13 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
952
975
|
return len(self._clients) > 0
|
|
953
976
|
|
|
954
977
|
async def _load_caches(self) -> bool:
|
|
955
|
-
"""Load files to
|
|
978
|
+
"""Load files to store."""
|
|
956
979
|
if DataOperationResult.LOAD_FAIL in (
|
|
957
980
|
await self._device_descriptions.load(),
|
|
958
981
|
await self._paramset_descriptions.load(),
|
|
959
982
|
):
|
|
960
|
-
_LOGGER.warning("LOAD_CACHES failed: Unable to load
|
|
961
|
-
await self.
|
|
983
|
+
_LOGGER.warning("LOAD_CACHES failed: Unable to load store for %s. Clearing files", self.name)
|
|
984
|
+
await self.clear_files()
|
|
962
985
|
return False
|
|
963
986
|
await self._device_details.load()
|
|
964
987
|
await self._data_cache.load()
|
|
@@ -1047,7 +1070,7 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
1047
1070
|
for address in addresses:
|
|
1048
1071
|
if device := self._devices.get(address):
|
|
1049
1072
|
self.remove_device(device=device)
|
|
1050
|
-
await self.
|
|
1073
|
+
await self.save_files(save_device_descriptions=True, save_paramset_descriptions=True)
|
|
1051
1074
|
|
|
1052
1075
|
@callback_backend_system(system_event=BackendSystemEvent.NEW_DEVICES)
|
|
1053
1076
|
async def add_new_devices(self, *, interface_id: str, device_descriptions: tuple[DeviceDescription, ...]) -> None:
|
|
@@ -1068,7 +1091,7 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
1068
1091
|
)
|
|
1069
1092
|
return
|
|
1070
1093
|
client = self._clients[interface_id]
|
|
1071
|
-
if (device_descriptions := await client.
|
|
1094
|
+
if (device_descriptions := await client.get_all_device_descriptions(device_address=address)) is None:
|
|
1072
1095
|
_LOGGER.warning(
|
|
1073
1096
|
"ADD_NEW_DEVICES_MANUALLY failed: No device description found for address %s on interface_id %s",
|
|
1074
1097
|
address,
|
|
@@ -1149,7 +1172,7 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
1149
1172
|
extract_exc_args(exc=exc),
|
|
1150
1173
|
)
|
|
1151
1174
|
|
|
1152
|
-
await self.
|
|
1175
|
+
await self.save_files(
|
|
1153
1176
|
save_device_descriptions=save_descriptions,
|
|
1154
1177
|
save_paramset_descriptions=save_descriptions,
|
|
1155
1178
|
)
|
|
@@ -1607,10 +1630,11 @@ class CentralUnit(LogContextMixin, PayloadMixin):
|
|
|
1607
1630
|
)
|
|
1608
1631
|
return candidates
|
|
1609
1632
|
|
|
1610
|
-
async def
|
|
1611
|
-
"""
|
|
1633
|
+
async def clear_files(self) -> None:
|
|
1634
|
+
"""Remove all stored files and caches."""
|
|
1612
1635
|
await self._device_descriptions.clear()
|
|
1613
1636
|
await self._paramset_descriptions.clear()
|
|
1637
|
+
await self._recorder.clear()
|
|
1614
1638
|
self._device_details.clear()
|
|
1615
1639
|
self._data_cache.clear()
|
|
1616
1640
|
|
|
@@ -1988,7 +2012,8 @@ class CentralConfig:
|
|
|
1988
2012
|
periodic_refresh_interval: int = DEFAULT_PERIODIC_REFRESH_INTERVAL,
|
|
1989
2013
|
program_markers: tuple[DescriptionMarker | str, ...] = DEFAULT_PROGRAM_MARKERS,
|
|
1990
2014
|
start_direct: bool = False,
|
|
1991
|
-
|
|
2015
|
+
start_recorder_for_minutes: int = 0,
|
|
2016
|
+
storage_directory: str = DEFAULT_STORAGE_DIRECTORY,
|
|
1992
2017
|
sys_scan_interval: int = DEFAULT_SYS_SCAN_INTERVAL,
|
|
1993
2018
|
sysvar_markers: tuple[DescriptionMarker | str, ...] = DEFAULT_SYSVAR_MARKERS,
|
|
1994
2019
|
tls: bool = DEFAULT_TLS,
|
|
@@ -2023,7 +2048,9 @@ class CentralConfig:
|
|
|
2023
2048
|
self.periodic_refresh_interval = periodic_refresh_interval
|
|
2024
2049
|
self.program_markers: Final = program_markers
|
|
2025
2050
|
self.start_direct: Final = start_direct
|
|
2026
|
-
self.
|
|
2051
|
+
self.start_recorder_for_minutes: Final = start_recorder_for_minutes
|
|
2052
|
+
self.start_recorder = start_recorder_for_minutes > 0
|
|
2053
|
+
self.storage_directory: Final = storage_directory
|
|
2027
2054
|
self.sys_scan_interval: Final = sys_scan_interval
|
|
2028
2055
|
self.sysvar_markers: Final = sysvar_markers
|
|
2029
2056
|
self.tls: Final = tls
|
|
@@ -2058,7 +2085,7 @@ class CentralConfig:
|
|
|
2058
2085
|
|
|
2059
2086
|
@property
|
|
2060
2087
|
def use_caches(self) -> bool:
|
|
2061
|
-
"""Return if
|
|
2088
|
+
"""Return if store should be used."""
|
|
2062
2089
|
return self.start_direct is False
|
|
2063
2090
|
|
|
2064
2091
|
def check_config(self) -> None:
|
|
@@ -2068,7 +2095,7 @@ class CentralConfig:
|
|
|
2068
2095
|
host=self.host,
|
|
2069
2096
|
username=self.username,
|
|
2070
2097
|
password=self.password,
|
|
2071
|
-
|
|
2098
|
+
storage_directory=self.storage_directory,
|
|
2072
2099
|
callback_host=self.callback_host,
|
|
2073
2100
|
callback_port_xml_rpc=self.callback_port_xml_rpc,
|
|
2074
2101
|
json_port=self.json_port,
|
|
@@ -2105,6 +2132,7 @@ class CentralConfig:
|
|
|
2105
2132
|
client_session=self.client_session,
|
|
2106
2133
|
tls=self.tls,
|
|
2107
2134
|
verify_tls=self.verify_tls,
|
|
2135
|
+
session_recorder=central.recorder,
|
|
2108
2136
|
)
|
|
2109
2137
|
|
|
2110
2138
|
|
|
@@ -2122,7 +2150,7 @@ class CentralConnectionState:
|
|
|
2122
2150
|
self._json_issues.append(iid)
|
|
2123
2151
|
_LOGGER.debug("add_issue: add issue [%s] for JsonRpcAioHttpClient", iid)
|
|
2124
2152
|
return True
|
|
2125
|
-
if isinstance(issuer,
|
|
2153
|
+
if isinstance(issuer, BaseRpcProxy) and iid not in self._rpc_proxy_issues:
|
|
2126
2154
|
self._rpc_proxy_issues.append(iid)
|
|
2127
2155
|
_LOGGER.debug("add_issue: add issue [%s] for %s", iid, issuer.interface_id)
|
|
2128
2156
|
return True
|
|
@@ -2134,7 +2162,7 @@ class CentralConnectionState:
|
|
|
2134
2162
|
self._json_issues.remove(iid)
|
|
2135
2163
|
_LOGGER.debug("remove_issue: removing issue [%s] for JsonRpcAioHttpClient", iid)
|
|
2136
2164
|
return True
|
|
2137
|
-
if isinstance(issuer,
|
|
2165
|
+
if isinstance(issuer, BaseRpcProxy) and issuer.interface_id in self._rpc_proxy_issues:
|
|
2138
2166
|
self._rpc_proxy_issues.remove(iid)
|
|
2139
2167
|
_LOGGER.debug("remove_issue: removing issue [%s] for %s", iid, issuer.interface_id)
|
|
2140
2168
|
return True
|
|
@@ -2144,7 +2172,7 @@ class CentralConnectionState:
|
|
|
2144
2172
|
"""Add issue to collection."""
|
|
2145
2173
|
if isinstance(issuer, AioJsonRpcAioHttpClient):
|
|
2146
2174
|
return iid in self._json_issues
|
|
2147
|
-
if isinstance(issuer, (
|
|
2175
|
+
if isinstance(issuer, (BaseRpcProxy)):
|
|
2148
2176
|
return iid in self._rpc_proxy_issues
|
|
2149
2177
|
|
|
2150
2178
|
def handle_exception_log(
|
aiohomematic/client/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-2025
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
3
|
"""
|
|
4
4
|
Client adapters for communicating with Homematic CCU and compatible backends.
|
|
5
5
|
|
|
@@ -55,7 +55,7 @@ import logging
|
|
|
55
55
|
from typing import Any, Final, cast
|
|
56
56
|
|
|
57
57
|
from aiohomematic import central as hmcu
|
|
58
|
-
from aiohomematic.
|
|
58
|
+
from aiohomematic.client.json_rpc import AioJsonRpcAioHttpClient
|
|
59
59
|
from aiohomematic.client.rpc_proxy import AioXmlRpcProxy, BaseRpcProxy
|
|
60
60
|
from aiohomematic.const import (
|
|
61
61
|
CALLBACK_WARN_INTERVAL,
|
|
@@ -97,6 +97,7 @@ from aiohomematic.exceptions import BaseHomematicException, ClientException, NoC
|
|
|
97
97
|
from aiohomematic.model.device import Device
|
|
98
98
|
from aiohomematic.model.support import convert_value
|
|
99
99
|
from aiohomematic.property_decorators import hm_property
|
|
100
|
+
from aiohomematic.store import CommandCache, PingPongCache
|
|
100
101
|
from aiohomematic.support import (
|
|
101
102
|
LogContextMixin,
|
|
102
103
|
build_xml_rpc_headers,
|
|
@@ -108,7 +109,13 @@ from aiohomematic.support import (
|
|
|
108
109
|
supports_rx_mode,
|
|
109
110
|
)
|
|
110
111
|
|
|
111
|
-
__all__ = [
|
|
112
|
+
__all__ = [
|
|
113
|
+
"AioJsonRpcAioHttpClient",
|
|
114
|
+
"BaseRpcProxy",
|
|
115
|
+
"Client",
|
|
116
|
+
"InterfaceConfig",
|
|
117
|
+
"ClientConfig",
|
|
118
|
+
]
|
|
112
119
|
|
|
113
120
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
114
121
|
|
|
@@ -132,7 +139,7 @@ _CCU_JSON_VALUE_TYPE: Final = {
|
|
|
132
139
|
class Client(ABC, LogContextMixin):
|
|
133
140
|
"""Client object to access the backends via XML-RPC or JSON-RPC."""
|
|
134
141
|
|
|
135
|
-
def __init__(self, *, client_config:
|
|
142
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
136
143
|
"""Initialize the Client."""
|
|
137
144
|
self._config: Final = client_config
|
|
138
145
|
self._last_value_send_cache = CommandCache(interface_id=client_config.interface_id)
|
|
@@ -507,7 +514,7 @@ class Client(ABC, LogContextMixin):
|
|
|
507
514
|
return None
|
|
508
515
|
|
|
509
516
|
@inspector(re_raise=False)
|
|
510
|
-
async def
|
|
517
|
+
async def get_all_device_descriptions(self, *, device_address: str) -> tuple[DeviceDescription, ...] | None:
|
|
511
518
|
"""Get all device descriptions from the backend."""
|
|
512
519
|
all_device_description: list[DeviceDescription] = []
|
|
513
520
|
if main_dd := await self.get_device_description(device_address=device_address):
|
|
@@ -1086,7 +1093,7 @@ class Client(ABC, LogContextMixin):
|
|
|
1086
1093
|
device_address,
|
|
1087
1094
|
)
|
|
1088
1095
|
return
|
|
1089
|
-
await self.central.
|
|
1096
|
+
await self.central.save_files(save_paramset_descriptions=True)
|
|
1090
1097
|
|
|
1091
1098
|
def __str__(self) -> str:
|
|
1092
1099
|
"""Provide some useful information."""
|
|
@@ -1096,7 +1103,7 @@ class Client(ABC, LogContextMixin):
|
|
|
1096
1103
|
class ClientCCU(Client):
|
|
1097
1104
|
"""Client implementation for CCU backend."""
|
|
1098
1105
|
|
|
1099
|
-
def __init__(self, *, client_config:
|
|
1106
|
+
def __init__(self, *, client_config: ClientConfig) -> None:
|
|
1100
1107
|
"""Initialize the Client."""
|
|
1101
1108
|
self._json_rpc_client: Final = client_config.central.json_rpc_client
|
|
1102
1109
|
super().__init__(client_config=client_config)
|
|
@@ -1591,7 +1598,7 @@ class ClientHomegear(Client):
|
|
|
1591
1598
|
return SystemInformation(available_interfaces=(Interface.BIDCOS_RF,), serial=f"{self.interface}_{DUMMY_SERIAL}")
|
|
1592
1599
|
|
|
1593
1600
|
|
|
1594
|
-
class
|
|
1601
|
+
class ClientConfig:
|
|
1595
1602
|
"""Config for a Client."""
|
|
1596
1603
|
|
|
1597
1604
|
def __init__(
|
|
@@ -1600,6 +1607,7 @@ class _ClientConfig:
|
|
|
1600
1607
|
central: hmcu.CentralUnit,
|
|
1601
1608
|
interface_config: InterfaceConfig,
|
|
1602
1609
|
) -> None:
|
|
1610
|
+
"""Initialize the config."""
|
|
1603
1611
|
self.central: Final = central
|
|
1604
1612
|
self.version: str = "0"
|
|
1605
1613
|
self.system_information = SystemInformation()
|
|
@@ -1667,6 +1675,7 @@ class _ClientConfig:
|
|
|
1667
1675
|
async def create_rpc_proxy(
|
|
1668
1676
|
self, *, interface: Interface, auth_enabled: bool | None = None, max_workers: int = DEFAULT_MAX_WORKERS
|
|
1669
1677
|
) -> BaseRpcProxy:
|
|
1678
|
+
"""Return a RPC proxy for the backend communication."""
|
|
1670
1679
|
return await self._create_xml_rpc_proxy(auth_enabled=auth_enabled, max_workers=max_workers)
|
|
1671
1680
|
|
|
1672
1681
|
async def _create_xml_rpc_proxy(
|
|
@@ -1690,16 +1699,13 @@ class _ClientConfig:
|
|
|
1690
1699
|
headers=xml_rpc_headers,
|
|
1691
1700
|
tls=config.tls,
|
|
1692
1701
|
verify_tls=config.verify_tls,
|
|
1702
|
+
session_recorder=self.central.recorder,
|
|
1693
1703
|
)
|
|
1694
1704
|
await xml_proxy.do_init()
|
|
1695
1705
|
return xml_proxy
|
|
1696
1706
|
|
|
1697
1707
|
async def _create_simple_rpc_proxy(self, *, interface: Interface) -> BaseRpcProxy:
|
|
1698
1708
|
"""Return a RPC proxy for the backend communication."""
|
|
1699
|
-
return await self._create_xml_rpc_proxy()
|
|
1700
|
-
|
|
1701
|
-
async def _create_simple_xml_rpc_proxy(self) -> AioXmlRpcProxy:
|
|
1702
|
-
"""Return a XmlRPC proxy for the backend communication."""
|
|
1703
1709
|
return await self._create_xml_rpc_proxy(auth_enabled=True, max_workers=0)
|
|
1704
1710
|
|
|
1705
1711
|
|
|
@@ -1744,7 +1750,7 @@ async def create_client(
|
|
|
1744
1750
|
interface_config: InterfaceConfig,
|
|
1745
1751
|
) -> Client:
|
|
1746
1752
|
"""Return a new client for with a given interface_config."""
|
|
1747
|
-
return await
|
|
1753
|
+
return await ClientConfig(central=central, interface_config=interface_config).create_client()
|
|
1748
1754
|
|
|
1749
1755
|
|
|
1750
1756
|
def get_client(interface_id: str) -> Client | None:
|
aiohomematic/client/json_rpc.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-2025
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
3
|
"""
|
|
4
4
|
Asynchronous JSON-RPC client for Homematic CCU-compatible backends.
|
|
5
5
|
|
|
@@ -24,7 +24,7 @@ used directly for advanced tasks. Typical flow:
|
|
|
24
24
|
Notes
|
|
25
25
|
-----
|
|
26
26
|
- Some JSON-RPC methods are backend/firmware dependent. The client detects and
|
|
27
|
-
|
|
27
|
+
store supported methods at runtime.
|
|
28
28
|
- Binary/text encodings are handled carefully (UTF-8 / ISO-8859-1) for script IO.
|
|
29
29
|
|
|
30
30
|
"""
|
|
@@ -92,6 +92,7 @@ from aiohomematic.exceptions import (
|
|
|
92
92
|
)
|
|
93
93
|
from aiohomematic.model.support import convert_value
|
|
94
94
|
from aiohomematic.property_decorators import hm_property
|
|
95
|
+
from aiohomematic.store import SessionRecorder
|
|
95
96
|
from aiohomematic.support import (
|
|
96
97
|
LogContextMixin,
|
|
97
98
|
cleanup_text_from_html_tags,
|
|
@@ -192,6 +193,7 @@ class AioJsonRpcAioHttpClient(LogContextMixin):
|
|
|
192
193
|
client_session: ClientSession | None,
|
|
193
194
|
tls: bool = False,
|
|
194
195
|
verify_tls: bool = False,
|
|
196
|
+
session_recorder: SessionRecorder | None = None,
|
|
195
197
|
) -> None:
|
|
196
198
|
"""Session setup."""
|
|
197
199
|
self._client_session: Final = (
|
|
@@ -210,6 +212,7 @@ class AioJsonRpcAioHttpClient(LogContextMixin):
|
|
|
210
212
|
self._script_cache: Final[dict[str, str]] = {}
|
|
211
213
|
self._last_session_id_refresh: datetime | None = None
|
|
212
214
|
self._session_id: str | None = None
|
|
215
|
+
self._session_recorder: Final = session_recorder
|
|
213
216
|
self._supported_methods: tuple[str, ...] | None = None
|
|
214
217
|
self._sema: Final = Semaphore(value=MAX_CONCURRENT_HTTP_SESSIONS)
|
|
215
218
|
|
|
@@ -431,7 +434,7 @@ class AioJsonRpcAioHttpClient(LogContextMixin):
|
|
|
431
434
|
|
|
432
435
|
if response.status == 200:
|
|
433
436
|
json_response = await asyncio.shield(self._get_json_reponse(response=response))
|
|
434
|
-
|
|
437
|
+
self._record_session(method=method, params=params, response=json_response)
|
|
435
438
|
if error := json_response[_JsonKey.ERROR]:
|
|
436
439
|
# Map JSON-RPC error to actionable exception with context
|
|
437
440
|
ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
|
|
@@ -466,6 +469,7 @@ class AioJsonRpcAioHttpClient(LogContextMixin):
|
|
|
466
469
|
raise exc
|
|
467
470
|
raise ClientException(message)
|
|
468
471
|
except BaseHomematicException as bhe:
|
|
472
|
+
self._record_session(method=method, params=params, exc=bhe)
|
|
469
473
|
if method in (_JsonRpcMethod.SESSION_LOGIN, _JsonRpcMethod.SESSION_LOGOUT, _JsonRpcMethod.SESSION_RENEW):
|
|
470
474
|
self.clear_session()
|
|
471
475
|
# Domain error at boundary -> warning
|
|
@@ -531,6 +535,28 @@ class AioJsonRpcAioHttpClient(LogContextMixin):
|
|
|
531
535
|
)
|
|
532
536
|
raise ClientException(exc) from exc
|
|
533
537
|
|
|
538
|
+
def _record_session(
|
|
539
|
+
self,
|
|
540
|
+
*,
|
|
541
|
+
method: str,
|
|
542
|
+
params: Mapping[str, Any],
|
|
543
|
+
response: dict[str, Any] | None = None,
|
|
544
|
+
exc: Exception | None = None,
|
|
545
|
+
) -> bool:
|
|
546
|
+
"""Record the session."""
|
|
547
|
+
if method == _JsonRpcMethod.SESSION_LOGIN and isinstance(params, dict):
|
|
548
|
+
if params.get(_JsonKey.USERNAME):
|
|
549
|
+
params[_JsonKey.USERNAME] = "********"
|
|
550
|
+
if params.get(_JsonKey.PASSWORD):
|
|
551
|
+
params[_JsonKey.PASSWORD] = "********"
|
|
552
|
+
|
|
553
|
+
if self._session_recorder and self._session_recorder.active:
|
|
554
|
+
self._session_recorder.add_json_rpc_session(
|
|
555
|
+
method=method, params=dict(params), response=response, session_exc=exc
|
|
556
|
+
)
|
|
557
|
+
return True
|
|
558
|
+
return False
|
|
559
|
+
|
|
534
560
|
async def _get_json_reponse(self, *, response: ClientResponse) -> dict[str, Any] | Any:
|
|
535
561
|
"""Return the json object from response."""
|
|
536
562
|
try:
|
aiohomematic/client/rpc_proxy.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
|
-
# Copyright (c) 2021-2025
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
3
|
"""
|
|
4
4
|
XML-RPC transport proxy with concurrency control and connection awareness.
|
|
5
5
|
|
|
@@ -43,6 +43,7 @@ from aiohomematic.exceptions import (
|
|
|
43
43
|
NoConnectionException,
|
|
44
44
|
UnsupportedException,
|
|
45
45
|
)
|
|
46
|
+
from aiohomematic.store import SessionRecorder
|
|
46
47
|
from aiohomematic.support import extract_exc_args, get_tls_context, log_boundary_error
|
|
47
48
|
|
|
48
49
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
@@ -96,10 +97,12 @@ class BaseRpcProxy(ABC):
|
|
|
96
97
|
magic_method: Callable,
|
|
97
98
|
tls: bool = False,
|
|
98
99
|
verify_tls: bool = False,
|
|
100
|
+
session_recorder: SessionRecorder | None = None,
|
|
99
101
|
) -> None:
|
|
100
102
|
"""Initialize new proxy for server and get local ip."""
|
|
101
103
|
self._interface_id: Final = interface_id
|
|
102
104
|
self._connection_state: Final = connection_state
|
|
105
|
+
self._session_recorder: Final = session_recorder
|
|
103
106
|
self._magic_method: Final = magic_method
|
|
104
107
|
self._looper: Final = Looper()
|
|
105
108
|
self._proxy_executor: Final = (
|
|
@@ -136,6 +139,17 @@ class BaseRpcProxy(ABC):
|
|
|
136
139
|
"""Magic method dispatcher."""
|
|
137
140
|
return self._magic_method(self._async_request, *args, **kwargs)
|
|
138
141
|
|
|
142
|
+
def _record_session(
|
|
143
|
+
self, *, method: str, params: tuple[Any, ...], response: Any | None = None, exc: Exception | None = None
|
|
144
|
+
) -> bool:
|
|
145
|
+
"""Record the session."""
|
|
146
|
+
if method in (_RpcMethod.PING,):
|
|
147
|
+
return False
|
|
148
|
+
if self._session_recorder and self._session_recorder.active:
|
|
149
|
+
self._session_recorder.add_xml_rpc_session(method=method, params=params, response=response, session_exc=exc)
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
139
153
|
|
|
140
154
|
# noinspection PyProtectedMember,PyUnresolvedReferences
|
|
141
155
|
class AioXmlRpcProxy(BaseRpcProxy, xmlrpc.client.ServerProxy):
|
|
@@ -151,6 +165,7 @@ class AioXmlRpcProxy(BaseRpcProxy, xmlrpc.client.ServerProxy):
|
|
|
151
165
|
headers: list[tuple[str, str]],
|
|
152
166
|
tls: bool = False,
|
|
153
167
|
verify_tls: bool = False,
|
|
168
|
+
session_recorder: SessionRecorder | None = None,
|
|
154
169
|
) -> None:
|
|
155
170
|
"""Initialize new proxy for server and get local ip."""
|
|
156
171
|
super().__init__(
|
|
@@ -160,6 +175,7 @@ class AioXmlRpcProxy(BaseRpcProxy, xmlrpc.client.ServerProxy):
|
|
|
160
175
|
magic_method=xmlrpc.client._Method,
|
|
161
176
|
tls=tls,
|
|
162
177
|
verify_tls=verify_tls,
|
|
178
|
+
session_recorder=session_recorder,
|
|
163
179
|
)
|
|
164
180
|
|
|
165
181
|
xmlrpc.client.ServerProxy.__init__(
|
|
@@ -193,10 +209,12 @@ class AioXmlRpcProxy(BaseRpcProxy, xmlrpc.client.ServerProxy):
|
|
|
193
209
|
executor=self._proxy_executor,
|
|
194
210
|
)
|
|
195
211
|
)
|
|
212
|
+
self._record_session(method=method, params=args[1], response=result)
|
|
196
213
|
self._connection_state.remove_issue(issuer=self, iid=self._interface_id)
|
|
197
214
|
return result
|
|
198
215
|
raise NoConnectionException(f"No connection to {self._interface_id}")
|
|
199
|
-
except BaseHomematicException:
|
|
216
|
+
except BaseHomematicException as bhe:
|
|
217
|
+
self._record_session(method=args[0], params=args[1:], exc=bhe)
|
|
200
218
|
raise
|
|
201
219
|
except SSLError as sslerr:
|
|
202
220
|
message = f"SSLError on {self._interface_id}: {extract_exc_args(exc=sslerr)}"
|