aiohomematic 2025.9.1__py3-none-any.whl → 2025.9.2__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 (55) hide show
  1. aiohomematic/caches/dynamic.py +1 -6
  2. aiohomematic/central/__init__.py +34 -23
  3. aiohomematic/central/xml_rpc_server.py +1 -1
  4. aiohomematic/client/__init__.py +35 -29
  5. aiohomematic/client/json_rpc.py +44 -12
  6. aiohomematic/client/xml_rpc.py +53 -20
  7. aiohomematic/const.py +2 -2
  8. aiohomematic/decorators.py +56 -21
  9. aiohomematic/model/__init__.py +1 -1
  10. aiohomematic/model/calculated/__init__.py +1 -1
  11. aiohomematic/model/calculated/climate.py +1 -1
  12. aiohomematic/model/calculated/data_point.py +2 -2
  13. aiohomematic/model/calculated/operating_voltage_level.py +7 -21
  14. aiohomematic/model/calculated/support.py +20 -0
  15. aiohomematic/model/custom/__init__.py +1 -1
  16. aiohomematic/model/custom/climate.py +18 -18
  17. aiohomematic/model/custom/cover.py +1 -1
  18. aiohomematic/model/custom/data_point.py +1 -1
  19. aiohomematic/model/custom/light.py +1 -1
  20. aiohomematic/model/custom/lock.py +1 -1
  21. aiohomematic/model/custom/siren.py +1 -1
  22. aiohomematic/model/custom/switch.py +1 -1
  23. aiohomematic/model/custom/valve.py +1 -1
  24. aiohomematic/model/data_point.py +18 -18
  25. aiohomematic/model/device.py +21 -20
  26. aiohomematic/model/event.py +3 -8
  27. aiohomematic/model/generic/__init__.py +1 -1
  28. aiohomematic/model/generic/binary_sensor.py +1 -1
  29. aiohomematic/model/generic/button.py +1 -1
  30. aiohomematic/model/generic/data_point.py +3 -5
  31. aiohomematic/model/generic/number.py +1 -1
  32. aiohomematic/model/generic/select.py +1 -1
  33. aiohomematic/model/generic/sensor.py +1 -1
  34. aiohomematic/model/generic/switch.py +4 -4
  35. aiohomematic/model/generic/text.py +1 -1
  36. aiohomematic/model/hub/binary_sensor.py +1 -1
  37. aiohomematic/model/hub/button.py +2 -2
  38. aiohomematic/model/hub/data_point.py +4 -7
  39. aiohomematic/model/hub/number.py +1 -1
  40. aiohomematic/model/hub/select.py +2 -2
  41. aiohomematic/model/hub/sensor.py +1 -1
  42. aiohomematic/model/hub/switch.py +3 -3
  43. aiohomematic/model/hub/text.py +1 -1
  44. aiohomematic/model/support.py +1 -40
  45. aiohomematic/model/update.py +5 -4
  46. aiohomematic/property_decorators.py +327 -0
  47. aiohomematic/support.py +70 -85
  48. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/METADATA +7 -5
  49. aiohomematic-2025.9.2.dist-info/RECORD +78 -0
  50. aiohomematic_support/client_local.py +5 -5
  51. aiohomematic/model/decorators.py +0 -194
  52. aiohomematic-2025.9.1.dist-info/RECORD +0 -78
  53. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/WHEEL +0 -0
  54. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/licenses/LICENSE +0 -0
  55. {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/top_level.txt +0 -0
@@ -21,8 +21,9 @@ from aiohomematic.decorators import inspector
21
21
  from aiohomematic.exceptions import AioHomematicException
22
22
  from aiohomematic.model import device as hmd
23
23
  from aiohomematic.model.data_point import CallbackDataPoint
24
- from aiohomematic.model.decorators import config_property, state_property
25
- from aiohomematic.model.support import DataPointPathData, PayloadMixin, generate_unique_id
24
+ from aiohomematic.model.support import DataPointPathData, generate_unique_id
25
+ from aiohomematic.property_decorators import config_property, state_property
26
+ from aiohomematic.support import PayloadMixin
26
27
 
27
28
  __all__ = ["DpUpdate"]
28
29
 
@@ -130,12 +131,12 @@ class DpUpdate(CallbackDataPoint, PayloadMixin):
130
131
  self._custom_id = None
131
132
  self._device.unregister_firmware_update_callback(cb)
132
133
 
133
- @inspector()
134
+ @inspector
134
135
  async def update_firmware(self, refresh_after_update_intervals: tuple[int, ...]) -> bool:
135
136
  """Turn the update on."""
136
137
  return await self._device.update_firmware(refresh_after_update_intervals=refresh_after_update_intervals)
137
138
 
138
- @inspector()
139
+ @inspector
139
140
  async def refresh_firmware_data(self) -> None:
140
141
  """Refresh device firmware data."""
141
142
  await self._device.central.refresh_firmware_data(device_address=self._device.address)
@@ -0,0 +1,327 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """Decorators for data points used within aiohomematic."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable, Mapping
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from typing import Any, ParamSpec, TypeVar, cast, overload
11
+ from weakref import WeakKeyDictionary
12
+
13
+ from aiohomematic import support as hms
14
+
15
+ __all__ = [
16
+ "config_property",
17
+ "get_attributes_for_config_property",
18
+ "get_attributes_for_info_property",
19
+ "get_attributes_for_state_property",
20
+ "info_property",
21
+ "state_property",
22
+ ]
23
+
24
+ P = ParamSpec("P")
25
+ T = TypeVar("T")
26
+ R = TypeVar("R")
27
+
28
+
29
+ class _GenericProperty[GETTER, SETTER](property):
30
+ """Generic property implementation."""
31
+
32
+ fget: Callable[[Any], GETTER] | None
33
+ fset: Callable[[Any, SETTER], None] | None
34
+ fdel: Callable[[Any], None] | None
35
+
36
+ def __init__(
37
+ self,
38
+ fget: Callable[[Any], GETTER] | None = None,
39
+ fset: Callable[[Any, SETTER], None] | None = None,
40
+ fdel: Callable[[Any], None] | None = None,
41
+ doc: str | None = None,
42
+ log_context: bool = False,
43
+ ) -> None:
44
+ """Init the generic property."""
45
+ super().__init__(fget, fset, fdel, doc)
46
+ if doc is None and fget is not None:
47
+ doc = fget.__doc__
48
+ self.__doc__ = doc
49
+ self.log_context = log_context
50
+
51
+ def getter(self, fget: Callable[[Any], GETTER], /) -> _GenericProperty:
52
+ """Return generic getter."""
53
+ return type(self)(fget, self.fset, self.fdel, self.__doc__) # pragma: no cover
54
+
55
+ def setter(self, fset: Callable[[Any, SETTER], None], /) -> _GenericProperty:
56
+ """Return generic setter."""
57
+ return type(self)(self.fget, fset, self.fdel, self.__doc__)
58
+
59
+ def deleter(self, fdel: Callable[[Any], None], /) -> _GenericProperty:
60
+ """Return generic deleter."""
61
+ return type(self)(self.fget, self.fset, fdel, self.__doc__)
62
+
63
+ def __get__(self, obj: Any, gtype: type | None = None, /) -> GETTER: # type: ignore[override]
64
+ """Return the attribute."""
65
+ if obj is None:
66
+ return self # type: ignore[return-value]
67
+ if self.fget is None:
68
+ raise AttributeError("unreadable attribute") # pragma: no cover
69
+ return self.fget(obj)
70
+
71
+ def __set__(self, obj: Any, value: Any, /) -> None:
72
+ """Set the attribute."""
73
+ if self.fset is None:
74
+ raise AttributeError("can't set attribute") # pragma: no cover
75
+ self.fset(obj, value)
76
+
77
+ def __delete__(self, obj: Any, /) -> None:
78
+ """Delete the attribute."""
79
+ if self.fdel is None:
80
+ raise AttributeError("can't delete attribute") # pragma: no cover
81
+ self.fdel(obj)
82
+
83
+
84
+ # ----- config_property -----
85
+
86
+
87
+ class _ConfigProperty[GETTER, SETTER](_GenericProperty[GETTER, SETTER]):
88
+ """Decorate to mark own config properties."""
89
+
90
+
91
+ @overload
92
+ def config_property[PR](func: Callable[[Any], PR], /) -> _ConfigProperty[PR, Any]: ...
93
+
94
+
95
+ @overload
96
+ def config_property(*, log_context: bool = ...) -> Callable[[Callable[[Any], R]], _ConfigProperty[R, Any]]: ...
97
+
98
+
99
+ def config_property[PR](
100
+ func: Callable[[Any], PR] | None = None,
101
+ *,
102
+ log_context: bool = False,
103
+ ) -> _ConfigProperty[PR, Any] | Callable[[Callable[[Any], PR]], _ConfigProperty[PR, Any]]:
104
+ """
105
+ Return an instance of _ConfigProperty wrapping the given function.
106
+
107
+ Decorator for config properties supporting both usages:
108
+ - @config_property
109
+ - @config_property(log_context=True)
110
+ """
111
+ if func is None:
112
+
113
+ def wrapper(f: Callable[[Any], PR]) -> _ConfigProperty[PR, Any]:
114
+ return _ConfigProperty(f, log_context=log_context)
115
+
116
+ return wrapper
117
+ return _ConfigProperty(func, log_context=log_context)
118
+
119
+
120
+ # Expose the underlying property class for discovery
121
+ setattr(config_property, "__property_class__", _ConfigProperty)
122
+
123
+
124
+ # ----- info_property -----
125
+
126
+
127
+ class _InfoProperty[GETTER, SETTER](_GenericProperty[GETTER, SETTER]):
128
+ """Decorate to mark own info properties."""
129
+
130
+
131
+ @overload
132
+ def info_property[PR](func: Callable[[Any], PR], /) -> _InfoProperty[PR, Any]: ...
133
+
134
+
135
+ @overload
136
+ def info_property(*, log_context: bool = ...) -> Callable[[Callable[[Any], R]], _InfoProperty[R, Any]]: ...
137
+
138
+
139
+ def info_property[PR](
140
+ func: Callable[[Any], PR] | None = None,
141
+ *,
142
+ log_context: bool = False,
143
+ ) -> _InfoProperty[PR, Any] | Callable[[Callable[[Any], PR]], _InfoProperty[PR, Any]]:
144
+ """
145
+ Return an instance of _InfoProperty wrapping the given function.
146
+
147
+ Decorator for info properties supporting both usages:
148
+ - @info_property
149
+ - @info_property(log_context=True)
150
+ """
151
+ if func is None:
152
+
153
+ def wrapper(f: Callable[[Any], PR]) -> _InfoProperty[PR, Any]:
154
+ return _InfoProperty(f, log_context=log_context)
155
+
156
+ return wrapper
157
+ return _InfoProperty(func, log_context=log_context)
158
+
159
+
160
+ # Expose the underlying property class for discovery
161
+ setattr(info_property, "__property_class__", _InfoProperty)
162
+
163
+
164
+ # ----- state_property -----
165
+
166
+
167
+ class _StateProperty[GETTER, SETTER](_GenericProperty[GETTER, SETTER]):
168
+ """Decorate to mark own config properties."""
169
+
170
+
171
+ @overload
172
+ def state_property[PR](func: Callable[[Any], PR], /) -> _StateProperty[PR, Any]: ...
173
+
174
+
175
+ @overload
176
+ def state_property(*, log_context: bool = ...) -> Callable[[Callable[[Any], R]], _StateProperty[R, Any]]: ...
177
+
178
+
179
+ def state_property[PR](
180
+ func: Callable[[Any], PR] | None = None,
181
+ *,
182
+ log_context: bool = False,
183
+ ) -> _StateProperty[PR, Any] | Callable[[Callable[[Any], PR]], _StateProperty[PR, Any]]:
184
+ """
185
+ Return an instance of _StateProperty wrapping the given function.
186
+
187
+ Decorator for state properties supporting both usages:
188
+ - @state_property
189
+ - @state_property(log_context=True)
190
+ """
191
+ if func is None:
192
+
193
+ def wrapper(f: Callable[[Any], PR]) -> _StateProperty[PR, Any]:
194
+ return _StateProperty(f, log_context=log_context)
195
+
196
+ return wrapper
197
+ return _StateProperty(func, log_context=log_context)
198
+
199
+
200
+ # Expose the underlying property class for discovery
201
+ setattr(state_property, "__property_class__", _StateProperty)
202
+
203
+ # ----------
204
+
205
+ # Cache for per-class attribute names by decorator to avoid repeated dir() scans
206
+ # Use WeakKeyDictionary to allow classes to be garbage-collected without leaking cache entries.
207
+ # Structure: {cls: {decorator_class: (attr_name1, attr_name2, ...)}}
208
+ _PUBLIC_ATTR_CACHE: WeakKeyDictionary[type, dict[type, tuple[str, ...]]] = WeakKeyDictionary()
209
+
210
+
211
+ def _get_attributes_by_decorator(
212
+ data_object: Any, decorator: Callable, context: bool = False, only_names: bool = False
213
+ ) -> Mapping[str, Any]:
214
+ """
215
+ Return the object attributes by decorator.
216
+
217
+ This caches the attribute names per (class, decorator) to reduce overhead
218
+ from repeated dir()/getattr() scans. Values are not cached as they are
219
+ instance-dependent and may change over time.
220
+
221
+ To minimize side effects, exceptions raised by property getters are caught
222
+ and the corresponding value is set to None. This ensures that payload
223
+ construction and attribute introspection do not fail due to individual
224
+ properties with transient errors or expensive side effects.
225
+ """
226
+ cls = data_object.__class__
227
+
228
+ # Resolve function-based decorators to their underlying property class, if provided
229
+ resolved_decorator: Any = decorator
230
+ if not isinstance(decorator, type):
231
+ resolved_decorator = getattr(decorator, "__property_class__", decorator)
232
+
233
+ # Get or create the per-class cache dict
234
+ if (decorator_cache := _PUBLIC_ATTR_CACHE.get(cls)) is None:
235
+ decorator_cache = {}
236
+ _PUBLIC_ATTR_CACHE[cls] = decorator_cache
237
+
238
+ # Get or compute the attribute names for this decorator
239
+ if (names := decorator_cache.get(resolved_decorator)) is None:
240
+ names = tuple(y for y in dir(cls) if isinstance(getattr(cls, y), resolved_decorator))
241
+ decorator_cache[resolved_decorator] = names
242
+
243
+ result: dict[str, Any] = {}
244
+ if only_names:
245
+ return dict.fromkeys(names)
246
+ for name in names:
247
+ if context and getattr(cls, name).log_context is False:
248
+ continue
249
+ try:
250
+ value = getattr(data_object, name)
251
+ if isinstance(value, hms.LogContextMixin):
252
+ result.update({f"{name[:1]}.{k}": v for k, v in value.log_context.items()})
253
+ else:
254
+ result[name] = _get_text_value(value)
255
+ except Exception:
256
+ # Avoid propagating side effects/errors from getters
257
+ result[name] = None
258
+ return result
259
+
260
+
261
+ def _get_text_value(value: Any) -> Any:
262
+ """Convert value to text."""
263
+ if isinstance(value, list | tuple | set):
264
+ return tuple(_get_text_value(v) for v in value)
265
+ if isinstance(value, Enum):
266
+ return str(value)
267
+ if isinstance(value, datetime):
268
+ return datetime.timestamp(value)
269
+ return value
270
+
271
+
272
+ def get_attributes_for_config_property(data_object: Any) -> Mapping[str, Any]:
273
+ """Return the object attributes by decorator config_property."""
274
+ return _get_attributes_by_decorator(data_object=data_object, decorator=config_property)
275
+
276
+
277
+ def get_attributes_for_info_property(data_object: Any) -> Mapping[str, Any]:
278
+ """Return the object attributes by decorator info_property."""
279
+ return _get_attributes_by_decorator(data_object=data_object, decorator=info_property)
280
+
281
+
282
+ def get_attributes_for_log_context(data_object: Any) -> Mapping[str, Any]:
283
+ """Return the object attributes by decorator info_property."""
284
+ return (
285
+ dict(_get_attributes_by_decorator(data_object=data_object, decorator=config_property, context=True))
286
+ | dict(_get_attributes_by_decorator(data_object=data_object, decorator=info_property, context=True))
287
+ | dict(_get_attributes_by_decorator(data_object=data_object, decorator=state_property, context=True))
288
+ )
289
+
290
+
291
+ def get_attributes_for_state_property(data_object: Any) -> Mapping[str, Any]:
292
+ """Return the object attributes by decorator state_property."""
293
+ return _get_attributes_by_decorator(data_object=data_object, decorator=state_property)
294
+
295
+
296
+ # pylint: disable=invalid-name
297
+ class cached_slot_property[T, R]:
298
+ """A property-like descriptor that caches the computed value in a slot attribute. Designed to work with classes that use __slots__ and do not define __dict__."""
299
+
300
+ def __init__(self, func: Callable[[T], R]) -> None:
301
+ """Init the cached property."""
302
+ self._func = func # The function to compute the value
303
+ self._cache_attr = f"_cached_{func.__name__}" # Default name of the cache attribute
304
+ self._name = func.__name__
305
+
306
+ def __get__(self, instance: T | None, owner: type | None = None) -> R:
307
+ """Return the cached value if it exists. Otherwise, compute it using the function and cache it."""
308
+ if instance is None:
309
+ # Accessed from class, return the descriptor itself
310
+ return cast(R, self)
311
+
312
+ # If the cached value is not set yet, compute and store it
313
+ if not hasattr(instance, self._cache_attr):
314
+ value = self._func(instance)
315
+ setattr(instance, self._cache_attr, value)
316
+
317
+ # Return the cached value
318
+ return cast(R, getattr(instance, self._cache_attr))
319
+
320
+ def __set__(self, instance: T, value: Any) -> None:
321
+ """Raise an error to prevent manual assignment to the property."""
322
+ raise AttributeError(f"Can't set read-only cached property '{self._name}'")
323
+
324
+ def __delete__(self, instance: T) -> None:
325
+ """Delete the cached value so it can be recomputed on next access."""
326
+ if hasattr(instance, self._cache_attr):
327
+ delattr(instance, self._cache_attr)
aiohomematic/support.py CHANGED
@@ -49,6 +49,13 @@ from aiohomematic.const import (
49
49
  SysvarType,
50
50
  )
51
51
  from aiohomematic.exceptions import AioHomematicException, BaseHomematicException
52
+ from aiohomematic.property_decorators import (
53
+ cached_slot_property,
54
+ get_attributes_for_config_property,
55
+ get_attributes_for_info_property,
56
+ get_attributes_for_log_context,
57
+ get_attributes_for_state_property,
58
+ )
52
59
 
53
60
  _LOGGER: Final = logging.getLogger(__name__)
54
61
 
@@ -465,13 +472,13 @@ def hash_sha256(value: Any) -> str:
465
472
 
466
473
  def _make_value_hashable(value: Any) -> Any:
467
474
  """Make a hashable object."""
468
- if isinstance(value, (tuple, list)):
475
+ if isinstance(value, tuple | list):
469
476
  return tuple(_make_value_hashable(e) for e in value)
470
477
 
471
478
  if isinstance(value, dict):
472
479
  return tuple(sorted((k, _make_value_hashable(v)) for k, v in value.items()))
473
480
 
474
- if isinstance(value, (set, frozenset)):
481
+ if isinstance(value, set | frozenset):
475
482
  return tuple(sorted(_make_value_hashable(e) for e in value))
476
483
 
477
484
  return value
@@ -514,7 +521,7 @@ def cleanup_text_from_html_tags(text: str) -> str:
514
521
  _BOUNDARY_MSG = "error_boundary"
515
522
 
516
523
 
517
- def _safe_context(context: Mapping[str, Any] | None) -> dict[str, Any]:
524
+ def _safe_log_context(context: Mapping[str, Any] | None) -> dict[str, Any]:
518
525
  """Extract safe context from a mapping."""
519
526
  ctx: dict[str, Any] = {}
520
527
  if not context:
@@ -534,57 +541,6 @@ def _safe_context(context: Mapping[str, Any] | None) -> dict[str, Any]:
534
541
  return ctx
535
542
 
536
543
 
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
544
  def log_boundary_error(
589
545
  logger: logging.Logger,
590
546
  *,
@@ -592,7 +548,8 @@ def log_boundary_error(
592
548
  action: str,
593
549
  err: Exception,
594
550
  level: int | None = None,
595
- context: Mapping[str, Any] | None = None,
551
+ log_context: Mapping[str, Any] | None = None,
552
+ message: str | None = None,
596
553
  ) -> None:
597
554
  """
598
555
  Log a boundary error with the provided logger.
@@ -602,42 +559,70 @@ def log_boundary_error(
602
559
  logging level if not explicitly provided. Additionally, it enriches the log
603
560
  record with extra context about the error and action boundaries.
604
561
 
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
562
  """
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
- }
563
+ err_name = err.__class__.__name__
564
+ log_message = f"[boundary={boundary} action={action} err={err_name}"
565
+
566
+ if (err_args := extract_exc_args(exc=err)) and err_args != err_name:
567
+ log_message += f": {err_args}"
568
+ log_message += "]"
569
+
570
+ if message:
571
+ log_message += f" {message}"
572
+
573
+ if log_context:
574
+ log_message += f" ctx={orjson.dumps(_safe_log_context(log_context), option=orjson.OPT_SORT_KEYS).decode()}"
630
575
 
631
576
  # Choose level if not provided:
632
- chosen_level = level
633
- if chosen_level is None:
577
+ if (chosen_level := level) is None:
634
578
  # Use WARNING for expected/recoverable domain errors, ERROR otherwise.
635
579
  chosen_level = logging.WARNING if isinstance(err, BaseHomematicException) else logging.ERROR
636
580
 
637
- if chosen_level >= logging.ERROR:
638
- logger.exception(_BOUNDARY_MSG, extra=extra)
639
- else:
640
- logger.log(chosen_level, _BOUNDARY_MSG, extra=extra)
581
+ logger.log(chosen_level, log_message)
582
+
583
+
584
+ class LogContextMixin:
585
+ """Mixin to add log context methods to class."""
586
+
587
+ __slots__ = ("_cached_log_context",)
588
+
589
+ @cached_slot_property
590
+ def log_context(self) -> Mapping[str, Any]:
591
+ """Return the log context for this object."""
592
+ return {
593
+ key: value for key, value in get_attributes_for_log_context(data_object=self).items() if value is not None
594
+ }
595
+
596
+
597
+ class PayloadMixin:
598
+ """Mixin to add payload methods to class."""
599
+
600
+ __slots__ = ()
601
+
602
+ @property
603
+ def config_payload(self) -> Mapping[str, Any]:
604
+ """Return the config payload."""
605
+ return {
606
+ key: value
607
+ for key, value in get_attributes_for_config_property(data_object=self).items()
608
+ if value is not None
609
+ }
610
+
611
+ @property
612
+ def info_payload(self) -> Mapping[str, Any]:
613
+ """Return the info payload."""
614
+ return {
615
+ key: value for key, value in get_attributes_for_info_property(data_object=self).items() if value is not None
616
+ }
617
+
618
+ @property
619
+ def state_payload(self) -> Mapping[str, Any]:
620
+ """Return the state payload."""
621
+ return {
622
+ key: value
623
+ for key, value in get_attributes_for_state_property(data_object=self).items()
624
+ if value is not None
625
+ }
641
626
 
642
627
 
643
628
  # Define public API for this module
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohomematic
3
- Version: 2025.9.1
3
+ Version: 2025.9.2
4
4
  Summary: Homematic interface for Home Assistant running on Python 3.
5
5
  Home-page: https://github.com/sukramj/aiohomematic
6
6
  Author-email: SukramJ <sukramj@icloud.com>, Daniel Perna <danielperna84@gmail.com>
@@ -52,7 +52,7 @@ Unlike pyhomematic, which required manual device mappings, aiohomematic automati
52
52
  Use the Home Assistant custom integration "Homematic(IP) Local", which is powered by aiohomematic.
53
53
 
54
54
  1. Prerequisites
55
- - Home Assistant 2024.6 or newer recommended.
55
+ - Use latest version of Home Assistant.
56
56
  - A CCU3, RaspberryMatic, or Homegear instance reachable from Home Assistant.
57
57
  - For HomematicIP devices, ensure CCU firmware meets the minimum versions listed below.
58
58
  2. Install the integration
@@ -60,11 +60,11 @@ Use the Home Assistant custom integration "Homematic(IP) Local", which is powere
60
60
  - Follow the installation guide: https://github.com/sukramj/homematicip_local/wiki/Installation
61
61
  3. Configure via Home Assistant UI
62
62
  - In Home Assistant: Settings → Devices & Services → Add Integration → search for "Homematic(IP) Local".
63
- - Enter the CCU/Homegear host (IP or hostname). If you use HTTPS on the CCU, enable SSL and accept the certificate if self‑signed.
64
- - Provide credentials if your CCU requires them.
63
+ - Enter the CCU/Homegear host (IP or hostname). If you use HTTPS on the CCU, enable TLS and don't use verify if self‑signed.
64
+ - Always enter credentials.
65
65
  - Choose which interfaces to enable (HM, HmIP, Virtual). Default ports are typically 2001 (HM), 2010 (HmIP), 9292 (Virtual).
66
66
  4. Network callbacks
67
- - The integration needs to receive XML‑RPC callbacks from the CCU. Make sure Home Assistant is reachable from the CCU (no NAT/firewall blocking). The default callback port is 43439; you can adjust it in advanced options.
67
+ - The integration needs to receive XML‑RPC callbacks from the CCU. Make sure Home Assistant is reachable from the CCU (no NAT/firewall blocking). Callbacks ar only required for special network setups.
68
68
  5. Verify
69
69
  - After setup, devices should appear under Devices & Services → Homematic(IP) Local. Discovery may take a few seconds after the first connection while paramsets are fetched and cached for faster restarts.
70
70
 
@@ -84,10 +84,12 @@ See details here: https://github.com/jens-maus/RaspberryMatic/issues/843. Other
84
84
  - The public API of aiohomematic is explicitly defined via **all** in each module and subpackage.
85
85
  - Backwards‑compatible imports should target these modules:
86
86
  - aiohomematic.central: CentralUnit, CentralConfig and related schemas
87
+ - aiohomematic.central.event: display received events from the backend
87
88
  - aiohomematic.client: Client, InterfaceConfig, create_client, get_client
88
89
  - aiohomematic.model: device/data point abstractions (see subpackages for details)
89
90
  - aiohomematic.exceptions: library exception types intended for consumers
90
91
  - aiohomematic.const: constants and enums (stable subset; see module **all**)
92
+ - aiohomematic.performance: display some performance metrics (enabled when DEBUG is enabled)
91
93
  - The top‑level package only exposes **version** to avoid import cycles and keep startup lean. Prefer importing from the specific submodules listed above.
92
94
 
93
95
  Example: