aiohomematic 2026.1.29__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.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
aiohomematic/support.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Helper functions used within aiohomematic.
|
|
5
|
+
|
|
6
|
+
Public API of this module is defined by __all__.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
from collections.abc import Collection, Mapping
|
|
14
|
+
import contextlib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
import glob
|
|
19
|
+
import hashlib
|
|
20
|
+
import inspect
|
|
21
|
+
from ipaddress import IPv4Address, IPv6Address
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
import re
|
|
26
|
+
import socket
|
|
27
|
+
import ssl
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
import orjson
|
|
32
|
+
|
|
33
|
+
from aiohomematic import i18n
|
|
34
|
+
from aiohomematic.const import (
|
|
35
|
+
ADDRESS_SEPARATOR,
|
|
36
|
+
CCU_PASSWORD_PATTERN,
|
|
37
|
+
CHANNEL_ADDRESS_PATTERN,
|
|
38
|
+
DEVICE_ADDRESS_PATTERN,
|
|
39
|
+
HOSTNAME_PATTERN,
|
|
40
|
+
HTMLTAG_PATTERN,
|
|
41
|
+
INIT_DATETIME,
|
|
42
|
+
IPV4_PATTERN,
|
|
43
|
+
IPV6_PATTERN,
|
|
44
|
+
ISO_8859_1,
|
|
45
|
+
MAX_CACHE_AGE,
|
|
46
|
+
NO_CACHE_ENTRY,
|
|
47
|
+
TIMEOUT,
|
|
48
|
+
UTF_8,
|
|
49
|
+
CommandRxMode,
|
|
50
|
+
DeviceDescription,
|
|
51
|
+
HubValueType,
|
|
52
|
+
ParamsetKey,
|
|
53
|
+
RxMode,
|
|
54
|
+
)
|
|
55
|
+
from aiohomematic.exceptions import AioHomematicException, BaseHomematicException, ValidationException
|
|
56
|
+
from aiohomematic.property_decorators import Kind, get_hm_property_by_kind, get_hm_property_by_log_context, hm_property
|
|
57
|
+
|
|
58
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_exc_args(*, exc: Exception) -> tuple[Any, ...] | Any:
|
|
62
|
+
"""Return the first arg, if there is only one arg."""
|
|
63
|
+
if exc.args:
|
|
64
|
+
return exc.args[0] if len(exc.args) == 1 else exc.args
|
|
65
|
+
return exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_xml_rpc_uri(
|
|
69
|
+
*,
|
|
70
|
+
host: str,
|
|
71
|
+
port: int | None,
|
|
72
|
+
path: str | None,
|
|
73
|
+
tls: bool = False,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Build XML-RPC API URL from components."""
|
|
76
|
+
scheme = "http"
|
|
77
|
+
s_port = f":{port}" if port else ""
|
|
78
|
+
if not path:
|
|
79
|
+
path = ""
|
|
80
|
+
if path and not path.startswith("/"):
|
|
81
|
+
path = f"/{path}"
|
|
82
|
+
if tls:
|
|
83
|
+
scheme += "s"
|
|
84
|
+
return f"{scheme}://{host}{s_port}{path}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_xml_rpc_headers(
|
|
88
|
+
*,
|
|
89
|
+
username: str,
|
|
90
|
+
password: str,
|
|
91
|
+
) -> list[tuple[str, str]]:
|
|
92
|
+
"""Build XML-RPC API header."""
|
|
93
|
+
cred_bytes = f"{username}:{password}".encode()
|
|
94
|
+
base64_message = base64.b64encode(cred_bytes).decode(ISO_8859_1)
|
|
95
|
+
return [("Authorization", f"Basic {base64_message}")]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def delete_file(directory: str, file_name: str) -> None: # kwonly: disable
|
|
99
|
+
"""Delete the file. File can contain a wildcard."""
|
|
100
|
+
if os.path.exists(directory):
|
|
101
|
+
for file_path in glob.glob(os.path.join(directory, file_name)):
|
|
102
|
+
if os.path.isfile(file_path):
|
|
103
|
+
os.remove(file_path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cleanup_script_for_session_recorder(*, script: str) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Cleanup the script for session recording.
|
|
109
|
+
|
|
110
|
+
Keep only the first line (script name) and lines starting with '!# param:'.
|
|
111
|
+
The first line contains the script identifier (e.g., '!# name: script.fn' or '!# script.fn').
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
if not (lines := script.splitlines()):
|
|
115
|
+
return ""
|
|
116
|
+
# Keep the first line (script name) and all param lines
|
|
117
|
+
result = [lines[0]]
|
|
118
|
+
result.extend(line for line in lines[1:] if line.startswith("!# param:"))
|
|
119
|
+
return "\n".join(result)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _check_or_create_directory_sync(*, directory: str) -> bool:
|
|
123
|
+
"""Check / create directory (internal sync implementation)."""
|
|
124
|
+
if not directory:
|
|
125
|
+
return False
|
|
126
|
+
if not os.path.exists(directory):
|
|
127
|
+
try:
|
|
128
|
+
os.makedirs(directory)
|
|
129
|
+
except OSError as oserr:
|
|
130
|
+
raise AioHomematicException(
|
|
131
|
+
i18n.tr(
|
|
132
|
+
key="exception.support.check_or_create_directory.failed",
|
|
133
|
+
directory=directory,
|
|
134
|
+
reason=oserr.strerror,
|
|
135
|
+
)
|
|
136
|
+
) from oserr
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def check_or_create_directory(*, directory: str) -> bool:
|
|
141
|
+
"""Check / create directory asynchronously."""
|
|
142
|
+
return await asyncio.to_thread(_check_or_create_directory_sync, directory=directory)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_device_addresses_from_device_descriptions(
|
|
146
|
+
*, device_descriptions: tuple[DeviceDescription, ...]
|
|
147
|
+
) -> tuple[str, ...]:
|
|
148
|
+
"""Extract addresses from device descriptions."""
|
|
149
|
+
return tuple(
|
|
150
|
+
{
|
|
151
|
+
parent_address
|
|
152
|
+
for dev_desc in device_descriptions
|
|
153
|
+
if (parent_address := dev_desc.get("PARENT")) and (is_device_address(address=parent_address))
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_sys_var(*, data_type: HubValueType | None, raw_value: Any) -> Any:
|
|
159
|
+
"""Parse system variables to fix type."""
|
|
160
|
+
if not data_type:
|
|
161
|
+
return raw_value
|
|
162
|
+
if data_type in (HubValueType.ALARM, HubValueType.LOGIC):
|
|
163
|
+
return to_bool(value=raw_value)
|
|
164
|
+
if data_type == HubValueType.FLOAT:
|
|
165
|
+
return float(raw_value)
|
|
166
|
+
if data_type in (HubValueType.INTEGER, HubValueType.LIST):
|
|
167
|
+
return int(raw_value)
|
|
168
|
+
return raw_value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def to_bool(*, value: Any) -> bool:
|
|
172
|
+
"""Convert defined string values to bool."""
|
|
173
|
+
if isinstance(value, bool):
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
if not isinstance(value, str):
|
|
177
|
+
raise TypeError(i18n.tr(key="exception.support.boolean.invalid_type"))
|
|
178
|
+
|
|
179
|
+
return value.lower() in ["y", "yes", "t", "true", "on", "1"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_password(*, password: str | None) -> bool:
|
|
183
|
+
"""Check password."""
|
|
184
|
+
if password is None:
|
|
185
|
+
return False
|
|
186
|
+
if CCU_PASSWORD_PATTERN.fullmatch(password) is None:
|
|
187
|
+
_LOGGER.error(
|
|
188
|
+
i18n.tr(
|
|
189
|
+
key="log.support.check_password.invalid_chars",
|
|
190
|
+
pattern=CCU_PASSWORD_PATTERN.pattern,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return False
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _create_tls_context(*, verify_tls: bool) -> ssl.SSLContext:
|
|
198
|
+
"""Create tls verified/unverified context."""
|
|
199
|
+
sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
200
|
+
if not verify_tls:
|
|
201
|
+
sslcontext.check_hostname = False
|
|
202
|
+
sslcontext.verify_mode = ssl.CERT_NONE
|
|
203
|
+
with contextlib.suppress(AttributeError):
|
|
204
|
+
# This only works for OpenSSL >= 1.0.0
|
|
205
|
+
sslcontext.options |= ssl.OP_NO_COMPRESSION
|
|
206
|
+
sslcontext.set_default_verify_paths()
|
|
207
|
+
return sslcontext
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_DEFAULT_NO_VERIFY_SSL_CONTEXT = _create_tls_context(verify_tls=False)
|
|
211
|
+
_DEFAULT_SSL_CONTEXT = _create_tls_context(verify_tls=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_tls_context(*, verify_tls: bool) -> ssl.SSLContext:
|
|
215
|
+
"""Return tls verified/unverified context."""
|
|
216
|
+
return _DEFAULT_SSL_CONTEXT if verify_tls else _DEFAULT_NO_VERIFY_SSL_CONTEXT
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_channel_address(*, device_address: str, channel_no: int | None) -> str:
|
|
220
|
+
"""Return the channel address."""
|
|
221
|
+
return device_address if channel_no is None else f"{device_address}:{channel_no}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_device_address(*, address: str) -> str:
|
|
225
|
+
"""Return the device part of an address."""
|
|
226
|
+
return get_split_channel_address(channel_address=address)[0]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_channel_no(*, address: str) -> int | None:
|
|
230
|
+
"""Return the channel part of an address."""
|
|
231
|
+
return get_split_channel_address(channel_address=address)[1]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def is_address(*, address: str) -> bool:
|
|
235
|
+
"""Check if it is a address."""
|
|
236
|
+
return is_device_address(address=address) or is_channel_address(address=address)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_channel_address(*, address: str) -> bool:
|
|
240
|
+
"""Check if it is a channel address."""
|
|
241
|
+
return CHANNEL_ADDRESS_PATTERN.match(address) is not None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def is_device_address(*, address: str) -> bool:
|
|
245
|
+
"""Check if it is a device address."""
|
|
246
|
+
return DEVICE_ADDRESS_PATTERN.match(address) is not None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_paramset_key(*, paramset_key: ParamsetKey | str) -> bool:
|
|
250
|
+
"""Check if it is a paramset key."""
|
|
251
|
+
return isinstance(paramset_key, ParamsetKey) or (isinstance(paramset_key, str) and paramset_key in ParamsetKey)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@lru_cache(maxsize=4096)
|
|
255
|
+
def get_split_channel_address(*, channel_address: str) -> tuple[str, int | None]:
|
|
256
|
+
"""
|
|
257
|
+
Return the device part of an address.
|
|
258
|
+
|
|
259
|
+
Cached to avoid redundant parsing across layers when repeatedly handling
|
|
260
|
+
the same channel addresses.
|
|
261
|
+
"""
|
|
262
|
+
if ADDRESS_SEPARATOR in channel_address:
|
|
263
|
+
device_address, channel_no = channel_address.split(ADDRESS_SEPARATOR)
|
|
264
|
+
if channel_no in (None, "None"):
|
|
265
|
+
return device_address, None
|
|
266
|
+
return device_address, int(channel_no)
|
|
267
|
+
return channel_address, None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def changed_within_seconds(*, last_change: datetime, max_age: int = MAX_CACHE_AGE) -> bool:
|
|
271
|
+
"""DataPoint has been modified within X minutes."""
|
|
272
|
+
if last_change == INIT_DATETIME:
|
|
273
|
+
return False
|
|
274
|
+
delta = datetime.now() - last_change
|
|
275
|
+
return delta.seconds < max_age
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def find_free_port() -> int:
|
|
279
|
+
"""Find a free port for XmlRpc server default port."""
|
|
280
|
+
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
|
|
281
|
+
sock.bind(("", 0))
|
|
282
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
283
|
+
return int(sock.getsockname()[1])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def get_ip_addr(*, host: str, port: int) -> str | None:
|
|
287
|
+
"""Get local_ip from socket using async DNS resolution."""
|
|
288
|
+
loop = asyncio.get_running_loop()
|
|
289
|
+
try:
|
|
290
|
+
# Async DNS resolution instead of blocking socket.gethostbyname()
|
|
291
|
+
await loop.getaddrinfo(host, port, family=socket.AF_INET)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
raise AioHomematicException(
|
|
294
|
+
i18n.tr(
|
|
295
|
+
key="exception.support.get_local_ip.resolve_failed",
|
|
296
|
+
host=host,
|
|
297
|
+
port=port,
|
|
298
|
+
reason=extract_exc_args(exc=exc),
|
|
299
|
+
)
|
|
300
|
+
) from exc
|
|
301
|
+
# UDP socket operations are non-blocking (just sets destination)
|
|
302
|
+
tmp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
303
|
+
tmp_socket.settimeout(TIMEOUT)
|
|
304
|
+
tmp_socket.connect((host, port))
|
|
305
|
+
local_ip = str(tmp_socket.getsockname()[0])
|
|
306
|
+
tmp_socket.close()
|
|
307
|
+
_LOGGER.debug("GET_LOCAL_IP: Got local ip: %s", local_ip)
|
|
308
|
+
return local_ip
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def is_host(*, host: str | None) -> bool:
|
|
312
|
+
"""Return True if host is valid."""
|
|
313
|
+
try:
|
|
314
|
+
validate_host(host=host)
|
|
315
|
+
except ValidationException:
|
|
316
|
+
return False
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def validate_host(*, host: str | None) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Validate host format (hostname or IP address).
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
host: The host to validate.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
ValidationException: If the host format is invalid.
|
|
329
|
+
|
|
330
|
+
"""
|
|
331
|
+
if not host or not host.strip():
|
|
332
|
+
raise ValidationException(i18n.tr(key="exception.support.host_empty"))
|
|
333
|
+
|
|
334
|
+
host_clean = host.strip()
|
|
335
|
+
|
|
336
|
+
# Check for valid hostname or IP
|
|
337
|
+
if not (HOSTNAME_PATTERN.match(host_clean) or IPV4_PATTERN.match(host_clean) or IPV6_PATTERN.match(host_clean)):
|
|
338
|
+
raise ValidationException(i18n.tr(key="exception.support.host_invalid", host=host))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def is_ipv4_address(*, address: str | None) -> bool:
|
|
342
|
+
"""Return True if ipv4_address is valid."""
|
|
343
|
+
if not address:
|
|
344
|
+
return False
|
|
345
|
+
try:
|
|
346
|
+
IPv4Address(address=address)
|
|
347
|
+
except ValueError:
|
|
348
|
+
return False
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def is_ipv6_address(*, address: str | None) -> bool:
|
|
353
|
+
"""Return True if ipv6_address is valid."""
|
|
354
|
+
if not address:
|
|
355
|
+
return False
|
|
356
|
+
try:
|
|
357
|
+
IPv6Address(address=address)
|
|
358
|
+
except ValueError:
|
|
359
|
+
return False
|
|
360
|
+
return True
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def is_port(*, port: int) -> bool:
|
|
364
|
+
"""Return True if port is valid."""
|
|
365
|
+
return 0 <= port <= 65535
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@lru_cache(maxsize=2048)
|
|
369
|
+
def _element_matches_key_cached(
|
|
370
|
+
*,
|
|
371
|
+
search_elements: tuple[str, ...] | str,
|
|
372
|
+
compare_with: str,
|
|
373
|
+
ignore_case: bool,
|
|
374
|
+
do_left_wildcard_search: bool,
|
|
375
|
+
do_right_wildcard_search: bool,
|
|
376
|
+
) -> bool:
|
|
377
|
+
"""Cache element matching for hashable inputs."""
|
|
378
|
+
compare_with_processed = compare_with.lower() if ignore_case else compare_with
|
|
379
|
+
|
|
380
|
+
if isinstance(search_elements, str):
|
|
381
|
+
element = search_elements.lower() if ignore_case else search_elements
|
|
382
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
383
|
+
return element in compare_with_processed
|
|
384
|
+
if do_left_wildcard_search:
|
|
385
|
+
return compare_with_processed.endswith(element)
|
|
386
|
+
if do_right_wildcard_search:
|
|
387
|
+
return compare_with_processed.startswith(element)
|
|
388
|
+
return compare_with_processed == element
|
|
389
|
+
|
|
390
|
+
# search_elements is a tuple
|
|
391
|
+
for item in search_elements:
|
|
392
|
+
element = item.lower() if ignore_case else item
|
|
393
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
394
|
+
if element in compare_with_processed:
|
|
395
|
+
return True
|
|
396
|
+
elif do_left_wildcard_search:
|
|
397
|
+
if compare_with_processed.endswith(element):
|
|
398
|
+
return True
|
|
399
|
+
elif do_right_wildcard_search:
|
|
400
|
+
if compare_with_processed.startswith(element):
|
|
401
|
+
return True
|
|
402
|
+
elif compare_with_processed == element:
|
|
403
|
+
return True
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def element_matches_key(
|
|
408
|
+
*,
|
|
409
|
+
search_elements: str | Collection[str],
|
|
410
|
+
compare_with: str | None,
|
|
411
|
+
search_key: str | None = None,
|
|
412
|
+
ignore_case: bool = True,
|
|
413
|
+
do_left_wildcard_search: bool = False,
|
|
414
|
+
do_right_wildcard_search: bool = True,
|
|
415
|
+
) -> bool:
|
|
416
|
+
"""
|
|
417
|
+
Return if collection element is key.
|
|
418
|
+
|
|
419
|
+
Default search uses a right wildcard.
|
|
420
|
+
A set search_key assumes that search_elements is initially a dict,
|
|
421
|
+
and it tries to identify a matching key (wildcard) in the dict keys to use it on the dict.
|
|
422
|
+
"""
|
|
423
|
+
if compare_with is None or not search_elements:
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
# Handle dict case with search_key
|
|
427
|
+
if isinstance(search_elements, dict) and search_key:
|
|
428
|
+
if match_key := _get_search_key(search_elements=search_elements, search_key=search_key):
|
|
429
|
+
if (elements := search_elements.get(match_key)) is None:
|
|
430
|
+
return False
|
|
431
|
+
search_elements = elements
|
|
432
|
+
else:
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
search_elements_hashable: str | Collection[str]
|
|
436
|
+
# Convert to hashable types for caching
|
|
437
|
+
if isinstance(search_elements, str):
|
|
438
|
+
search_elements_hashable = search_elements
|
|
439
|
+
elif isinstance(search_elements, (list, set)):
|
|
440
|
+
search_elements_hashable = tuple(search_elements)
|
|
441
|
+
elif isinstance(search_elements, tuple):
|
|
442
|
+
search_elements_hashable = search_elements
|
|
443
|
+
else:
|
|
444
|
+
# Fall back to non-cached version for other collection types
|
|
445
|
+
compare_with_processed = compare_with.lower() if ignore_case else compare_with
|
|
446
|
+
for item in search_elements:
|
|
447
|
+
element = item.lower() if ignore_case else item
|
|
448
|
+
if do_left_wildcard_search is True and do_right_wildcard_search is True:
|
|
449
|
+
if element in compare_with_processed:
|
|
450
|
+
return True
|
|
451
|
+
elif do_left_wildcard_search:
|
|
452
|
+
if compare_with_processed.endswith(element):
|
|
453
|
+
return True
|
|
454
|
+
elif do_right_wildcard_search:
|
|
455
|
+
if compare_with_processed.startswith(element):
|
|
456
|
+
return True
|
|
457
|
+
elif compare_with_processed == element:
|
|
458
|
+
return True
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
return _element_matches_key_cached(
|
|
462
|
+
search_elements=search_elements_hashable,
|
|
463
|
+
compare_with=compare_with,
|
|
464
|
+
ignore_case=ignore_case,
|
|
465
|
+
do_left_wildcard_search=do_left_wildcard_search,
|
|
466
|
+
do_right_wildcard_search=do_right_wildcard_search,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _get_search_key(*, search_elements: Collection[str], search_key: str) -> str | None:
|
|
471
|
+
"""Search for a matching key in a collection."""
|
|
472
|
+
for element in search_elements:
|
|
473
|
+
if search_key.startswith(element):
|
|
474
|
+
return element
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
479
|
+
class CacheEntry:
|
|
480
|
+
"""An entry for the value cache."""
|
|
481
|
+
|
|
482
|
+
value: Any
|
|
483
|
+
refresh_at: datetime
|
|
484
|
+
|
|
485
|
+
@staticmethod
|
|
486
|
+
def empty() -> CacheEntry:
|
|
487
|
+
"""Return empty cache entry."""
|
|
488
|
+
return CacheEntry(value=NO_CACHE_ENTRY, refresh_at=datetime.min)
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def is_valid(self) -> bool:
|
|
492
|
+
"""Return if entry is valid."""
|
|
493
|
+
if self.value == NO_CACHE_ENTRY:
|
|
494
|
+
return False
|
|
495
|
+
return changed_within_seconds(last_change=self.refresh_at)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def debug_enabled() -> bool:
|
|
499
|
+
"""Check if debug mode is enabled."""
|
|
500
|
+
try:
|
|
501
|
+
if sys.gettrace() is not None:
|
|
502
|
+
return True
|
|
503
|
+
except AttributeError:
|
|
504
|
+
pass
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
if sys.monitoring.get_tool(sys.monitoring.DEBUGGER_ID) is not None:
|
|
508
|
+
return True
|
|
509
|
+
except AttributeError:
|
|
510
|
+
pass
|
|
511
|
+
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def hash_sha256(*, value: Any) -> str:
|
|
516
|
+
"""
|
|
517
|
+
Hash a value with sha256.
|
|
518
|
+
|
|
519
|
+
Uses orjson to serialize the value with sorted keys for a fast and stable
|
|
520
|
+
representation. Falls back to the repr-based approach if
|
|
521
|
+
serialization fails (e.g., unsupported types).
|
|
522
|
+
"""
|
|
523
|
+
hasher = hashlib.sha256()
|
|
524
|
+
try:
|
|
525
|
+
data = orjson.dumps(value, option=orjson.OPT_SORT_KEYS | orjson.OPT_NON_STR_KEYS)
|
|
526
|
+
except Exception:
|
|
527
|
+
# Fallback: convert to a hashable representation and use repr()
|
|
528
|
+
data = repr(_make_value_hashable(value=value)).encode(encoding=UTF_8)
|
|
529
|
+
hasher.update(data)
|
|
530
|
+
return base64.b64encode(hasher.digest()).decode(encoding=UTF_8)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _make_value_hashable(*, value: Any) -> Any:
|
|
534
|
+
"""Make a hashable object."""
|
|
535
|
+
if isinstance(value, tuple | list):
|
|
536
|
+
return tuple(_make_value_hashable(value=e) for e in value)
|
|
537
|
+
|
|
538
|
+
if isinstance(value, dict):
|
|
539
|
+
return tuple(sorted((k, _make_value_hashable(value=v)) for k, v in value.items()))
|
|
540
|
+
|
|
541
|
+
if isinstance(value, set | frozenset):
|
|
542
|
+
return tuple(sorted(_make_value_hashable(value=e) for e in value))
|
|
543
|
+
|
|
544
|
+
return value
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def get_rx_modes(*, mode: int | None) -> tuple[RxMode, ...]:
|
|
548
|
+
"""Convert int to rx modes."""
|
|
549
|
+
if mode is None:
|
|
550
|
+
return ()
|
|
551
|
+
rx_modes: set[RxMode] = set()
|
|
552
|
+
if mode & RxMode.LAZY_CONFIG:
|
|
553
|
+
mode -= RxMode.LAZY_CONFIG
|
|
554
|
+
rx_modes.add(RxMode.LAZY_CONFIG)
|
|
555
|
+
if mode & RxMode.WAKEUP:
|
|
556
|
+
mode -= RxMode.WAKEUP
|
|
557
|
+
rx_modes.add(RxMode.WAKEUP)
|
|
558
|
+
if mode & RxMode.CONFIG:
|
|
559
|
+
mode -= RxMode.CONFIG
|
|
560
|
+
rx_modes.add(RxMode.CONFIG)
|
|
561
|
+
if mode & RxMode.BURST:
|
|
562
|
+
mode -= RxMode.BURST
|
|
563
|
+
rx_modes.add(RxMode.BURST)
|
|
564
|
+
if mode & RxMode.ALWAYS:
|
|
565
|
+
rx_modes.add(RxMode.ALWAYS)
|
|
566
|
+
return tuple(rx_modes)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def supports_rx_mode(*, command_rx_mode: CommandRxMode, rx_modes: tuple[RxMode, ...]) -> bool:
|
|
570
|
+
"""Check if rx mode is supported."""
|
|
571
|
+
return (command_rx_mode == CommandRxMode.BURST and RxMode.BURST in rx_modes) or (
|
|
572
|
+
command_rx_mode == CommandRxMode.WAKEUP and RxMode.WAKEUP in rx_modes
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def cleanup_text_from_html_tags(*, text: str) -> str:
|
|
577
|
+
"""Cleanup text from html tags."""
|
|
578
|
+
return re.sub(HTMLTAG_PATTERN, "", text)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def create_random_device_addresses(*, addresses: list[str]) -> dict[str, str]:
|
|
582
|
+
"""Create a random device address."""
|
|
583
|
+
return {adr: f"VCU{int(random.randint(1000000, 9999999))}" for adr in addresses}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# --- Structured error boundary logging helpers ---
|
|
587
|
+
|
|
588
|
+
_BOUNDARY_MSG = "error_boundary"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _safe_log_context(*, context: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
592
|
+
"""Extract safe context from a mapping."""
|
|
593
|
+
ctx: dict[str, Any] = {}
|
|
594
|
+
if not context:
|
|
595
|
+
return ctx
|
|
596
|
+
# Avoid logging potentially sensitive values by redacting common keys
|
|
597
|
+
redact_keys = {"password", "passwd", "pwd", "token", "authorization", "auth"}
|
|
598
|
+
for k, v in context.items():
|
|
599
|
+
if k.lower() in redact_keys:
|
|
600
|
+
ctx[k] = "***"
|
|
601
|
+
else:
|
|
602
|
+
# Ensure value is serializable / printable
|
|
603
|
+
try:
|
|
604
|
+
str(v)
|
|
605
|
+
ctx[k] = v
|
|
606
|
+
except Exception:
|
|
607
|
+
ctx[k] = repr(v)
|
|
608
|
+
return ctx
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def log_boundary_error(
|
|
612
|
+
*,
|
|
613
|
+
logger: logging.Logger,
|
|
614
|
+
boundary: str,
|
|
615
|
+
action: str,
|
|
616
|
+
err: Exception,
|
|
617
|
+
level: int | None = None,
|
|
618
|
+
log_context: Mapping[str, Any] | None = None,
|
|
619
|
+
message: str | None = None,
|
|
620
|
+
) -> None:
|
|
621
|
+
"""
|
|
622
|
+
Log a boundary error with the provided logger.
|
|
623
|
+
|
|
624
|
+
This function differentiates
|
|
625
|
+
between recoverable and non-recoverable domain errors to select an appropriate
|
|
626
|
+
logging level if not explicitly provided. Additionally, it enriches the log
|
|
627
|
+
record with extra context about the error and action boundaries.
|
|
628
|
+
|
|
629
|
+
"""
|
|
630
|
+
err_name = err.__class__.__name__
|
|
631
|
+
log_message = f"[boundary={boundary} action={action} err={err_name}"
|
|
632
|
+
|
|
633
|
+
if (err_args := extract_exc_args(exc=err)) and err_args != err_name:
|
|
634
|
+
log_message += f": {err_args}"
|
|
635
|
+
log_message += "]"
|
|
636
|
+
|
|
637
|
+
if message:
|
|
638
|
+
log_message += f" {message}"
|
|
639
|
+
|
|
640
|
+
if log_context:
|
|
641
|
+
log_message += f" ctx={orjson.dumps(_safe_log_context(context=log_context), option=orjson.OPT_SORT_KEYS).decode(encoding=UTF_8)}"
|
|
642
|
+
|
|
643
|
+
# Choose level if not provided:
|
|
644
|
+
if (chosen_level := level) is None:
|
|
645
|
+
# Use WARNING for expected/recoverable domain errors, ERROR otherwise.
|
|
646
|
+
chosen_level = logging.WARNING if isinstance(err, BaseHomematicException) else logging.ERROR
|
|
647
|
+
|
|
648
|
+
logger.log(chosen_level, log_message)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class LogContextMixin:
|
|
652
|
+
"""Mixin to add log context methods to class."""
|
|
653
|
+
|
|
654
|
+
__slots__ = ("_cached_log_context",)
|
|
655
|
+
|
|
656
|
+
@hm_property(cached=True)
|
|
657
|
+
def log_context(self) -> Mapping[str, Any]:
|
|
658
|
+
"""Return the log context for this object."""
|
|
659
|
+
return {
|
|
660
|
+
key: value for key, value in get_hm_property_by_log_context(data_object=self).items() if value is not None
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
class PayloadMixin:
|
|
665
|
+
"""Mixin to add payload methods to class."""
|
|
666
|
+
|
|
667
|
+
__slots__ = ()
|
|
668
|
+
|
|
669
|
+
@property
|
|
670
|
+
def config_payload(self) -> Mapping[str, Any]:
|
|
671
|
+
"""Return the config payload."""
|
|
672
|
+
return {
|
|
673
|
+
key: value
|
|
674
|
+
for key, value in get_hm_property_by_kind(data_object=self, kind=Kind.CONFIG).items()
|
|
675
|
+
if value is not None
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
@property
|
|
679
|
+
def info_payload(self) -> Mapping[str, Any]:
|
|
680
|
+
"""Return the info payload."""
|
|
681
|
+
return {
|
|
682
|
+
key: value
|
|
683
|
+
for key, value in get_hm_property_by_kind(data_object=self, kind=Kind.INFO).items()
|
|
684
|
+
if value is not None
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def state_payload(self) -> Mapping[str, Any]:
|
|
689
|
+
"""Return the state payload."""
|
|
690
|
+
return {
|
|
691
|
+
key: value
|
|
692
|
+
for key, value in get_hm_property_by_kind(data_object=self, kind=Kind.STATE).items()
|
|
693
|
+
if value is not None
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# Define public API for this module
|
|
698
|
+
__all__ = tuple(
|
|
699
|
+
sorted(
|
|
700
|
+
name
|
|
701
|
+
for name, obj in globals().items()
|
|
702
|
+
if not name.startswith("_")
|
|
703
|
+
and (inspect.isfunction(obj) or inspect.isclass(obj))
|
|
704
|
+
and getattr(obj, "__module__", __name__) == __name__
|
|
705
|
+
)
|
|
706
|
+
)
|