aiohomematic 2025.11.3__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 (77) hide show
  1. aiohomematic/__init__.py +61 -0
  2. aiohomematic/async_support.py +212 -0
  3. aiohomematic/central/__init__.py +2309 -0
  4. aiohomematic/central/decorators.py +155 -0
  5. aiohomematic/central/rpc_server.py +295 -0
  6. aiohomematic/client/__init__.py +1848 -0
  7. aiohomematic/client/_rpc_errors.py +81 -0
  8. aiohomematic/client/json_rpc.py +1326 -0
  9. aiohomematic/client/rpc_proxy.py +311 -0
  10. aiohomematic/const.py +1127 -0
  11. aiohomematic/context.py +18 -0
  12. aiohomematic/converter.py +108 -0
  13. aiohomematic/decorators.py +302 -0
  14. aiohomematic/exceptions.py +164 -0
  15. aiohomematic/hmcli.py +186 -0
  16. aiohomematic/model/__init__.py +140 -0
  17. aiohomematic/model/calculated/__init__.py +84 -0
  18. aiohomematic/model/calculated/climate.py +290 -0
  19. aiohomematic/model/calculated/data_point.py +327 -0
  20. aiohomematic/model/calculated/operating_voltage_level.py +299 -0
  21. aiohomematic/model/calculated/support.py +234 -0
  22. aiohomematic/model/custom/__init__.py +177 -0
  23. aiohomematic/model/custom/climate.py +1532 -0
  24. aiohomematic/model/custom/cover.py +792 -0
  25. aiohomematic/model/custom/data_point.py +334 -0
  26. aiohomematic/model/custom/definition.py +871 -0
  27. aiohomematic/model/custom/light.py +1128 -0
  28. aiohomematic/model/custom/lock.py +394 -0
  29. aiohomematic/model/custom/siren.py +275 -0
  30. aiohomematic/model/custom/support.py +41 -0
  31. aiohomematic/model/custom/switch.py +175 -0
  32. aiohomematic/model/custom/valve.py +114 -0
  33. aiohomematic/model/data_point.py +1123 -0
  34. aiohomematic/model/device.py +1445 -0
  35. aiohomematic/model/event.py +208 -0
  36. aiohomematic/model/generic/__init__.py +217 -0
  37. aiohomematic/model/generic/action.py +34 -0
  38. aiohomematic/model/generic/binary_sensor.py +30 -0
  39. aiohomematic/model/generic/button.py +27 -0
  40. aiohomematic/model/generic/data_point.py +171 -0
  41. aiohomematic/model/generic/dummy.py +147 -0
  42. aiohomematic/model/generic/number.py +76 -0
  43. aiohomematic/model/generic/select.py +39 -0
  44. aiohomematic/model/generic/sensor.py +74 -0
  45. aiohomematic/model/generic/switch.py +54 -0
  46. aiohomematic/model/generic/text.py +29 -0
  47. aiohomematic/model/hub/__init__.py +333 -0
  48. aiohomematic/model/hub/binary_sensor.py +24 -0
  49. aiohomematic/model/hub/button.py +28 -0
  50. aiohomematic/model/hub/data_point.py +340 -0
  51. aiohomematic/model/hub/number.py +39 -0
  52. aiohomematic/model/hub/select.py +49 -0
  53. aiohomematic/model/hub/sensor.py +37 -0
  54. aiohomematic/model/hub/switch.py +44 -0
  55. aiohomematic/model/hub/text.py +30 -0
  56. aiohomematic/model/support.py +586 -0
  57. aiohomematic/model/update.py +143 -0
  58. aiohomematic/property_decorators.py +496 -0
  59. aiohomematic/py.typed +0 -0
  60. aiohomematic/rega_scripts/fetch_all_device_data.fn +92 -0
  61. aiohomematic/rega_scripts/get_program_descriptions.fn +30 -0
  62. aiohomematic/rega_scripts/get_serial.fn +44 -0
  63. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +30 -0
  64. aiohomematic/rega_scripts/set_program_state.fn +12 -0
  65. aiohomematic/rega_scripts/set_system_variable.fn +15 -0
  66. aiohomematic/store/__init__.py +34 -0
  67. aiohomematic/store/dynamic.py +551 -0
  68. aiohomematic/store/persistent.py +988 -0
  69. aiohomematic/store/visibility.py +812 -0
  70. aiohomematic/support.py +664 -0
  71. aiohomematic/validator.py +112 -0
  72. aiohomematic-2025.11.3.dist-info/METADATA +144 -0
  73. aiohomematic-2025.11.3.dist-info/RECORD +77 -0
  74. aiohomematic-2025.11.3.dist-info/WHEEL +5 -0
  75. aiohomematic-2025.11.3.dist-info/entry_points.txt +2 -0
  76. aiohomematic-2025.11.3.dist-info/licenses/LICENSE +21 -0
  77. aiohomematic-2025.11.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """Module for data points implemented using the update category."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from datetime import datetime
9
+ from functools import partial
10
+ from typing import Final
11
+
12
+ from aiohomematic.const import (
13
+ CALLBACK_TYPE,
14
+ HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES,
15
+ HMIP_FIRMWARE_UPDATE_READY_STATES,
16
+ DataPointCategory,
17
+ Interface,
18
+ InternalCustomID,
19
+ )
20
+ from aiohomematic.decorators import inspector
21
+ from aiohomematic.exceptions import AioHomematicException
22
+ from aiohomematic.model import device as hmd
23
+ from aiohomematic.model.data_point import CallbackDataPoint
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
27
+
28
+ __all__ = ["DpUpdate"]
29
+
30
+
31
+ class DpUpdate(CallbackDataPoint, PayloadMixin):
32
+ """
33
+ Implementation of a update.
34
+
35
+ This is a default data point that gets automatically generated.
36
+ """
37
+
38
+ __slots__ = ("_device",)
39
+
40
+ _category = DataPointCategory.UPDATE
41
+
42
+ def __init__(self, *, device: hmd.Device) -> None:
43
+ """Init the callback data_point."""
44
+ PayloadMixin.__init__(self)
45
+ self._device: Final = device
46
+ super().__init__(
47
+ central=device.central,
48
+ unique_id=generate_unique_id(central=device.central, address=device.address, parameter="Update"),
49
+ )
50
+ self._set_modified_at(modified_at=datetime.now())
51
+
52
+ @state_property
53
+ def available(self) -> bool:
54
+ """Return the availability of the device."""
55
+ return self._device.available
56
+
57
+ @property
58
+ def device(self) -> hmd.Device:
59
+ """Return the device of the data_point."""
60
+ return self._device
61
+
62
+ @property
63
+ def full_name(self) -> str:
64
+ """Return the full name of the data_point."""
65
+ return f"{self._device.name} Update"
66
+
67
+ @config_property
68
+ def name(self) -> str:
69
+ """Return the name of the data_point."""
70
+ return "Update"
71
+
72
+ @state_property
73
+ def firmware(self) -> str | None:
74
+ """Version installed and in use."""
75
+ return self._device.firmware
76
+
77
+ @state_property
78
+ def firmware_update_state(self) -> str | None:
79
+ """Latest version available for install."""
80
+ return self._device.firmware_update_state
81
+
82
+ @state_property
83
+ def in_progress(self) -> bool:
84
+ """Update installation progress."""
85
+ if self._device.interface == Interface.HMIP_RF:
86
+ return self._device.firmware_update_state in HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES
87
+ return False
88
+
89
+ @state_property
90
+ def latest_firmware(self) -> str | None:
91
+ """Latest firmware available for install."""
92
+ if self._device.available_firmware and (
93
+ (
94
+ self._device.interface == Interface.HMIP_RF
95
+ and self._device.firmware_update_state in HMIP_FIRMWARE_UPDATE_READY_STATES
96
+ )
97
+ or self._device.interface in (Interface.BIDCOS_RF, Interface.BIDCOS_WIRED)
98
+ ):
99
+ return self._device.available_firmware
100
+ return self._device.firmware
101
+
102
+ def _get_signature(self) -> str:
103
+ """Return the signature of the data_point."""
104
+ return f"{self._category}/{self._device.model}"
105
+
106
+ def _get_path_data(self) -> DataPointPathData:
107
+ """Return the path data of the data_point."""
108
+ return DataPointPathData(
109
+ interface=None,
110
+ address=self._device.address,
111
+ channel_no=None,
112
+ kind=DataPointCategory.UPDATE,
113
+ )
114
+
115
+ def register_data_point_updated_callback(self, *, cb: Callable, custom_id: str) -> CALLBACK_TYPE:
116
+ """Register update callback."""
117
+ if custom_id != InternalCustomID.DEFAULT:
118
+ if self._custom_id is not None:
119
+ raise AioHomematicException(
120
+ f"REGISTER_UPDATE_CALLBACK failed: hm_data_point: {self.full_name} is already registered by {self._custom_id}"
121
+ )
122
+ self._custom_id = custom_id
123
+
124
+ if self._device.register_firmware_update_callback(cb=cb) is not None:
125
+ return partial(self._unregister_data_point_updated_callback, cb=cb, custom_id=custom_id)
126
+ return None
127
+
128
+ def _unregister_data_point_updated_callback(self, *, cb: Callable, custom_id: str) -> None:
129
+ """Unregister update callback."""
130
+ if custom_id is not None:
131
+ self._custom_id = None
132
+ self._device.unregister_firmware_update_callback(cb=cb)
133
+
134
+ @inspector
135
+ async def update_firmware(self, *, refresh_after_update_intervals: tuple[int, ...]) -> bool:
136
+ """Turn the update on."""
137
+ return await self._device.update_firmware(refresh_after_update_intervals=refresh_after_update_intervals)
138
+
139
+ @inspector
140
+ async def refresh_firmware_data(self) -> None:
141
+ """Refresh device firmware data."""
142
+ await self._device.central.refresh_firmware_data(device_address=self._device.address)
143
+ self._set_modified_at(modified_at=datetime.now())
@@ -0,0 +1,496 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """
4
+ Decorators and helpers for declaring public attributes on data point classes.
5
+
6
+ This module provides four decorator factories that behave like the built-in
7
+ @property, but additionally annotate properties with a semantic category so they
8
+ can be automatically collected to build payloads and log contexts:
9
+ - config_property: configuration-related properties.
10
+ - info_property: informational/metadata properties.
11
+ - state_property: dynamic state properties.
12
+ - hm_property: can be used to mark log_context or cached, where the other properties don't match
13
+
14
+ All decorators accept an optional keyword-only argument log_context. If set to
15
+ True, the property will be included in the LogContextMixin.log_context mapping.
16
+
17
+ Notes on caching
18
+ - Marked with cached=True always store on first access and invalidates on set/delete.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable, Mapping
24
+ from datetime import datetime
25
+ from enum import Enum, StrEnum
26
+ from typing import Any, Final, ParamSpec, TypeVar, cast, overload
27
+ from weakref import WeakKeyDictionary
28
+
29
+ from aiohomematic import support as hms
30
+
31
+ __all__ = [
32
+ "config_property",
33
+ "get_hm_property_by_kind",
34
+ "info_property",
35
+ "state_property",
36
+ ]
37
+
38
+ P = ParamSpec("P")
39
+ T = TypeVar("T")
40
+ R = TypeVar("R")
41
+
42
+
43
+ class Kind(StrEnum):
44
+ """Enum for property feature flags."""
45
+
46
+ CONFIG = "config"
47
+ INFO = "info"
48
+ SIMPLE = "simple"
49
+ STATE = "state"
50
+
51
+
52
+ class _GenericProperty[GETTER, SETTER](property):
53
+ """
54
+ Base descriptor used by all property decorators in this module.
55
+
56
+ Extends the built-in property to optionally cache the computed value on the
57
+ instance and to carry a log_context flag.
58
+
59
+ Args:
60
+ - fget/fset/fdel: Standard property callables.
61
+ - doc: Optional docstring of the property.
62
+ - cached: If True, the computed value is cached per instance and
63
+ invalidated when the descriptor receives a set/delete.
64
+ - log_context: If True, the property is included in get_attributes_for_log_context().
65
+
66
+ """
67
+
68
+ __kwonly_check__ = False
69
+
70
+ fget: Callable[[Any], GETTER] | None
71
+ fset: Callable[[Any, SETTER], None] | None
72
+ fdel: Callable[[Any], None] | None
73
+
74
+ def __init__(
75
+ self,
76
+ fget: Callable[[Any], GETTER] | None = None,
77
+ fset: Callable[[Any, SETTER], None] | None = None,
78
+ fdel: Callable[[Any], None] | None = None,
79
+ doc: str | None = None,
80
+ kind: Kind = Kind.SIMPLE,
81
+ cached: bool = False,
82
+ log_context: bool = False,
83
+ ) -> None:
84
+ """
85
+ Initialize the descriptor.
86
+
87
+ Mirrors the standard property signature and adds two options:
88
+ - kind: specify the kind of property (e.g. simple, cached, config, info, state).
89
+ - cached: enable per-instance caching of the computed value.
90
+ - log_context: mark this property as relevant for structured logging.
91
+ """
92
+ super().__init__(fget, fset, fdel, doc)
93
+ if doc is None and fget is not None:
94
+ doc = fget.__doc__
95
+ self.__doc__ = doc
96
+ self.kind: Final = kind
97
+ self._cached: Final = cached
98
+ self.log_context = log_context
99
+ if cached:
100
+ if fget is not None:
101
+ func_name = fget.__name__
102
+ elif fset is not None:
103
+ func_name = fset.__name__
104
+ elif fdel is not None:
105
+ func_name = fdel.__name__
106
+ else:
107
+ func_name = "prop"
108
+ self._cache_attr = f"_cached_{func_name}"
109
+
110
+ def getter(self, fget: Callable[[Any], GETTER], /) -> _GenericProperty:
111
+ """Return generic getter."""
112
+ return type(self)(
113
+ fget=fget,
114
+ fset=self.fset,
115
+ fdel=self.fdel,
116
+ doc=self.__doc__,
117
+ kind=self.kind,
118
+ cached=self._cached,
119
+ log_context=self.log_context,
120
+ )
121
+
122
+ def setter(self, fset: Callable[[Any, SETTER], None], /) -> _GenericProperty:
123
+ """Return generic setter."""
124
+ return type(self)(
125
+ fget=self.fget,
126
+ fset=fset,
127
+ fdel=self.fdel,
128
+ doc=self.__doc__,
129
+ kind=self.kind,
130
+ cached=self._cached,
131
+ log_context=self.log_context,
132
+ )
133
+
134
+ def deleter(self, fdel: Callable[[Any], None], /) -> _GenericProperty:
135
+ """Return generic deleter."""
136
+ return type(self)(
137
+ fget=self.fget,
138
+ fset=self.fset,
139
+ fdel=fdel,
140
+ doc=self.__doc__,
141
+ kind=self.kind,
142
+ cached=self._cached,
143
+ log_context=self.log_context,
144
+ )
145
+
146
+ def __get__(self, instance: Any, gtype: type | None = None, /) -> GETTER: # type: ignore[override]
147
+ """
148
+ Return the attribute value.
149
+
150
+ If caching is enabled, compute on first access and return the per-instance
151
+ cached value on subsequent accesses.
152
+ """
153
+ if instance is None:
154
+ # Accessed from class, return the descriptor itself
155
+ return cast(GETTER, self)
156
+
157
+ if (fget := self.fget) is None:
158
+ raise AttributeError("unreadable attribute")
159
+
160
+ if not self._cached:
161
+ return fget(instance)
162
+
163
+ # Use direct __dict__ access when available for better performance
164
+ # Store cache_attr in local variable to avoid repeated attribute lookup
165
+ cache_attr = self._cache_attr
166
+
167
+ try:
168
+ inst_dict = instance.__dict__
169
+ # Use 'in' check first to distinguish between missing and None
170
+ if cache_attr in inst_dict:
171
+ return cast(GETTER, inst_dict[cache_attr])
172
+
173
+ # Not cached yet, compute and store
174
+ value = fget(instance)
175
+ inst_dict[cache_attr] = value
176
+ except AttributeError:
177
+ # Object uses __slots__, fall back to getattr/setattr
178
+ try:
179
+ return cast(GETTER, getattr(instance, cache_attr))
180
+ except AttributeError:
181
+ value = fget(instance)
182
+ setattr(instance, cache_attr, value)
183
+ return value
184
+
185
+ def __set__(self, instance: Any, value: Any, /) -> None:
186
+ """Set the attribute value and invalidate cache if enabled."""
187
+ # Delete the cached value so it can be recomputed on next access.
188
+ if self._cached:
189
+ try:
190
+ instance.__dict__.pop(self._cache_attr, None)
191
+ except AttributeError:
192
+ # Object uses __slots__, fall back to delattr
193
+ if hasattr(instance, self._cache_attr):
194
+ delattr(instance, self._cache_attr)
195
+
196
+ if self.fset is None:
197
+ raise AttributeError("can't set attribute")
198
+ self.fset(instance, value)
199
+
200
+ def __delete__(self, instance: Any, /) -> None:
201
+ """Delete the attribute and invalidate cache if enabled."""
202
+
203
+ # Delete the cached value so it can be recomputed on next access.
204
+ if self._cached:
205
+ try:
206
+ instance.__dict__.pop(self._cache_attr, None)
207
+ except AttributeError:
208
+ # Object uses __slots__, fall back to delattr
209
+ if hasattr(instance, self._cache_attr):
210
+ delattr(instance, self._cache_attr)
211
+
212
+ if self.fdel is None:
213
+ raise AttributeError("can't delete attribute")
214
+ self.fdel(instance)
215
+
216
+
217
+ # ----- hm_property -----
218
+
219
+
220
+ @overload
221
+ def hm_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
222
+
223
+
224
+ @overload
225
+ def hm_property(
226
+ *, kind: Kind = ..., cached: bool = ..., log_context: bool = ...
227
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
228
+
229
+
230
+ def hm_property[PR](
231
+ func: Callable[[Any], PR] | None = None,
232
+ *,
233
+ kind: Kind = Kind.SIMPLE,
234
+ cached: bool = False,
235
+ log_context: bool = False,
236
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
237
+ """
238
+ Decorate a method as a computed attribute.
239
+
240
+ Supports both usages:
241
+ - @hm_property
242
+ - @hm_property(kind=..., cached=True, log_context=True)
243
+
244
+ Args:
245
+ func: The function being decorated when used as @hm_property without
246
+ parentheses. When used as a factory (i.e., @hm_property(...)), this
247
+ is None and the returned callable expects the function to decorate.
248
+ kind: Specify the kind of property (e.g. simple, config, info, state).
249
+ cached: Optionally enable per-instance caching for this property.
250
+ log_context: Include this property in structured log context if True.
251
+
252
+ """
253
+ if func is None:
254
+
255
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
256
+ return _GenericProperty(f, kind=kind, cached=cached, log_context=log_context)
257
+
258
+ return wrapper
259
+ return _GenericProperty(func, kind=kind, cached=cached, log_context=log_context)
260
+
261
+
262
+ # ----- config_property -----
263
+
264
+
265
+ @overload
266
+ def config_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
267
+
268
+
269
+ @overload
270
+ def config_property(
271
+ *, cached: bool = ..., log_context: bool = ...
272
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
273
+
274
+
275
+ def config_property[PR](
276
+ func: Callable[[Any], PR] | None = None,
277
+ *,
278
+ cached: bool = False,
279
+ log_context: bool = False,
280
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
281
+ """
282
+ Decorate a method as a configuration property.
283
+
284
+ Supports both usages:
285
+ - @config_property
286
+ - @config_property(cached=True, log_context=True)
287
+
288
+ Args:
289
+ func: The function being decorated when used as @config_property without
290
+ parentheses. When used as a factory (i.e., @config_property(...)), this is
291
+ None and the returned callable expects the function to decorate.
292
+ cached: Enable per-instance caching for this property when True.
293
+ log_context: Include this property in structured log context if True.
294
+
295
+ """
296
+ if func is None:
297
+
298
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
299
+ return _GenericProperty(f, kind=Kind.CONFIG, cached=cached, log_context=log_context)
300
+
301
+ return wrapper
302
+ return _GenericProperty(func, kind=Kind.CONFIG, cached=cached, log_context=log_context)
303
+
304
+
305
+ # ----- info_property -----
306
+
307
+
308
+ @overload
309
+ def info_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
310
+
311
+
312
+ @overload
313
+ def info_property(
314
+ *, cached: bool = ..., log_context: bool = ...
315
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
316
+
317
+
318
+ def info_property[PR](
319
+ func: Callable[[Any], PR] | None = None,
320
+ *,
321
+ cached: bool = False,
322
+ log_context: bool = False,
323
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
324
+ """
325
+ Decorate a method as an informational/metadata property.
326
+
327
+ Supports both usages:
328
+ - @info_property
329
+ - @info_property(cached=True, log_context=True)
330
+
331
+ Args:
332
+ func: The function being decorated when used as @info_property without
333
+ parentheses. When used as a factory (i.e., @info_property(...)), this is
334
+ None and the returned callable expects the function to decorate.
335
+ cached: Enable per-instance caching for this property when True.
336
+ log_context: Include this property in structured log context if True.
337
+
338
+ """
339
+ if func is None:
340
+
341
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
342
+ return _GenericProperty(f, kind=Kind.INFO, cached=cached, log_context=log_context)
343
+
344
+ return wrapper
345
+ return _GenericProperty(func, kind=Kind.INFO, cached=cached, log_context=log_context)
346
+
347
+
348
+ # ----- state_property -----
349
+
350
+
351
+ @overload
352
+ def state_property[PR](func: Callable[[Any], PR], /) -> _GenericProperty[PR, Any]: ...
353
+
354
+
355
+ @overload
356
+ def state_property(
357
+ *, cached: bool = ..., log_context: bool = ...
358
+ ) -> Callable[[Callable[[Any], R]], _GenericProperty[R, Any]]: ...
359
+
360
+
361
+ def state_property[PR](
362
+ func: Callable[[Any], PR] | None = None,
363
+ *,
364
+ cached: bool = False,
365
+ log_context: bool = False,
366
+ ) -> _GenericProperty[PR, Any] | Callable[[Callable[[Any], PR]], _GenericProperty[PR, Any]]:
367
+ """
368
+ Decorate a method as a dynamic state property.
369
+
370
+ Supports both usages:
371
+ - @state_property
372
+ - @state_property(cached=True, log_context=True)
373
+
374
+ Args:
375
+ func: The function being decorated when used as @state_property without
376
+ parentheses. When used as a factory (i.e., @state_property(...)), this is
377
+ None and the returned callable expects the function to decorate.
378
+ cached: Enable per-instance caching for this property when True.
379
+ log_context: Include this property in structured log context if True.
380
+
381
+ """
382
+ if func is None:
383
+
384
+ def wrapper(f: Callable[[Any], PR]) -> _GenericProperty[PR, Any]:
385
+ return _GenericProperty(f, kind=Kind.STATE, cached=cached, log_context=log_context)
386
+
387
+ return wrapper
388
+ return _GenericProperty(func, kind=Kind.STATE, cached=cached, log_context=log_context)
389
+
390
+
391
+ # ----------
392
+
393
+
394
+ # Cache for per-class attribute names by decorator to avoid repeated dir() scans
395
+ # Use WeakKeyDictionary to allow classes to be garbage-collected without leaking cache entries.
396
+ # Structure: {cls: {decorator_class: (attr_name1, attr_name2, ...)}}
397
+ _PUBLIC_ATTR_CACHE: WeakKeyDictionary[type, dict[Kind, tuple[str, ...]]] = WeakKeyDictionary()
398
+
399
+
400
+ def get_hm_property_by_kind(data_object: Any, kind: Kind, context: bool = False) -> Mapping[str, Any]:
401
+ """
402
+ Collect properties from an object that are defined using a specific decorator.
403
+
404
+ Args:
405
+ data_object: The instance to inspect.
406
+ kind: The decorator class to use for filtering.
407
+ context: If True, only include properties where the descriptor has
408
+ log_context=True. When such a property's value is a LogContextMixin, its
409
+ items are flattened into the result using a short prefix of the property
410
+ name (e.g. "p.key").
411
+
412
+ Returns:
413
+ Mapping[str, Any]: A mapping of attribute name to normalized value. Values are converted via
414
+ _get_text_value() to provide stable JSON/log-friendly types.
415
+
416
+ Notes:
417
+ Attribute NAMES are cached per (class, decorator) to avoid repeated dir()
418
+ scans. Values are never cached here since they are instance-dependent.
419
+ Getter exceptions are swallowed and represented as None so payload building
420
+ remains robust and side-effect free.
421
+
422
+ """
423
+ cls = data_object.__class__
424
+
425
+ # Get or create the per-class cache dict
426
+ if (decorator_cache := _PUBLIC_ATTR_CACHE.get(cls)) is None:
427
+ decorator_cache = {}
428
+ _PUBLIC_ATTR_CACHE[cls] = decorator_cache
429
+
430
+ if (names := decorator_cache.get(kind)) is None:
431
+ names = tuple(
432
+ y for y in dir(cls) if (gp := getattr(cls, y)) and isinstance(gp, _GenericProperty) and gp.kind == kind
433
+ )
434
+ decorator_cache[kind] = names
435
+
436
+ result: dict[str, Any] = {}
437
+ for name in names:
438
+ if context and getattr(cls, name).log_context is False:
439
+ continue
440
+ try:
441
+ value = getattr(data_object, name)
442
+ if isinstance(value, hms.LogContextMixin):
443
+ result.update({f"{name[:1]}.{k}": v for k, v in value.log_context.items()})
444
+ else:
445
+ result[name] = _get_text_value(value)
446
+ except Exception:
447
+ # Avoid propagating side effects/errors from getters
448
+ result[name] = None
449
+ return result
450
+
451
+
452
+ def _get_text_value(value: Any) -> Any:
453
+ """
454
+ Normalize values for payload/logging purposes.
455
+
456
+ - list/tuple/set are converted to tuples and their items normalized recursively
457
+ - Enum values are converted to their string representation
458
+ - datetime objects are converted to unix timestamps (float)
459
+ - all other types are returned unchanged
460
+
461
+ Args:
462
+ value: The input value to normalize into a log-/JSON-friendly representation.
463
+
464
+ Returns:
465
+ Any: The normalized value, potentially converted as described above.
466
+
467
+ """
468
+ if isinstance(value, list | tuple | set):
469
+ return tuple(_get_text_value(v) for v in value)
470
+ if isinstance(value, Enum):
471
+ return str(value)
472
+ if isinstance(value, datetime):
473
+ return datetime.timestamp(value)
474
+ return value
475
+
476
+
477
+ def get_hm_property_by_log_context(data_object: Any) -> Mapping[str, Any]:
478
+ """
479
+ Return combined log context attributes across all property categories.
480
+
481
+ Includes only properties declared with log_context=True and flattens
482
+ values that implement LogContextMixin by prefixing with a short key.
483
+
484
+ Args:
485
+ data_object: The instance from which to collect attributes marked for
486
+ log context across all property categories.
487
+
488
+ Returns:
489
+ Mapping[str, Any]: A mapping of attribute name to normalized value for logging.
490
+
491
+ """
492
+ result: dict[str, Any] = {}
493
+ for kind in Kind:
494
+ result.update(get_hm_property_by_kind(data_object=data_object, kind=kind, context=True))
495
+
496
+ return result
aiohomematic/py.typed ADDED
File without changes