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,67 @@
1
+ !# name: trigger_firmware_update.fn
2
+ !#
3
+ !# This script triggers an unattended firmware update.
4
+ !# Only supported on OpenCCU (uses checkFirmwareUpdate.sh).
5
+ !#
6
+ !# The script validates:
7
+ !# 1. checkFirmwareUpdate.sh exists and is executable
8
+ !# 2. Script supports required flags (-a, -r) via -h output
9
+ !#
10
+ !# Then runs the update with nohup in background:
11
+ !# -a = apply update (download and stage)
12
+ !# -r = reboot immediately after staging
13
+ !#
14
+ !# Note: Backup (-b) is NOT used here. Use create_backup_and_download()
15
+ !# before triggering the firmware update to download a backup.
16
+ !#
17
+ !# The script is started with nohup to ensure it continues running
18
+ !# even if the connection is lost during the update/reboot process.
19
+ !#
20
+
21
+ string sResult = "";
22
+ boolean bScriptAvailable = false;
23
+ boolean bSuccess = false;
24
+ string sMessage = "";
25
+
26
+ !# Check if checkFirmwareUpdate.sh is available and supports required flags
27
+ system.Exec("test -x /bin/checkFirmwareUpdate.sh && /bin/checkFirmwareUpdate.sh -h 2>&1", &sResult);
28
+ if (sResult) {
29
+ !# Verify script supports required flags: -a (apply), -r (reboot)
30
+ boolean bHasApply = (sResult.Find("-a") >= 0);
31
+ boolean bHasReboot = (sResult.Find("-r") >= 0);
32
+
33
+ if (bHasApply && bHasReboot) {
34
+ bScriptAvailable = true;
35
+ }
36
+ }
37
+
38
+ if (bScriptAvailable) {
39
+ !# Save system state before update
40
+ system.Save();
41
+
42
+ !# Run checkFirmwareUpdate.sh with nohup in background
43
+ !# -a = apply update (download and stage)
44
+ !# -r = reboot immediately after staging
45
+ !# Note: Use create_backup_and_download() before this for backup
46
+ sResult = "";
47
+ system.Exec("nohup /bin/checkFirmwareUpdate.sh -a -r >/dev/null 2>&1 &", &sResult);
48
+
49
+ bSuccess = true;
50
+ sMessage = "Firmware update triggered, system will reboot when ready";
51
+ } else {
52
+ sMessage = "checkFirmwareUpdate.sh not available or missing required flags (only supported on OpenCCU)";
53
+ }
54
+
55
+ Write('{"success":');
56
+ if (bSuccess) {
57
+ Write('true,');
58
+ } else {
59
+ Write('false,');
60
+ }
61
+ Write('"script_available":');
62
+ if (bScriptAvailable) {
63
+ Write('true,');
64
+ } else {
65
+ Write('false,');
66
+ }
67
+ Write('"message":"' # sMessage # '"}');
@@ -0,0 +1,256 @@
1
+ """
2
+ Validation and normalization schemas for API data structures.
3
+
4
+ Uses voluptuous to validate and normalize data received from Homematic backends.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING, Any, Final
11
+
12
+ import voluptuous as vol
13
+
14
+ if TYPE_CHECKING:
15
+ from aiohomematic.const import DeviceDescription, ParameterData
16
+
17
+ _LOGGER: Final = logging.getLogger(__name__)
18
+
19
+ # ============================================================================
20
+ # DeviceDescription Schema
21
+ # ============================================================================
22
+
23
+
24
+ def _normalize_children(*, value: Any) -> list[str]:
25
+ """Normalize CHILDREN field to always be a list."""
26
+ if value is None:
27
+ return []
28
+ if isinstance(value, str):
29
+ return [] if value == "" else [value]
30
+ if isinstance(value, (list, tuple)):
31
+ return list(value)
32
+ return []
33
+
34
+
35
+ def _normalize_paramsets(*, value: Any) -> list[str]:
36
+ """Normalize PARAMSETS field to always be a list."""
37
+ if value is None:
38
+ return ["MASTER", "VALUES"]
39
+ if isinstance(value, (list, tuple)):
40
+ return list(value)
41
+ return ["MASTER", "VALUES"]
42
+
43
+
44
+ DEVICE_DESCRIPTION_SCHEMA = vol.Schema(
45
+ {
46
+ # Required fields per API spec
47
+ vol.Required("TYPE"): vol.Coerce(str),
48
+ vol.Required("ADDRESS"): vol.Coerce(str),
49
+ vol.Required("PARAMSETS", default=["MASTER", "VALUES"]): lambda x: _normalize_paramsets(value=x),
50
+ # Optional fields with normalization
51
+ vol.Optional("CHILDREN", default=[]): lambda x: _normalize_children(value=x),
52
+ vol.Optional("PARENT"): vol.Any(None, str),
53
+ vol.Optional("PARENT_TYPE"): vol.Any(None, str),
54
+ vol.Optional("SUBTYPE"): vol.Any(None, str),
55
+ vol.Optional("FIRMWARE"): vol.Any(None, str),
56
+ vol.Optional("AVAILABLE_FIRMWARE"): vol.Any(None, str),
57
+ vol.Optional("UPDATABLE"): vol.Coerce(bool),
58
+ vol.Optional("FIRMWARE_UPDATE_STATE"): vol.Any(None, str),
59
+ vol.Optional("FIRMWARE_UPDATABLE"): vol.Any(None, bool),
60
+ vol.Optional("INTERFACE"): vol.Any(None, str),
61
+ # Per API spec: RX_MODE is Integer (bitmask)
62
+ vol.Optional("RX_MODE"): vol.Any(None, vol.Coerce(int)),
63
+ vol.Optional("LINK_SOURCE_ROLES"): vol.Any(None, str),
64
+ vol.Optional("LINK_TARGET_ROLES"): vol.Any(None, str),
65
+ # Additional fields from spec (currently commented in const.py)
66
+ vol.Optional("RF_ADDRESS"): vol.Any(None, vol.Coerce(int)),
67
+ vol.Optional("INDEX"): vol.Any(None, vol.Coerce(int)),
68
+ vol.Optional("AES_ACTIVE"): vol.Any(None, vol.Coerce(int)),
69
+ vol.Optional("VERSION"): vol.Any(None, vol.Coerce(int)),
70
+ vol.Optional("FLAGS"): vol.Any(None, vol.Coerce(int)),
71
+ vol.Optional("DIRECTION"): vol.Any(None, vol.Coerce(int)),
72
+ vol.Optional("GROUP"): vol.Any(None, str),
73
+ vol.Optional("TEAM"): vol.Any(None, str),
74
+ vol.Optional("TEAM_TAG"): vol.Any(None, str),
75
+ vol.Optional("TEAM_CHANNELS"): vol.Any(None, list),
76
+ vol.Optional("ROAMING"): vol.Any(None, vol.Coerce(int)),
77
+ },
78
+ extra=vol.ALLOW_EXTRA, # Allow backend-specific extra fields
79
+ )
80
+
81
+
82
+ # ============================================================================
83
+ # ParameterData Schema (ParameterDescription in API)
84
+ # ============================================================================
85
+
86
+ # Parameter TYPE values per API spec
87
+ VALID_PARAMETER_TYPES = {
88
+ "FLOAT",
89
+ "INTEGER",
90
+ "BOOL",
91
+ "ENUM",
92
+ "STRING",
93
+ "ACTION",
94
+ # Additional types found in practice
95
+ "DUMMY",
96
+ "",
97
+ }
98
+
99
+
100
+ def _normalize_parameter_type(*, value: Any) -> str:
101
+ """Normalize and validate parameter TYPE field."""
102
+ if value is None:
103
+ return ""
104
+ str_val = str(value).upper()
105
+ return str_val if str_val in VALID_PARAMETER_TYPES else ""
106
+
107
+
108
+ def _normalize_operations(*, value: Any) -> int:
109
+ """Normalize OPERATIONS to integer bitmask."""
110
+ if value is None:
111
+ return 0
112
+ try:
113
+ return int(value)
114
+ except (ValueError, TypeError):
115
+ return 0
116
+
117
+
118
+ def _normalize_flags(*, value: Any) -> int:
119
+ """Normalize FLAGS to integer bitmask."""
120
+ if value is None:
121
+ return 0
122
+ try:
123
+ return int(value)
124
+ except (ValueError, TypeError):
125
+ return 0
126
+
127
+
128
+ def _normalize_value_list(*, value: Any) -> list[str]:
129
+ """Normalize VALUE_LIST to list of strings."""
130
+ if value is None:
131
+ return []
132
+ if not isinstance(value, (list, tuple)):
133
+ return []
134
+ # Convert all items to strings
135
+ return [str(item) for item in value]
136
+
137
+
138
+ PARAMETER_DATA_SCHEMA = vol.Schema(
139
+ {
140
+ vol.Optional("TYPE"): lambda x: _normalize_parameter_type(value=x),
141
+ vol.Optional("OPERATIONS", default=0): lambda x: _normalize_operations(value=x),
142
+ vol.Optional("FLAGS", default=0): lambda x: _normalize_flags(value=x),
143
+ vol.Optional("DEFAULT"): vol.Any(None, str, int, float, bool),
144
+ vol.Optional("MAX"): vol.Any(None, str, int, float),
145
+ vol.Optional("MIN"): vol.Any(None, str, int, float),
146
+ vol.Optional("UNIT"): vol.Any(None, str),
147
+ vol.Optional("ID"): vol.Any(None, str),
148
+ # Per API spec: TAB_ORDER is Integer (display ordering)
149
+ vol.Optional("TAB_ORDER"): vol.Any(None, vol.Coerce(int)),
150
+ # Per API spec: CONTROL is String (UI hint)
151
+ vol.Optional("CONTROL"): vol.Any(None, str),
152
+ # Per API spec: VALUE_LIST is Array of String (for ENUM type)
153
+ vol.Optional("VALUE_LIST", default=[]): lambda x: _normalize_value_list(value=x),
154
+ # Per API spec: SPECIAL is Array of Struct {ID: String, VALUE: <TYPE>}
155
+ # In practice: Dict with special value IDs as keys (e.g., {"NOT_USED": 111600.0})
156
+ vol.Optional("SPECIAL"): vol.Any(None, list, dict),
157
+ },
158
+ extra=vol.ALLOW_EXTRA,
159
+ )
160
+
161
+
162
+ # ============================================================================
163
+ # ParamsetDescription Schema
164
+ # ============================================================================
165
+
166
+
167
+ def normalize_paramset_description(
168
+ *,
169
+ paramset: dict[str, Any] | None,
170
+ ) -> dict[str, ParameterData]:
171
+ """
172
+ Normalize a paramset description dict.
173
+
174
+ A ParamsetDescription is a Struct where each key is a parameter name
175
+ and each value is a ParameterDescription (ParameterData).
176
+ """
177
+ if paramset is None:
178
+ return {}
179
+ result: dict[str, ParameterData] = {}
180
+ for param_name, param_data in paramset.items():
181
+ try:
182
+ result[param_name] = PARAMETER_DATA_SCHEMA(param_data)
183
+ except vol.Invalid as err:
184
+ # Log validation failures for debugging
185
+ _LOGGER.debug(
186
+ "Parameter validation failed for %s: %s. Using raw data.",
187
+ param_name,
188
+ err,
189
+ )
190
+ # Keep original data if validation fails
191
+ result[param_name] = param_data
192
+ return result
193
+
194
+
195
+ # ============================================================================
196
+ # Public API
197
+ # ============================================================================
198
+
199
+
200
+ def normalize_device_description(*, device_description: dict[str, Any] | DeviceDescription) -> DeviceDescription:
201
+ """
202
+ Normalize a device description dict.
203
+
204
+ Should be called at all ingestion points:
205
+ - After receiving from list_devices()
206
+ - After receiving from get_device_description()
207
+ - After receiving from newDevices() callback
208
+ - After loading from cache
209
+
210
+ Args:
211
+ device_description: Raw device description from backend or cache.
212
+
213
+ Returns:
214
+ Normalized DeviceDescription dict with guaranteed field types.
215
+
216
+ """
217
+ try:
218
+ return dict(DEVICE_DESCRIPTION_SCHEMA(device_description)) # type: ignore[return-value]
219
+ except vol.Invalid as err:
220
+ # Log validation failures for debugging
221
+ address = device_description.get("ADDRESS", "UNKNOWN")
222
+ _LOGGER.debug(
223
+ "Device description validation failed for %s: %s. Applying fallback normalization.",
224
+ address,
225
+ err,
226
+ )
227
+ # On validation failure, at minimum ensure CHILDREN is a list
228
+ result = dict(device_description)
229
+ children = result.get("CHILDREN")
230
+ if children is None or isinstance(children, str):
231
+ result["CHILDREN"] = []
232
+ return result # type: ignore[return-value]
233
+
234
+
235
+ def normalize_parameter_data(*, parameter_data: dict[str, Any]) -> ParameterData:
236
+ """
237
+ Normalize a parameter data dict (ParameterDescription).
238
+
239
+ Args:
240
+ parameter_data: Raw parameter data from backend or cache.
241
+
242
+ Returns:
243
+ Normalized ParameterData dict with guaranteed field types.
244
+
245
+ """
246
+ try:
247
+ return dict(PARAMETER_DATA_SCHEMA(parameter_data)) # type: ignore[return-value]
248
+ except vol.Invalid as err:
249
+ # Log validation failures for debugging
250
+ param_id = parameter_data.get("ID", "UNKNOWN")
251
+ _LOGGER.debug(
252
+ "Parameter data validation failed for %s: %s. Using raw data.",
253
+ param_id,
254
+ err,
255
+ )
256
+ return dict(parameter_data) # type: ignore[return-value]
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Store packages for AioHomematic.
5
+
6
+ This package groups store implementations used throughout the library:
7
+ - persistent: Long-lived on-disk registries for device and paramset descriptions.
8
+ - dynamic: Short-lived in-memory caches/trackers for runtime values and connection health.
9
+ - visibility: Parameter visibility rules to decide which parameters are relevant.
10
+ - storage: Abstraction layer for file persistence with factory pattern.
11
+
12
+ Package structure
13
+ -----------------
14
+ - storage.py: Storage abstraction with factory pattern for HA Store integration
15
+ - persistent/: DeviceDescriptionRegistry, ParamsetDescriptionRegistry, SessionRecorder
16
+ - dynamic/: CommandCache, DeviceDetailsCache, CentralDataCache, PingPongTracker
17
+ - visibility/: ParameterVisibilityRegistry
18
+ - types.py: Shared type definitions (CachedCommand, PongTracker, type aliases)
19
+ - serialization.py: Freeze/unfreeze utilities for session recording
20
+
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from aiohomematic.store.serialization import cleanup_params_for_session, freeze_params, unfreeze_params
26
+ from aiohomematic.store.storage import (
27
+ LocalStorageFactory,
28
+ MigrateFunc,
29
+ Storage,
30
+ StorageError,
31
+ StorageFactoryProtocol,
32
+ StorageProtocol,
33
+ )
34
+ from aiohomematic.store.types import CacheName, CacheStatistics, IncidentSeverity, IncidentSnapshot, IncidentType
35
+
36
+ __all__ = [
37
+ # Cache
38
+ "CacheName",
39
+ "CacheStatistics",
40
+ # Incident types
41
+ "IncidentSeverity",
42
+ "IncidentSnapshot",
43
+ "IncidentType",
44
+ # Serialization
45
+ "cleanup_params_for_session",
46
+ "freeze_params",
47
+ "unfreeze_params",
48
+ # Storage abstraction
49
+ "LocalStorageFactory",
50
+ "MigrateFunc",
51
+ "Storage",
52
+ "StorageError",
53
+ "StorageFactoryProtocol",
54
+ "StorageProtocol",
55
+ ]
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Dynamic store used at runtime by the central unit and clients.
5
+
6
+ This package provides short-lived, in-memory stores that support robust and efficient
7
+ communication with Homematic interfaces.
8
+
9
+ Package structure
10
+ -----------------
11
+ - command: CommandTracker for tracking sent commands
12
+ - details: DeviceDetailsCache for device metadata
13
+ - data: CentralDataCache for parameter values
14
+ - ping_pong: PingPongTracker for connection health monitoring
15
+
16
+ Key behaviors
17
+ -------------
18
+ - Stores are intentionally ephemeral and cleared/aged according to rules
19
+ - Memory footprint is kept predictable while improving responsiveness
20
+
21
+ Public API
22
+ ----------
23
+ - CommandTracker: Tracks recently sent commands per data point
24
+ - DeviceDetailsCache: Device names, rooms, functions, interfaces
25
+ - CentralDataCache: Stores recently fetched parameter values
26
+ - PingPongTracker: Connection health monitoring via ping/pong
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from aiohomematic.store.dynamic.command import CommandTracker
32
+ from aiohomematic.store.dynamic.data import CentralDataCache
33
+ from aiohomematic.store.dynamic.details import DeviceDetailsCache
34
+ from aiohomematic.store.dynamic.ping_pong import PingPongTracker
35
+
36
+ __all__ = [
37
+ # Caches
38
+ "CentralDataCache",
39
+ "DeviceDetailsCache",
40
+ # Trackers
41
+ "CommandTracker",
42
+ "PingPongTracker",
43
+ ]
@@ -0,0 +1,250 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2026
3
+ """
4
+ Command tracker for tracking recently sent commands.
5
+
6
+ This module provides CommandTracker which tracks recently sent commands per data point
7
+ with automatic expiry and configurable size limits to prevent unbounded memory growth.
8
+
9
+ Memory management strategy (three-tier approach):
10
+ 1. Lazy cleanup: When tracker exceeds CLEANUP_THRESHOLD, remove expired entries
11
+ 2. Warning threshold: Log warning when approaching MAX_SIZE (hysteresis prevents spam)
12
+ 3. Hard limit eviction: When MAX_SIZE reached, remove oldest 20% of entries
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime
18
+ import logging
19
+ from typing import Any, Final
20
+
21
+ from aiohomematic.const import (
22
+ COMMAND_TRACKER_MAX_SIZE,
23
+ COMMAND_TRACKER_WARNING_THRESHOLD,
24
+ DP_KEY_VALUE,
25
+ LAST_COMMAND_SEND_STORE_TIMEOUT,
26
+ LAST_COMMAND_SEND_TRACKER_CLEANUP_THRESHOLD,
27
+ DataPointKey,
28
+ ParamsetKey,
29
+ )
30
+ from aiohomematic.converter import CONVERTABLE_PARAMETERS, convert_combined_parameter_to_paramset
31
+ from aiohomematic.store.types import CachedCommand, TrackerStatistics
32
+ from aiohomematic.support import changed_within_seconds
33
+
34
+ _LOGGER: Final = logging.getLogger(__name__)
35
+
36
+
37
+ class CommandTracker:
38
+ """
39
+ Tracker for sent commands with resource limits.
40
+
41
+ Tracks recently sent commands per data point with automatic expiry
42
+ and configurable size limits to prevent unbounded memory growth.
43
+
44
+ Memory management strategy (three-tier approach):
45
+ 1. Lazy cleanup: When tracker exceeds CLEANUP_THRESHOLD, remove expired entries
46
+ 2. Warning threshold: Log warning when approaching MAX_SIZE (hysteresis prevents spam)
47
+ 3. Hard limit eviction: When MAX_SIZE reached, remove oldest 20% of entries
48
+
49
+ The 20% eviction rate balances memory reclamation against the cost of repeated
50
+ evictions (avoiding evicting just 1 entry repeatedly).
51
+ """
52
+
53
+ __slots__ = (
54
+ "_interface_id",
55
+ "_last_send_command",
56
+ "_stats",
57
+ "_warning_logged",
58
+ )
59
+
60
+ def __init__(self, *, interface_id: str) -> None:
61
+ """Initialize command tracker."""
62
+ self._interface_id: Final = interface_id
63
+ self._stats: Final = TrackerStatistics()
64
+ # Maps DataPointKey to CachedCommand for tracking recent commands.
65
+ # Used to detect duplicate sends and for unconfirmed value tracking.
66
+ self._last_send_command: Final[dict[DataPointKey, CachedCommand]] = {}
67
+ # Hysteresis flag to prevent repeated warning logs
68
+ self._warning_logged: bool = False
69
+
70
+ @property
71
+ def size(self) -> int:
72
+ """Return the current tracker size."""
73
+ return len(self._last_send_command)
74
+
75
+ @property
76
+ def statistics(self) -> TrackerStatistics:
77
+ """Return the tracker statistics."""
78
+ return self._stats
79
+
80
+ def add_combined_parameter(
81
+ self, *, parameter: str, channel_address: str, combined_parameter: str
82
+ ) -> set[DP_KEY_VALUE]:
83
+ """Add data from combined parameter."""
84
+ if values := convert_combined_parameter_to_paramset(parameter=parameter, value=combined_parameter):
85
+ return self.add_put_paramset(
86
+ channel_address=channel_address,
87
+ paramset_key=ParamsetKey.VALUES,
88
+ values=values,
89
+ )
90
+ return set()
91
+
92
+ def add_put_paramset(
93
+ self, *, channel_address: str, paramset_key: ParamsetKey, values: dict[str, Any]
94
+ ) -> set[DP_KEY_VALUE]:
95
+ """Add data from put paramset command."""
96
+ # Cleanup expired entries when tracker size exceeds threshold
97
+ if len(self._last_send_command) > LAST_COMMAND_SEND_TRACKER_CLEANUP_THRESHOLD:
98
+ self.cleanup_expired()
99
+
100
+ # Enforce hard size limit
101
+ self._enforce_size_limit()
102
+
103
+ dpk_values: set[DP_KEY_VALUE] = set()
104
+ now_ts = datetime.now()
105
+ for parameter, value in values.items():
106
+ dpk = DataPointKey(
107
+ interface_id=self._interface_id,
108
+ channel_address=channel_address,
109
+ paramset_key=paramset_key,
110
+ parameter=parameter,
111
+ )
112
+ self._last_send_command[dpk] = CachedCommand(value=value, sent_at=now_ts)
113
+ dpk_values.add((dpk, value))
114
+ return dpk_values
115
+
116
+ def add_set_value(
117
+ self,
118
+ *,
119
+ channel_address: str,
120
+ parameter: str,
121
+ value: Any,
122
+ ) -> set[DP_KEY_VALUE]:
123
+ """Add data from set value command."""
124
+ if parameter in CONVERTABLE_PARAMETERS:
125
+ return self.add_combined_parameter(
126
+ parameter=parameter, channel_address=channel_address, combined_parameter=value
127
+ )
128
+
129
+ # Cleanup expired entries when tracker size exceeds threshold
130
+ if len(self._last_send_command) > LAST_COMMAND_SEND_TRACKER_CLEANUP_THRESHOLD:
131
+ self.cleanup_expired()
132
+
133
+ # Enforce hard size limit
134
+ self._enforce_size_limit()
135
+
136
+ now_ts = datetime.now()
137
+ dpk = DataPointKey(
138
+ interface_id=self._interface_id,
139
+ channel_address=channel_address,
140
+ paramset_key=ParamsetKey.VALUES,
141
+ parameter=parameter,
142
+ )
143
+ self._last_send_command[dpk] = CachedCommand(value=value, sent_at=now_ts)
144
+ return {(dpk, value)}
145
+
146
+ def cleanup_expired(self, *, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT) -> int:
147
+ """
148
+ Remove expired command tracker entries.
149
+
150
+ Return the number of entries removed.
151
+
152
+ Two-pass algorithm (safer than deleting during iteration):
153
+ 1. First pass: Collect keys of expired entries into a list
154
+ 2. Second pass: Delete collected keys from the dictionary
155
+
156
+ This avoids "dictionary changed size during iteration" errors.
157
+ """
158
+ # Pass 1: Identify expired entries without modifying the dict
159
+ expired_keys = [
160
+ dpk
161
+ for dpk, cached in self._last_send_command.items()
162
+ if not changed_within_seconds(last_change=cached.sent_at, max_age=max_age)
163
+ ]
164
+ # Pass 2: Delete expired entries
165
+ for dpk in expired_keys:
166
+ del self._last_send_command[dpk]
167
+ # Track evictions via local counter
168
+ if expired_keys:
169
+ self._stats.record_eviction(count=len(expired_keys))
170
+ return len(expired_keys)
171
+
172
+ def clear(self) -> None:
173
+ """Clear all tracked command entries."""
174
+ self._last_send_command.clear()
175
+
176
+ def get_last_value_send(self, *, dpk: DataPointKey, max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT) -> Any:
177
+ """Return the last send values."""
178
+ if cached := self._last_send_command.get(dpk):
179
+ if cached.sent_at and changed_within_seconds(last_change=cached.sent_at, max_age=max_age):
180
+ return cached.value
181
+ self.remove_last_value_send(
182
+ dpk=dpk,
183
+ max_age=max_age,
184
+ )
185
+ return None
186
+
187
+ def remove_last_value_send(
188
+ self,
189
+ *,
190
+ dpk: DataPointKey,
191
+ value: Any = None,
192
+ max_age: int = LAST_COMMAND_SEND_STORE_TIMEOUT,
193
+ ) -> None:
194
+ """Remove the last send value."""
195
+ if (cached := self._last_send_command.get(dpk)) is not None and (
196
+ not changed_within_seconds(last_change=cached.sent_at, max_age=max_age)
197
+ or (value is not None and cached.value == value)
198
+ ):
199
+ del self._last_send_command[dpk]
200
+
201
+ def _enforce_size_limit(self) -> None:
202
+ """
203
+ Enforce size limits on the tracker to prevent unbounded growth.
204
+
205
+ LRU-style eviction algorithm:
206
+ When tracker reaches MAX_SIZE, evict the oldest 20% of entries.
207
+ The 20% threshold is a heuristic that balances:
208
+ - Memory reclamation (enough entries removed to be meaningful)
209
+ - Performance (not called too frequently)
210
+ - Data retention (most recent entries are preserved)
211
+
212
+ Warning hysteresis:
213
+ The _warning_logged flag prevents log spam when tracker size oscillates
214
+ near the warning threshold. Warning is logged once when threshold is
215
+ exceeded, then reset only when size drops below threshold.
216
+ """
217
+ current_size = len(self._last_send_command)
218
+
219
+ # Warning with hysteresis: log once when crossing threshold, reset when below
220
+ if current_size >= COMMAND_TRACKER_WARNING_THRESHOLD and not self._warning_logged:
221
+ _LOGGER.warning( # i18n-log: ignore
222
+ "CommandTracker for %s approaching size limit: %d/%d entries",
223
+ self._interface_id,
224
+ current_size,
225
+ COMMAND_TRACKER_MAX_SIZE,
226
+ )
227
+ self._warning_logged = True
228
+ elif current_size < COMMAND_TRACKER_WARNING_THRESHOLD:
229
+ # Reset warning flag when tracker shrinks below threshold
230
+ self._warning_logged = False
231
+
232
+ # Hard limit enforcement with LRU eviction
233
+ if current_size >= COMMAND_TRACKER_MAX_SIZE:
234
+ # Sort entries by timestamp (oldest first) for LRU eviction
235
+ sorted_entries = sorted(
236
+ self._last_send_command.items(),
237
+ key=lambda item: item[1].sent_at,
238
+ )
239
+ # Remove oldest 20% of entries (at least 1)
240
+ remove_count = max(1, current_size // 5)
241
+ for dpk, _ in sorted_entries[:remove_count]:
242
+ del self._last_send_command[dpk]
243
+ # Track evictions via local counter
244
+ self._stats.record_eviction(count=remove_count)
245
+ _LOGGER.debug(
246
+ "CommandTracker for %s evicted %d oldest entries (size was %d)",
247
+ self._interface_id,
248
+ remove_count,
249
+ current_size,
250
+ )