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,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
 
@@ -671,10 +673,11 @@ class Device(PayloadMixin):
671
673
  """Provide some useful information."""
672
674
  return (
673
675
  f"address: {self._address}, "
674
- f"model: {len(self._model)}, "
676
+ f"model: {self._model}, "
675
677
  f"name: {self._name}, "
676
- f"generic_data_points: {len(self.generic_data_points)}, "
677
- f"custom_data_points: {len(self.custom_data_points)}, "
678
+ f"generic dps: {len(self.generic_data_points)}, "
679
+ f"calculated dps: {len(self.calculated_data_points)}, "
680
+ f"custom dps: {len(self.custom_data_points)}, "
678
681
  f"events: {len(self.generic_events)}"
679
682
  )
680
683
 
@@ -1077,8 +1080,9 @@ class Channel(PayloadMixin):
1077
1080
  return (
1078
1081
  f"address: {self._address}, "
1079
1082
  f"type: {self._type_name}, "
1080
- f"generic_data_points: {len(self._generic_data_points)}, "
1081
- f"custom_data_point: {self._custom_data_point is not None}, "
1083
+ f"generic dps: {len(self._generic_data_points)}, "
1084
+ f"calculated dps: {len(self._calculated_data_points)}, "
1085
+ f"custom dp: {self._custom_data_point is not None}, "
1082
1086
  f"events: {len(self._generic_events)}"
1083
1087
  )
1084
1088
 
@@ -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
@@ -107,6 +109,10 @@ class GenericHubDataPoint(CallbackDataPoint, PayloadMixin):
107
109
  """Return, if the state is uncertain."""
108
110
  return self._state_uncertain
109
111
 
112
+ def _get_signature(self) -> str:
113
+ """Return the signature of the data_point."""
114
+ return f"{self._category}/{self.name}"
115
+
110
116
 
111
117
  class GenericSysvarDataPoint(GenericHubDataPoint):
112
118
  """Class for a HomeMatic system variable."""
@@ -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
@@ -96,6 +98,10 @@ class DpUpdate(CallbackDataPoint, PayloadMixin):
96
98
  return self._device.available_firmware
97
99
  return self._device.firmware
98
100
 
101
+ def _get_signature(self) -> str:
102
+ """Return the signature of the data_point."""
103
+ return f"{self._category}/{self._device.model}"
104
+
99
105
  def _get_path_data(self) -> DataPointPathData:
100
106
  """Return the path data of the data_point."""
