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,275 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Context variables for request tracking and implicit context propagation.
5
+
6
+ This module provides context variables that flow through async call chains
7
+ without explicit parameter passing, enabling request correlation, tracing,
8
+ and cross-cutting concerns.
9
+
10
+ Key features:
11
+ - RequestContext for tracking operations with correlation IDs
12
+ - Automatic propagation through async call chains
13
+ - Context manager for scoped context setting
14
+ - Service call detection via is_in_service()
15
+
16
+ Example:
17
+ async with request_context(operation="set_value", device_address="ABC123"):
18
+ await device.send_value(...) # Context propagates automatically
19
+
20
+ # Access context anywhere in the call chain
21
+ ctx = get_request_context()
22
+ request_id = get_request_id()
23
+
24
+ Public API of this module is defined by __all__.
25
+
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from contextvars import ContextVar, Token
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime
33
+ from typing import Any
34
+ import uuid
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class RequestContext:
39
+ """
40
+ Context for a single request/operation.
41
+
42
+ Automatically propagates through async call chains via context variables.
43
+ Immutable to prevent accidental modification; use with_* methods to create
44
+ modified copies.
45
+
46
+ Attributes:
47
+ request_id: Unique identifier for this request (8 chars from UUID).
48
+ operation: Name of the operation being performed.
49
+ device_address: Address of the device being operated on, if applicable.
50
+ interface_id: ID of the interface being used, if applicable.
51
+ started_at: Timestamp when the request started.
52
+ extra: Additional context-specific attributes.
53
+
54
+ """
55
+
56
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
57
+ operation: str = ""
58
+ device_address: str | None = None
59
+ interface_id: str | None = None
60
+ started_at: datetime = field(default_factory=datetime.now)
61
+ extra: dict[str, Any] = field(default_factory=dict)
62
+
63
+ @property
64
+ def elapsed_ms(self) -> float:
65
+ """Return milliseconds since request started."""
66
+ return (datetime.now() - self.started_at).total_seconds() * 1000
67
+
68
+ def with_device(self, *, device_address: str) -> RequestContext:
69
+ """
70
+ Create new context with updated device address.
71
+
72
+ Args:
73
+ device_address: The device address to set.
74
+
75
+ Returns:
76
+ New RequestContext with updated device, preserving other fields.
77
+
78
+ """
79
+ return RequestContext(
80
+ request_id=self.request_id,
81
+ operation=self.operation,
82
+ device_address=device_address,
83
+ interface_id=self.interface_id,
84
+ started_at=self.started_at,
85
+ extra=self.extra,
86
+ )
87
+
88
+ def with_extra(self, **kwargs: Any) -> RequestContext:
89
+ """
90
+ Create new context with additional extra attributes.
91
+
92
+ Args:
93
+ **kwargs: Additional attributes to merge into extra.
94
+
95
+ Returns:
96
+ New RequestContext with merged extra attributes.
97
+
98
+ """
99
+ return RequestContext(
100
+ request_id=self.request_id,
101
+ operation=self.operation,
102
+ device_address=self.device_address,
103
+ interface_id=self.interface_id,
104
+ started_at=self.started_at,
105
+ extra={**self.extra, **kwargs},
106
+ )
107
+
108
+ def with_operation(self, *, operation: str) -> RequestContext:
109
+ """
110
+ Create new context with updated operation.
111
+
112
+ Args:
113
+ operation: The new operation name.
114
+
115
+ Returns:
116
+ New RequestContext with updated operation, preserving other fields.
117
+
118
+ """
119
+ return RequestContext(
120
+ request_id=self.request_id,
121
+ operation=operation,
122
+ device_address=self.device_address,
123
+ interface_id=self.interface_id,
124
+ started_at=self.started_at,
125
+ extra=self.extra,
126
+ )
127
+
128
+
129
+ # Context variable for request tracking
130
+ _request_context: ContextVar[RequestContext | None] = ContextVar(
131
+ "request_context",
132
+ default=None,
133
+ )
134
+
135
+
136
+ def get_request_context() -> RequestContext | None:
137
+ """
138
+ Get the current request context.
139
+
140
+ Returns:
141
+ The current RequestContext, or None if no context is set.
142
+
143
+ """
144
+ return _request_context.get()
145
+
146
+
147
+ def get_request_id() -> str:
148
+ """
149
+ Get the current request ID or a default value.
150
+
151
+ Returns:
152
+ The current request ID, or "anonymous" if no context is set.
153
+
154
+ """
155
+ ctx = _request_context.get()
156
+ return ctx.request_id if ctx else "anonymous"
157
+
158
+
159
+ class request_context: # noqa: N801
160
+ """
161
+ Context manager for request tracking.
162
+
163
+ Sets a RequestContext for the duration of the block, automatically
164
+ propagating through async call chains.
165
+
166
+ Example:
167
+ async with request_context(operation="set_value", device_address="ABC123"):
168
+ await device.send_value(...) # Context propagates automatically
169
+
170
+ # Synchronous usage also supported
171
+ with request_context(operation="validate"):
172
+ validate_config()
173
+
174
+ """
175
+
176
+ __slots__ = ("_ctx", "_token")
177
+
178
+ def __init__(
179
+ self,
180
+ *,
181
+ operation: str = "",
182
+ device_address: str | None = None,
183
+ interface_id: str | None = None,
184
+ **extra: Any,
185
+ ) -> None:
186
+ """
187
+ Initialize request context.
188
+
189
+ Args:
190
+ operation: Name of the operation being performed.
191
+ device_address: Address of the device being operated on.
192
+ interface_id: ID of the interface being used.
193
+ **extra: Additional context-specific attributes.
194
+
195
+ """
196
+ self._ctx = RequestContext(
197
+ operation=operation,
198
+ device_address=device_address,
199
+ interface_id=interface_id,
200
+ extra=extra,
201
+ )
202
+ self._token: Token[RequestContext | None] | None = None
203
+
204
+ async def __aenter__(self) -> RequestContext:
205
+ """Async enter - delegates to sync enter."""
206
+ return self.__enter__()
207
+
208
+ async def __aexit__(self, *args: object) -> None:
209
+ """Async exit - delegates to sync exit."""
210
+ self.__exit__()
211
+
212
+ def __enter__(self) -> RequestContext:
213
+ """Enter context and set the request context."""
214
+ self._token = _request_context.set(self._ctx)
215
+ return self._ctx
216
+
217
+ def __exit__(self, *args: object) -> None:
218
+ """Exit context and reset the request context."""
219
+ if self._token is not None:
220
+ _request_context.reset(self._token)
221
+
222
+
223
+ def set_request_context(*, ctx: RequestContext) -> Token[RequestContext | None]:
224
+ """
225
+ Manually set the request context.
226
+
227
+ Returns a token that must be used with reset_request_context().
228
+ Prefer using the request_context context manager instead.
229
+
230
+ Args:
231
+ ctx: The RequestContext to set.
232
+
233
+ Returns:
234
+ Token for resetting the context.
235
+
236
+ """
237
+ return _request_context.set(ctx)
238
+
239
+
240
+ def reset_request_context(*, token: Token[RequestContext | None]) -> None:
241
+ """
242
+ Reset the request context using a token from set_request_context().
243
+
244
+ Args:
245
+ token: Token returned from set_request_context().
246
+
247
+ """
248
+ _request_context.reset(token)
249
+
250
+
251
+ def is_in_service() -> bool:
252
+ """
253
+ Check if currently executing within a service call.
254
+
255
+ A service call is identified by having a RequestContext with an operation
256
+ that starts with "service:".
257
+
258
+ Returns:
259
+ True if currently inside a service call, False otherwise.
260
+
261
+ """
262
+ ctx = _request_context.get()
263
+ return ctx is not None and ctx.operation.startswith("service:")
264
+
265
+
266
+ # Define public API for this module
267
+ __all__ = [
268
+ "RequestContext",
269
+ "get_request_context",
270
+ "get_request_id",
271
+ "is_in_service",
272
+ "request_context",
273
+ "reset_request_context",
274
+ "set_request_context",
275
+ ]
@@ -0,0 +1,270 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Converters used by aiohomematic.
5
+
6
+ This module provides two categories of converters:
7
+
8
+ 1. General type converters using singledispatch:
9
+ - to_homematic_value: Convert Python types to Homematic-compatible values
10
+ - from_homematic_value: Convert Homematic values to Python types
11
+
12
+ 2. Combined parameter converters:
13
+ - convert_combined_parameter_to_paramset: Parse combined parameter strings
14
+ - convert_hm_level_to_cpv: Convert level to combined parameter value
15
+
16
+ The singledispatch converters are extensible - register new type handlers with:
17
+ @to_homematic_value.register(YourType)
18
+ def _(value: YourType) -> Any:
19
+ return converted_value
20
+
21
+ Public API of this module is defined by __all__.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ from datetime import datetime, timedelta
28
+ from enum import Enum
29
+ from functools import lru_cache, singledispatch
30
+ import inspect
31
+ import logging
32
+ from typing import Any, Final, cast
33
+
34
+ from aiohomematic.const import Parameter
35
+ from aiohomematic.support import extract_exc_args
36
+
37
+ _LOGGER = logging.getLogger(__name__)
38
+
39
+
40
+ # =============================================================================
41
+ # SINGLEDISPATCH CONVERTERS: Python → Homematic
42
+ # =============================================================================
43
+
44
+
45
+ @singledispatch
46
+ def to_homematic_value(value: Any) -> Any: # kwonly: disable
47
+ """
48
+ Convert Python values to Homematic-compatible values.
49
+
50
+ Uses singledispatch for type-based conversion. The function automatically
51
+ selects the appropriate converter based on the input type.
52
+
53
+ Default behavior (unregistered types):
54
+ Returns value unchanged.
55
+
56
+ Registered conversions:
57
+ - bool → int (True=1, False=0)
58
+ - float → float (rounded to 6 decimal places)
59
+ - datetime → str (ISO format)
60
+ - timedelta → float (total seconds)
61
+ - Enum → value attribute
62
+ - list → list (items converted recursively)
63
+ - dict → dict (values converted recursively)
64
+
65
+ Args:
66
+ value: Python value to convert.
67
+
68
+ Returns:
69
+ Homematic-compatible value.
70
+
71
+ Example:
72
+ >>> to_homematic_value(True)
73
+ 1
74
+ >>> to_homematic_value(3.14159265359)
75
+ 3.141593
76
+ >>> to_homematic_value(MyEnum.VALUE)
77
+ 'VALUE'
78
+
79
+ Extensibility:
80
+ Register handlers for custom types:
81
+
82
+ @to_homematic_value.register(Color)
83
+ def _(value: Color) -> int:
84
+ return (value.r << 16) | (value.g << 8) | value.b
85
+
86
+ """
87
+ return value
88
+
89
+
90
+ @to_homematic_value.register(bool)
91
+ def _to_hm_bool(value: bool) -> int: # kwonly: disable
92
+ """Convert boolean to Homematic integer (1/0)."""
93
+ return 1 if value else 0
94
+
95
+
96
+ @to_homematic_value.register(float)
97
+ def _to_hm_float(value: float) -> float: # kwonly: disable
98
+ """Convert float to Homematic float (6 decimal places max)."""
99
+ return round(value, 6)
100
+
101
+
102
+ @to_homematic_value.register(datetime)
103
+ def _to_hm_datetime(value: datetime) -> str: # kwonly: disable
104
+ """Convert datetime to ISO format string."""
105
+ return value.isoformat()
106
+
107
+
108
+ @to_homematic_value.register(timedelta)
109
+ def _to_hm_timedelta(value: timedelta) -> float: # kwonly: disable
110
+ """Convert timedelta to total seconds."""
111
+ return value.total_seconds()
112
+
113
+
114
+ @to_homematic_value.register(Enum)
115
+ def _to_hm_enum(value: Enum) -> Any: # kwonly: disable
116
+ """Convert Enum to its value."""
117
+ return value.value
118
+
119
+
120
+ @to_homematic_value.register(list)
121
+ def _to_hm_list(value: list[Any]) -> list[Any]: # kwonly: disable
122
+ """Convert list elements recursively."""
123
+ return [to_homematic_value(item) for item in value]
124
+
125
+
126
+ @to_homematic_value.register(dict)
127
+ def _to_hm_dict(value: dict[str, Any]) -> dict[str, Any]: # kwonly: disable
128
+ """Convert dict values recursively."""
129
+ return {k: to_homematic_value(v) for k, v in value.items()}
130
+
131
+
132
+ # =============================================================================
133
+ # SINGLEDISPATCH CONVERTERS: Homematic → Python
134
+ # =============================================================================
135
+
136
+
137
+ @singledispatch
138
+ def from_homematic_value(value: Any, *, target_type: type | None = None) -> Any: # kwonly: disable
139
+ """
140
+ Convert Homematic values to Python types.
141
+
142
+ Uses singledispatch for type-based conversion. Optionally converts
143
+ to a specific target type when provided.
144
+
145
+ Default behavior (unregistered types):
146
+ Returns value unchanged.
147
+
148
+ Registered conversions:
149
+ - int with target_type=bool → bool
150
+ - str with target_type=datetime → datetime (ISO parse)
151
+
152
+ Args:
153
+ value: Homematic value to convert.
154
+ target_type: Optional target Python type for conversion hint.
155
+
156
+ Returns:
157
+ Python value.
158
+
159
+ Example:
160
+ >>> from_homematic_value(1, target_type=bool)
161
+ True
162
+ >>> from_homematic_value("2025-01-15T10:30:00", target_type=datetime)
163
+ datetime(2025, 1, 15, 10, 30)
164
+
165
+ """
166
+ return value
167
+
168
+
169
+ @from_homematic_value.register(int)
170
+ def _from_hm_int(value: int, *, target_type: type | None = None) -> int | bool: # kwonly: disable
171
+ """Convert Homematic integer, optionally to bool."""
172
+ if target_type is bool:
173
+ return bool(value)
174
+ return value
175
+
176
+
177
+ @from_homematic_value.register(str)
178
+ def _from_hm_str(value: str, *, target_type: type | None = None) -> str | datetime: # kwonly: disable
179
+ """Convert Homematic string, optionally to datetime."""
180
+ if target_type is datetime:
181
+ return datetime.fromisoformat(value)
182
+ return value
183
+
184
+
185
+ @lru_cache(maxsize=1024)
186
+ def _convert_cpv_to_hm_level(*, value: Any) -> Any:
187
+ """Convert combined parameter value for hm level."""
188
+ if isinstance(value, str) and value.startswith("0x"):
189
+ return ast.literal_eval(value) / 100 / 2
190
+ return value
191
+
192
+
193
+ @lru_cache(maxsize=1024)
194
+ def _convert_cpv_to_hmip_level(*, value: Any) -> Any:
195
+ """Convert combined parameter value for hmip level."""
196
+ return int(value) / 100
197
+
198
+
199
+ @lru_cache(maxsize=1024)
200
+ def convert_hm_level_to_cpv(*, value: Any) -> Any:
201
+ """Convert hm level to combined parameter value."""
202
+ return format(int(value * 100 * 2), "#04x")
203
+
204
+
205
+ CONVERTABLE_PARAMETERS: Final = (Parameter.COMBINED_PARAMETER, Parameter.LEVEL_COMBINED)
206
+
207
+ _COMBINED_PARAMETER_TO_HM_CONVERTER: Final = {
208
+ Parameter.LEVEL_COMBINED: _convert_cpv_to_hm_level,
209
+ Parameter.LEVEL: _convert_cpv_to_hmip_level,
210
+ Parameter.LEVEL_2: _convert_cpv_to_hmip_level,
211
+ }
212
+
213
+ _COMBINED_PARAMETER_NAMES: Final = {"L": Parameter.LEVEL, "L2": Parameter.LEVEL_2}
214
+
215
+
216
+ @lru_cache(maxsize=1024)
217
+ def _convert_combined_parameter_to_paramset(*, value: str) -> dict[str, Any]:
218
+ """Convert combined parameter to paramset."""
219
+ paramset: dict[str, Any] = {}
220
+ for cp_param_value in value.split(","):
221
+ cp_param, value = cp_param_value.split("=")
222
+ if parameter := _COMBINED_PARAMETER_NAMES.get(cp_param):
223
+ if converter := _COMBINED_PARAMETER_TO_HM_CONVERTER.get(parameter):
224
+ paramset[parameter] = converter(value=value)
225
+ else:
226
+ paramset[parameter] = value
227
+ return paramset
228
+
229
+
230
+ @lru_cache(maxsize=1024)
231
+ def _convert_level_combined_to_paramset(*, value: str) -> dict[str, Any]:
232
+ """Convert combined parameter to paramset."""
233
+ if "," in value:
234
+ l1_value, l2_value = value.split(",")
235
+ if converter := _COMBINED_PARAMETER_TO_HM_CONVERTER.get(Parameter.LEVEL_COMBINED):
236
+ return {
237
+ Parameter.LEVEL: converter(value=l1_value),
238
+ Parameter.LEVEL_SLATS: converter(value=l2_value),
239
+ }
240
+ return {}
241
+
242
+
243
+ _COMBINED_PARAMETER_TO_PARAMSET_CONVERTER: Final = {
244
+ Parameter.COMBINED_PARAMETER: _convert_combined_parameter_to_paramset,
245
+ Parameter.LEVEL_COMBINED: _convert_level_combined_to_paramset,
246
+ }
247
+
248
+
249
+ @lru_cache(maxsize=1024)
250
+ def convert_combined_parameter_to_paramset(*, parameter: str, value: str) -> dict[str, Any]:
251
+ """Convert combined parameter to paramset."""
252
+ try:
253
+ if converter := _COMBINED_PARAMETER_TO_PARAMSET_CONVERTER.get(parameter): # type: ignore[call-overload]
254
+ return cast(dict[str, Any], converter(value=value))
255
+ _LOGGER.debug("CONVERT_COMBINED_PARAMETER_TO_PARAMSET: No converter found for %s: %s", parameter, value)
256
+ except Exception as exc:
257
+ _LOGGER.debug("CONVERT_COMBINED_PARAMETER_TO_PARAMSET: Convert failed %s", extract_exc_args(exc=exc))
258
+ return {}
259
+
260
+
261
+ # Define public API for this module
262
+ __all__ = tuple(
263
+ sorted(
264
+ name
265
+ for name, obj in globals().items()
266
+ if not name.startswith("_")
267
+ and (name.isupper() or inspect.isfunction(obj) or inspect.isclass(obj))
268
+ and getattr(obj, "__module__", __name__) == __name__
269
+ )
270
+ )