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.

Files changed (77) hide show
  1. aiohomematic/__init__.py +61 -0
  2. aiohomematic/async_support.py +212 -0
  3. aiohomematic/central/__init__.py +2309 -0
  4. aiohomematic/central/decorators.py +155 -0
  5. aiohomematic/central/rpc_server.py +295 -0
  6. aiohomematic/client/__init__.py +1848 -0
  7. aiohomematic/client/_rpc_errors.py +81 -0
  8. aiohomematic/client/json_rpc.py +1326 -0
  9. aiohomematic/client/rpc_proxy.py +311 -0
  10. aiohomematic/const.py +1127 -0
  11. aiohomematic/context.py +18 -0
  12. aiohomematic/converter.py +108 -0
  13. aiohomematic/decorators.py +302 -0
  14. aiohomematic/exceptions.py +164 -0
  15. aiohomematic/hmcli.py +186 -0
  16. aiohomematic/model/__init__.py +140 -0
  17. aiohomematic/model/calculated/__init__.py +84 -0
  18. aiohomematic/model/calculated/climate.py +290 -0
  19. aiohomematic/model/calculated/data_point.py +327 -0
  20. aiohomematic/model/calculated/operating_voltage_level.py +299 -0
  21. aiohomematic/model/calculated/support.py +234 -0
  22. aiohomematic/model/custom/__init__.py +177 -0
  23. aiohomematic/model/custom/climate.py +1532 -0
  24. aiohomematic/model/custom/cover.py +792 -0
  25. aiohomematic/model/custom/data_point.py +334 -0
  26. aiohomematic/model/custom/definition.py +871 -0
  27. aiohomematic/model/custom/light.py +1128 -0
  28. aiohomematic/model/custom/lock.py +394 -0
  29. aiohomematic/model/custom/siren.py +275 -0
  30. aiohomematic/model/custom/support.py +41 -0
  31. aiohomematic/model/custom/switch.py +175 -0
  32. aiohomematic/model/custom/valve.py +114 -0
  33. aiohomematic/model/data_point.py +1123 -0
  34. aiohomematic/model/device.py +1445 -0
  35. aiohomematic/model/event.py +208 -0
  36. aiohomematic/model/generic/__init__.py +217 -0
  37. aiohomematic/model/generic/action.py +34 -0
  38. aiohomematic/model/generic/binary_sensor.py +30 -0
  39. aiohomematic/model/generic/button.py +27 -0
  40. aiohomematic/model/generic/data_point.py +171 -0
  41. aiohomematic/model/generic/dummy.py +147 -0
  42. aiohomematic/model/generic/number.py +76 -0
  43. aiohomematic/model/generic/select.py +39 -0
  44. aiohomematic/model/generic/sensor.py +74 -0
  45. aiohomematic/model/generic/switch.py +54 -0
  46. aiohomematic/model/generic/text.py +29 -0
  47. aiohomematic/model/hub/__init__.py +333 -0
  48. aiohomematic/model/hub/binary_sensor.py +24 -0
  49. aiohomematic/model/hub/button.py +28 -0
  50. aiohomematic/model/hub/data_point.py +340 -0
  51. aiohomematic/model/hub/number.py +39 -0
  52. aiohomematic/model/hub/select.py +49 -0
  53. aiohomematic/model/hub/sensor.py +37 -0
  54. aiohomematic/model/hub/switch.py +44 -0
  55. aiohomematic/model/hub/text.py +30 -0
  56. aiohomematic/model/support.py +586 -0
  57. aiohomematic/model/update.py +143 -0
  58. aiohomematic/property_decorators.py +496 -0
  59. aiohomematic/py.typed +0 -0
  60. aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
  61. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  62. aiohomematic/rega_scripts/get_serial.fn +44 -0
  63. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  64. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  65. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  66. aiohomematic/store/__init__.py +34 -0
  67. aiohomematic/store/dynamic.py +551 -0
  68. aiohomematic/store/persistent.py +988 -0
  69. aiohomematic/store/visibility.py +812 -0
  70. aiohomematic/support.py +664 -0
  71. aiohomematic/validator.py +112 -0
  72. aiohomematic-2025.11.3.dist-info/METADATA +144 -0
  73. aiohomematic-2025.11.3.dist-info/RECORD +77 -0
  74. aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
  75. aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
  76. aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
  77. 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