101
107
  return DataPointPathData(
aiohomematic/support.py CHANGED
@@ -1,14 +1,22 @@
1
- """Helper functions used within aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Helper functions 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
 
5
11
  import base64
6
12
  from collections import defaultdict
7
- from collections.abc import Callable, Collection, Set as AbstractSet
13
+ from collections.abc import Callable, Collection, Mapping, Set as AbstractSet
8
14
  import contextlib
9
15
  from dataclasses import dataclass
10
16
  from datetime import datetime
17
+ from functools import lru_cache
11
18
  import hashlib
19
+ import inspect
12
20
  from ipaddress import IPv4Address
13
21
  import logging
14
22
  import os
@@ -18,6 +26,8 @@ import ssl
18
26
  import sys
19
27
  from typing import Any, Final, cast
20
28
 
29
+ import orjson
30
+
21
31
  from aiohomematic import client as hmcl
22
32
  from aiohomematic.const import (
23
33
  ADDRESS_SEPARATOR,
@@ -254,8 +264,14 @@ def is_paramset_key(paramset_key: ParamsetKey | str) -> bool:
254
264
  return isinstance(paramset_key, ParamsetKey) or (isinstance(paramset_key, str) and paramset_key in ParamsetKey)
255
265
 
256
266
 
267
+ @lru_cache(maxsize=4096)
257
268
  def get_split_channel_address(channel_address: str) -> tuple[str, int | None]:
258
- """Return the device part of an address."""
269
+ """
270
+ Return the device part of an address.
271
+
272
+ Cached to avoid redundant parsing across layers when repeatedly handling
273
+ the same channel addresses.
274
+ """
259
275
  if ADDRESS_SEPARATOR in channel_address:
260
276
  device_address, channel_no = channel_address.split(ADDRESS_SEPARATOR)
261
277
  if channel_no in (None, "None"):
@@ -430,9 +446,20 @@ def debug_enabled() -> bool:
430
446
 
431
447
 
432
448
  def hash_sha256(value: Any) -> str:
433
- """Hash a value with sha256."""
449
+ """
450
+ Hash a value with sha256.
451
+
452
+ Uses orjson to serialize the value with sorted keys for a fast and stable
453
+ representation. Falls back to the repr-based approach if
454
+ serialization fails (e.g., unsupported types).
455
+ """
434
456
  hasher = hashlib.sha256()
435
- hasher.update(repr(_make_value_hashable(value)).encode())
457
+ try:
458
+ data = orjson.dumps(value, option=orjson.OPT_SORT_KEYS | orjson.OPT_NON_STR_KEYS)
459
+ except Exception:
460
+ # Fallback: convert to a hashable representation and use repr()
461
+ data = repr(_make_value_hashable(value)).encode()
462
+ hasher.update(data)
436
463
  return base64.b64encode(hasher.digest()).decode()
437
464
 
438
465
 
@@ -480,3 +507,146 @@ def supports_rx_mode(command_rx_mode: CommandRxMode, rx_modes: tuple[RxMode, ...
480
507
  def cleanup_text_from_html_tags(text: str) -> str:
481
508
  """Cleanup text from html tags."""
482
509
  return re.sub(HTMLTAG_PATTERN, "", text)
510
+
511
+
512
+ # --- Structured error boundary logging helpers ---
513
+
514
+ _BOUNDARY_MSG = "error_boundary"
515
+
516
+
517
+ def _safe_context(context: Mapping[str, Any] | None) -> dict[str, Any]:
518
+ """Extract safe context from a mapping."""
519
+ ctx: dict[str, Any] = {}
520
+ if not context:
521
+ return ctx
522
+ # Avoid logging potentially sensitive values by redacting common keys
523
+ redact_keys = {"password", "passwd", "pwd", "token", "authorization", "auth"}
524
+ for k, v in context.items():
525
+ if k.lower() in redact_keys:
526
+ ctx[k] = "***"
527
+ else:
528
+ # Ensure value is serializable / printable
529
+ try:
530
+ str(v)
531
+ ctx[k] = v
532
+ except Exception:
533
+ ctx[k] = repr(v)
534
+ return ctx
535
+
536
+
537
+ def build_log_context_from_obj(obj: Any | None) -> dict[str, Any]:
538
+ """
539
+ Extract structured context like device_id/channel/parameter from common objects.
540
+
541
+ Tries best-effort extraction without raising. Returns a dict suitable for logger.extra.
542
+ """
543
+ ctx: dict[str, Any] = {}
544
+ if obj is None:
545
+ return ctx
546
+ try:
547
+ # DataPoint-like: has channel and parameter
548
+ if hasattr(obj, "channel"):
549
+ ch = getattr(obj, "channel")
550
+ try:
551
+ # channel address/id
552
+ channel_address = ch.address if not callable(ch.address) else ch.address()
553
+ ctx["channel"] = channel_address
554
+ except Exception:
555
+ # Fallback to str
556
+ ctx["channel"] = str(ch)
557
+ try:
558
+ if (dev := ch.device if hasattr(ch, "device") else None) is not None:
559
+ device_id = dev.id if not callable(dev.id) else dev.id()
560
+ ctx["device_id"] = device_id
561
+ except Exception:
562
+ pass
563
+ # Parameter on DataPoint-like
564
+ if hasattr(obj, "parameter"):
565
+ with contextlib.suppress(Exception):
566
+ ctx["parameter"] = getattr(obj, "parameter")
567
+
568
+ # Also support objects exposing address directly
569
+ if "device_id" not in ctx and hasattr(obj, "device"):
570
+ dev = getattr(obj, "device")
571
+ try:
572
+ device_id = dev.id if not callable(dev.id) else dev.id()
573
+ ctx["device_id"] = device_id
574
+ except Exception:
575
+ pass
576
+ if "channel" not in ctx and hasattr(obj, "address"):
577
+ try:
578
+ addr = obj.address if not callable(obj.address) else obj.address()
579
+ ctx["channel"] = addr
580
+ except Exception:
581
+ pass
582
+ except Exception:
583
+ # Never allow context building to break the application
584
+ return {}
585
+ return ctx
586
+
587
+
588
+ def log_boundary_error(
589
+ logger: logging.Logger,
590
+ *,
591
+ boundary: str,
592
+ action: str,
593
+ err: Exception,
594
+ level: int | None = None,
595
+ context: Mapping[str, Any] | None = None,
596
+ ) -> None:
597
+ """
598
+ Log a boundary error with the provided logger.
599
+
600
+ This function differentiates
601
+ between recoverable and non-recoverable domain errors to select an appropriate
602
+ logging level if not explicitly provided. Additionally, it enriches the log
603
+ record with extra context about the error and action boundaries.
604
+
605
+ :param logger: The logger instance used to log the error.
606
+ :type logger: logging.Logger
607
+ :param boundary: The name of the boundary at which the error occurred.
608
+ :type boundary: str
609
+ :param action: The action being performed when the error occurred.
610
+ :type action: str
611
+ :param err: The exception instance representing the error to log.
612
+ :type err: Exception
613
+ :param level: The optional logging level. Defaults to WARNING for recoverable
614
+ domain errors and ERROR for non-recoverable errors if not provided.
615
+ :type level: int | None
616
+ :param context: Optional mapping of additional information or context to
617
+ include in the log record.
618
+ :type context: Mapping[str, Any] | None
619
+ :return: None. This function logs the provided information but does not
620
+ return a value.
621
+ :rtype: None
622
+ """
623
+ extra = {
624
+ "boundary": boundary,
625
+ "action": action,
626
+ "err_type": err.__class__.__name__,
627
+ "err": extract_exc_args(exc=err),
628
+ **_safe_context(context),
629
+ }
630
+
631
+ # Choose level if not provided:
632
+ chosen_level = level
633
+ if chosen_level is None:
634
+ # Use WARNING for expected/recoverable domain errors, ERROR otherwise.
635
+ chosen_level = logging.WARNING if isinstance(err, BaseHomematicException) else logging.ERROR
636
+
637
+ if chosen_level >= logging.ERROR:
638
+ logger.exception(_BOUNDARY_MSG, extra=extra)
639
+ else:
640
+ logger.log(chosen_level, _BOUNDARY_MSG, extra=extra)
641
+
642
+
643
+ # Define public API for this module
644
+ __all__ = tuple(
645
+ sorted(
646
+ name
647
+ for name, obj in globals().items()
648
+ if not name.startswith("_")
649
+ and (inspect.isfunction(obj) or inspect.isclass(obj))
650
+ and getattr(obj, "__module__", __name__) == __name__
651
+ )
652
+ )
aiohomematic/validator.py CHANGED
@@ -1,10 +1,19 @@
1
- """Validator functions used within aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Validator functions 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
 
11
+ import inspect
12
+
5
13
  import voluptuous as vol
6
14
 
7
- from aiohomematic.const import MAX_WAIT_FOR_CALLBACK
15
+ from aiohomematic.const import BLOCKED_CATEGORIES, CATEGORIES, HUB_CATEGORIES, MAX_WAIT_FOR_CALLBACK, DataPointCategory
16
+ from aiohomematic.model.custom import definition as hmed
8
17
  from aiohomematic.support import (
9
18
  check_password,
10
19
  is_channel_address,
@@ -63,3 +72,41 @@ def paramset_key(value: str) -> str:
63
72
 
64
73
  address = vol.All(vol.Coerce(str), vol.Any(device_address, channel_address))
65
74
  host = vol.All(vol.Coerce(str), vol.Any(hostname, ipv4_address))
75
+
76
+
77
+ def validate_startup() -> None:
78
+ """
79
+ Validate enum and mapping exhaustiveness at startup.
80
+
81
+ - Ensure DataPointCategory coverage: all categories except UNDEFINED must be present
82
+ in either HUB_CATEGORIES or CATEGORIES. UNDEFINED must not appear in those lists.
83
+ """
84
+ categories_in_lists = set(BLOCKED_CATEGORIES) | set(CATEGORIES) | set(HUB_CATEGORIES)
85
+ all_categories = set(DataPointCategory)
86
+ if DataPointCategory.UNDEFINED in categories_in_lists:
87
+ raise vol.Invalid(
88
+ "DataPointCategory.UNDEFINED must not be present in BLOCKED_CATEGORIES/CATEGORIES/HUB_CATEGORIES"
89
+ )
90
+
91
+ if missing := all_categories - {DataPointCategory.UNDEFINED} - categories_in_lists:
92
+ missing_str = ", ".join(sorted(c.value for c in missing))
93
+ raise vol.Invalid(
94
+ f"BLOCKED_CATEGORIES/CATEGORIES/HUB_CATEGORIES are not exhaustive. Missing categories: {missing_str}"
95
+ )
96
+
97
+ # Validate custom definition mapping schema (Field <-> Parameter mappings)
98
+ # This ensures Field mappings are valid and consistent at startup.
99
+ if hmed.validate_custom_data_point_definition() is None:
100
+ raise vol.Invalid("Custom data point definition schema is invalid")
101
+
102
+
103
+ # Define public API for this module
104
+ __all__ = tuple(
105
+ sorted(
106
+ name
107
+ for name, obj in globals().items()
108
+ if not name.startswith("_")
109
+ and (inspect.isfunction(obj) or inspect.isclass(obj))
110
+ and getattr(obj, "__module__", __name__) == __name__
111
+ )
112
+ )