aiohomematic 2025.11.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aiohomematic might be problematic. Click here for more details.

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