aiohomematic 2025.8.8__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.
- 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 +29 -22
- aiohomematic/caches/visibility.py +277 -252
- aiohomematic/central/__init__.py +69 -49
- 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 +145 -77
- aiohomematic/context.py +11 -1
- aiohomematic/converter.py +27 -1
- aiohomematic/decorators.py +88 -19
- 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 +7 -1
- 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 +6 -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 +30 -3
- aiohomematic/model/decorators.py +29 -8
- aiohomematic/model/device.py +9 -5
- 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 +6 -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 +6 -0
- aiohomematic/support.py +175 -5
- aiohomematic/validator.py +49 -2
- aiohomematic-2025.8.10.dist-info/METADATA +124 -0
- aiohomematic-2025.8.10.dist-info/RECORD +78 -0
- {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
- aiohomematic-2025.8.8.dist-info/METADATA +0 -69
- aiohomematic-2025.8.8.dist-info/RECORD +0 -77
- {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/top_level.txt +0 -0
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,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(
|
|
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)
|
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
"""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
)
|
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 with base class for calculated data points."""
|
|
2
4
|
|
|
3
5
|
from __future__ import annotations
|
|
@@ -280,6 +282,10 @@ class CalculatedDataPoint[ParameterT: GenericParameterType](BaseDataPoint):
|
|
|
280
282
|
"""Generate the usage for the data point."""
|
|
281
283
|
return DataPointUsage.DATA_POINT
|
|
282
284
|
|
|
285
|
+
def _get_signature(self) -> str:
|
|
286
|
+
"""Return the signature of the data_point."""
|
|
287
|
+
return f"{self._category}/{self._channel.device.model}/{self._calculated_parameter}"
|
|
288
|
+
|
|
283
289
|
async def load_data_point_value(self, call_source: CallSource, direct_call: bool = False) -> None:
|
|
284
290
|
"""Init the data point values."""
|
|
285
291
|
for dp in self._readable_data_points:
|
|
@@ -300,7 +306,7 @@ class CalculatedDataPoint[ParameterT: GenericParameterType](BaseDataPoint):
|
|
|
300
306
|
@property
|
|
301
307
|
def _should_fire_data_point_updated_callback(self) -> bool:
|
|
302
308
|
"""Check if a data point has been updated or refreshed."""
|
|
303
|
-
if self.fired_recently:
|
|
309
|
+
if self.fired_recently:
|
|
304
310
|
return False
|
|
305
311
|
|
|
306
312
|
if (relevant_values_data_point := self._relevant_values_data_points) is not None and len(
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -177,6 +179,10 @@ class CustomDataPoint(BaseDataPoint):
|
|
|
177
179
|
return DataPointUsage.CDP_PRIMARY
|
|
178
180
|
return DataPointUsage.CDP_SECONDARY
|
|
179
181
|
|
|
182
|
+
def _get_signature(self) -> str:
|
|
183
|
+
"""Return the signature of the data_point."""
|
|
184
|
+
return f"{self._category}/{self._channel.device.model}/{self.data_point_name_postfix}"
|
|
185
|
+
|
|
180
186
|
async def load_data_point_value(self, call_source: CallSource, direct_call: bool = False) -> None:
|
|
181
187
|
"""Init the data point values."""
|
|
182
188
|
for dp in self._readable_data_points:
|
|
@@ -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 light category."""
|
|
2
4
|
|
|
3
5
|
from __future__ import annotations
|
|
@@ -471,29 +473,29 @@ class CustomDpIpRGBWLight(CustomDpDimmer):
|
|
|
471
473
|
@property
|
|
472
474
|
def _relevant_data_points(self) -> tuple[GenericDataPoint, ...]:
|
|
473
475
|
"""Returns the list of relevant data points. To be overridden by subclasses."""
|
|
474
|
-
if self.
|
|
476
|
+
if self._device_operation_mode == _DeviceOperationMode.RGBW:
|
|
475
477
|
return (
|
|
476
478
|
self._dp_hue,
|
|
477
479
|
self._dp_level,
|
|
478
480
|
self._dp_saturation,
|
|
479
481
|
self._dp_color_temperature_kelvin,
|
|
480
482
|
)
|
|
481
|
-
if self.
|
|
483
|
+
if self._device_operation_mode == _DeviceOperationMode.RGB:
|
|
482
484
|
return self._dp_hue, self._dp_level, self._dp_saturation
|
|
483
|
-
if self.
|
|
485
|
+
if self._device_operation_mode == _DeviceOperationMode.TUNABLE_WHITE:
|
|
484
486
|
return self._dp_level, self._dp_color_temperature_kelvin
|
|
485
487
|
return (self._dp_level,)
|
|
486
488
|
|
|
487
489
|
@property
|
|
488
490
|
def supports_color_temperature(self) -> bool:
|
|
489
491
|
"""Flag if light supports color temperature."""
|
|
490
|
-
return self.
|
|
492
|
+
return self._device_operation_mode == _DeviceOperationMode.TUNABLE_WHITE
|
|
491
493
|
|
|
492
494
|
@property
|
|
493
495
|
def supports_effects(self) -> bool:
|
|
494
496
|
"""Flag if light supports effects."""
|
|
495
497
|
return (
|
|
496
|
-
self.
|
|
498
|
+
self._device_operation_mode != _DeviceOperationMode.PWM
|
|
497
499
|
and self.effects is not None
|
|
498
500
|
and len(self.effects) > 0
|
|
499
501
|
)
|
|
@@ -501,7 +503,7 @@ class CustomDpIpRGBWLight(CustomDpDimmer):
|
|
|
501
503
|
@property
|
|
502
504
|
def supports_hs_color(self) -> bool:
|
|
503
505
|
"""Flag if light supports color."""
|
|
504
|
-
return self.
|
|
506
|
+
return self._device_operation_mode in (
|
|
505
507
|
_DeviceOperationMode.RGBW,
|
|
506
508
|
_DeviceOperationMode.RGB,
|
|
507
509
|
)
|
|
@@ -514,11 +516,9 @@ class CustomDpIpRGBWLight(CustomDpDimmer):
|
|
|
514
516
|
Avoid creating data points that are not usable in selected device operation mode.
|
|
515
517
|
"""
|
|
516
518
|
if (
|
|
517
|
-
self.
|
|
519
|
+
self._device_operation_mode in (_DeviceOperationMode.RGB, _DeviceOperationMode.RGBW)
|
|
518
520
|
and self._channel.no in (2, 3, 4)
|
|
519
|
-
) or (
|
|
520
|
-
self._dp_device_operation_mode.value == _DeviceOperationMode.TUNABLE_WHITE and self._channel.no in (3, 4)
|
|
521
|
-
):
|
|
521
|
+
) or (self._device_operation_mode == _DeviceOperationMode.TUNABLE_WHITE and self._channel.no in (3, 4)):
|
|
522
522
|
return DataPointUsage.NO_CREATE
|
|
523
523
|
return self._get_data_point_usage()
|
|
524
524
|
|
|
@@ -555,6 +555,13 @@ class CustomDpIpRGBWLight(CustomDpDimmer):
|
|
|
555
555
|
await self._set_on_time_value(on_time=_NOT_USED, collector=collector)
|
|
556
556
|
await super().turn_off(collector=collector, **kwargs)
|
|
557
557
|
|
|
558
|
+
@property
|
|
559
|
+
def _device_operation_mode(self) -> _DeviceOperationMode:
|
|
560
|
+
"""Return the device operation mode."""
|
|
561
|
+
if (mode := self._dp_device_operation_mode.value) is None:
|
|
562
|
+
return _DeviceOperationMode.RGBW
|
|
563
|
+
return _DeviceOperationMode(mode)
|
|
564
|
+
|
|
558
565
|
@bind_collector()
|
|
559
566
|
async def _set_on_time_value(self, on_time: float, collector: CallParameterCollector | None = None) -> None:
|
|
560
567
|
"""Set the on time value in seconds."""
|
|
@@ -1068,6 +1075,7 @@ DEVICES: Mapping[str, CustomConfig | tuple[CustomConfig, ...]] = {
|
|
|
1068
1075
|
"HmIP-FDT": CustomConfig(make_ce_func=make_ip_dimmer, channels=(2,)),
|
|
1069
1076
|
"HmIP-PDT": CustomConfig(make_ce_func=make_ip_dimmer, channels=(3,)),
|
|
1070
1077
|
"HmIP-RGBW": CustomConfig(make_ce_func=make_ip_rgbw_light),
|
|
1078
|
+
"HmIP-LSC": CustomConfig(make_ce_func=make_ip_rgbw_light),
|
|
1071
1079
|
"HmIP-SCTH230": CustomConfig(
|
|
1072
1080
|
make_ce_func=make_ip_dimmer,
|
|
1073
1081
|
channels=(12,),
|
|
@@ -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 siren category."""
|
|
2
4
|
|
|
3
5
|
from __future__ import annotations
|
|
@@ -8,6 +10,7 @@ from enum import StrEnum
|
|
|
8
10
|
from typing import Final, TypedDict, Unpack
|
|
9
11
|
|
|
10
12
|
from aiohomematic.const import DataPointCategory
|
|
13
|
+
from aiohomematic.exceptions import ValidationException
|
|
11
14
|
from aiohomematic.model import device as hmd
|
|
12
15
|
from aiohomematic.model.custom import definition as hmed
|
|
13
16
|
from aiohomematic.model.custom.const import DeviceProfile, Field
|
|
@@ -147,14 +150,14 @@ class CustomDpIpSiren(BaseCustomDpSiren):
|
|
|
147
150
|
|
|
148
151
|
acoustic_alarm = kwargs.get("acoustic_alarm", self._dp_acoustic_alarm_selection.default)
|
|
149
152
|
if self.available_tones and acoustic_alarm and acoustic_alarm not in self.available_tones:
|
|
150
|
-
raise
|
|
153
|
+
raise ValidationException(
|
|
151
154
|
f"Invalid tone specified for data_point {self.full_name}: {acoustic_alarm}, "
|
|
152
155
|
"check the available_tones attribute for valid tones to pass in"
|
|
153
156
|
)
|
|
154
157
|
|
|
155
158
|
optical_alarm = kwargs.get("optical_alarm", self._dp_optical_alarm_selection.default)
|
|
156
159
|
if self.available_lights and optical_alarm and optical_alarm not in self.available_lights:
|
|
157
|
-
raise
|
|
160
|
+
raise ValidationException(
|
|
158
161
|
f"Invalid light specified for data_point {self.full_name}: {optical_alarm}, "
|
|
159
162
|
"check the available_lights attribute for valid tones to pass in"
|
|
160
163
|
)
|
aiohomematic/model/data_point.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2025 Daniel Perna, SukramJ
|
|
1
3
|
"""
|
|
2
4
|
Core data point model for AioHomematic.
|
|
3
5
|
|
|
@@ -80,7 +82,6 @@ __all__ = [
|
|
|
80
82
|
"BaseParameterDataPoint",
|
|
81
83
|
"CallParameterCollector",
|
|
82
84
|
"CallbackDataPoint",
|
|
83
|
-
"EVENT_DATA_SCHEMA",
|
|
84
85
|
"bind_collector",
|
|
85
86
|
]
|
|
86
87
|
|
|
@@ -146,6 +147,7 @@ class CallbackDataPoint(ABC):
|
|
|
146
147
|
"_modified_at",
|
|
147
148
|
"_path_data",
|
|
148
149
|
"_refreshed_at",
|
|
150
|
+
"_signature",
|
|
149
151
|
"_temporary_modified_at",
|
|
150
152
|
"_temporary_refreshed_at",
|
|
151
153
|
"_unique_id",
|
|
@@ -164,6 +166,7 @@ class CallbackDataPoint(ABC):
|
|
|
164
166
|
self._fired_at: datetime = INIT_DATETIME
|
|
165
167
|
self._modified_at: datetime = INIT_DATETIME
|
|
166
168
|
self._refreshed_at: datetime = INIT_DATETIME
|
|
169
|
+
self._signature: Final = self._get_signature()
|
|
167
170
|
self._temporary_modified_at: datetime = INIT_DATETIME
|
|
168
171
|
self._temporary_refreshed_at: datetime = INIT_DATETIME
|
|
169
172
|
|
|
@@ -252,6 +255,11 @@ class CallbackDataPoint(ABC):
|
|
|
252
255
|
def name(self) -> str:
|
|
253
256
|
"""Return the name of the data_point."""
|
|
254
257
|
|
|
258
|
+
@property
|
|
259
|
+
def signature(self) -> str:
|
|
260
|
+
"""Return the data_point signature."""
|
|
261
|
+
return self._signature
|
|
262
|
+
|
|
255
263
|
@config_property
|
|
256
264
|
def unique_id(self) -> str:
|
|
257
265
|
"""Return the unique_id."""
|
|
@@ -325,6 +333,10 @@ class CallbackDataPoint(ABC):
|
|
|
325
333
|
def _get_path_data(self) -> PathData:
|
|
326
334
|
"""Return the path data."""
|
|
327
335
|
|
|
336
|
+
@abstractmethod
|
|
337
|
+
def _get_signature(self) -> str:
|
|
338
|
+
"""Return the signature of the data_point."""
|
|
339
|
+
|
|
328
340
|
def _unregister_data_point_updated_callback(self, cb: Callable, custom_id: str) -> None:
|
|
329
341
|
"""Unregister data_point updated callback."""
|
|
330
342
|
if cb in self._data_point_updated_callbacks:
|
|
@@ -840,6 +852,10 @@ class BaseParameterDataPoint[
|
|
|
840
852
|
return multiplier
|
|
841
853
|
return DEFAULT_MULTIPLIER
|
|
842
854
|
|
|
855
|
+
def _get_signature(self) -> str:
|
|
856
|
+
"""Return the signature of the data_point."""
|
|
857
|
+
return f"{self._category}/{self._channel.device.model}/{self._parameter}"
|
|
858
|
+
|
|
843
859
|
@abstractmethod
|
|
844
860
|
async def event(self, value: Any, received_at: datetime | None = None) -> None:
|
|
845
861
|
"""Handle event for which this handler has subscribed."""
|
|
@@ -1070,12 +1086,23 @@ def bind_collector(
|
|
|
1070
1086
|
IN_SERVICE_VAR.reset(token)
|
|
1071
1087
|
in_service = IN_SERVICE_VAR.get()
|
|
1072
1088
|
if not in_service and log_level > logging.NOTSET:
|
|
1073
|
-
logging.getLogger(args[0].__module__)
|
|
1089
|
+
logger = logging.getLogger(args[0].__module__)
|
|
1090
|
+
extra = {
|
|
1091
|
+
"err_type": bhexc.__class__.__name__,
|
|
1092
|
+
"err": extract_exc_args(exc=bhexc),
|
|
1093
|
+
"function": func.__name__,
|
|
1094
|
+
**hms.build_log_context_from_obj(obj=args[0]),
|
|
1095
|
+
}
|
|
1096
|
+
if log_level >= logging.ERROR:
|
|
1097
|
+
logger.exception("service_error", extra=extra)
|
|
1098
|
+
else:
|
|
1099
|
+
logger.log(level=log_level, msg="service_error", extra=extra)
|
|
1100
|
+
# Re-raise domain-specific exceptions so callers and tests can handle them
|
|
1101
|
+
raise
|
|
1074
1102
|
else:
|
|
1075
1103
|
if token:
|
|
1076
1104
|
IN_SERVICE_VAR.reset(token)
|
|
1077
1105
|
return return_value
|
|
1078
|
-
return None
|
|
1079
1106
|
|
|
1080
1107
|
setattr(bind_wrapper, "ha_service", True)
|
|
1081
1108
|
return bind_wrapper # type: ignore[return-value]
|