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.
Files changed (188) hide show
  1. aiohomematic/__init__.py +110 -0
  2. aiohomematic/_log_context_protocol.py +29 -0
  3. aiohomematic/api.py +410 -0
  4. aiohomematic/async_support.py +250 -0
  5. aiohomematic/backend_detection.py +462 -0
  6. aiohomematic/central/__init__.py +103 -0
  7. aiohomematic/central/async_rpc_server.py +760 -0
  8. aiohomematic/central/central_unit.py +1152 -0
  9. aiohomematic/central/config.py +463 -0
  10. aiohomematic/central/config_builder.py +772 -0
  11. aiohomematic/central/connection_state.py +160 -0
  12. aiohomematic/central/coordinators/__init__.py +38 -0
  13. aiohomematic/central/coordinators/cache.py +414 -0
  14. aiohomematic/central/coordinators/client.py +480 -0
  15. aiohomematic/central/coordinators/connection_recovery.py +1141 -0
  16. aiohomematic/central/coordinators/device.py +1166 -0
  17. aiohomematic/central/coordinators/event.py +514 -0
  18. aiohomematic/central/coordinators/hub.py +532 -0
  19. aiohomematic/central/decorators.py +184 -0
  20. aiohomematic/central/device_registry.py +229 -0
  21. aiohomematic/central/events/__init__.py +104 -0
  22. aiohomematic/central/events/bus.py +1392 -0
  23. aiohomematic/central/events/integration.py +424 -0
  24. aiohomematic/central/events/types.py +194 -0
  25. aiohomematic/central/health.py +762 -0
  26. aiohomematic/central/rpc_server.py +353 -0
  27. aiohomematic/central/scheduler.py +794 -0
  28. aiohomematic/central/state_machine.py +391 -0
  29. aiohomematic/client/__init__.py +203 -0
  30. aiohomematic/client/_rpc_errors.py +187 -0
  31. aiohomematic/client/backends/__init__.py +48 -0
  32. aiohomematic/client/backends/base.py +335 -0
  33. aiohomematic/client/backends/capabilities.py +138 -0
  34. aiohomematic/client/backends/ccu.py +487 -0
  35. aiohomematic/client/backends/factory.py +116 -0
  36. aiohomematic/client/backends/homegear.py +294 -0
  37. aiohomematic/client/backends/json_ccu.py +252 -0
  38. aiohomematic/client/backends/protocol.py +316 -0
  39. aiohomematic/client/ccu.py +1857 -0
  40. aiohomematic/client/circuit_breaker.py +459 -0
  41. aiohomematic/client/config.py +64 -0
  42. aiohomematic/client/handlers/__init__.py +40 -0
  43. aiohomematic/client/handlers/backup.py +157 -0
  44. aiohomematic/client/handlers/base.py +79 -0
  45. aiohomematic/client/handlers/device_ops.py +1085 -0
  46. aiohomematic/client/handlers/firmware.py +144 -0
  47. aiohomematic/client/handlers/link_mgmt.py +199 -0
  48. aiohomematic/client/handlers/metadata.py +436 -0
  49. aiohomematic/client/handlers/programs.py +144 -0
  50. aiohomematic/client/handlers/sysvars.py +100 -0
  51. aiohomematic/client/interface_client.py +1304 -0
  52. aiohomematic/client/json_rpc.py +2068 -0
  53. aiohomematic/client/request_coalescer.py +282 -0
  54. aiohomematic/client/rpc_proxy.py +629 -0
  55. aiohomematic/client/state_machine.py +324 -0
  56. aiohomematic/const.py +2207 -0
  57. aiohomematic/context.py +275 -0
  58. aiohomematic/converter.py +270 -0
  59. aiohomematic/decorators.py +390 -0
  60. aiohomematic/exceptions.py +185 -0
  61. aiohomematic/hmcli.py +997 -0
  62. aiohomematic/i18n.py +193 -0
  63. aiohomematic/interfaces/__init__.py +407 -0
  64. aiohomematic/interfaces/central.py +1067 -0
  65. aiohomematic/interfaces/client.py +1096 -0
  66. aiohomematic/interfaces/coordinators.py +63 -0
  67. aiohomematic/interfaces/model.py +1921 -0
  68. aiohomematic/interfaces/operations.py +217 -0
  69. aiohomematic/logging_context.py +134 -0
  70. aiohomematic/metrics/__init__.py +125 -0
  71. aiohomematic/metrics/_protocols.py +140 -0
  72. aiohomematic/metrics/aggregator.py +534 -0
  73. aiohomematic/metrics/dataclasses.py +489 -0
  74. aiohomematic/metrics/emitter.py +292 -0
  75. aiohomematic/metrics/events.py +183 -0
  76. aiohomematic/metrics/keys.py +300 -0
  77. aiohomematic/metrics/observer.py +563 -0
  78. aiohomematic/metrics/stats.py +172 -0
  79. aiohomematic/model/__init__.py +189 -0
  80. aiohomematic/model/availability.py +65 -0
  81. aiohomematic/model/calculated/__init__.py +89 -0
  82. aiohomematic/model/calculated/climate.py +276 -0
  83. aiohomematic/model/calculated/data_point.py +315 -0
  84. aiohomematic/model/calculated/field.py +147 -0
  85. aiohomematic/model/calculated/operating_voltage_level.py +286 -0
  86. aiohomematic/model/calculated/support.py +232 -0
  87. aiohomematic/model/custom/__init__.py +214 -0
  88. aiohomematic/model/custom/capabilities/__init__.py +67 -0
  89. aiohomematic/model/custom/capabilities/climate.py +41 -0
  90. aiohomematic/model/custom/capabilities/light.py +87 -0
  91. aiohomematic/model/custom/capabilities/lock.py +44 -0
  92. aiohomematic/model/custom/capabilities/siren.py +63 -0
  93. aiohomematic/model/custom/climate.py +1130 -0
  94. aiohomematic/model/custom/cover.py +722 -0
  95. aiohomematic/model/custom/data_point.py +360 -0
  96. aiohomematic/model/custom/definition.py +300 -0
  97. aiohomematic/model/custom/field.py +89 -0
  98. aiohomematic/model/custom/light.py +1174 -0
  99. aiohomematic/model/custom/lock.py +322 -0
  100. aiohomematic/model/custom/mixins.py +445 -0
  101. aiohomematic/model/custom/profile.py +945 -0
  102. aiohomematic/model/custom/registry.py +251 -0
  103. aiohomematic/model/custom/siren.py +462 -0
  104. aiohomematic/model/custom/switch.py +195 -0
  105. aiohomematic/model/custom/text_display.py +289 -0
  106. aiohomematic/model/custom/valve.py +78 -0
  107. aiohomematic/model/data_point.py +1416 -0
  108. aiohomematic/model/device.py +1840 -0
  109. aiohomematic/model/event.py +216 -0
  110. aiohomematic/model/generic/__init__.py +327 -0
  111. aiohomematic/model/generic/action.py +40 -0
  112. aiohomematic/model/generic/action_select.py +62 -0
  113. aiohomematic/model/generic/binary_sensor.py +30 -0
  114. aiohomematic/model/generic/button.py +31 -0
  115. aiohomematic/model/generic/data_point.py +177 -0
  116. aiohomematic/model/generic/dummy.py +150 -0
  117. aiohomematic/model/generic/number.py +76 -0
  118. aiohomematic/model/generic/select.py +56 -0
  119. aiohomematic/model/generic/sensor.py +76 -0
  120. aiohomematic/model/generic/switch.py +54 -0
  121. aiohomematic/model/generic/text.py +33 -0
  122. aiohomematic/model/hub/__init__.py +100 -0
  123. aiohomematic/model/hub/binary_sensor.py +24 -0
  124. aiohomematic/model/hub/button.py +28 -0
  125. aiohomematic/model/hub/connectivity.py +190 -0
  126. aiohomematic/model/hub/data_point.py +342 -0
  127. aiohomematic/model/hub/hub.py +864 -0
  128. aiohomematic/model/hub/inbox.py +135 -0
  129. aiohomematic/model/hub/install_mode.py +393 -0
  130. aiohomematic/model/hub/metrics.py +208 -0
  131. aiohomematic/model/hub/number.py +42 -0
  132. aiohomematic/model/hub/select.py +52 -0
  133. aiohomematic/model/hub/sensor.py +37 -0
  134. aiohomematic/model/hub/switch.py +43 -0
  135. aiohomematic/model/hub/text.py +30 -0
  136. aiohomematic/model/hub/update.py +221 -0
  137. aiohomematic/model/support.py +592 -0
  138. aiohomematic/model/update.py +140 -0
  139. aiohomematic/model/week_profile.py +1827 -0
  140. aiohomematic/property_decorators.py +719 -0
  141. aiohomematic/py.typed +0 -0
  142. aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
  143. aiohomematic/rega_scripts/create_backup_start.fn +28 -0
  144. aiohomematic/rega_scripts/create_backup_status.fn +89 -0
  145. aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
  146. aiohomematic/rega_scripts/get_backend_info.fn +25 -0
  147. aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
  148. aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
  149. aiohomematic/rega_scripts/get_serial.fn +44 -0
  150. aiohomematic/rega_scripts/get_service_messages.fn +83 -0
  151. aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
  152. aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
  153. aiohomematic/rega_scripts/set_program_state.fn +17 -0
  154. aiohomematic/rega_scripts/set_system_variable.fn +19 -0
  155. aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
  156. aiohomematic/schemas.py +256 -0
  157. aiohomematic/store/__init__.py +55 -0
  158. aiohomematic/store/dynamic/__init__.py +43 -0
  159. aiohomematic/store/dynamic/command.py +250 -0
  160. aiohomematic/store/dynamic/data.py +175 -0
  161. aiohomematic/store/dynamic/details.py +187 -0
  162. aiohomematic/store/dynamic/ping_pong.py +416 -0
  163. aiohomematic/store/persistent/__init__.py +71 -0
  164. aiohomematic/store/persistent/base.py +285 -0
  165. aiohomematic/store/persistent/device.py +233 -0
  166. aiohomematic/store/persistent/incident.py +380 -0
  167. aiohomematic/store/persistent/paramset.py +241 -0
  168. aiohomematic/store/persistent/session.py +556 -0
  169. aiohomematic/store/serialization.py +150 -0
  170. aiohomematic/store/storage.py +689 -0
  171. aiohomematic/store/types.py +526 -0
  172. aiohomematic/store/visibility/__init__.py +40 -0
  173. aiohomematic/store/visibility/parser.py +141 -0
  174. aiohomematic/store/visibility/registry.py +722 -0
  175. aiohomematic/store/visibility/rules.py +307 -0
  176. aiohomematic/strings.json +237 -0
  177. aiohomematic/support.py +706 -0
  178. aiohomematic/tracing.py +236 -0
  179. aiohomematic/translations/de.json +237 -0
  180. aiohomematic/translations/en.json +237 -0
  181. aiohomematic/type_aliases.py +51 -0
  182. aiohomematic/validator.py +128 -0
  183. aiohomematic-2026.1.29.dist-info/METADATA +296 -0
  184. aiohomematic-2026.1.29.dist-info/RECORD +188 -0
  185. aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
  186. aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
  187. aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
  188. aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
@@ -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
+ )