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,155 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """Decorators for central used within aiohomematic."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Awaitable, Callable
8
+ from datetime import datetime
9
+ from functools import wraps
10
+ import inspect
11
+ import logging
12
+ from typing import Any, Final, cast
13
+
14
+ from aiohomematic import central as hmcu, client as hmcl
15
+ from aiohomematic.central import rpc_server as rpc
16
+ from aiohomematic.const import BackendSystemEvent
17
+ from aiohomematic.exceptions import AioHomematicException
18
+ from aiohomematic.support import extract_exc_args
19
+
20
+ _LOGGER: Final = logging.getLogger(__name__)
21
+ _INTERFACE_ID: Final = "interface_id"
22
+ _CHANNEL_ADDRESS: Final = "channel_address"
23
+ _PARAMETER: Final = "parameter"
24
+ _VALUE: Final = "value"
25
+
26
+
27
+ def callback_backend_system(system_event: BackendSystemEvent) -> Callable:
28
+ """Check if backend_system_callback is set and call it AFTER original function."""
29
+
30
+ def decorator_backend_system_callback[**P, R](
31
+ func: Callable[P, R | Awaitable[R]],
32
+ ) -> Callable[P, R | Awaitable[R]]:
33
+ """Decorate callback system events."""
34
+
35
+ @wraps(func)
36
+ async def async_wrapper_backend_system_callback(*args: P.args, **kwargs: P.kwargs) -> R:
37
+ """Wrap async callback system events."""
38
+ return_value = cast(R, await func(*args, **kwargs)) # type: ignore[misc]
39
+ await _exec_backend_system_callback(*args, **kwargs)
40
+ return return_value
41
+
42
+ @wraps(func)
43
+ def wrapper_backend_system_callback(*args: P.args, **kwargs: P.kwargs) -> R:
44
+ """Wrap callback system events."""
45
+ return_value = cast(R, func(*args, **kwargs))
46
+ try:
47
+ unit = args[0]
48
+ central: hmcu.CentralUnit | None = None
49
+ if isinstance(unit, hmcu.CentralUnit):
50
+ central = unit
51
+ if central is None and isinstance(unit, rpc.RPCFunctions):
52
+ central = unit.get_central(interface_id=str(args[1]))
53
+ if central:
54
+ central.looper.create_task(
55
+ target=lambda: _exec_backend_system_callback(*args, **kwargs),
56
+ name="wrapper_backend_system_callback",
57
+ )
58
+ except Exception as exc:
59
+ _LOGGER.warning(
60
+ "EXEC_BACKEND_SYSTEM_CALLBACK failed: Problem with identifying central: %s",
61
+ extract_exc_args(exc=exc),
62
+ )
63
+ return return_value
64
+
65
+ async def _exec_backend_system_callback(*args: Any, **kwargs: Any) -> None:
66
+ """Execute the callback for a system event."""
67
+
68
+ if not ((len(args) > 1 and not kwargs) or (len(args) == 1 and kwargs)):
69
+ _LOGGER.warning("EXEC_BACKEND_SYSTEM_CALLBACK failed: *args not supported for callback_system_event")
70
+ try:
71
+ args = args[1:]
72
+ interface_id: str = args[0] if len(args) > 0 else str(kwargs[_INTERFACE_ID])
73
+ if client := hmcl.get_client(interface_id=interface_id):
74
+ client.modified_at = datetime.now()
75
+ client.central.emit_backend_system_callback(system_event=system_event, **kwargs)
76
+ except Exception as exc: # pragma: no cover
77
+ _LOGGER.warning(
78
+ "EXEC_BACKEND_SYSTEM_CALLBACK failed: Unable to reduce kwargs for backend_system_callback"
79
+ )
80
+ raise AioHomematicException(
81
+ f"args-exception backend_system_callback [{extract_exc_args(exc=exc)}]"
82
+ ) from exc
83
+
84
+ if inspect.iscoroutinefunction(func):
85
+ return async_wrapper_backend_system_callback
86
+ return wrapper_backend_system_callback
87
+
88
+ return decorator_backend_system_callback
89
+
90
+
91
+ def callback_event[**P, R](func: Callable[P, R]) -> Callable:
92
+ """Check if event_callback is set and call it AFTER original function."""
93
+
94
+ def _exec_event_callback(*args: Any, **kwargs: Any) -> None:
95
+ """Execute the callback for a data_point event."""
96
+ try:
97
+ # Expected signature: (self, interface_id, channel_address, parameter, value)
98
+ interface_id: str
99
+ if len(args) > 1:
100
+ interface_id = cast(str, args[1])
101
+ channel_address = cast(str, args[2])
102
+ parameter = cast(str, args[3])
103
+ value = args[4] if len(args) > 4 else kwargs.get(_VALUE)
104
+ else:
105
+ interface_id = cast(str, kwargs[_INTERFACE_ID])
106
+ channel_address = cast(str, kwargs[_CHANNEL_ADDRESS])
107
+ parameter = cast(str, kwargs[_PARAMETER])
108
+ value = kwargs[_VALUE]
109
+
110
+ if client := hmcl.get_client(interface_id=interface_id):
111
+ client.modified_at = datetime.now()
112
+ client.central.emit_backend_parameter_callback(
113
+ interface_id=interface_id, channel_address=channel_address, parameter=parameter, value=value
114
+ )
115
+ except Exception as exc: # pragma: no cover
116
+ _LOGGER.warning("EXEC_DATA_POINT_EVENT_CALLBACK failed: Unable to process args/kwargs for event_callback")
117
+ raise AioHomematicException(f"args-exception event_callback [{extract_exc_args(exc=exc)}]") from exc
118
+
119
+ def _schedule_or_exec(*args: Any, **kwargs: Any) -> None:
120
+ """Schedule event callback on central looper when possible, else execute inline."""
121
+ try:
122
+ # Prefer scheduling on the CentralUnit looper when available to avoid blocking hot path
123
+ unit = args[0]
124
+ if isinstance(unit, hmcu.CentralUnit):
125
+ unit.looper.create_task(
126
+ target=lambda: _async_wrap_sync(_exec_event_callback, *args, **kwargs),
127
+ name="wrapper_event_callback",
128
+ )
129
+ return
130
+ except Exception:
131
+ # Fall through to inline execution on any error
132
+ pass
133
+ _exec_event_callback(*args, **kwargs)
134
+
135
+ @wraps(func)
136
+ async def async_wrapper_event_callback(*args: P.args, **kwargs: P.kwargs) -> R:
137
+ """Wrap async callback events."""
138
+ return_value = cast(R, await func(*args, **kwargs)) # type: ignore[misc]
139
+ _schedule_or_exec(*args, **kwargs)
140
+ return return_value
141
+
142
+ @wraps(func)
143
+ def wrapper_event_callback(*args: P.args, **kwargs: P.kwargs) -> R:
144
+ """Wrap sync callback events."""
145
+ return_value = func(*args, **kwargs)
146
+ _schedule_or_exec(*args, **kwargs)
147
+ return return_value
148
+
149
+ # Helper to create a trivial coroutine from a sync callable
150
+ async def _async_wrap_sync(cb: Callable[..., None], *a: Any, **kw: Any) -> None:
151
+ cb(*a, **kw)
152
+
153
+ if inspect.iscoroutinefunction(func):
154
+ return async_wrapper_event_callback
155
+ return wrapper_event_callback
@@ -0,0 +1,295 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """
4
+ XML-RPC server module.
5
+
6
+ Provides the XML-RPC server which handles communication
7
+ with the backend.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import logging
14
+ import threading
15
+ from typing import Any, Final, cast
16
+ from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
17
+
18
+ from aiohomematic import central as hmcu
19
+ from aiohomematic.central.decorators import callback_backend_system
20
+ from aiohomematic.const import IP_ANY_V4, PORT_ANY, BackendSystemEvent
21
+ from aiohomematic.support import log_boundary_error
22
+
23
+ _LOGGER: Final = logging.getLogger(__name__)
24
+
25
+
26
+ # pylint: disable=invalid-name
27
+ class RPCFunctions:
28
+ """The RPC functions the backend will expect."""
29
+
30
+ # Disable kw-only linter
31
+ __kwonly_check__ = False
32
+
33
+ def __init__(self, *, rpc_server: RpcServer) -> None:
34
+ """Init RPCFunctions."""
35
+ self._rpc_server: Final = rpc_server
36
+
37
+ def event(self, interface_id: str, channel_address: str, parameter: str, value: Any, /) -> None:
38
+ """If a device emits some sort event, we will handle it here."""
39
+ if central := self.get_central(interface_id=interface_id):
40
+ central.looper.create_task(
41
+ target=central.data_point_event(
42
+ interface_id=interface_id,
43
+ channel_address=channel_address,
44
+ parameter=parameter,
45
+ value=value,
46
+ ),
47
+ name=f"event-{interface_id}-{channel_address}-{parameter}",
48
+ )
49
+
50
+ @callback_backend_system(system_event=BackendSystemEvent.ERROR)
51
+ def error(self, interface_id: str, error_code: str, msg: str, /) -> None:
52
+ """When some error occurs the backend will send its error message here."""
53
+ # Structured boundary log (warning level). RPC server received error notification.
54
+ try:
55
+ raise RuntimeError(str(msg))
56
+ except RuntimeError as err:
57
+ log_boundary_error(
58
+ logger=_LOGGER,
59
+ boundary="rpc-server",
60
+ action="error",
61
+ err=err,
62
+ level=logging.WARNING,
63
+ log_context={"interface_id": interface_id, "error_code": int(error_code)},
64
+ )
65
+ _LOGGER.warning(
66
+ "ERROR failed: interface_id = %s, error_code = %i, message = %s",
67
+ interface_id,
68
+ int(error_code),
69
+ str(msg),
70
+ )
71
+
72
+ def listDevices(self, interface_id: str, /) -> list[dict[str, Any]]:
73
+ """Return already existing devices to the backend."""
74
+ if central := self.get_central(interface_id=interface_id):
75
+ return [dict(device_description) for device_description in central.list_devices(interface_id=interface_id)]
76
+ return []
77
+
78
+ def newDevices(self, interface_id: str, device_descriptions: list[dict[str, Any]], /) -> None:
79
+ """Add new devices send from the backend."""
80
+ central: hmcu.CentralUnit | None
81
+ if central := self.get_central(interface_id=interface_id):
82
+ central.looper.create_task(
83
+ target=central.add_new_devices(
84
+ interface_id=interface_id, device_descriptions=tuple(device_descriptions)
85
+ ),
86
+ name=f"newDevices-{interface_id}",
87
+ )
88
+
89
+ def deleteDevices(self, interface_id: str, addresses: list[str], /) -> None:
90
+ """Delete devices send from the backend."""
91
+ central: hmcu.CentralUnit | None
92
+ if central := self.get_central(interface_id=interface_id):
93
+ central.looper.create_task(
94
+ target=central.delete_devices(interface_id=interface_id, addresses=tuple(addresses)),
95
+ name=f"deleteDevices-{interface_id}",
96
+ )
97
+
98
+ @callback_backend_system(system_event=BackendSystemEvent.UPDATE_DEVICE)
99
+ def updateDevice(self, interface_id: str, address: str, hint: int, /) -> None:
100
+ """
101
+ Update a device.
102
+
103
+ Irrelevant, as currently only changes to link
104
+ partners are reported.
105
+ """
106
+ _LOGGER.debug(
107
+ "UPDATEDEVICE: interface_id = %s, address = %s, hint = %s",
108
+ interface_id,
109
+ address,
110
+ str(hint),
111
+ )
112
+
113
+ @callback_backend_system(system_event=BackendSystemEvent.REPLACE_DEVICE)
114
+ def replaceDevice(self, interface_id: str, old_device_address: str, new_device_address: str, /) -> None:
115
+ """Replace a device. Probably irrelevant for us."""
116
+ _LOGGER.debug(
117
+ "REPLACEDEVICE: interface_id = %s, oldDeviceAddress = %s, newDeviceAddress = %s",
118
+ interface_id,
119
+ old_device_address,
120
+ new_device_address,
121
+ )
122
+
123
+ @callback_backend_system(system_event=BackendSystemEvent.RE_ADDED_DEVICE)
124
+ def readdedDevice(self, interface_id: str, addresses: list[str], /) -> None:
125
+ """
126
+ Re-Add device from the backend.
127
+
128
+ Probably irrelevant for us.
129
+ Gets called when a known devices is put into learn-mode
130
+ while installation mode is active.
131
+ """
132
+ _LOGGER.debug(
133
+ "READDEDDEVICES: interface_id = %s, addresses = %s",
134
+ interface_id,
135
+ str(addresses),
136
+ )
137
+
138
+ def get_central(self, *, interface_id: str) -> hmcu.CentralUnit | None:
139
+ """Return the central by interface_id."""
140
+ return self._rpc_server.get_central(interface_id=interface_id)
141
+
142
+
143
+ # Restrict to specific paths.
144
+ class RequestHandler(SimpleXMLRPCRequestHandler):
145
+ """We handle requests to / and /RPC2."""
146
+
147
+ rpc_paths = (
148
+ "/",
149
+ "/RPC2",
150
+ )
151
+
152
+
153
+ class HomematicXMLRPCServer(SimpleXMLRPCServer):
154
+ """
155
+ Simple XML-RPC server.
156
+
157
+ Simple XML-RPC server that allows functions and a single instance
158
+ to be installed to handle requests. The default implementation
159
+ attempts to dispatch XML-RPC calls to the functions or instance
160
+ installed in the server. Override the _dispatch method inherited
161
+ from SimpleXMLRPCDispatcher to change this behavior.
162
+
163
+ This implementation adds an additional method:
164
+ system_listMethods(self, interface_id: str.
165
+ """
166
+
167
+ __kwonly_check__ = False
168
+
169
+ def system_listMethods(self, interface_id: str | None = None, /) -> list[str]:
170
+ """Return a list of the methods supported by the server."""
171
+ return SimpleXMLRPCServer.system_listMethods(self)
172
+
173
+
174
+ class RpcServer(threading.Thread):
175
+ """RPC server thread to handle messages from the backend."""
176
+
177
+ _initialized: bool = False
178
+ _instances: Final[dict[tuple[str, int], RpcServer]] = {}
179
+
180
+ def __init__(self, *, server: SimpleXMLRPCServer) -> None:
181
+ """Init XmlRPC server."""
182
+ self._server = server
183
+ self._server.register_introspection_functions()
184
+ self._server.register_multicall_functions()
185
+ self._server.register_instance(RPCFunctions(rpc_server=self), allow_dotted_names=True)
186
+ self._initialized = True
187
+ self._address: Final[tuple[str, int]] = cast(tuple[str, int], server.server_address)
188
+ self._listen_ip_addr: Final = self._address[0]
189
+ self._listen_port: Final = self._address[1]
190
+ self._centrals: Final[dict[str, hmcu.CentralUnit]] = {}
191
+ self._instances[self._address] = self
192
+ threading.Thread.__init__(self, name=f"RpcServer {self._listen_ip_addr}:{self._listen_port}")
193
+
194
+ def run(self) -> None:
195
+ """Run the RPC-Server thread."""
196
+ _LOGGER.debug(
197
+ "RUN: Starting RPC-Server listening on %s:%i",
198
+ self._listen_ip_addr,
199
+ self._listen_port,
200
+ )
201
+ if self._server:
202
+ self._server.serve_forever()
203
+
204
+ def stop(self) -> None:
205
+ """Stop the RPC-Server."""
206
+ _LOGGER.debug("STOP: Shutting down RPC-Server")
207
+ self._server.shutdown()
208
+ _LOGGER.debug("STOP: Stopping RPC-Server")
209
+ self._server.server_close()
210
+ # Ensure the server thread has actually terminated to avoid slow teardown
211
+ with contextlib.suppress(RuntimeError):
212
+ self.join(timeout=1.0)
213
+ _LOGGER.debug("STOP: RPC-Server stopped")
214
+ if self._address in self._instances:
215
+ del self._instances[self._address]
216
+
217
+ @property
218
+ def listen_ip_addr(self) -> str:
219
+ """Return the local ip address."""
220
+ return self._listen_ip_addr
221
+
222
+ @property
223
+ def listen_port(self) -> int:
224
+ """Return the local port."""
225
+ return self._listen_port
226
+
227
+ @property
228
+ def started(self) -> bool:
229
+ """Return if thread is active."""
230
+ return self._started.is_set() is True # type: ignore[attr-defined]
231
+
232
+ def add_central(self, *, central: hmcu.CentralUnit) -> None:
233
+ """Register a central in the RPC-Server."""
234
+ if not self._centrals.get(central.name):
235
+ self._centrals[central.name] = central
236
+
237
+ def remove_central(self, *, central: hmcu.CentralUnit) -> None:
238
+ """Unregister a central from RPC-Server."""
239
+ if self._centrals.get(central.name):
240
+ del self._centrals[central.name]
241
+
242
+ def get_central(self, *, interface_id: str) -> hmcu.CentralUnit | None:
243
+ """Return a central by interface_id."""
244
+ for central in self._centrals.values():
245
+ if central.has_client(interface_id=interface_id):
246
+ return central
247
+ return None
248
+
249
+ @property
250
+ def no_central_assigned(self) -> bool:
251
+ """Return if no central is assigned."""
252
+ return len(self._centrals) == 0
253
+
254
+
255
+ class XmlRpcServer(RpcServer):
256
+ """XML-RPC server thread to handle messages from the backend."""
257
+
258
+ def __init__(
259
+ self,
260
+ *,
261
+ ip_addr: str,
262
+ port: int,
263
+ ) -> None:
264
+ """Init XmlRPC server."""
265
+
266
+ if self._initialized:
267
+ return
268
+ super().__init__(
269
+ server=HomematicXMLRPCServer(
270
+ addr=(ip_addr, port),
271
+ requestHandler=RequestHandler,
272
+ logRequests=False,
273
+ allow_none=True,
274
+ )
275
+ )
276
+
277
+ def __new__(cls, ip_addr: str, port: int) -> XmlRpcServer: # noqa: PYI034 # kwonly: disable
278
+ """Create new RPC server."""
279
+ if (rpc := cls._instances.get((ip_addr, port))) is None:
280
+ _LOGGER.debug("Creating XmlRpc server")
281
+ return super().__new__(cls)
282
+ return cast(XmlRpcServer, rpc)
283
+
284
+
285
+ def create_xml_rpc_server(*, ip_addr: str = IP_ANY_V4, port: int = PORT_ANY) -> XmlRpcServer:
286
+ """Register the rpc server."""
287
+ rpc = XmlRpcServer(ip_addr=ip_addr, port=port)
288
+ if not rpc.started:
289
+ rpc.start()
290
+ _LOGGER.debug(
291
+ "CREATE_XML_RPC_SERVER: Starting XmlRPC-Server listening on %s:%i",
292
+ rpc.listen_ip_addr,
293
+ rpc.listen_port,
294
+ )
295
+ return rpc