aiohomematic 2025.11.3__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 +61 -0
- aiohomematic/async_support.py +212 -0
- aiohomematic/central/__init__.py +2309 -0
- aiohomematic/central/decorators.py +155 -0
- aiohomematic/central/rpc_server.py +295 -0
- aiohomematic/client/__init__.py +1848 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +1326 -0
- aiohomematic/client/rpc_proxy.py +311 -0
- aiohomematic/const.py +1127 -0
- aiohomematic/context.py +18 -0
- aiohomematic/converter.py +108 -0
- aiohomematic/decorators.py +302 -0
- aiohomematic/exceptions.py +164 -0
- aiohomematic/hmcli.py +186 -0
- aiohomematic/model/__init__.py +140 -0
- aiohomematic/model/calculated/__init__.py +84 -0
- aiohomematic/model/calculated/climate.py +290 -0
- aiohomematic/model/calculated/data_point.py +327 -0
- aiohomematic/model/calculated/operating_voltage_level.py +299 -0
- aiohomematic/model/calculated/support.py +234 -0
- aiohomematic/model/custom/__init__.py +177 -0
- aiohomematic/model/custom/climate.py +1532 -0
- aiohomematic/model/custom/cover.py +792 -0
- aiohomematic/model/custom/data_point.py +334 -0
- aiohomematic/model/custom/definition.py +871 -0
- aiohomematic/model/custom/light.py +1128 -0
- aiohomematic/model/custom/lock.py +394 -0
- aiohomematic/model/custom/siren.py +275 -0
- aiohomematic/model/custom/support.py +41 -0
- aiohomematic/model/custom/switch.py +175 -0
- aiohomematic/model/custom/valve.py +114 -0
- aiohomematic/model/data_point.py +1123 -0
- aiohomematic/model/device.py +1445 -0
- aiohomematic/model/event.py +208 -0
- aiohomematic/model/generic/__init__.py +217 -0
- aiohomematic/model/generic/action.py +34 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +27 -0
- aiohomematic/model/generic/data_point.py +171 -0
- aiohomematic/model/generic/dummy.py +147 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +39 -0
- aiohomematic/model/generic/sensor.py +74 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +29 -0
- aiohomematic/model/hub/__init__.py +333 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/data_point.py +340 -0
- aiohomematic/model/hub/number.py +39 -0
- aiohomematic/model/hub/select.py +49 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +44 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/support.py +586 -0
- aiohomematic/model/update.py +143 -0
- aiohomematic/property_decorators.py +496 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
- aiohomematic/rega_scripts/set_program_state.fn +12 -0
- aiohomematic/rega_scripts/set_system_variable.fn +15 -0
- aiohomematic/store/__init__.py +34 -0
- aiohomematic/store/dynamic.py +551 -0
- aiohomematic/store/persistent.py +988 -0
- aiohomematic/store/visibility.py +812 -0
- aiohomematic/support.py +664 -0
- aiohomematic/validator.py +112 -0
- aiohomematic-2025.11.3.dist-info/METADATA +144 -0
- aiohomematic-2025.11.3.dist-info/RECORD +77 -0
- aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
- aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
- aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025
|
|
3
|
+
"""
|
|
4
|
+
XML-RPC transport proxy with concurrency control and connection awareness.
|
|
5
|
+
|
|
6
|
+
Overview
|
|
7
|
+
--------
|
|
8
|
+
XmlRpcProxy extends xmlrpc.client.ServerProxy to:
|
|
9
|
+
- Execute RPC calls in a thread pool to avoid blocking the event loop
|
|
10
|
+
- Integrate with CentralConnectionState to mark/report connection issues
|
|
11
|
+
- Optionally use TLS with configurable certificate verification
|
|
12
|
+
- Filter unsupported methods at runtime via system.listMethods
|
|
13
|
+
|
|
14
|
+
Notes
|
|
15
|
+
-----
|
|
16
|
+
- The proxy cleans and normalizes argument encodings for XML-RPC.
|
|
17
|
+
- Certain methods are allowed even when the connection is flagged down
|
|
18
|
+
(e.g., ping, init, getVersion) to support recovery.
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
import asyncio
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
28
|
+
from enum import Enum, IntEnum, StrEnum
|
|
29
|
+
import errno
|
|
30
|
+
import logging
|
|
31
|
+
from ssl import SSLContext, SSLError
|
|
32
|
+
from typing import Any, Final
|
|
33
|
+
import xmlrpc.client
|
|
34
|
+
|
|
35
|
+
from aiohomematic import central as hmcu
|
|
36
|
+
from aiohomematic.async_support import Looper
|
|
37
|
+
from aiohomematic.client._rpc_errors import RpcContext, map_xmlrpc_fault
|
|
38
|
+
from aiohomematic.const import ISO_8859_1
|
|
39
|
+
from aiohomematic.exceptions import (
|
|
40
|
+
AuthFailure,
|
|
41
|
+
BaseHomematicException,
|
|
42
|
+
ClientException,
|
|
43
|
+
NoConnectionException,
|
|
44
|
+
UnsupportedException,
|
|
45
|
+
)
|
|
46
|
+
from aiohomematic.store import SessionRecorder
|
|
47
|
+
from aiohomematic.support import extract_exc_args, get_tls_context, log_boundary_error
|
|
48
|
+
|
|
49
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_CONTEXT: Final = "context"
|
|
52
|
+
_TLS: Final = "tls"
|
|
53
|
+
_VERIFY_TLS: Final = "verify_tls"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _RpcMethod(StrEnum):
|
|
57
|
+
"""Enum for Homematic json rpc methods types."""
|
|
58
|
+
|
|
59
|
+
GET_VERSION = "getVersion"
|
|
60
|
+
HOMEGEAR_INIT = "clientServerInitialized"
|
|
61
|
+
INIT = "init"
|
|
62
|
+
PING = "ping"
|
|
63
|
+
SYSTEM_LIST_METHODS = "system.listMethods"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_VALID_RPC_COMMANDS_ON_NO_CONNECTION: Final[tuple[str, ...]] = (
|
|
67
|
+
_RpcMethod.GET_VERSION,
|
|
68
|
+
_RpcMethod.HOMEGEAR_INIT,
|
|
69
|
+
_RpcMethod.INIT,
|
|
70
|
+
_RpcMethod.PING,
|
|
71
|
+
_RpcMethod.SYSTEM_LIST_METHODS,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_SSL_ERROR_CODES: Final[dict[int, str]] = {
|
|
75
|
+
errno.ENOEXEC: "EOF occurred in violation of protocol",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_OS_ERROR_CODES: Final[dict[int, str]] = {
|
|
79
|
+
errno.ECONNREFUSED: "Connection refused",
|
|
80
|
+
errno.EHOSTUNREACH: "No route to host",
|
|
81
|
+
errno.ENETUNREACH: "Network is unreachable",
|
|
82
|
+
errno.ENOEXEC: "Exec",
|
|
83
|
+
errno.ETIMEDOUT: "Operation timed out",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# noinspection PyProtectedMember,PyUnresolvedReferences
|
|
88
|
+
class BaseRpcProxy(ABC):
|
|
89
|
+
"""ServerProxy implementation with ThreadPoolExecutor when request is executing."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
max_workers: int,
|
|
95
|
+
interface_id: str,
|
|
96
|
+
connection_state: hmcu.CentralConnectionState,
|
|
97
|
+
magic_method: Callable,
|
|
98
|
+
tls: bool = False,
|
|
99
|
+
verify_tls: bool = False,
|
|
100
|
+
session_recorder: SessionRecorder | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Initialize new proxy for server and get local ip."""
|
|
103
|
+
self._interface_id: Final = interface_id
|
|
104
|
+
self._connection_state: Final = connection_state
|
|
105
|
+
self._session_recorder: Final = session_recorder
|
|
106
|
+
self._magic_method: Final = magic_method
|
|
107
|
+
self._looper: Final = Looper()
|
|
108
|
+
self._proxy_executor: Final = (
|
|
109
|
+
ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=interface_id) if max_workers > 0 else None
|
|
110
|
+
)
|
|
111
|
+
self._tls: Final[bool | SSLContext] = get_tls_context(verify_tls=verify_tls) if tls else False
|
|
112
|
+
self._supported_methods: tuple[str, ...] = ()
|
|
113
|
+
self._kwargs: dict[str, Any] = {}
|
|
114
|
+
if tls:
|
|
115
|
+
self._kwargs[_CONTEXT] = self._tls
|
|
116
|
+
# Due to magic method the log_context must be defined manually.
|
|
117
|
+
self.log_context: Final[Mapping[str, Any]] = {"interface_id": self._interface_id, "tls": tls}
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
async def do_init(self) -> None:
|
|
121
|
+
"""Init the rpc proxy."""
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def supported_methods(self) -> tuple[str, ...]:
|
|
125
|
+
"""Return the supported methods."""
|
|
126
|
+
return self._supported_methods
|
|
127
|
+
|
|
128
|
+
async def stop(self) -> None:
|
|
129
|
+
"""Stop depending services."""
|
|
130
|
+
await self._looper.block_till_done()
|
|
131
|
+
if self._proxy_executor:
|
|
132
|
+
self._proxy_executor.shutdown()
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
async def _async_request(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
136
|
+
"""Call method on server side."""
|
|
137
|
+
|
|
138
|
+
def __getattr__(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
139
|
+
"""Magic method dispatcher."""
|
|
140
|
+
return self._magic_method(self._async_request, *args, **kwargs)
|
|
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
|
+
|
|
153
|
+
|
|
154
|
+
# noinspection PyProtectedMember,PyUnresolvedReferences
|
|
155
|
+
class AioXmlRpcProxy(BaseRpcProxy, xmlrpc.client.ServerProxy):
|
|
156
|
+
"""ServerProxy implementation with ThreadPoolExecutor when request is executing."""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
max_workers: int,
|
|
162
|
+
interface_id: str,
|
|
163
|
+
connection_state: hmcu.CentralConnectionState,
|
|
164
|
+
uri: str,
|
|
165
|
+
headers: list[tuple[str, str]],
|
|
166
|
+
tls: bool = False,
|
|
167
|
+
verify_tls: bool = False,
|
|
168
|
+
session_recorder: SessionRecorder | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Initialize new proxy for server and get local ip."""
|
|
171
|
+
super().__init__(
|
|
172
|
+
max_workers=max_workers,
|
|
173
|
+
interface_id=interface_id,
|
|
174
|
+
connection_state=connection_state,
|
|
175
|
+
magic_method=xmlrpc.client._Method,
|
|
176
|
+
tls=tls,
|
|
177
|
+
verify_tls=verify_tls,
|
|
178
|
+
session_recorder=session_recorder,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
xmlrpc.client.ServerProxy.__init__(
|
|
182
|
+
self,
|
|
183
|
+
uri=uri,
|
|
184
|
+
encoding=ISO_8859_1,
|
|
185
|
+
headers=headers,
|
|
186
|
+
**self._kwargs,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def _async_request(self, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
190
|
+
"""Call method on server side."""
|
|
191
|
+
parent = xmlrpc.client.ServerProxy
|
|
192
|
+
try:
|
|
193
|
+
method = args[0]
|
|
194
|
+
if self._supported_methods and method not in self._supported_methods:
|
|
195
|
+
raise UnsupportedException(f"XmlRPC.__ASYNC_REQUEST: method '{method} not supported by the backend.")
|
|
196
|
+
|
|
197
|
+
if method in _VALID_RPC_COMMANDS_ON_NO_CONNECTION or not self._connection_state.has_issue(
|
|
198
|
+
issuer=self, iid=self._interface_id
|
|
199
|
+
):
|
|
200
|
+
args = _cleanup_args(*args)
|
|
201
|
+
_LOGGER.debug("XmlRPC.__ASYNC_REQUEST: %s", args)
|
|
202
|
+
result = await asyncio.shield(
|
|
203
|
+
self._looper.async_add_executor_job(
|
|
204
|
+
# pylint: disable=protected-access
|
|
205
|
+
parent._ServerProxy__request, # type: ignore[attr-defined]
|
|
206
|
+
self,
|
|
207
|
+
*args,
|
|
208
|
+
name="xmp_rpc_proxy",
|
|
209
|
+
executor=self._proxy_executor,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
self._record_session(method=method, params=args[1], response=result)
|
|
213
|
+
self._connection_state.remove_issue(issuer=self, iid=self._interface_id)
|
|
214
|
+
return result
|
|
215
|
+
raise NoConnectionException(f"No connection to {self._interface_id}")
|
|
216
|
+
except BaseHomematicException as bhe:
|
|
217
|
+
self._record_session(method=args[0], params=args[1:], exc=bhe)
|
|
218
|
+
raise
|
|
219
|
+
except SSLError as sslerr: # pragma: no cover - SSL handshake/cert errors are OS/OpenSSL dependent and not reliably reproducible in CI
|
|
220
|
+
message = f"SSLError on {self._interface_id}: {extract_exc_args(exc=sslerr)}"
|
|
221
|
+
level = logging.ERROR
|
|
222
|
+
if sslerr.args[0] in _SSL_ERROR_CODES:
|
|
223
|
+
message = (
|
|
224
|
+
f"{message} - {sslerr.args[0]}: {sslerr.args[1]}. "
|
|
225
|
+
f"Please check your configuration for {self._interface_id}."
|
|
226
|
+
)
|
|
227
|
+
if not self._connection_state.add_issue(issuer=self, iid=self._interface_id):
|
|
228
|
+
level = logging.DEBUG
|
|
229
|
+
|
|
230
|
+
log_boundary_error(
|
|
231
|
+
logger=_LOGGER,
|
|
232
|
+
boundary="xml-rpc",
|
|
233
|
+
action=str(args[0]),
|
|
234
|
+
err=sslerr,
|
|
235
|
+
level=level,
|
|
236
|
+
message=message,
|
|
237
|
+
log_context=self.log_context,
|
|
238
|
+
)
|
|
239
|
+
raise NoConnectionException(message) from sslerr
|
|
240
|
+
except OSError as oserr: # pragma: no cover - Network/socket errno differences are platform/environment specific; simulating reliably in CI would be flaky
|
|
241
|
+
message = f"OSError on {self._interface_id}: {extract_exc_args(exc=oserr)}"
|
|
242
|
+
level = (
|
|
243
|
+
logging.ERROR
|
|
244
|
+
if oserr.args[0] in _OS_ERROR_CODES
|
|
245
|
+
and not self._connection_state.add_issue(issuer=self, iid=self._interface_id)
|
|
246
|
+
else logging.DEBUG
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
log_boundary_error(
|
|
250
|
+
logger=_LOGGER,
|
|
251
|
+
boundary="xml-rpc",
|
|
252
|
+
action=str(args[0]),
|
|
253
|
+
err=oserr,
|
|
254
|
+
level=level,
|
|
255
|
+
log_context=self.log_context,
|
|
256
|
+
)
|
|
257
|
+
raise NoConnectionException(message) from oserr
|
|
258
|
+
except xmlrpc.client.Fault as flt:
|
|
259
|
+
ctx = RpcContext(protocol="xml-rpc", method=str(args[0]), interface=self._interface_id)
|
|
260
|
+
raise map_xmlrpc_fault(code=flt.faultCode, fault_string=flt.faultString, ctx=ctx) from flt
|
|
261
|
+
except TypeError as terr:
|
|
262
|
+
raise ClientException(terr) from terr
|
|
263
|
+
except xmlrpc.client.ProtocolError as perr:
|
|
264
|
+
if not self._connection_state.has_issue(issuer=self, iid=self._interface_id):
|
|
265
|
+
if perr.errmsg == "Unauthorized":
|
|
266
|
+
raise AuthFailure(perr) from perr
|
|
267
|
+
raise NoConnectionException(f"No connection to {self.log_context} ({perr.errmsg})") from perr
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
raise ClientException(exc) from exc
|
|
270
|
+
|
|
271
|
+
async def do_init(self) -> None:
|
|
272
|
+
"""Init the xml rpc proxy."""
|
|
273
|
+
if supported_methods := await self.system.listMethods():
|
|
274
|
+
# ping is missing in VirtualDevices interface but can be used.
|
|
275
|
+
supported_methods.append(_RpcMethod.PING)
|
|
276
|
+
self._supported_methods = tuple(supported_methods)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _cleanup_args(*args: Any) -> Any:
|
|
280
|
+
"""Cleanup the type of args."""
|
|
281
|
+
if len(args[1]) == 0:
|
|
282
|
+
return args
|
|
283
|
+
if len(args) == 2:
|
|
284
|
+
new_args: list[Any] = []
|
|
285
|
+
for data in args[1]:
|
|
286
|
+
if isinstance(data, dict):
|
|
287
|
+
new_args.append(_cleanup_paramset(paramset=data))
|
|
288
|
+
else:
|
|
289
|
+
new_args.append(_cleanup_item(item=data))
|
|
290
|
+
return (args[0], tuple(new_args))
|
|
291
|
+
_LOGGER.error("XmlRpcProxy command: Too many arguments")
|
|
292
|
+
return args
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _cleanup_item(*, item: Any) -> Any:
|
|
296
|
+
"""Cleanup a single item."""
|
|
297
|
+
if isinstance(item, StrEnum):
|
|
298
|
+
return str(item)
|
|
299
|
+
if isinstance(item, IntEnum):
|
|
300
|
+
return int(item)
|
|
301
|
+
if isinstance(item, Enum):
|
|
302
|
+
_LOGGER.error("XmlRpcProxy command: Enum is not supported as parameter value")
|
|
303
|
+
return item
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _cleanup_paramset(*, paramset: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
307
|
+
"""Cleanup a paramset."""
|
|
308
|
+
new_paramset: dict[str, Any] = {}
|
|
309
|
+
for name, value in paramset.items():
|
|
310
|
+
new_paramset[_cleanup_item(item=name)] = _cleanup_item(item=value)
|
|
311
|
+
return new_paramset
|