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.

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 +29 -22
  6. aiohomematic/caches/visibility.py +277 -252
  7. aiohomematic/central/__init__.py +69 -49
  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 +145 -77
  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 +7 -1
  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 +6 -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 +30 -3
  39. aiohomematic/model/decorators.py +29 -8
  40. aiohomematic/model/device.py +9 -5
  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 +6 -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 +6 -0
  63. aiohomematic/support.py +175 -5
  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.8.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
  68. aiohomematic-2025.8.8.dist-info/METADATA +0 -69
  69. aiohomematic-2025.8.8.dist-info/RECORD +0 -77
  70. {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
  71. {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/top_level.txt +0 -0
@@ -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
@@ -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: # pylint: disable=using-constant-test
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 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
@@ -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
  """The module contains device descriptions 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
  """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._dp_device_operation_mode.value == _DeviceOperationMode.RGBW:
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._dp_device_operation_mode.value == _DeviceOperationMode.RGB:
483
+ if self._device_operation_mode == _DeviceOperationMode.RGB:
482
484
  return self._dp_hue, self._dp_level, self._dp_saturation
483
- if self._dp_device_operation_mode.value == _DeviceOperationMode.TUNABLE_WHITE:
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._dp_device_operation_mode.value == _DeviceOperationMode.TUNABLE_WHITE
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._dp_device_operation_mode.value != _DeviceOperationMode.PWM
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._dp_device_operation_mode.value in (
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._dp_device_operation_mode.value in (_DeviceOperationMode.RGB, _DeviceOperationMode.RGBW)
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 lock 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 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 ValueError(
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 ValueError(
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
  )
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Support classes 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 switch 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 for data points implemented using the valve 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
  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__).log(level=log_level, msg=extract_exc_args(exc=bhexc))
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]