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
  """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
 
@@ -1085,12 +1086,23 @@ def bind_collector(
1085
1086
  IN_SERVICE_VAR.reset(token)
1086
1087
  in_service = IN_SERVICE_VAR.get()
1087
1088
  if not in_service and log_level > logging.NOTSET:
1088
- 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
1089
1102
  else:
1090
1103
  if token:
1091
1104
  IN_SERVICE_VAR.reset(token)
1092
1105
  return return_value
1093
- return None
1094
1106
 
1095
1107
  setattr(bind_wrapper, "ha_service", True)
1096
1108
  return bind_wrapper # type: ignore[return-value]
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Decorators for data points used within aiohomematic."""
2
4
 
3
5
  from __future__ import annotations
@@ -6,6 +8,7 @@ from collections.abc import Callable, Mapping
6
8
  from datetime import datetime
7
9
  from enum import Enum
8
10
  from typing import Any, ParamSpec, TypeVar, cast
11
+ from weakref import WeakKeyDictionary
9
12
 
10
13
  __all__ = [
11
14
  "config_property",
@@ -18,7 +21,6 @@ __all__ = [
18
21
 
19
22
  P = ParamSpec("P")
20
23
  T = TypeVar("T")
21
- R = TypeVar("R")
22
24
 
23
25
 
24
26
  # pylint: disable=invalid-name
@@ -91,8 +93,9 @@ class state_property[GETTER, SETTER](generic_property[GETTER, SETTER]):
91
93
 
92
94
 
93
95
  # Cache for per-class attribute names by decorator to avoid repeated dir() scans
94
- # Keyed by (class, decorator class); value is a tuple of attribute names
95
- _PUBLIC_ATTR_CACHE: dict[tuple[type, type], tuple[str, ...]] = {}
96
+ # Use WeakKeyDictionary to allow classes to be garbage-collected without leaking cache entries.
97
+ # Structure: {cls: {decorator_class: (attr_name1, attr_name2, ...)}}
98
+ _PUBLIC_ATTR_CACHE: WeakKeyDictionary[type, dict[type, tuple[str, ...]]] = WeakKeyDictionary()
96
99
 
97
100
 
98
101
  def _get_public_attributes_by_class_decorator(data_object: Any, class_decorator: type) -> Mapping[str, Any]:
@@ -102,15 +105,33 @@ def _get_public_attributes_by_class_decorator(data_object: Any, class_decorator:
102
105
  This caches the attribute names per (class, decorator) to reduce overhead
103
106
  from repeated dir()/getattr() scans. Values are not cached as they are
104
107
  instance-dependent and may change over time.
108
+
109
+ To minimize side effects, exceptions raised by property getters are caught
110
+ and the corresponding value is set to None. This ensures that payload
111
+ construction and attribute introspection do not fail due to individual
112
+ properties with transient errors or expensive side effects.
105
113
  """
106
114
  cls = data_object.__class__
107
- cache_key = (cls, class_decorator)
108
115
 
109
- if (names := _PUBLIC_ATTR_CACHE.get(cache_key)) is None:
110
- names = tuple(y for y in dir(cls) if not y.startswith("_") and isinstance(getattr(cls, y), class_decorator))
111
- _PUBLIC_ATTR_CACHE[cache_key] = names
116
+ # Get or create the per-class cache dict
117
+ if (decorator_cache := _PUBLIC_ATTR_CACHE.get(cls)) is None:
118
+ decorator_cache = {}
119
+ _PUBLIC_ATTR_CACHE[cls] = decorator_cache
112
120
 
113
- return {name: _get_text_value(getattr(data_object, name)) for name in names}
121
+ # Get or compute the attribute names for this decorator
122
+ if (names := decorator_cache.get(class_decorator)) is None:
123
+ names = tuple(y for y in dir(cls) if not y.startswith("_") and isinstance(getattr(cls, y), class_decorator))
124
+ decorator_cache[class_decorator] = names
125
+
126
+ result: dict[str, Any] = {}
127
+ for name in names:
128
+ try:
129
+ value = getattr(data_object, name)
130
+ except Exception:
131
+ # Avoid propagating side effects/errors from getters
132
+ value = None
133
+ result[name] = _get_text_value(value)
134
+ return result
114
135
 
115
136
 
116
137
  def _get_text_value(value: Any) -> Any:
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Device and channel 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
  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
  Generic 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
  """
2
4
  Module for action data points.
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 binary_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 for data points implemented using the button 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
  """Generic python representation of a CCU parameter."""
2
4
 
3
5
  from __future__ import annotations
@@ -16,6 +18,7 @@ from aiohomematic.const import (
16
18
  ParamsetKey,
17
19
  )
18
20
  from aiohomematic.decorators import inspector
21
+ from aiohomematic.exceptions import ValidationException
19
22
  from aiohomematic.model import data_point as hme, device as hmd
20
23
  from aiohomematic.model.decorators import cached_slot_property
21
24
  from aiohomematic.model.support import DataPointNameData, GenericParameterType, get_data_point_name_data
@@ -101,7 +104,7 @@ class GenericDataPoint[ParameterT: GenericParameterType, InputParameterT: Generi
101
104
  return set()
102
105
  try:
103
106
  prepared_value = self._prepare_value_for_sending(value=value, do_validate=do_validate)
104
- except ValueError as verr:
107
+ except (ValueError, ValidationException) as verr:
105
108
  _LOGGER.warning(verr)
106
109
  return set()
107
110
 
@@ -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 number category."""
2
4
 
3
5
  from __future__ import annotations
@@ -5,6 +7,7 @@ from __future__ import annotations
5
7
  from typing import cast
6
8
 
7
9
  from aiohomematic.const import DataPointCategory
10
+ from aiohomematic.exceptions import ValidationException
8
11
  from aiohomematic.model.decorators import state_property
9
12
  from aiohomematic.model.generic.data_point import GenericDataPoint
10
13
 
@@ -30,7 +33,7 @@ class BaseDpNumber[NumberParameterT: int | float | None](GenericDataPoint[Number
30
33
  return cast(NumberParameterT, type_converter(value))
31
34
  if self._special and isinstance(value, str) and value in self._special:
32
35
  return cast(NumberParameterT, type_converter(self._special[value]))
33
- raise ValueError(
36
+ raise ValidationException(
34
37
  f"NUMBER failed: Invalid value: {value} (min: {self._min}, max: {self._max}, special:{self._special})"
35
38
  )
36
39
 
@@ -1,8 +1,11 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """Module for data points implemented using the select category."""
2
4
 
3
5
  from __future__ import annotations
4
6
 
5
7
  from aiohomematic.const import DataPointCategory
8
+ from aiohomematic.exceptions import ValidationException
6
9
  from aiohomematic.model.decorators import state_property
7
10
  from aiohomematic.model.generic.data_point import GenericDataPoint
8
11
  from aiohomematic.model.support import get_value_from_value_list
@@ -33,4 +36,4 @@ class DpSelect(GenericDataPoint[int | str, int | float | str]):
33
36
  return int(value)
34
37
  if self._values and value in self._values:
35
38
  return self._values.index(value)
36
- raise ValueError(f"Value not in value_list for {self.name}/{self.unique_id}")
39
+ raise ValidationException(f"Value not in value_list for {self.name}/{self.unique_id}")
@@ -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 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 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 text 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
  Hub (backend) 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 hub data points implemented using the binary_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 for hub data points implemented using the button 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 AioHomematic hub 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 number 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 hub data points implemented using the select 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 hub data points implemented using 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 for hub 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 hub data points implemented using the text 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
  """Support for data points used within aiohomematic."""
2
4
 
3
5
  from __future__ import annotations
@@ -5,6 +7,7 @@ from __future__ import annotations
5
7
  from abc import abstractmethod
6
8
  from collections.abc import Mapping
7
9
  from enum import StrEnum
10
+ from functools import lru_cache
8
11
  import logging
9
12
  from typing import Any, Final
10
13
 
@@ -533,7 +536,29 @@ def _check_channel_name_with_channel_no(name: str) -> bool:
533
536
 
534
537
 
535
538
  def convert_value(value: Any, target_type: ParameterType, value_list: tuple[str, ...] | None) -> Any:
536
- """Convert a value to target_type."""
539
+ """
540
+ Convert a value to target_type with safe memoization.
541
+
542
+ To avoid redundant conversions across layers, we use an internal
543
+ LRU-cached helper for hashable inputs. For unhashable inputs, we
544
+ fall back to a direct conversion path.
545
+ """
546
+ # Normalize value_list to tuple to ensure hashability where possible
547
+ norm_value_list: tuple[str, ...] | None = tuple(value_list) if isinstance(value_list, list) else value_list
548
+ try:
549
+ # This will be cached if all arguments are hashable
550
+ return _convert_value_cached(value, target_type, norm_value_list)
551
+ except TypeError:
552
+ # Fallback non-cached path if any argument is unhashable
553
+ return _convert_value_noncached(value, target_type, norm_value_list)
554
+
555
+
556
+ @lru_cache(maxsize=2048)
557
+ def _convert_value_cached(value: Any, target_type: ParameterType, value_list: tuple[str, ...] | None) -> Any:
558
+ return _convert_value_noncached(value, target_type, value_list)
559
+
560
+
561
+ def _convert_value_noncached(value: Any, target_type: ParameterType, value_list: tuple[str, ...] | None) -> Any:
537
562
  if value is None:
538
563
  return None
539
564
  if target_type == ParameterType.BOOL:
@@ -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 update category."""
2
4
 
3
5
  from __future__ import annotations