aiohomematic 2025.8.9__py3-none-any.whl → 2025.8.10__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 (71) hide show
  1. aiohomematic/__init__.py +15 -1
  2. aiohomematic/async_support.py +15 -2
  3. aiohomematic/caches/__init__.py +2 -0
  4. aiohomematic/caches/dynamic.py +2 -0
  5. aiohomematic/caches/persistent.py +2 -0
  6. aiohomematic/caches/visibility.py +2 -0
  7. aiohomematic/central/__init__.py +43 -18
  8. aiohomematic/central/decorators.py +60 -15
  9. aiohomematic/central/xml_rpc_server.py +15 -1
  10. aiohomematic/client/__init__.py +2 -0
  11. aiohomematic/client/_rpc_errors.py +81 -0
  12. aiohomematic/client/json_rpc.py +68 -19
  13. aiohomematic/client/xml_rpc.py +15 -8
  14. aiohomematic/const.py +44 -3
  15. aiohomematic/context.py +11 -1
  16. aiohomematic/converter.py +27 -1
  17. aiohomematic/decorators.py +88 -19
  18. aiohomematic/exceptions.py +19 -1
  19. aiohomematic/hmcli.py +13 -1
  20. aiohomematic/model/__init__.py +2 -0
  21. aiohomematic/model/calculated/__init__.py +2 -0
  22. aiohomematic/model/calculated/climate.py +2 -0
  23. aiohomematic/model/calculated/data_point.py +2 -0
  24. aiohomematic/model/calculated/operating_voltage_level.py +2 -0
  25. aiohomematic/model/calculated/support.py +2 -0
  26. aiohomematic/model/custom/__init__.py +2 -0
  27. aiohomematic/model/custom/climate.py +3 -1
  28. aiohomematic/model/custom/const.py +2 -0
  29. aiohomematic/model/custom/cover.py +30 -2
  30. aiohomematic/model/custom/data_point.py +2 -0
  31. aiohomematic/model/custom/definition.py +2 -0
  32. aiohomematic/model/custom/light.py +18 -10
  33. aiohomematic/model/custom/lock.py +2 -0
  34. aiohomematic/model/custom/siren.py +5 -2
  35. aiohomematic/model/custom/support.py +2 -0
  36. aiohomematic/model/custom/switch.py +2 -0
  37. aiohomematic/model/custom/valve.py +2 -0
  38. aiohomematic/model/data_point.py +15 -3
  39. aiohomematic/model/decorators.py +29 -8
  40. aiohomematic/model/device.py +2 -0
  41. aiohomematic/model/event.py +2 -0
  42. aiohomematic/model/generic/__init__.py +2 -0
  43. aiohomematic/model/generic/action.py +2 -0
  44. aiohomematic/model/generic/binary_sensor.py +2 -0
  45. aiohomematic/model/generic/button.py +2 -0
  46. aiohomematic/model/generic/data_point.py +4 -1
  47. aiohomematic/model/generic/number.py +4 -1
  48. aiohomematic/model/generic/select.py +4 -1
  49. aiohomematic/model/generic/sensor.py +2 -0
  50. aiohomematic/model/generic/switch.py +2 -0
  51. aiohomematic/model/generic/text.py +2 -0
  52. aiohomematic/model/hub/__init__.py +2 -0
  53. aiohomematic/model/hub/binary_sensor.py +2 -0
  54. aiohomematic/model/hub/button.py +2 -0
  55. aiohomematic/model/hub/data_point.py +2 -0
  56. aiohomematic/model/hub/number.py +2 -0
  57. aiohomematic/model/hub/select.py +2 -0
  58. aiohomematic/model/hub/sensor.py +2 -0
  59. aiohomematic/model/hub/switch.py +2 -0
  60. aiohomematic/model/hub/text.py +2 -0
  61. aiohomematic/model/support.py +26 -1
  62. aiohomematic/model/update.py +2 -0
  63. aiohomematic/support.py +160 -3
  64. aiohomematic/validator.py +49 -2
  65. aiohomematic-2025.8.10.dist-info/METADATA +124 -0
  66. aiohomematic-2025.8.10.dist-info/RECORD +78 -0
  67. {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
  68. aiohomematic-2025.8.9.dist-info/METADATA +0 -69
  69. aiohomematic-2025.8.9.dist-info/RECORD +0 -77
  70. {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
  71. {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.8.10.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  XML-RPC transport proxy with concurrency control and connection awareness.
3
5
 
@@ -19,6 +21,7 @@ Notes
19
21
 
20
22
  from __future__ import annotations
21
23
 
24
+ import asyncio
22
25
  from collections.abc import Mapping
23
26
  from concurrent.futures import ThreadPoolExecutor
24
27
  from enum import Enum, IntEnum, StrEnum
@@ -30,6 +33,7 @@ import xmlrpc.client
30
33
 
31
34
  from aiohomematic import central as hmcu
32
35
  from aiohomematic.async_support import Looper
36
+ from aiohomematic.client._rpc_errors import RpcContext, map_xmlrpc_fault
33
37
  from aiohomematic.const import ISO_8859_1
34
38
  from aiohomematic.exceptions import (
35
39
  AuthFailure,
@@ -134,13 +138,15 @@ class XmlRpcProxy(xmlrpc.client.ServerProxy):
134
138
  ):
135
139
  args = _cleanup_args(*args)
136
140
  _LOGGER.debug("__ASYNC_REQUEST: %s", args)
137
- result = await self._looper.async_add_executor_job(
138
- # pylint: disable=protected-access
139
- parent._ServerProxy__request, # type: ignore[attr-defined]
140
- self,
141
- *args,
142
- name="xmp_rpc_proxy",
143
- executor=self._proxy_executor,
141
+ result = await asyncio.shield(
142
+ self._looper.async_add_executor_job(
143
+ # pylint: disable=protected-access
144
+ parent._ServerProxy__request, # type: ignore[attr-defined]
145
+ self,
146
+ *args,
147
+ name="xmp_rpc_proxy",
148
+ executor=self._proxy_executor,
149
+ )
144
150
  )
145
151
  self._connection_state.remove_issue(issuer=self, iid=self.interface_id)
146
152
  return result
@@ -165,7 +171,8 @@ class XmlRpcProxy(xmlrpc.client.ServerProxy):
165
171
  _LOGGER.error(message)
166
172
  raise NoConnectionException(message) from oserr
167
173
  except xmlrpc.client.Fault as flt:
168
- raise ClientException(f"XMLRPC Fault from backend: {flt.faultCode} {flt.faultString}") from flt
174
+ ctx = RpcContext(protocol="xml-rpc", method=str(args[0]), interface=self.interface_id)
175
+ raise map_xmlrpc_fault(code=flt.faultCode, fault_string=flt.faultString, ctx=ctx) from flt
169
176
  except TypeError as terr:
170
177
  raise ClientException(terr) from terr
171
178
  except xmlrpc.client.ProtocolError as perr:
aiohomematic/const.py CHANGED
@@ -1,4 +1,10 @@
1
- """Constants used by aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Constants used by aiohomematic.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -6,13 +12,14 @@ from collections.abc import Callable, Iterable, Mapping
6
12
  from dataclasses import dataclass, field
7
13
  from datetime import datetime
8
14
  from enum import Enum, IntEnum, StrEnum
15
+ import inspect
9
16
  import os
10
17
  import re
11
18
  import sys
12
19
  from types import MappingProxyType
13
20
  from typing import Any, Final, NamedTuple, Required, TypeAlias, TypedDict
14
21
 
15
- VERSION: Final = "2025.8.9"
22
+ VERSION: Final = "2025.8.10"
16
23
 
17
24
  # Detect test speedup mode via environment
18
25
  _TEST_SPEEDUP: Final = (
@@ -67,7 +74,8 @@ RENAME_SYSVAR_BY_NAME: Final[Mapping[str, str]] = MappingProxyType(
67
74
  }
68
75
  )
69
76
 
70
- SYSVAR_ENABLE_DEFAULT: Final[frozenset[str]] = frozenset({"40", "41"})
77
+ # Deprecated alias (use ALWAYS_ENABLE_SYSVARS_BY_ID). Kept for backward compatibility.
78
+ SYSVAR_ENABLE_DEFAULT: Final[frozenset[str]] = ALWAYS_ENABLE_SYSVARS_BY_ID
71
79
 
72
80
  ADDRESS_SEPARATOR: Final = ":"
73
81
  BLOCK_LOG_TIMEOUT: Final = 60
@@ -161,6 +169,17 @@ class CallSource(StrEnum):
161
169
  MANUAL_OR_SCHEDULED = "manual_or_scheduled"
162
170
 
163
171
 
172
+ class CentralUnitState(StrEnum):
173
+ """Enum with central unit states."""
174
+
175
+ INITIALIZING = "initializing"
176
+ NEW = "new"
177
+ RUNNING = "running"
178
+ STOPPED = "stopped"
179
+ STOPPED_BY_ERROR = "stopped_by_error"
180
+ STOPPING = "stopping"
181
+
182
+
164
183
  class DataOperationResult(Enum):
165
184
  """Enum with data operation results."""
166
185
 
@@ -601,6 +620,8 @@ KEY_CHANNEL_OPERATION_MODE_VISIBILITY: Final[Mapping[str, frozenset[str]]] = Map
601
620
  }
602
621
  )
603
622
 
623
+ BLOCKED_CATEGORIES: Final[tuple[DataPointCategory, ...]] = (DataPointCategory.ACTION,)
624
+
604
625
  HUB_CATEGORIES: Final[tuple[DataPointCategory, ...]] = (
605
626
  DataPointCategory.HUB_BINARY_SENSOR,
606
627
  DataPointCategory.HUB_BUTTON,
@@ -820,3 +841,23 @@ class DeviceDescription(TypedDict, total=False):
820
841
  INTERFACE: str | None
821
842
  # ROAMING: int | None
822
843
  RX_MODE: int
844
+
845
+
846
+ # Define public API for this module
847
+ __all__ = tuple(
848
+ sorted(
849
+ name
850
+ for name, obj in globals().items()
851
+ if not name.startswith("_")
852
+ and (
853
+ name.isupper() # constants like VERSION, patterns, defaults
854
+ or inspect.isclass(obj) # Enums, dataclasses, TypedDicts, NamedTuple classes
855
+ or inspect.isfunction(obj) # module functions
856
+ )
857
+ and (
858
+ getattr(obj, "__module__", __name__) == __name__
859
+ if not isinstance(obj, (int, float, str, bytes, tuple, frozenset, dict))
860
+ else True
861
+ )
862
+ )
863
+ )
aiohomematic/context.py CHANGED
@@ -1,4 +1,10 @@
1
- """Collection of context variables."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Collection of context variables.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -6,3 +12,7 @@ from contextvars import ContextVar
6
12
 
7
13
  # context var for storing if call is running within a service
8
14
  IN_SERVICE_VAR: ContextVar[bool] = ContextVar("in_service_var", default=False)
15
+
16
+
17
+ # Define public API for this module
18
+ __all__ = ["IN_SERVICE_VAR"]
aiohomematic/converter.py CHANGED
@@ -1,8 +1,16 @@
1
- """Converters used by aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Converters used by aiohomematic.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
11
  import ast
12
+ from functools import lru_cache
13
+ import inspect
6
14
  import logging
7
15
  from typing import Any, Final, cast
8
16
 
@@ -12,6 +20,7 @@ from aiohomematic.support import extract_exc_args
12
20
  _LOGGER = logging.getLogger(__name__)
13
21
 
14
22
 
23
+ @lru_cache(maxsize=1024)
15
24
  def _convert_cpv_to_hm_level(cpv: Any) -> Any:
16
25
  """Convert combined parameter value for hm level."""
17
26
  if isinstance(cpv, str) and cpv.startswith("0x"):
@@ -19,11 +28,13 @@ def _convert_cpv_to_hm_level(cpv: Any) -> Any:
19
28
  return cpv
20
29
 
21
30
 
31
+ @lru_cache(maxsize=1024)
22
32
  def _convert_cpv_to_hmip_level(cpv: Any) -> Any:
23
33
  """Convert combined parameter value for hmip level."""
24
34
  return int(cpv) / 100
25
35
 
26
36
 
37
+ @lru_cache(maxsize=1024)
27
38
  def convert_hm_level_to_cpv(hm_level: Any) -> Any:
28
39
  """Convert hm level to combined parameter value."""
29
40
  return format(int(hm_level * 100 * 2), "#04x")
@@ -40,6 +51,7 @@ _COMBINED_PARAMETER_TO_HM_CONVERTER: Final = {
40
51
  _COMBINED_PARAMETER_NAMES: Final = {"L": Parameter.LEVEL, "L2": Parameter.LEVEL_2}
41
52
 
42
53
 
54
+ @lru_cache(maxsize=1024)
43
55
  def _convert_combined_parameter_to_paramset(cpv: str) -> dict[str, Any]:
44
56
  """Convert combined parameter to paramset."""
45
57
  paramset: dict[str, Any] = {}
@@ -53,6 +65,7 @@ def _convert_combined_parameter_to_paramset(cpv: str) -> dict[str, Any]:
53
65
  return paramset
54
66
 
55
67
 
68
+ @lru_cache(maxsize=1024)
56
69
  def _convert_level_combined_to_paramset(lcv: str) -> dict[str, Any]:
57
70
  """Convert combined parameter to paramset."""
58
71
  if "," in lcv:
@@ -71,6 +84,7 @@ _COMBINED_PARAMETER_TO_PARAMSET_CONVERTER: Final = {
71
84
  }
72
85
 
73
86
 
87
+ @lru_cache(maxsize=1024)
74
88
  def convert_combined_parameter_to_paramset(parameter: str, cpv: str) -> dict[str, Any]:
75
89
  """Convert combined parameter to paramset."""
76
90
  try:
@@ -80,3 +94,15 @@ def convert_combined_parameter_to_paramset(parameter: str, cpv: str) -> dict[str
80
94
  except Exception as exc:
81
95
  _LOGGER.debug("CONVERT_COMBINED_PARAMETER_TO_PARAMSET: Convert failed %s", extract_exc_args(exc=exc))
82
96
  return {}
97
+
98
+
99
+ # Define public API for this module
100
+ __all__ = tuple(
101
+ sorted(
102
+ name
103
+ for name, obj in globals().items()
104
+ if not name.startswith("_")
105
+ and (name.isupper() or inspect.isfunction(obj) or inspect.isclass(obj))
106
+ and getattr(obj, "__module__", __name__) == __name__
107
+ )
108
+ )
@@ -1,4 +1,10 @@
1
- """Common Decorators used within aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Common Decorators used within aiohomematic.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -8,16 +14,21 @@ import inspect
8
14
  import logging
9
15
  from time import monotonic
10
16
  from typing import Any, Final, ParamSpec, TypeVar, cast
17
+ from weakref import WeakKeyDictionary
11
18
 
12
19
  from aiohomematic.context import IN_SERVICE_VAR
13
20
  from aiohomematic.exceptions import BaseHomematicException
14
- from aiohomematic.support import extract_exc_args
21
+ from aiohomematic.support import build_log_context_from_obj, extract_exc_args
15
22
 
16
23
  P = ParamSpec("P")
17
24
  R = TypeVar("R")
18
25
 
19
26
  _LOGGER: Final = logging.getLogger(__name__)
20
27
 
28
+ # Cache for per-class service call method names to avoid repeated scans.
29
+ # Structure: {cls: (method_name1, method_name2, ...)}
30
+ _SERVICE_CALLS_CACHE: WeakKeyDictionary[type, tuple[str, ...]] = WeakKeyDictionary()
31
+
21
32
 
22
33
  def inspector( # noqa: C901
23
34
  log_level: int = logging.ERROR,
@@ -54,11 +65,22 @@ def inspector( # noqa: C901
54
65
 
55
66
  """
56
67
 
57
- def handle_exception(exc: Exception, func: Callable, is_sub_service_call: bool, is_homematic: bool) -> R:
58
- """Handle exceptions for decorated functions."""
68
+ def handle_exception(
69
+ exc: Exception, func: Callable, is_sub_service_call: bool, is_homematic: bool, context_obj: Any | None
70
+ ) -> R:
71
+ """Handle exceptions for decorated functions with structured logging."""
59
72
  if not is_sub_service_call and log_level > logging.NOTSET:
60
- message = f"{func.__name__.upper()} failed: {extract_exc_args(exc=exc)}"
61
- logging.getLogger(func.__module__).log(level=log_level, msg=message)
73
+ logger = logging.getLogger(func.__module__)
74
+ extra = {
75
+ "err_type": exc.__class__.__name__,
76
+ "err": extract_exc_args(exc=exc),
77
+ "function": func.__name__,
78
+ **build_log_context_from_obj(obj=context_obj),
79
+ }
80
+ if log_level >= logging.ERROR:
81
+ logger.exception("service_error", extra=extra)
82
+ else:
83
+ logger.log(level=log_level, msg="service_error", extra=extra)
62
84
  if re_raise or not is_homematic:
63
85
  raise exc
64
86
  return cast(R, no_raise_return)
@@ -75,13 +97,21 @@ def inspector( # noqa: C901
75
97
  if token:
76
98
  IN_SERVICE_VAR.reset(token)
77
99
  return handle_exception(
78
- exc=bhexc, func=func, is_sub_service_call=IN_SERVICE_VAR.get(), is_homematic=True
100
+ exc=bhexc,
101
+ func=func,
102
+ is_sub_service_call=IN_SERVICE_VAR.get(),
103
+ is_homematic=True,
104
+ context_obj=(args[0] if args else None),
79
105
  )
80
106
  except Exception as exc:
81
107
  if token:
82
108
  IN_SERVICE_VAR.reset(token)
83
109
  return handle_exception(
84
- exc=exc, func=func, is_sub_service_call=IN_SERVICE_VAR.get(), is_homematic=False
110
+ exc=exc,
111
+ func=func,
112
+ is_sub_service_call=IN_SERVICE_VAR.get(),
113
+ is_homematic=False,
114
+ context_obj=(args[0] if args else None),
85
115
  )
86
116
  else:
87
117
  if token:
@@ -103,13 +133,21 @@ def inspector( # noqa: C901
103
133
  if token:
104
134
  IN_SERVICE_VAR.reset(token)
105
135
  return handle_exception(
106
- exc=bhexc, func=func, is_sub_service_call=IN_SERVICE_VAR.get(), is_homematic=True
136
+ exc=bhexc,
137
+ func=func,
138
+ is_sub_service_call=IN_SERVICE_VAR.get(),
139
+ is_homematic=True,
140
+ context_obj=(args[0] if args else None),
107
141
  )
108
142
  except Exception as exc:
109
143
  if token:
110
144
  IN_SERVICE_VAR.reset(token)
111
145
  return handle_exception(
112
- exc=exc, func=func, is_sub_service_call=IN_SERVICE_VAR.get(), is_homematic=False
146
+ exc=exc,
147
+ func=func,
148
+ is_sub_service_call=IN_SERVICE_VAR.get(),
149
+ is_homematic=False,
150
+ context_obj=(args[0] if args else None),
113
151
  )
114
152
  else:
115
153
  if token:
@@ -147,15 +185,34 @@ def _log_performance_message(func: Callable, start: float, *args: P.args, **kwar
147
185
 
148
186
 
149
187
  def get_service_calls(obj: object) -> dict[str, Callable]:
150
- """Get all methods decorated with the "bind_collector" or "service_call" decorator."""
151
- return {
152
- name: getattr(obj, name)
153
- for name in dir(obj)
154
- if not name.startswith("_")
155
- and name not in ("service_methods", "service_method_names")
156
- and callable(getattr(obj, name))
157
- and hasattr(getattr(obj, name), "ha_service")
158
- }
188
+ """
189
+ Get all methods decorated with the service decorator (ha_service attribute).
190
+
191
+ To reduce overhead, we cache the discovered method names per class using a WeakKeyDictionary.
192
+ """
193
+ cls = obj.__class__
194
+
195
+ # Try cache first
196
+ if (names := _SERVICE_CALLS_CACHE.get(cls)) is None:
197
+ # Compute method names using class attributes to avoid creating bound methods during checks
198
+ exclusions = {"service_methods", "service_method_names"}
199
+ computed: list[str] = []
200
+ for name in dir(cls):
201
+ if name.startswith("_") or name in exclusions:
202
+ continue
203
+ try:
204
+ # Check the attribute on the class (function/descriptor)
205
+ attr = getattr(cls, name)
206
+ except Exception:
207
+ continue
208
+ # Only consider callables exposed on the instance and marked with ha_service on the function/wrapper
209
+ if callable(getattr(obj, name, None)) and hasattr(attr, "ha_service"):
210
+ computed.append(name)
211
+ names = tuple(computed)
212
+ _SERVICE_CALLS_CACHE[cls] = names
213
+
214
+ # Return a mapping of bound methods for this instance
215
+ return {name: getattr(obj, name) for name in names}
159
216
 
160
217
 
161
218
  def measure_execution_time[CallableT: Callable[..., Any]](func: CallableT) -> CallableT:
@@ -186,3 +243,15 @@ def measure_execution_time[CallableT: Callable[..., Any]](func: CallableT) -> Ca
186
243
  if inspect.iscoroutinefunction(func):
187
244
  return async_measure_wrapper # type: ignore[return-value]
188
245
  return measure_wrapper # type: ignore[return-value]
246
+
247
+
248
+ # Define public API for this module
249
+ __all__ = tuple(
250
+ sorted(
251
+ name
252
+ for name, obj in globals().items()
253
+ if not name.startswith("_")
254
+ and (inspect.isfunction(obj) or inspect.isclass(obj))
255
+ and getattr(obj, "__module__", __name__) == __name__
256
+ )
257
+ )
@@ -1,4 +1,10 @@
1
- """Module for AioHomematicExceptions."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Module for AioHomematicExceptions.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -143,3 +149,15 @@ def log_exception[**P, R](
143
149
  return wrapper_log_exception
144
150
 
145
151
  return decorator_log_exception
152
+
153
+
154
+ # Define public API for this module
155
+ __all__ = tuple(
156
+ sorted(
157
+ name
158
+ for name, obj in globals().items()
159
+ if not name.startswith("_")
160
+ and (name.isupper() or inspect.isclass(obj) or inspect.isfunction(obj))
161
+ and getattr(obj, "__module__", __name__) == __name__
162
+ )
163
+ )
aiohomematic/hmcli.py CHANGED
@@ -1,5 +1,14 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  #!/usr/bin/python3
2
- """Commandline tool to query HomeMatic hubs via XML-RPC."""
4
+ """
5
+ Commandline tool to query HomeMatic hubs via XML-RPC.
6
+
7
+ Public API of this module is defined by __all__.
8
+
9
+ This module provides a command-line interface; as a library surface it only
10
+ exposes the 'main' entrypoint for invocation. All other names are internal.
11
+ """
3
12
 
4
13
  from __future__ import annotations
5
14
 
@@ -12,6 +21,9 @@ from aiohomematic import __version__
12
21
  from aiohomematic.const import ParamsetKey
13
22
  from aiohomematic.support import build_xml_rpc_headers, build_xml_rpc_uri, get_tls_context
14
23
 
24
+ # Define public API for this module (CLI only)
25
+ __all__ = ["main"]
26
+
15
27
 
16
28
  def main() -> None:
17
29
  """Start the cli."""
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Data point and event model for AioHomematic.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Calculated (derived) data points for AioHomematic.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module for calculating the apparent temperature in the sensor category."""
2
4
 
3
5
  from __future__ import annotations
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module with base class for calculated data points."""
2
4
 
3
5
  from __future__ import annotations
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module for calculating the operating voltage level in the sensor category."""
2
4
 
3
5
  from __future__ import annotations
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  A number of functions used to calculate values based on existing data.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Custom data points for AioHomematic.
3
5
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module for data points implemented using the climate category."""
2
4
 
3
5
  from __future__ import annotations
@@ -335,7 +337,7 @@ class BaseCustomDpClimate(CustomDataPoint):
335
337
  do_validate = False
336
338
 
337
339
  if do_validate and not (self.min_temp <= temperature <= self.max_temp):
338
- raise ValueError(
340
+ raise ValidationException(
339
341
  f"SET_TEMPERATURE failed: Invalid temperature: {temperature} (min: {self.min_temp}, max: {self.max_temp})"
340
342
  )
341
343
 
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Constants used by aiohomematic custom data points."""
2
4
 
3
5
  from __future__ import annotations
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module for data points implemented using the cover category."""
2
4
 
3
5
  from __future__ import annotations
@@ -21,6 +23,11 @@ from aiohomematic.model.generic import DpAction, DpFloat, DpSelect, DpSensor
21
23
 
22
24
  _LOGGER: Final = logging.getLogger(__name__)
23
25
 
26
+ # Timeout for acquiring the per-instance command processing lock to avoid
27
+ # potential deadlocks or indefinite serialization if an awaited call inside
28
+ # the critical section stalls.
29
+ _COMMAND_LOCK_TIMEOUT: Final[float] = 5.0
30
+
24
31
  _CLOSED_LEVEL: Final = 0.0
25
32
  _COVER_VENT_MAX_POSITION: Final = 50
26
33
  _LEVEL_TO_POSITION_MULTIPLIER: Final = 100.0
@@ -336,7 +343,15 @@ class CustomDpBlind(CustomDpCover):
336
343
  """
337
344
  currently_moving = False
338
345
 
339
- async with self._command_processing_lock:
346
+ try:
347
+ acquired: bool = await asyncio.wait_for(
348
+ self._command_processing_lock.acquire(), timeout=_COMMAND_LOCK_TIMEOUT
349
+ )
350
+ except TimeoutError:
351
+ acquired = False
352
+ _LOGGER.warning("%s: command lock acquisition timed out; proceeding without lock", self)
353
+
354
+ try:
340
355
  if level is not None:
341
356
  _level = level
342
357
  elif self._target_level is not None:
@@ -360,6 +375,9 @@ class CustomDpBlind(CustomDpCover):
360
375
  await self._stop()
361
376
 
362
377
  await self._send_level(level=_level, tilt_level=_tilt_level, collector=collector)
378
+ finally:
379
+ if acquired:
380
+ self._command_processing_lock.release()
363
381
 
364
382
  @bind_collector()
365
383
  async def _send_level(
@@ -404,8 +422,18 @@ class CustomDpBlind(CustomDpCover):
404
422
  @bind_collector(enabled=False)
405
423
  async def stop(self, collector: CallParameterCollector | None = None) -> None:
406
424
  """Stop the device if in motion."""
407
- async with self._command_processing_lock:
425
+ try:
426
+ acquired: bool = await asyncio.wait_for(
427
+ self._command_processing_lock.acquire(), timeout=_COMMAND_LOCK_TIMEOUT
428
+ )
429
+ except TimeoutError:
430
+ acquired = False
431
+ _LOGGER.warning("%s: command lock acquisition timed out; proceeding without lock", self)
432
+ try:
408
433
  await self._stop(collector=collector)
434
+ finally:
435
+ if acquired:
436
+ self._command_processing_lock.release()
409
437
 
410
438
  @bind_collector(enabled=False)
411
439
  async def _stop(self, collector: CallParameterCollector | None = None) -> None:
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module with base class for custom data points."""
2
4
 
3
5
  from __future__ import annotations
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """The module contains device descriptions for custom data points."""
2
4
 
3
5
  from __future__ import annotations