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.
- aiohomematic/caches/dynamic.py +1 -6
- aiohomematic/central/__init__.py +34 -23
- aiohomematic/central/xml_rpc_server.py +1 -1
- aiohomematic/client/__init__.py +35 -29
- aiohomematic/client/json_rpc.py +44 -12
- aiohomematic/client/xml_rpc.py +53 -20
- aiohomematic/const.py +2 -2
- aiohomematic/decorators.py +56 -21
- aiohomematic/model/__init__.py +1 -1
- aiohomematic/model/calculated/__init__.py +1 -1
- aiohomematic/model/calculated/climate.py +1 -1
- aiohomematic/model/calculated/data_point.py +2 -2
- aiohomematic/model/calculated/operating_voltage_level.py +7 -21
- aiohomematic/model/calculated/support.py +20 -0
- aiohomematic/model/custom/__init__.py +1 -1
- aiohomematic/model/custom/climate.py +18 -18
- aiohomematic/model/custom/cover.py +1 -1
- aiohomematic/model/custom/data_point.py +1 -1
- aiohomematic/model/custom/light.py +1 -1
- aiohomematic/model/custom/lock.py +1 -1
- aiohomematic/model/custom/siren.py +1 -1
- aiohomematic/model/custom/switch.py +1 -1
- aiohomematic/model/custom/valve.py +1 -1
- aiohomematic/model/data_point.py +18 -18
- aiohomematic/model/device.py +21 -20
- aiohomematic/model/event.py +3 -8
- aiohomematic/model/generic/__init__.py +1 -1
- aiohomematic/model/generic/binary_sensor.py +1 -1
- aiohomematic/model/generic/button.py +1 -1
- aiohomematic/model/generic/data_point.py +3 -5
- aiohomematic/model/generic/number.py +1 -1
- aiohomematic/model/generic/select.py +1 -1
- aiohomematic/model/generic/sensor.py +1 -1
- aiohomematic/model/generic/switch.py +4 -4
- aiohomematic/model/generic/text.py +1 -1
- aiohomematic/model/hub/binary_sensor.py +1 -1
- aiohomematic/model/hub/button.py +2 -2
- aiohomematic/model/hub/data_point.py +4 -7
- aiohomematic/model/hub/number.py +1 -1
- aiohomematic/model/hub/select.py +2 -2
- aiohomematic/model/hub/sensor.py +1 -1
- aiohomematic/model/hub/switch.py +3 -3
- aiohomematic/model/hub/text.py +1 -1
- aiohomematic/model/support.py +1 -40
- aiohomematic/model/update.py +5 -4
- aiohomematic/property_decorators.py +327 -0
- aiohomematic/support.py +70 -85
- {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/METADATA +7 -5
- aiohomematic-2025.9.2.dist-info/RECORD +78 -0
- aiohomematic_support/client_local.py +5 -5
- aiohomematic/model/decorators.py +0 -194
- aiohomematic-2025.9.1.dist-info/RECORD +0 -78
- {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/licenses/LICENSE +0 -0
- {aiohomematic-2025.9.1.dist-info → aiohomematic-2025.9.2.dist-info}/top_level.txt +0 -0
aiohomematic/model/update.py
CHANGED
|
@@ -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.
|
|
25
|
-
from aiohomematic.
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
"
|
|
628
|
-
|
|
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
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
64
|
-
-
|
|
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).
|
|
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:
|