aiohomematic 2025.8.8__py3-none-any.whl → 2025.8.10__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (71) hide show
  1. aiohomematic/__init__.py +15 -1
  2. aiohomematic/async_support.py +15 -2
  3. aiohomematic/caches/__init__.py +2 -0
  4. aiohomematic/caches/dynamic.py +2 -0
  5. aiohomematic/caches/persistent.py +29 -22
  6. aiohomematic/caches/visibility.py +277 -252
  7. aiohomematic/central/__init__.py +69 -49
  8. aiohomematic/central/decorators.py +60 -15
  9. aiohomematic/central/xml_rpc_server.py +15 -1
  10. aiohomematic/client/__init__.py +2 -0
  11. aiohomematic/client/_rpc_errors.py +81 -0
  12. aiohomematic/client/json_rpc.py +68 -19
  13. aiohomematic/client/xml_rpc.py +15 -8
  14. aiohomematic/const.py +145 -77
  15. aiohomematic/context.py +11 -1
  16. aiohomematic/converter.py +27 -1
  17. aiohomematic/decorators.py +88 -19
  18. aiohomematic/exceptions.py +19 -1
  19. aiohomematic/hmcli.py +13 -1
  20. aiohomematic/model/__init__.py +2 -0
  21. aiohomematic/model/calculated/__init__.py +2 -0
  22. aiohomematic/model/calculated/climate.py +2 -0
  23. aiohomematic/model/calculated/data_point.py +7 -1
  24. aiohomematic/model/calculated/operating_voltage_level.py +2 -0
  25. aiohomematic/model/calculated/support.py +2 -0
  26. aiohomematic/model/custom/__init__.py +2 -0
  27. aiohomematic/model/custom/climate.py +3 -1
  28. aiohomematic/model/custom/const.py +2 -0
  29. aiohomematic/model/custom/cover.py +30 -2
  30. aiohomematic/model/custom/data_point.py +6 -0
  31. aiohomematic/model/custom/definition.py +2 -0
  32. aiohomematic/model/custom/light.py +18 -10
  33. aiohomematic/model/custom/lock.py +2 -0
  34. aiohomematic/model/custom/siren.py +5 -2
  35. aiohomematic/model/custom/support.py +2 -0
  36. aiohomematic/model/custom/switch.py +2 -0
  37. aiohomematic/model/custom/valve.py +2 -0
  38. aiohomematic/model/data_point.py +30 -3
  39. aiohomematic/model/decorators.py +29 -8
  40. aiohomematic/model/device.py +9 -5
  41. aiohomematic/model/event.py +2 -0
  42. aiohomematic/model/generic/__init__.py +2 -0
  43. aiohomematic/model/generic/action.py +2 -0
  44. aiohomematic/model/generic/binary_sensor.py +2 -0
  45. aiohomematic/model/generic/button.py +2 -0
  46. aiohomematic/model/generic/data_point.py +4 -1
  47. aiohomematic/model/generic/number.py +4 -1
  48. aiohomematic/model/generic/select.py +4 -1
  49. aiohomematic/model/generic/sensor.py +2 -0
  50. aiohomematic/model/generic/switch.py +2 -0
  51. aiohomematic/model/generic/text.py +2 -0
  52. aiohomematic/model/hub/__init__.py +2 -0
  53. aiohomematic/model/hub/binary_sensor.py +2 -0
  54. aiohomematic/model/hub/button.py +2 -0
  55. aiohomematic/model/hub/data_point.py +6 -0
  56. aiohomematic/model/hub/number.py +2 -0
  57. aiohomematic/model/hub/select.py +2 -0
  58. aiohomematic/model/hub/sensor.py +2 -0
  59. aiohomematic/model/hub/switch.py +2 -0
  60. aiohomematic/model/hub/text.py +2 -0
  61. aiohomematic/model/support.py +26 -1
  62. aiohomematic/model/update.py +6 -0
  63. aiohomematic/support.py +175 -5
  64. aiohomematic/validator.py +49 -2
  65. aiohomematic-2025.8.10.dist-info/METADATA +124 -0
  66. aiohomematic-2025.8.10.dist-info/RECORD +78 -0
  67. {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/licenses/LICENSE +1 -1
  68. aiohomematic-2025.8.8.dist-info/METADATA +0 -69
  69. aiohomematic-2025.8.8.dist-info/RECORD +0 -77
  70. {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/WHEEL +0 -0
  71. {aiohomematic-2025.8.8.dist-info → aiohomematic-2025.8.10.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  Asynchronous JSON-RPC client for HomeMatic CCU-compatible backends.
3
5
 
@@ -29,6 +31,7 @@ Notes
29
31
 
30
32
  from __future__ import annotations
31
33
 
34
+ import asyncio
32
35
  from asyncio import Semaphore
33
36
  from collections.abc import Mapping
34
37
  from datetime import datetime
@@ -54,6 +57,7 @@ import orjson
54
57
 
55
58
  from aiohomematic import central as hmcu
56
59
  from aiohomematic.async_support import Looper
60
+ from aiohomematic.client._rpc_errors import RpcContext, map_jsonrpc_error
57
61
  from aiohomematic.const import (
58
62
  ALWAYS_ENABLE_SYSVARS_BY_ID,
59
63
  DEFAULT_INCLUDE_INTERNAL_PROGRAMS,
@@ -78,7 +82,6 @@ from aiohomematic.const import (
78
82
  SysvarType,
79
83
  )
80
84
  from aiohomematic.exceptions import (
81
- AuthFailure,
82
85
  BaseHomematicException,
83
86
  ClientException,
84
87
  InternalBackendException,
@@ -91,6 +94,7 @@ from aiohomematic.support import (
91
94
  element_matches_key,
92
95
  extract_exc_args,
93
96
  get_tls_context,
97
+ log_boundary_error,
94
98
  parse_sys_var,
95
99
  )
96
100
 
@@ -402,38 +406,59 @@ class JsonRpcAioHttpClient:
402
406
  )
403
407
  if method in _PARALLEL_EXECUTION_LIMITED_JSONRPC_METHODS:
404
408
  async with self._sema:
405
- if (response := await post_call()) is None:
409
+ if (response := await asyncio.shield(post_call())) is None:
406
410
  raise ClientException("POST method failed with no response")
407
- elif (response := await post_call()) is None:
411
+ elif (response := await asyncio.shield(post_call())) is None:
408
412
  raise ClientException("POST method failed with no response")
409
413
 
410
414
  if response.status == 200:
411
- json_response = await self._get_json_reponse(response=response)
415
+ json_response = await asyncio.shield(self._get_json_reponse(response=response))
412
416
 
413
417
  if error := json_response[_JsonKey.ERROR]:
414
- error_message = error[_JsonKey.MESSAGE]
415
- message = f"POST method '{method}' failed: {error_message}"
416
- if error_message.startswith("access denied"):
417
- _LOGGER.debug(message)
418
- raise AuthFailure(message)
419
- if "internal error" in error_message:
420
- message = f"An internal error happened within your backend (Fix or ignore it): {message}"
421
- _LOGGER.debug(message)
422
- raise InternalBackendException(message)
423
- _LOGGER.debug(message)
424
- raise ClientException(message)
418
+ # Map JSON-RPC error to actionable exception with context
419
+ ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
420
+ exc = map_jsonrpc_error(error=error, ctx=ctx)
421
+ # Structured boundary log at warning level (recoverable per-call failure)
422
+ log_boundary_error(
423
+ logger=_LOGGER,
424
+ boundary="json-rpc",
425
+ action=str(method),
426
+ err=exc,
427
+ level=logging.WARNING,
428
+ context={"url": self._url},
429
+ )
430
+ _LOGGER.debug("POST: %s", exc)
431
+ raise exc
425
432
 
426
433
  return json_response
427
434
 
428
435
  message = f"Status: {response.status}"
429
- json_response = await self._get_json_reponse(response=response)
436
+ json_response = await asyncio.shield(self._get_json_reponse(response=response))
430
437
  if error := json_response[_JsonKey.ERROR]:
431
- error_message = error[_JsonKey.MESSAGE]
432
- message = f"{message}: {error_message}"
438
+ ctx = RpcContext(protocol="json-rpc", method=str(method), host=self._url)
439
+ exc = map_jsonrpc_error(error=error, ctx=ctx)
440
+ log_boundary_error(
441
+ logger=_LOGGER,
442
+ boundary="json-rpc",
443
+ action=str(method),
444
+ err=exc,
445
+ level=logging.WARNING,
446
+ context={"url": self._url, "status": response.status},
447
+ )
448
+ raise exc
433
449
  raise ClientException(message)
434
- except BaseHomematicException:
450
+ except BaseHomematicException as bhe:
435
451
  if method in (_JsonRpcMethod.SESSION_LOGIN, _JsonRpcMethod.SESSION_LOGOUT, _JsonRpcMethod.SESSION_RENEW):
436
452
  self.clear_session()
453
+ # Domain error at boundary -> warning
454
+ log_boundary_error(
455
+ logger=_LOGGER,
456
+ boundary="json-rpc",
457
+ action=str(method),
458
+ err=bhe,
459
+ level=logging.WARNING,
460
+ context={"url": self._url},
461
+ )
437
462
  raise
438
463
  except ClientConnectorCertificateError as cccerr:
439
464
  self.clear_session()
@@ -443,12 +468,36 @@ class JsonRpcAioHttpClient:
443
468
  f"{message}. Possible reason: 'Automatic forwarding to HTTPS' is enabled in backend, "
444
469
  f"but this integration is not configured to use TLS"
445
470
  )
471
+ log_boundary_error(
472
+ logger=_LOGGER,
473
+ boundary="json-rpc",
474
+ action=str(method),
475
+ err=cccerr,
476
+ level=logging.ERROR,
477
+ context={"url": self._url},
478
+ )
446
479
  raise ClientException(message) from cccerr
447
480
  except (ClientError, OSError) as err:
448
481
  self.clear_session()
482
+ log_boundary_error(
483
+ logger=_LOGGER,
484
+ boundary="json-rpc",
485
+ action=str(method),
486
+ err=err,
487
+ level=logging.ERROR,
488
+ context={"url": self._url},
489
+ )
449
490
  raise NoConnectionException(err) from err
450
491
  except (TypeError, Exception) as exc:
451
492
  self.clear_session()
493
+ log_boundary_error(
494
+ logger=_LOGGER,
495
+ boundary="json-rpc",
496
+ action=str(method),
497
+ err=exc,
498
+ level=logging.ERROR,
499
+ context={"url": self._url},
500
+ )
452
501
  raise ClientException(exc) from exc
453
502
 
454
503
  async def _get_json_reponse(self, response: ClientResponse) -> dict[str, Any] | Any:
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
1
3
  """
2
4
  XML-RPC transport proxy with concurrency control and connection awareness.
3
5
 
@@ -19,6 +21,7 @@ Notes
19
21
 
20
22
  from __future__ import annotations
21
23
 
24
+ import asyncio
22
25
  from collections.abc import Mapping
23
26
  from concurrent.futures import ThreadPoolExecutor
24
27
  from enum import Enum, IntEnum, StrEnum
@@ -30,6 +33,7 @@ import xmlrpc.client
30
33
 
31
34
  from aiohomematic import central as hmcu
32
35
  from aiohomematic.async_support import Looper
36
+ from aiohomematic.client._rpc_errors import RpcContext, map_xmlrpc_fault
33
37
  from aiohomematic.const import ISO_8859_1
34
38
  from aiohomematic.exceptions import (
35
39
  AuthFailure,
@@ -134,13 +138,15 @@ class XmlRpcProxy(xmlrpc.client.ServerProxy):
134
138
  ):
135
139
  args = _cleanup_args(*args)
136
140
  _LOGGER.debug("__ASYNC_REQUEST: %s", args)
137
- result = await self._looper.async_add_executor_job(
138
- # pylint: disable=protected-access
139
- parent._ServerProxy__request, # type: ignore[attr-defined]
140
- self,
141
- *args,
142
- name="xmp_rpc_proxy",
143
- executor=self._proxy_executor,
141
+ result = await asyncio.shield(
142
+ self._looper.async_add_executor_job(
143
+ # pylint: disable=protected-access
144
+ parent._ServerProxy__request, # type: ignore[attr-defined]
145
+ self,
146
+ *args,
147
+ name="xmp_rpc_proxy",
148
+ executor=self._proxy_executor,
149
+ )
144
150
  )
145
151
  self._connection_state.remove_issue(issuer=self, iid=self.interface_id)
146
152
  return result
@@ -165,7 +171,8 @@ class XmlRpcProxy(xmlrpc.client.ServerProxy):
165
171
  _LOGGER.error(message)
166
172
  raise NoConnectionException(message) from oserr
167
173
  except xmlrpc.client.Fault as flt:
168
- raise ClientException(f"XMLRPC Fault from backend: {flt.faultCode} {flt.faultString}") from flt
174
+ ctx = RpcContext(protocol="xml-rpc", method=str(args[0]), interface=self.interface_id)
175
+ raise map_xmlrpc_fault(code=flt.faultCode, fault_string=flt.faultString, ctx=ctx) from flt
169
176
  except TypeError as terr:
170
177
  raise ClientException(terr) from terr
171
178
  except xmlrpc.client.ProtocolError as perr:
aiohomematic/const.py CHANGED
@@ -1,4 +1,10 @@
1
- """Constants used by aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Constants used by aiohomematic.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -6,12 +12,14 @@ from collections.abc import Callable, Iterable, Mapping
6
12
  from dataclasses import dataclass, field
7
13
  from datetime import datetime
8
14
  from enum import Enum, IntEnum, StrEnum
15
+ import inspect
9
16
  import os
10
17
  import re
11
18
  import sys
12
- from typing import Any, Final, NamedTuple, Required, TypedDict
19
+ from types import MappingProxyType
20
+ from typing import Any, Final, NamedTuple, Required, TypeAlias, TypedDict
13
21
 
14
- VERSION: Final = "2025.8.8"
22
+ VERSION: Final = "2025.8.10"
15
23
 
16
24
  # Detect test speedup mode via environment
17
25
  _TEST_SPEEDUP: Final = (
@@ -24,7 +32,7 @@ DEFAULT_ENABLE_DEVICE_FIRMWARE_CHECK: Final = False
24
32
  DEFAULT_ENABLE_PROGRAM_SCAN: Final = True
25
33
  DEFAULT_ENABLE_SYSVAR_SCAN: Final = True
26
34
  DEFAULT_HM_MASTER_POLL_AFTER_SEND_INTERVALS: Final = (5,)
27
- DEFAULT_IGNORE_CUSTOM_DEVICE_DEFINITION_MODELS: Final[tuple[str, ...]] = ()
35
+ DEFAULT_IGNORE_CUSTOM_DEVICE_DEFINITION_MODELS: Final[frozenset[str]] = frozenset()
28
36
  DEFAULT_INCLUDE_INTERNAL_PROGRAMS: Final = False
29
37
  DEFAULT_INCLUDE_INTERNAL_SYSVARS: Final = True
30
38
  DEFAULT_MAX_READ_WORKERS: Final = 1
@@ -35,7 +43,7 @@ DEFAULT_PROGRAM_MARKERS: Final[tuple[DescriptionMarker | str, ...]] = ()
35
43
  DEFAULT_SYSVAR_MARKERS: Final[tuple[DescriptionMarker | str, ...]] = ()
36
44
  DEFAULT_SYS_SCAN_INTERVAL: Final = 30
37
45
  DEFAULT_TLS: Final = False
38
- DEFAULT_UN_IGNORES: Final[tuple[str, ...]] = ()
46
+ DEFAULT_UN_IGNORES: Final[frozenset[str]] = frozenset()
39
47
  DEFAULT_VERIFY_TLS: Final = False
40
48
 
41
49
  # Default encoding for json service calls, persistent cache
@@ -52,22 +60,25 @@ CHANNEL_ADDRESS_PATTERN: Final = re.compile(r"^[0-9a-zA-Z-]{5,20}:[0-9]{1,3}$")
52
60
  DEVICE_ADDRESS_PATTERN: Final = re.compile(r"^[0-9a-zA-Z-]{5,20}$")
53
61
  ALLOWED_HOSTNAME_PATTERN: Final = re.compile(r"(?!-)[a-z0-9-]{1,63}(?<!-)$", re.IGNORECASE)
54
62
  HTMLTAG_PATTERN: Final = re.compile(r"<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
55
- SCHEDULER_PROFILE_PATTERN = re.compile(
63
+ SCHEDULER_PROFILE_PATTERN: Final = re.compile(
56
64
  r"^P[1-6]_(ENDTIME|TEMPERATURE)_(MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FRIDAY|SATURDAY|SUNDAY)_([1-9]|1[0-3])$"
57
65
  )
58
- SCHEDULER_TIME_PATTERN = re.compile(r"^(([0-1]{0,1}[0-9])|(2[0-4])):[0-5][0-9]")
59
-
60
- ALWAYS_ENABLE_SYSVARS_BY_ID: Final = "40", "41"
61
- RENAME_SYSVAR_BY_NAME: Final = {
62
- "${sysVarAlarmMessages}": "ALARM_MESSAGES",
63
- "${sysVarPresence}": "PRESENCE",
64
- "${sysVarServiceMessages}": "SERVICE_MESSAGES",
65
- }
66
+ SCHEDULER_TIME_PATTERN: Final = re.compile(r"^(([0-1]{0,1}[0-9])|(2[0-4])):[0-5][0-9]")
67
+
68
+ ALWAYS_ENABLE_SYSVARS_BY_ID: Final[frozenset[str]] = frozenset({"40", "41"})
69
+ RENAME_SYSVAR_BY_NAME: Final[Mapping[str, str]] = MappingProxyType(
70
+ {
71
+ "${sysVarAlarmMessages}": "ALARM_MESSAGES",
72
+ "${sysVarPresence}": "PRESENCE",
73
+ "${sysVarServiceMessages}": "SERVICE_MESSAGES",
74
+ }
75
+ )
66
76
 
67
- SYSVAR_ENABLE_DEFAULT: Final = "40", "41"
77
+ # Deprecated alias (use ALWAYS_ENABLE_SYSVARS_BY_ID). Kept for backward compatibility.
78
+ SYSVAR_ENABLE_DEFAULT: Final[frozenset[str]] = ALWAYS_ENABLE_SYSVARS_BY_ID
68
79
 
69
80
  ADDRESS_SEPARATOR: Final = ":"
70
- BLOCK_LOG_TIMEOUT = 60
81
+ BLOCK_LOG_TIMEOUT: Final = 60
71
82
  CACHE_PATH: Final = "cache"
72
83
  CONF_PASSWORD: Final = "password"
73
84
  CONF_USERNAME: Final = "username"
@@ -79,7 +90,7 @@ DEVICE_DESCRIPTIONS_DIR: Final = "export_device_descriptions"
79
90
  DEVICE_FIRMWARE_CHECK_INTERVAL: Final = 21600 # 6h
80
91
  DEVICE_FIRMWARE_DELIVERING_CHECK_INTERVAL: Final = 3600 # 1h
81
92
  DEVICE_FIRMWARE_UPDATING_CHECK_INTERVAL: Final = 300 # 5m
82
- DUMMY_SERIAL = "SN0815"
93
+ DUMMY_SERIAL: Final = "SN0815"
83
94
  FILE_DEVICES: Final = "homematic_devices.json"
84
95
  FILE_PARAMSETS: Final = "homematic_paramsets.json"
85
96
  HUB_PATH: Final = "hub"
@@ -87,7 +98,7 @@ IDENTIFIER_SEPARATOR: Final = "@"
87
98
  INIT_DATETIME: Final = datetime.strptime("01.01.1970 00:00:00", DATETIME_FORMAT)
88
99
  IP_ANY_V4: Final = "0.0.0.0"
89
100
  JSON_SESSION_AGE: Final = 90
90
- KWARGS_ARG_DATA_POINT = "data_point"
101
+ KWARGS_ARG_DATA_POINT: Final = "data_point"
91
102
  LAST_COMMAND_SEND_STORE_TIMEOUT: Final = 60
92
103
  LOCAL_HOST: Final = "127.0.0.1"
93
104
  MAX_CACHE_AGE: Final = 10
@@ -113,7 +124,7 @@ WAIT_FOR_CALLBACK: Final[int | None] = None
113
124
  SCHEDULER_NOT_STARTED_SLEEP: Final = 0.05 if _TEST_SPEEDUP else 10
114
125
  SCHEDULER_LOOP_SLEEP: Final = 0.05 if _TEST_SPEEDUP else 5
115
126
 
116
- CALLBACK_WARN_INTERVAL = CONNECTION_CHECKER_INTERVAL * 40
127
+ CALLBACK_WARN_INTERVAL: Final = CONNECTION_CHECKER_INTERVAL * 40
117
128
 
118
129
  # Path
119
130
  PROGRAM_SET_PATH_ROOT: Final = "program/set"
@@ -125,7 +136,7 @@ SYSVAR_STATE_PATH_ROOT: Final = "sysvar/status"
125
136
  VIRTDEV_SET_PATH_ROOT: Final = "virtdev/set"
126
137
  VIRTDEV_STATE_PATH_ROOT: Final = "virtdev/status"
127
138
 
128
- CALLBACK_TYPE = Callable[[], None] | None
139
+ CALLBACK_TYPE: TypeAlias = Callable[[], None] | None
129
140
 
130
141
 
131
142
  class Backend(StrEnum):
@@ -158,6 +169,17 @@ class CallSource(StrEnum):
158
169
  MANUAL_OR_SCHEDULED = "manual_or_scheduled"
159
170
 
160
171
 
172
+ class CentralUnitState(StrEnum):
173
+ """Enum with central unit states."""
174
+
175
+ INITIALIZING = "initializing"
176
+ NEW = "new"
177
+ RUNNING = "running"
178
+ STOPPED = "stopped"
179
+ STOPPED_BY_ERROR = "stopped_by_error"
180
+ STOPPING = "stopping"
181
+
182
+
161
183
  class DataOperationResult(Enum):
162
184
  """Enum with data operation results."""
163
185
 
@@ -536,22 +558,26 @@ class ParameterType(StrEnum):
536
558
  EMPTY = ""
537
559
 
538
560
 
539
- CLICK_EVENTS: Final[tuple[Parameter, ...]] = (
540
- Parameter.PRESS,
541
- Parameter.PRESS_CONT,
542
- Parameter.PRESS_LOCK,
543
- Parameter.PRESS_LONG,
544
- Parameter.PRESS_LONG_RELEASE,
545
- Parameter.PRESS_LONG_START,
546
- Parameter.PRESS_SHORT,
547
- Parameter.PRESS_UNLOCK,
561
+ CLICK_EVENTS: Final[frozenset[Parameter]] = frozenset(
562
+ {
563
+ Parameter.PRESS,
564
+ Parameter.PRESS_CONT,
565
+ Parameter.PRESS_LOCK,
566
+ Parameter.PRESS_LONG,
567
+ Parameter.PRESS_LONG_RELEASE,
568
+ Parameter.PRESS_LONG_START,
569
+ Parameter.PRESS_SHORT,
570
+ Parameter.PRESS_UNLOCK,
571
+ }
548
572
  )
549
573
 
550
574
  DEVICE_ERROR_EVENTS: Final[tuple[Parameter, ...]] = (Parameter.ERROR, Parameter.SENSOR_ERROR)
551
575
 
552
- DATA_POINT_EVENTS: Final[tuple[EventType, ...]] = (
553
- EventType.IMPULSE,
554
- EventType.KEYPRESS,
576
+ DATA_POINT_EVENTS: Final[frozenset[EventType]] = frozenset(
577
+ {
578
+ EventType.IMPULSE,
579
+ EventType.KEYPRESS,
580
+ }
555
581
  )
556
582
 
557
583
 
@@ -567,26 +593,34 @@ class DataPointKey(NamedTuple):
567
593
  type DP_KEY_VALUE = tuple[DataPointKey, Any]
568
594
  type SYSVAR_TYPE = bool | float | int | str | None
569
595
 
570
- HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES: Final[tuple[DeviceFirmwareState, ...]] = (
571
- DeviceFirmwareState.DO_UPDATE_PENDING,
572
- DeviceFirmwareState.PERFORMING_UPDATE,
596
+ HMIP_FIRMWARE_UPDATE_IN_PROGRESS_STATES: Final[frozenset[DeviceFirmwareState]] = frozenset(
597
+ {
598
+ DeviceFirmwareState.DO_UPDATE_PENDING,
599
+ DeviceFirmwareState.PERFORMING_UPDATE,
600
+ }
573
601
  )
574
602
 
575
- HMIP_FIRMWARE_UPDATE_READY_STATES: Final[tuple[DeviceFirmwareState, ...]] = (
576
- DeviceFirmwareState.READY_FOR_UPDATE,
577
- DeviceFirmwareState.DO_UPDATE_PENDING,
578
- DeviceFirmwareState.PERFORMING_UPDATE,
603
+ HMIP_FIRMWARE_UPDATE_READY_STATES: Final[frozenset[DeviceFirmwareState]] = frozenset(
604
+ {
605
+ DeviceFirmwareState.READY_FOR_UPDATE,
606
+ DeviceFirmwareState.DO_UPDATE_PENDING,
607
+ DeviceFirmwareState.PERFORMING_UPDATE,
608
+ }
579
609
  )
580
610
 
581
- IMPULSE_EVENTS: Final[tuple[Parameter, ...]] = (Parameter.SEQUENCE_OK,)
611
+ IMPULSE_EVENTS: Final[frozenset[Parameter]] = frozenset({Parameter.SEQUENCE_OK})
582
612
 
583
- KEY_CHANNEL_OPERATION_MODE_VISIBILITY: Final[Mapping[str, tuple[str, ...]]] = {
584
- Parameter.STATE: ("BINARY_BEHAVIOR",),
585
- Parameter.PRESS_LONG: ("KEY_BEHAVIOR", "SWITCH_BEHAVIOR"),
586
- Parameter.PRESS_LONG_RELEASE: ("KEY_BEHAVIOR", "SWITCH_BEHAVIOR"),
587
- Parameter.PRESS_LONG_START: ("KEY_BEHAVIOR", "SWITCH_BEHAVIOR"),
588
- Parameter.PRESS_SHORT: ("KEY_BEHAVIOR", "SWITCH_BEHAVIOR"),
589
- }
613
+ KEY_CHANNEL_OPERATION_MODE_VISIBILITY: Final[Mapping[str, frozenset[str]]] = MappingProxyType(
614
+ {
615
+ Parameter.STATE: frozenset({"BINARY_BEHAVIOR"}),
616
+ Parameter.PRESS_LONG: frozenset({"KEY_BEHAVIOR", "SWITCH_BEHAVIOR"}),
617
+ Parameter.PRESS_LONG_RELEASE: frozenset({"KEY_BEHAVIOR", "SWITCH_BEHAVIOR"}),
618
+ Parameter.PRESS_LONG_START: frozenset({"KEY_BEHAVIOR", "SWITCH_BEHAVIOR"}),
619
+ Parameter.PRESS_SHORT: frozenset({"KEY_BEHAVIOR", "SWITCH_BEHAVIOR"}),
620
+ }
621
+ )
622
+
623
+ BLOCKED_CATEGORIES: Final[tuple[DataPointCategory, ...]] = (DataPointCategory.ACTION,)
590
624
 
591
625
  HUB_CATEGORIES: Final[tuple[DataPointCategory, ...]] = (
592
626
  DataPointCategory.HUB_BINARY_SENSOR,
@@ -616,42 +650,54 @@ CATEGORIES: Final[tuple[DataPointCategory, ...]] = (
616
650
  DataPointCategory.VALVE,
617
651
  )
618
652
 
619
- PRIMARY_CLIENT_CANDIDATE_INTERFACES: Final = (
620
- Interface.HMIP_RF,
621
- Interface.BIDCOS_RF,
622
- Interface.BIDCOS_WIRED,
653
+ PRIMARY_CLIENT_CANDIDATE_INTERFACES: Final[frozenset[Interface]] = frozenset(
654
+ {
655
+ Interface.HMIP_RF,
656
+ Interface.BIDCOS_RF,
657
+ Interface.BIDCOS_WIRED,
658
+ }
623
659
  )
624
660
 
625
- RELEVANT_INIT_PARAMETERS: Final[tuple[Parameter, ...]] = (
626
- Parameter.CONFIG_PENDING,
627
- Parameter.STICKY_UN_REACH,
628
- Parameter.UN_REACH,
661
+ RELEVANT_INIT_PARAMETERS: Final[frozenset[Parameter]] = frozenset(
662
+ {
663
+ Parameter.CONFIG_PENDING,
664
+ Parameter.STICKY_UN_REACH,
665
+ Parameter.UN_REACH,
666
+ }
629
667
  )
630
668
 
631
- INTERFACES_SUPPORTING_FIRMWARE_UPDATES: Final[tuple[Interface, ...]] = (
632
- Interface.BIDCOS_RF,
633
- Interface.BIDCOS_WIRED,
634
- Interface.HMIP_RF,
669
+ INTERFACES_SUPPORTING_FIRMWARE_UPDATES: Final[frozenset[Interface]] = frozenset(
670
+ {
671
+ Interface.BIDCOS_RF,
672
+ Interface.BIDCOS_WIRED,
673
+ Interface.HMIP_RF,
674
+ }
635
675
  )
636
676
 
637
- INTERFACES_SUPPORTING_XML_RPC: Final[tuple[Interface, ...]] = (
638
- Interface.BIDCOS_RF,
639
- Interface.BIDCOS_WIRED,
640
- Interface.HMIP_RF,
641
- Interface.VIRTUAL_DEVICES,
677
+ INTERFACES_SUPPORTING_XML_RPC: Final[frozenset[Interface]] = frozenset(
678
+ {
679
+ Interface.BIDCOS_RF,
680
+ Interface.BIDCOS_WIRED,
681
+ Interface.HMIP_RF,
682
+ Interface.VIRTUAL_DEVICES,
683
+ }
642
684
  )
643
685
 
644
- INTERFACES_REQUIRING_PERIODIC_REFRESH: Final[tuple[Interface, ...]] = (
645
- Interface.CCU_JACK,
646
- Interface.CUXD,
686
+ INTERFACES_REQUIRING_PERIODIC_REFRESH: Final[frozenset[Interface]] = frozenset(
687
+ {
688
+ Interface.CCU_JACK,
689
+ Interface.CUXD,
690
+ }
647
691
  )
648
692
 
649
693
  DEFAULT_USE_PERIODIC_SCAN_FOR_INTERFACES: Final = True
650
694
 
651
- IGNORE_FOR_UN_IGNORE_PARAMETERS: Final[tuple[Parameter, ...]] = (
652
- Parameter.CONFIG_PENDING,
653
- Parameter.STICKY_UN_REACH,
654
- Parameter.UN_REACH,
695
+ IGNORE_FOR_UN_IGNORE_PARAMETERS: Final[frozenset[Parameter]] = frozenset(
696
+ {
697
+ Parameter.CONFIG_PENDING,
698
+ Parameter.STICKY_UN_REACH,
699
+ Parameter.UN_REACH,
700
+ }
655
701
  )
656
702
 
657
703
 
@@ -659,12 +705,14 @@ IGNORE_FOR_UN_IGNORE_PARAMETERS: Final[tuple[Parameter, ...]] = (
659
705
  _IGNORE_ON_INITIAL_LOAD_PARAMETERS_END_RE: Final = re.compile(r".*(_ERROR)$")
660
706
  # Ignore Parameter on initial load that start with
661
707
  _IGNORE_ON_INITIAL_LOAD_PARAMETERS_START_RE: Final = re.compile(r"^(ERROR_|RSSI_)")
662
- _IGNORE_ON_INITIAL_LOAD_PARAMETERS: Final = (
663
- Parameter.DUTY_CYCLE,
664
- Parameter.DUTYCYCLE,
665
- Parameter.LOW_BAT,
666
- Parameter.LOWBAT,
667
- Parameter.OPERATING_VOLTAGE,
708
+ _IGNORE_ON_INITIAL_LOAD_PARAMETERS: Final[frozenset[Parameter]] = frozenset(
709
+ {
710
+ Parameter.DUTY_CYCLE,
711
+ Parameter.DUTYCYCLE,
712
+ Parameter.LOW_BAT,
713
+ Parameter.LOWBAT,
714
+ Parameter.OPERATING_VOLTAGE,
715
+ }
668
716
  )
669
717
 
670
718
 
@@ -793,3 +841,23 @@ class DeviceDescription(TypedDict, total=False):
793
841
  INTERFACE: str | None
794
842
  # ROAMING: int | None
795
843
  RX_MODE: int
844
+
845
+
846
+ # Define public API for this module
847
+ __all__ = tuple(
848
+ sorted(
849
+ name
850
+ for name, obj in globals().items()
851
+ if not name.startswith("_")
852
+ and (
853
+ name.isupper() # constants like VERSION, patterns, defaults
854
+ or inspect.isclass(obj) # Enums, dataclasses, TypedDicts, NamedTuple classes
855
+ or inspect.isfunction(obj) # module functions
856
+ )
857
+ and (
858
+ getattr(obj, "__module__", __name__) == __name__
859
+ if not isinstance(obj, (int, float, str, bytes, tuple, frozenset, dict))
860
+ else True
861
+ )
862
+ )
863
+ )
aiohomematic/context.py CHANGED
@@ -1,4 +1,10 @@
1
- """Collection of context variables."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Collection of context variables.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
@@ -6,3 +12,7 @@ from contextvars import ContextVar
6
12
 
7
13
  # context var for storing if call is running within a service
8
14
  IN_SERVICE_VAR: ContextVar[bool] = ContextVar("in_service_var", default=False)
15
+
16
+
17
+ # Define public API for this module
18
+ __all__ = ["IN_SERVICE_VAR"]
aiohomematic/converter.py CHANGED
@@ -1,8 +1,16 @@
1
- """Converters used by aiohomematic."""
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025 Daniel Perna, SukramJ
3
+ """
4
+ Converters used by aiohomematic.
5
+
6
+ Public API of this module is defined by __all__.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
11
  import ast
12
+ from functools import lru_cache
13
+ import inspect
6
14
  import logging
7
15
  from typing import Any, Final, cast
8
16
 
@@ -12,6 +20,7 @@ from aiohomematic.support import extract_exc_args
12
20
  _LOGGER = logging.getLogger(__name__)
13
21
 
14
22
 
23
+ @lru_cache(maxsize=1024)
15
24
  def _convert_cpv_to_hm_level(cpv: Any) -> Any:
16
25
  """Convert combined parameter value for hm level."""
17
26
  if isinstance(cpv, str) and cpv.startswith("0x"):
@@ -19,11 +28,13 @@ def _convert_cpv_to_hm_level(cpv: Any) -> Any:
19
28
  return cpv
20
29
 
21
30
 
31
+ @lru_cache(maxsize=1024)
22
32
  def _convert_cpv_to_hmip_level(cpv: Any) -> Any:
23
33
  """Convert combined parameter value for hmip level."""
24
34
  return int(cpv) / 100
25
35
 
26
36
 
37
+ @lru_cache(maxsize=1024)
27
38
  def convert_hm_level_to_cpv(hm_level: Any) -> Any:
28
39
  """Convert hm level to combined parameter value."""
29
40
  return format(int(hm_level * 100 * 2), "#04x")
@@ -40,6 +51,7 @@ _COMBINED_PARAMETER_TO_HM_CONVERTER: Final = {
40
51
  _COMBINED_PARAMETER_NAMES: Final = {"L": Parameter.LEVEL, "L2": Parameter.LEVEL_2}
41
52
 
42
53
 
54
+ @lru_cache(maxsize=1024)
43
55
  def _convert_combined_parameter_to_paramset(cpv: str) -> dict[str, Any]:
44
56
  """Convert combined parameter to paramset."""
45
57
  paramset: dict[str, Any] = {}
@@ -53,6 +65,7 @@ def _convert_combined_parameter_to_paramset(cpv: str) -> dict[str, Any]:
53
65
  return paramset
54
66
 
55
67
 
68
+ @lru_cache(maxsize=1024)
56
69
  def _convert_level_combined_to_paramset(lcv: str) -> dict[str, Any]:
57
70
  """Convert combined parameter to paramset."""
58
71
  if "," in lcv:
@@ -71,6 +84,7 @@ _COMBINED_PARAMETER_TO_PARAMSET_CONVERTER: Final = {
71
84
  }
72
85
 
73
86
 
87
+ @lru_cache(maxsize=1024)
74
88
  def convert_combined_parameter_to_paramset(parameter: str, cpv: str) -> dict[str, Any]:
75
89
  """Convert combined parameter to paramset."""
76
90
  try:
@@ -80,3 +94,15 @@ def convert_combined_parameter_to_paramset(parameter: str, cpv: str) -> dict[str
80
94
  except Exception as exc:
81
95
  _LOGGER.debug("CONVERT_COMBINED_PARAMETER_TO_PARAMSET: Convert failed %s", extract_exc_args(exc=exc))
82
96
  return {}
97
+
98
+
99
+ # Define public API for this module
100
+ __all__ = tuple(
101
+ sorted(
102
+ name
103
+ for name, obj in globals().items()
104
+ if not name.startswith("_")
105
+ and (name.isupper() or inspect.isfunction(obj) or inspect.isclass(obj))
106
+ and getattr(obj, "__module__", __name__) == __name__
107
+ )
108
+ )