aiohomematic 2025.8.9__py3-none-any.whl → 2025.9.1__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 +15 -1
- aiohomematic/async_support.py +15 -2
- aiohomematic/caches/__init__.py +2 -0
- aiohomematic/caches/dynamic.py +2 -0
- aiohomematic/caches/persistent.py +2 -0
- aiohomematic/caches/visibility.py +2 -0
- aiohomematic/central/__init__.py +43 -18
- aiohomematic/central/decorators.py +60 -15
- aiohomematic/central/xml_rpc_server.py +15 -1
- aiohomematic/client/__init__.py +2 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +68 -19
- aiohomematic/client/xml_rpc.py +15 -8
- aiohomematic/const.py +44 -3
- aiohomematic/context.py +11 -1
- aiohomematic/converter.py +27 -1
- aiohomematic/decorators.py +98 -25
- aiohomematic/exceptions.py +19 -1
- aiohomematic/hmcli.py +13 -1
- aiohomematic/model/__init__.py +2 -0
- aiohomematic/model/calculated/__init__.py +2 -0
- aiohomematic/model/calculated/climate.py +2 -0
- aiohomematic/model/calculated/data_point.py +2 -0
- aiohomematic/model/calculated/operating_voltage_level.py +2 -0
- aiohomematic/model/calculated/support.py +2 -0
- aiohomematic/model/custom/__init__.py +2 -0
- aiohomematic/model/custom/climate.py +3 -1
- aiohomematic/model/custom/const.py +2 -0
- aiohomematic/model/custom/cover.py +30 -2
- aiohomematic/model/custom/data_point.py +2 -0
- aiohomematic/model/custom/definition.py +2 -0
- aiohomematic/model/custom/light.py +18 -10
- aiohomematic/model/custom/lock.py +2 -0
- aiohomematic/model/custom/siren.py +5 -2
- aiohomematic/model/custom/support.py +2 -0
- aiohomematic/model/custom/switch.py +2 -0
- aiohomematic/model/custom/valve.py +2 -0
- aiohomematic/model/data_point.py +15 -3
- aiohomematic/model/decorators.py +29 -8
- aiohomematic/model/device.py +2 -0
- aiohomematic/model/event.py +2 -0
- aiohomematic/model/generic/__init__.py +2 -0
- aiohomematic/model/generic/action.py +2 -0
- aiohomematic/model/generic/binary_sensor.py +2 -0
- aiohomematic/model/generic/button.py +2 -0
- aiohomematic/model/generic/data_point.py +4 -1
- aiohomematic/model/generic/number.py +4 -1
- aiohomematic/model/generic/select.py +4 -1
- aiohomematic/model/generic/sensor.py +2 -0
- aiohomematic/model/generic/switch.py +2 -0
- aiohomematic/model/generic/text.py +2 -0
- aiohomematic/model/hub/__init__.py +2 -0
- aiohomematic/model/hub/binary_sensor.py +2 -0
- aiohomematic/model/hub/button.py +2 -0
- aiohomematic/model/hub/data_point.py +2 -0
- aiohomematic/model/hub/number.py +2 -0
- aiohomematic/model/hub/select.py +2 -0
- aiohomematic/model/hub/sensor.py +2 -0
- aiohomematic/model/hub/switch.py +2 -0
- aiohomematic/model/hub/text.py +2 -0
- aiohomematic/model/support.py +26 -1
- aiohomematic/model/update.py +2 -0
- aiohomematic/support.py +160 -3
- aiohomematic/validator.py +49 -2
- aiohomematic-2025.9.1.dist-info/METADATA +125 -0
- aiohomematic-2025.9.1.dist-info/RECORD +78 -0
- {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.9.1.dist-info}/licenses/LICENSE +1 -1
- aiohomematic-2025.8.9.dist-info/METADATA +0 -69
- aiohomematic-2025.8.9.dist-info/RECORD +0 -77
- {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.9.1.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.8.9.dist-info → aiohomematic-2025.9.1.dist-info}/top_level.txt +0 -0
aiohomematic/client/xml_rpc.py
CHANGED
|
@@ -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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
22
|
+
VERSION: Final = "2025.9.1"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|
aiohomematic/decorators.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
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,15 +14,20 @@ 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_PERFORMANCE: Final = logging.getLogger(f"{__package__}.performance")
|
|
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()
|
|
20
31
|
|
|
21
32
|
|
|
22
33
|
def inspector( # noqa: C901
|
|
@@ -54,11 +65,22 @@ def inspector( # noqa: C901
|
|
|
54
65
|
|
|
55
66
|
"""
|
|
56
67
|
|
|
57
|
-
def handle_exception(
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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)
|
|
@@ -67,7 +89,9 @@ def inspector( # noqa: C901
|
|
|
67
89
|
def wrap_sync_function(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
68
90
|
"""Wrap sync functions."""
|
|
69
91
|
|
|
70
|
-
start =
|
|
92
|
+
start = (
|
|
93
|
+
monotonic() if measure_performance and _LOGGER_PERFORMANCE.isEnabledFor(level=logging.DEBUG) else None
|
|
94
|
+
)
|
|
71
95
|
token = IN_SERVICE_VAR.set(True) if not IN_SERVICE_VAR.get() else None
|
|
72
96
|
try:
|
|
73
97
|
return_value: R = func(*args, **kwargs)
|
|
@@ -75,13 +99,21 @@ def inspector( # noqa: C901
|
|
|
75
99
|
if token:
|
|
76
100
|
IN_SERVICE_VAR.reset(token)
|
|
77
101
|
return handle_exception(
|
|
78
|
-
exc=bhexc,
|
|
102
|
+
exc=bhexc,
|
|
103
|
+
func=func,
|
|
104
|
+
is_sub_service_call=IN_SERVICE_VAR.get(),
|
|
105
|
+
is_homematic=True,
|
|
106
|
+
context_obj=(args[0] if args else None),
|
|
79
107
|
)
|
|
80
108
|
except Exception as exc:
|
|
81
109
|
if token:
|
|
82
110
|
IN_SERVICE_VAR.reset(token)
|
|
83
111
|
return handle_exception(
|
|
84
|
-
exc=exc,
|
|
112
|
+
exc=exc,
|
|
113
|
+
func=func,
|
|
114
|
+
is_sub_service_call=IN_SERVICE_VAR.get(),
|
|
115
|
+
is_homematic=False,
|
|
116
|
+
context_obj=(args[0] if args else None),
|
|
85
117
|
)
|
|
86
118
|
else:
|
|
87
119
|
if token:
|
|
@@ -95,7 +127,9 @@ def inspector( # noqa: C901
|
|
|
95
127
|
async def wrap_async_function(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
96
128
|
"""Wrap async functions."""
|
|
97
129
|
|
|
98
|
-
start =
|
|
130
|
+
start = (
|
|
131
|
+
monotonic() if measure_performance and _LOGGER_PERFORMANCE.isEnabledFor(level=logging.DEBUG) else None
|
|
132
|
+
)
|
|
99
133
|
token = IN_SERVICE_VAR.set(True) if not IN_SERVICE_VAR.get() else None
|
|
100
134
|
try:
|
|
101
135
|
return_value = await func(*args, **kwargs) # type: ignore[misc] # Await the async call
|
|
@@ -103,13 +137,21 @@ def inspector( # noqa: C901
|
|
|
103
137
|
if token:
|
|
104
138
|
IN_SERVICE_VAR.reset(token)
|
|
105
139
|
return handle_exception(
|
|
106
|
-
exc=bhexc,
|
|
140
|
+
exc=bhexc,
|
|
141
|
+
func=func,
|
|
142
|
+
is_sub_service_call=IN_SERVICE_VAR.get(),
|
|
143
|
+
is_homematic=True,
|
|
144
|
+
context_obj=(args[0] if args else None),
|
|
107
145
|
)
|
|
108
146
|
except Exception as exc:
|
|
109
147
|
if token:
|
|
110
148
|
IN_SERVICE_VAR.reset(token)
|
|
111
149
|
return handle_exception(
|
|
112
|
-
exc=exc,
|
|
150
|
+
exc=exc,
|
|
151
|
+
func=func,
|
|
152
|
+
is_sub_service_call=IN_SERVICE_VAR.get(),
|
|
153
|
+
is_homematic=False,
|
|
154
|
+
context_obj=(args[0] if args else None),
|
|
113
155
|
)
|
|
114
156
|
else:
|
|
115
157
|
if token:
|
|
@@ -143,19 +185,38 @@ def _log_performance_message(func: Callable, start: float, *args: P.args, **kwar
|
|
|
143
185
|
if iface:
|
|
144
186
|
message += f"/{iface}"
|
|
145
187
|
|
|
146
|
-
|
|
188
|
+
_LOGGER_PERFORMANCE.info(message)
|
|
147
189
|
|
|
148
190
|
|
|
149
191
|
def get_service_calls(obj: object) -> dict[str, Callable]:
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
192
|
+
"""
|
|
193
|
+
Get all methods decorated with the service decorator (ha_service attribute).
|
|
194
|
+
|
|
195
|
+
To reduce overhead, we cache the discovered method names per class using a WeakKeyDictionary.
|
|
196
|
+
"""
|
|
197
|
+
cls = obj.__class__
|
|
198
|
+
|
|
199
|
+
# Try cache first
|
|
200
|
+
if (names := _SERVICE_CALLS_CACHE.get(cls)) is None:
|
|
201
|
+
# Compute method names using class attributes to avoid creating bound methods during checks
|
|
202
|
+
exclusions = {"service_methods", "service_method_names"}
|
|
203
|
+
computed: list[str] = []
|
|
204
|
+
for name in dir(cls):
|
|
205
|
+
if name.startswith("_") or name in exclusions:
|
|
206
|
+
continue
|
|
207
|
+
try:
|
|
208
|
+
# Check the attribute on the class (function/descriptor)
|
|
209
|
+
attr = getattr(cls, name)
|
|
210
|
+
except Exception:
|
|
211
|
+
continue
|
|
212
|
+
# Only consider callables exposed on the instance and marked with ha_service on the function/wrapper
|
|
213
|
+
if callable(getattr(obj, name, None)) and hasattr(attr, "ha_service"):
|
|
214
|
+
computed.append(name)
|
|
215
|
+
names = tuple(computed)
|
|
216
|
+
_SERVICE_CALLS_CACHE[cls] = names
|
|
217
|
+
|
|
218
|
+
# Return a mapping of bound methods for this instance
|
|
219
|
+
return {name: getattr(obj, name) for name in names}
|
|
159
220
|
|
|
160
221
|
|
|
161
222
|
def measure_execution_time[CallableT: Callable[..., Any]](func: CallableT) -> CallableT:
|
|
@@ -165,7 +226,7 @@ def measure_execution_time[CallableT: Callable[..., Any]](func: CallableT) -> Ca
|
|
|
165
226
|
async def async_measure_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
166
227
|
"""Wrap method."""
|
|
167
228
|
|
|
168
|
-
start = monotonic() if
|
|
229
|
+
start = monotonic() if _LOGGER_PERFORMANCE.isEnabledFor(level=logging.DEBUG) else None
|
|
169
230
|
try:
|
|
170
231
|
return await func(*args, **kwargs)
|
|
171
232
|
finally:
|
|
@@ -176,7 +237,7 @@ def measure_execution_time[CallableT: Callable[..., Any]](func: CallableT) -> Ca
|
|
|
176
237
|
def measure_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
177
238
|
"""Wrap method."""
|
|
178
239
|
|
|
179
|
-
start = monotonic() if
|
|
240
|
+
start = monotonic() if _LOGGER_PERFORMANCE.isEnabledFor(level=logging.DEBUG) else None
|
|
180
241
|
try:
|
|
181
242
|
return func(*args, **kwargs)
|
|
182
243
|
finally:
|
|
@@ -186,3 +247,15 @@ def measure_execution_time[CallableT: Callable[..., Any]](func: CallableT) -> Ca
|
|
|
186
247
|
if inspect.iscoroutinefunction(func):
|
|
187
248
|
return async_measure_wrapper # type: ignore[return-value]
|
|
188
249
|
return measure_wrapper # type: ignore[return-value]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Define public API for this module
|
|
253
|
+
__all__ = tuple(
|
|
254
|
+
sorted(
|
|
255
|
+
name
|
|
256
|
+
for name, obj in globals().items()
|
|
257
|
+
if not name.startswith("_")
|
|
258
|
+
and (inspect.isfunction(obj) or inspect.isclass(obj))
|
|
259
|
+
and getattr(obj, "__module__", __name__) == __name__
|
|
260
|
+
)
|
|
261
|
+
)
|
aiohomematic/exceptions.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
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
|
-
"""
|
|
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."""
|
aiohomematic/model/__init__.py
CHANGED
|
@@ -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
|
|
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
|
"""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
|
-
|
|
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
|
-
|
|
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:
|