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.
- aiohomematic/__init__.py +15 -1
- aiohomematic/async_support.py +15 -2
- aiohomematic/caches/__init__.py +2 -0
- aiohomematic/caches/dynamic.py +2 -0
- aiohomematic/caches/persistent.py +29 -22
- aiohomematic/caches/visibility.py +277 -252
- aiohomematic/central/__init__.py +69 -49
- aiohomematic/central/decorators.py +60 -15
- aiohomematic/central/xml_rpc_server.py +15 -1
- aiohomematic/client/__init__.py +2 -0
- aiohomematic/client/_rpc_errors.py +81 -0
- aiohomematic/client/json_rpc.py +68 -19
- aiohomematic/client/xml_rpc.py +15 -8
- aiohomematic/const.py +145 -77
- aiohomematic/context.py +11 -1
- aiohomematic/converter.py +27 -1
- aiohomematic/decorators.py +88 -19
- aiohomematic/exceptions.py +19 -1
- aiohomematic/hmcli.py +13 -1
- aiohomematic/model/__init__.py +2 -0
- aiohomematic/model/calculated/__init__.py +2 -0
- aiohomematic/model/calculated/climate.py +2 -0
- aiohomematic/model/calculated/data_point.py +7 -1
- aiohomematic/model/calculated/operating_voltage_level.py +2 -0
- aiohomematic/model/calculated/support.py +2 -0
- aiohomematic/model/custom/__init__.py +2 -0
- aiohomematic/model/custom/climate.py +3 -1
- aiohomematic/model/custom/const.py +2 -0
- aiohomematic/model/custom/cover.py +30 -2
- aiohomematic/model/custom/data_point.py +6 -0
- aiohomematic/model/custom/definition.py +2 -0
- aiohomematic/model/custom/light.py +18 -10
- aiohomematic/model/custom/lock.py +2 -0
- aiohomematic/model/custom/siren.py +5 -2
- aiohomematic/model/custom/support.py +2 -0
- aiohomematic/model/custom/switch.py +2 -0
- aiohomematic/model/custom/valve.py +2 -0
- aiohomematic/model/data_point.py +30 -3
- aiohomematic/model/decorators.py +29 -8
- aiohomematic/model/device.py +9 -5
- aiohomematic/model/event.py +2 -0
- aiohomematic/model/generic/__init__.py +2 -0
- aiohomematic/model/generic/action.py +2 -0
- aiohomematic/model/generic/binary_sensor.py +2 -0
- aiohomematic/model/generic/button.py +2 -0
- aiohomematic/model/generic/data_point.py +4 -1
- aiohomematic/model/generic/number.py +4 -1
- aiohomematic/model/generic/select.py +4 -1
- aiohomematic/model/generic/sensor.py +2 -0
- aiohomematic/model/generic/switch.py +2 -0
- aiohomematic/model/generic/text.py +2 -0
- aiohomematic/model/hub/__init__.py +2 -0
- aiohomematic/model/hub/binary_sensor.py +2 -0
- aiohomematic/model/hub/button.py +2 -0
- aiohomematic/model/hub/data_point.py +6 -0
- aiohomematic/model/hub/number.py +2 -0
- aiohomematic/model/hub/select.py +2 -0
- aiohomematic/model/hub/sensor.py +2 -0
- aiohomematic/model/hub/switch.py +2 -0
- aiohomematic/model/hub/text.py +2 -0
- aiohomematic/model/support.py +26 -1
- aiohomematic/model/update.py +6 -0
- aiohomematic/support.py +175 -5
- aiohomematic/validator.py +49 -2
- aiohomematic-2025.8.10.dist-info/METADATA +124 -0
- aiohomematic-2025.8.10.dist-info/RECORD +78 -0
- {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
- aiohomematic-2025.8.8.dist-info/METADATA +0 -69
- aiohomematic-2025.8.8.dist-info/RECORD +0 -77
- {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
- {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/top_level.txt +0 -0
aiohomematic/model/decorators.py
CHANGED
|
@@ -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
|
-
#
|
|
95
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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:
|
aiohomematic/model/device.py
CHANGED
|
@@ -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: {
|
|
676
|
+
f"model: {self._model}, "
|
|
675
677
|
f"name: {self._name}, "
|
|
676
|
-
f"
|
|
677
|
-
f"
|
|
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"
|
|
1081
|
-
f"
|
|
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
|
|
aiohomematic/model/event.py
CHANGED
|
@@ -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
|
|
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
|
|
39
|
+
raise ValidationException(f"Value not in value_list for {self.name}/{self.unique_id}")
|
aiohomematic/model/hub/button.py
CHANGED
|
@@ -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."""
|
aiohomematic/model/hub/number.py
CHANGED
aiohomematic/model/hub/select.py
CHANGED
aiohomematic/model/hub/sensor.py
CHANGED
aiohomematic/model/hub/switch.py
CHANGED
aiohomematic/model/hub/text.py
CHANGED
aiohomematic/model/support.py
CHANGED
|
@@ -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
|
-
"""
|
|
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:
|
aiohomematic/model/update.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|