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,812 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2021-2025
3
+ """
4
+ Parameter visibility rules and cache for Homematic data points.
5
+
6
+ This module determines which parameters should be created, shown, hidden,
7
+ ignored, or un‑ignored for channels and devices. It centralizes the rules
8
+ that influence the visibility of data points exposed by the library.
9
+
10
+ Overview
11
+ - ParameterVisibilityCache: Computes and store visibility decisions per model,
12
+ channel number, paramset and parameter. It consolidates rules from multiple
13
+ sources, such as model‑specific defaults, paramset relevance, hidden lists,
14
+ and explicit un‑ignore directives.
15
+ - check_ignore_parameters_is_clean: Helper to verify that ignore/un‑ignore
16
+ configuration input is consistent.
17
+
18
+ Key concepts
19
+ - Relevant MASTER parameters: Certain MASTER paramset entries are promoted to
20
+ data points for selected models/channels (e.g. climate related settings), but
21
+ they may still be hidden by default for UI purposes.
22
+ - Ignored vs un‑ignored: Parameters can be broadly ignored, with exceptions
23
+ defined via explicit un‑ignore rules that match model/channel/paramset keys.
24
+ - Event suppression: For selected devices, button click events are suppressed to
25
+ avoid noise in event streams.
26
+
27
+ The cache avoids recomputing rule lookups by storing decisions per combination
28
+ of (model/channel/paramset/parameter). It is initialized on demand using data
29
+ available on the CentralUnit and model descriptors.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections import defaultdict
35
+ from collections.abc import Iterable, Mapping
36
+ from functools import cache
37
+ import logging
38
+ import re
39
+ from typing import Final, TypeAlias
40
+
41
+ from aiohomematic import central as hmcu, support as hms
42
+ from aiohomematic.const import ADDRESS_SEPARATOR, CLICK_EVENTS, UN_IGNORE_WILDCARD, Parameter, ParamsetKey
43
+ from aiohomematic.model import hmd
44
+ from aiohomematic.model.custom import get_required_parameters
45
+ from aiohomematic.support import element_matches_key
46
+
47
+ _LOGGER: Final = logging.getLogger(__name__)
48
+
49
+ # Readability type aliases for internal cache structures
50
+ TModelName: TypeAlias = str
51
+ TChannelNo: TypeAlias = int | None
52
+ TUnIgnoreChannelNo: TypeAlias = TChannelNo | str
53
+ TParameterName: TypeAlias = str
54
+
55
+ # Define which additional parameters from MASTER paramset should be created as data_point.
56
+ # By default these are also on the _HIDDEN_PARAMETERS, which prevents these data points
57
+ # from being display by default. Usually these enties are used within custom data points,
58
+ # and not for general display.
59
+ # {model: (channel_no, parameter)}
60
+
61
+ _RELEVANT_MASTER_PARAMSETS_BY_CHANNEL: Final[Mapping[TChannelNo, frozenset[Parameter]]] = {
62
+ None: frozenset({Parameter.GLOBAL_BUTTON_LOCK, Parameter.LOW_BAT_LIMIT}),
63
+ 0: frozenset({Parameter.GLOBAL_BUTTON_LOCK, Parameter.LOW_BAT_LIMIT}),
64
+ }
65
+
66
+ _CLIMATE_MASTER_PARAMETERS: Final[frozenset[Parameter]] = frozenset(
67
+ {
68
+ Parameter.HEATING_VALVE_TYPE,
69
+ Parameter.MIN_MAX_VALUE_NOT_RELEVANT_FOR_MANU_MODE,
70
+ Parameter.OPTIMUM_START_STOP,
71
+ Parameter.TEMPERATURE_MAXIMUM,
72
+ Parameter.TEMPERATURE_MINIMUM,
73
+ Parameter.TEMPERATURE_OFFSET,
74
+ Parameter.WEEK_PROGRAM_POINTER,
75
+ }
76
+ )
77
+
78
+ _RELEVANT_MASTER_PARAMSETS_BY_DEVICE: Final[Mapping[TModelName, tuple[frozenset[TChannelNo], frozenset[Parameter]]]] = {
79
+ "ALPHA-IP-RBG": (frozenset({1}), _CLIMATE_MASTER_PARAMETERS),
80
+ "ELV-SH-TACO": (frozenset({2}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
81
+ "HM-CC-RT-DN": (
82
+ frozenset({None}),
83
+ _CLIMATE_MASTER_PARAMETERS,
84
+ ),
85
+ "HM-CC-VG-1": (
86
+ frozenset({None}),
87
+ _CLIMATE_MASTER_PARAMETERS,
88
+ ),
89
+ "HM-TC-IT-WM-W-EU": (
90
+ frozenset({None}),
91
+ _CLIMATE_MASTER_PARAMETERS,
92
+ ),
93
+ "HmIP-BWTH": (frozenset({1, 8}), _CLIMATE_MASTER_PARAMETERS),
94
+ "HmIP-DRBLI4": (
95
+ frozenset({1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 17, 21}),
96
+ frozenset({Parameter.CHANNEL_OPERATION_MODE}),
97
+ ),
98
+ "HmIP-DRDI3": (frozenset({1, 2, 3}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
99
+ "HmIP-DRSI1": (frozenset({1}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
100
+ "HmIP-DRSI4": (frozenset({1, 2, 3, 4}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
101
+ "HmIP-DSD-PCB": (frozenset({1}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
102
+ "HmIP-FCI1": (frozenset({1}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
103
+ "HmIP-FCI6": (frozenset(range(1, 7)), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
104
+ "HmIP-FSI16": (frozenset({1}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
105
+ "HmIP-HEATING": (frozenset({1}), _CLIMATE_MASTER_PARAMETERS),
106
+ "HmIP-MIO16-PCB": (frozenset({13, 14, 15, 16}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
107
+ "HmIP-MOD-RC8": (frozenset(range(1, 9)), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
108
+ "HmIP-RGBW": (frozenset({0}), frozenset({Parameter.DEVICE_OPERATION_MODE})),
109
+ "HmIP-STH": (frozenset({1}), _CLIMATE_MASTER_PARAMETERS),
110
+ "HmIP-WGT": (frozenset({8, 14}), _CLIMATE_MASTER_PARAMETERS),
111
+ "HmIP-WTH": (frozenset({1}), _CLIMATE_MASTER_PARAMETERS),
112
+ "HmIP-eTRV": (frozenset({1}), _CLIMATE_MASTER_PARAMETERS),
113
+ "HmIPW-DRBL4": (frozenset({1, 5, 9, 13}), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
114
+ "HmIPW-DRI16": (frozenset(range(1, 17)), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
115
+ "HmIPW-DRI32": (frozenset(range(1, 33)), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
116
+ "HmIPW-FIO6": (frozenset(range(1, 7)), frozenset({Parameter.CHANNEL_OPERATION_MODE})),
117
+ "HmIPW-STH": (frozenset({1}), _CLIMATE_MASTER_PARAMETERS),
118
+ }
119
+
120
+ # Ignore events for some devices
121
+ _IGNORE_DEVICES_FOR_DATA_POINT_EVENTS: Final[Mapping[TModelName, frozenset[Parameter]]] = {
122
+ "HmIP-PS": CLICK_EVENTS,
123
+ }
124
+
125
+ _IGNORE_DEVICES_FOR_DATA_POINT_EVENTS_LOWER: Final[Mapping[TModelName, frozenset[Parameter]]] = {
126
+ model.lower(): frozenset(events) for model, events in _IGNORE_DEVICES_FOR_DATA_POINT_EVENTS.items()
127
+ }
128
+
129
+ # data points that will be created, but should be hidden.
130
+ _HIDDEN_PARAMETERS: Final[frozenset[Parameter]] = frozenset(
131
+ {
132
+ Parameter.ACTIVITY_STATE,
133
+ Parameter.CHANNEL_OPERATION_MODE,
134
+ Parameter.CONFIG_PENDING,
135
+ Parameter.DIRECTION,
136
+ Parameter.ERROR,
137
+ Parameter.HEATING_VALVE_TYPE,
138
+ Parameter.LOW_BAT_LIMIT,
139
+ Parameter.MIN_MAX_VALUE_NOT_RELEVANT_FOR_MANU_MODE,
140
+ Parameter.OPTIMUM_START_STOP,
141
+ Parameter.SECTION,
142
+ Parameter.STICKY_UN_REACH,
143
+ Parameter.TEMPERATURE_MAXIMUM,
144
+ Parameter.TEMPERATURE_MINIMUM,
145
+ Parameter.TEMPERATURE_OFFSET,
146
+ Parameter.UN_REACH,
147
+ Parameter.UPDATE_PENDING,
148
+ Parameter.WORKING,
149
+ }
150
+ )
151
+
152
+ # Parameters within the VALUES paramset for which we don't create data points.
153
+ _IGNORED_PARAMETERS: Final[frozenset[TParameterName]] = frozenset(
154
+ {
155
+ "ACCESS_AUTHORIZATION",
156
+ "ACOUSTIC_NOTIFICATION_SELECTION",
157
+ "ADAPTION_DRIVE",
158
+ "AES_KEY",
159
+ "ALARM_COUNT",
160
+ "ALL_LEDS",
161
+ "ARROW_DOWN",
162
+ "ARROW_UP",
163
+ "BACKLIGHT",
164
+ "BEEP",
165
+ "BELL",
166
+ "BLIND",
167
+ "BOOST_STATE",
168
+ "BOOST_TIME",
169
+ "BOOT",
170
+ "BULB",
171
+ "CLEAR_ERROR",
172
+ "CLEAR_WINDOW_OPEN_SYMBOL",
173
+ "CLOCK",
174
+ "CMD_RETL", # CUxD
175
+ "CMD_RETS", # CUxD
176
+ "CONTROL_DIFFERENTIAL_TEMPERATURE",
177
+ "DATE_TIME_UNKNOWN",
178
+ "DECISION_VALUE",
179
+ "DEVICE_IN_BOOTLOADER",
180
+ "DISPLAY_DATA_ALIGNMENT",
181
+ "DISPLAY_DATA_BACKGROUND_COLOR",
182
+ "DISPLAY_DATA_COMMIT",
183
+ "DISPLAY_DATA_ICON",
184
+ "DISPLAY_DATA_ID",
185
+ "DISPLAY_DATA_STRING",
186
+ "DISPLAY_DATA_TEXT_COLOR",
187
+ "DOOR",
188
+ "EXTERNAL_CLOCK",
189
+ "FROST_PROTECTION",
190
+ "HUMIDITY_LIMITER",
191
+ "IDENTIFICATION_MODE_KEY_VISUAL",
192
+ "IDENTIFICATION_MODE_LCD_BACKLIGHT",
193
+ "INCLUSION_UNSUPPORTED_DEVICE",
194
+ "INHIBIT",
195
+ "INSTALL_MODE",
196
+ "INTERVAL",
197
+ "LEVEL_REAL",
198
+ "OLD_LEVEL",
199
+ "OVERFLOW",
200
+ "OVERRUN",
201
+ "PARTY_SET_POINT_TEMPERATURE",
202
+ "PARTY_TEMPERATURE",
203
+ "PARTY_TIME_END",
204
+ "PARTY_TIME_START",
205
+ "PHONE",
206
+ "PROCESS",
207
+ "QUICK_VETO_TIME",
208
+ "RAMP_STOP",
209
+ "RELOCK_DELAY",
210
+ "SCENE",
211
+ "SELF_CALIBRATION",
212
+ "SERVICE_COUNT",
213
+ "SET_SYMBOL_FOR_HEATING_PHASE",
214
+ "SHADING_SPEED",
215
+ "SHEV_POS",
216
+ "SPEED",
217
+ "STATE_UNCERTAIN",
218
+ "SUBMIT",
219
+ "SWITCH_POINT_OCCURED",
220
+ "TEMPERATURE_LIMITER",
221
+ "TEMPERATURE_OUT_OF_RANGE",
222
+ "TEXT",
223
+ "USER_COLOR",
224
+ "USER_PROGRAM",
225
+ "VALVE_ADAPTION",
226
+ "WINDOW",
227
+ "WIN_RELEASE",
228
+ "WIN_RELEASE_ACT",
229
+ }
230
+ )
231
+
232
+
233
+ # Precompile Regex patterns for wildcard checks
234
+ # Ignore Parameter that end with
235
+ _IGNORED_PARAMETERS_END_RE: Final = re.compile(r".*(_OVERFLOW|_OVERRUN|_REPORTING|_RESULT|_STATUS|_SUBMIT)$")
236
+ # Ignore Parameter that start with
237
+ _IGNORED_PARAMETERS_START_RE: Final = re.compile(
238
+ r"^(ADJUSTING_|ERR_TTM_|HANDLE_|IDENTIFY_|PARTY_START_|PARTY_STOP_|STATUS_FLAG_|WEEK_PROGRAM_)"
239
+ )
240
+
241
+
242
+ def _parameter_is_wildcard_ignored(*, parameter: TParameterName) -> bool:
243
+ """Check if a parameter matches common wildcard patterns."""
244
+ return bool(_IGNORED_PARAMETERS_END_RE.match(parameter) or _IGNORED_PARAMETERS_START_RE.match(parameter))
245
+
246
+
247
+ # Parameters within the paramsets for which we create data points.
248
+ _UN_IGNORE_PARAMETERS_BY_DEVICE: Final[Mapping[TModelName, frozenset[Parameter]]] = {
249
+ "HmIP-DLD": frozenset({Parameter.ERROR_JAMMED}),
250
+ "HmIP-SWSD": frozenset({Parameter.SMOKE_DETECTOR_ALARM_STATUS}),
251
+ "HM-OU-LED16": frozenset({Parameter.LED_STATUS}),
252
+ "HM-Sec-Win": frozenset({Parameter.DIRECTION, Parameter.WORKING, Parameter.ERROR, Parameter.STATUS}),
253
+ "HM-Sec-Key": frozenset({Parameter.DIRECTION, Parameter.ERROR}),
254
+ "HmIP-PCBS-BAT": frozenset({Parameter.OPERATING_VOLTAGE, Parameter.LOW_BAT}), # To override ignore for HmIP-PCBS
255
+ }
256
+
257
+ _UN_IGNORE_PARAMETERS_BY_MODEL_LOWER: Final[dict[TModelName, frozenset[Parameter]]] = {
258
+ model.lower(): frozenset(parameters) for model, parameters in _UN_IGNORE_PARAMETERS_BY_DEVICE.items()
259
+ }
260
+
261
+
262
+ @cache
263
+ def _get_parameters_for_model_prefix(*, model_prefix: str | None) -> frozenset[Parameter] | None:
264
+ """Return the dict value by wildcard type."""
265
+ if model_prefix is None:
266
+ return None
267
+
268
+ for model, parameters in _UN_IGNORE_PARAMETERS_BY_MODEL_LOWER.items():
269
+ if model.startswith(model_prefix):
270
+ return parameters
271
+ return None
272
+
273
+
274
+ # Parameters by device within the VALUES paramset for which we don't create data points.
275
+ _IGNORE_PARAMETERS_BY_DEVICE: Final[Mapping[Parameter, frozenset[TModelName]]] = {
276
+ Parameter.CURRENT_ILLUMINATION: frozenset(
277
+ {
278
+ "HmIP-SMI",
279
+ "HmIP-SMO",
280
+ "HmIP-SPI",
281
+ }
282
+ ),
283
+ Parameter.LOWBAT: frozenset(
284
+ {
285
+ "HM-LC-Sw1-DR",
286
+ "HM-LC-Sw1-FM",
287
+ "HM-LC-Sw1-PCB",
288
+ "HM-LC-Sw1-Pl",
289
+ "HM-LC-Sw1-Pl-DN-R1",
290
+ "HM-LC-Sw1PBU-FM",
291
+ "HM-LC-Sw2-FM",
292
+ "HM-LC-Sw4-DR",
293
+ "HM-SwI-3-FM",
294
+ }
295
+ ),
296
+ Parameter.LOW_BAT: frozenset({"HmIP-BWTH", "HmIP-PCBS"}),
297
+ Parameter.OPERATING_VOLTAGE: frozenset(
298
+ {
299
+ "ELV-SH-BS2",
300
+ "HmIP-BDT",
301
+ "HmIP-BROLL",
302
+ "HmIP-BS2",
303
+ "HmIP-BSL",
304
+ "HmIP-BSM",
305
+ "HmIP-BWTH",
306
+ "HmIP-DR",
307
+ "HmIP-FDT",
308
+ "HmIP-FROLL",
309
+ "HmIP-FSM",
310
+ "HmIP-MOD-OC8",
311
+ "HmIP-PCBS",
312
+ "HmIP-PDT",
313
+ "HmIP-PMFS",
314
+ "HmIP-PS",
315
+ "HmIP-SFD",
316
+ "HmIP-SMO230",
317
+ "HmIP-WGT",
318
+ }
319
+ ),
320
+ Parameter.VALVE_STATE: frozenset({"HmIP-FALMOT-C8", "HmIPW-FALMOT-C12", "HmIP-FALMOT-C12"}),
321
+ }
322
+
323
+ _IGNORE_PARAMETERS_BY_DEVICE_LOWER: Final[dict[TParameterName, frozenset[TModelName]]] = {
324
+ parameter: frozenset(model.lower() for model in s) for parameter, s in _IGNORE_PARAMETERS_BY_DEVICE.items()
325
+ }
326
+
327
+ # Some devices have parameters on multiple channels,
328
+ # but we want to use it only from a certain channel.
329
+ _ACCEPT_PARAMETER_ONLY_ON_CHANNEL: Final[Mapping[TParameterName, int]] = {Parameter.LOWBAT: 0}
330
+
331
+
332
+ class ParameterVisibilityCache:
333
+ """
334
+ Cache for parameter visibility.
335
+
336
+ The cache centralizes rules that determine whether a data point parameter is
337
+ created, ignored, un-ignored, or merely hidden for UI purposes. It combines
338
+ static rules (per-model/per-channel) with dynamic user-provided overrides and
339
+ memoizes decisions per (model/channel/paramset/parameter) to avoid repeated
340
+ computations during runtime. Behavior is unchanged; this class only clarifies
341
+ intent and structure through documentation and small naming improvements.
342
+ """
343
+
344
+ __slots__ = (
345
+ "_central",
346
+ "_custom_un_ignore_complex",
347
+ "_custom_un_ignore_values_parameters",
348
+ "_ignore_custom_device_definition_models",
349
+ "_param_ignored_cache",
350
+ "_param_un_ignored_cache",
351
+ "_raw_un_ignores",
352
+ "_relevant_master_paramsets_by_device",
353
+ "_relevant_prefix_cache",
354
+ "_required_parameters",
355
+ "_storage_directory",
356
+ "_un_ignore_parameters_by_device_paramset_key",
357
+ "_un_ignore_prefix_cache",
358
+ )
359
+
360
+ def __init__(
361
+ self,
362
+ *,
363
+ central: hmcu.CentralUnit,
364
+ ) -> None:
365
+ """Init the parameter visibility cache."""
366
+ self._central = central
367
+ self._storage_directory: Final = central.config.storage_directory
368
+ self._required_parameters: Final = get_required_parameters()
369
+ self._raw_un_ignores: Final[frozenset[str]] = central.config.un_ignore_list or frozenset()
370
+
371
+ # un_ignore from custom un_ignore files
372
+ # parameter
373
+ self._custom_un_ignore_values_parameters: Final[set[TParameterName]] = set()
374
+
375
+ # model -> channel_no -> paramset_key -> set(parameter)
376
+ self._custom_un_ignore_complex: Final[
377
+ dict[TModelName, dict[TUnIgnoreChannelNo, dict[ParamsetKey, set[TParameterName]]]]
378
+ ] = defaultdict(lambda: defaultdict(lambda: defaultdict(set)))
379
+ self._ignore_custom_device_definition_models: Final[frozenset[TModelName]] = (
380
+ central.config.ignore_custom_device_definition_models
381
+ )
382
+
383
+ # model, channel_no, paramset_key, set[parameter]
384
+ self._un_ignore_parameters_by_device_paramset_key: Final[
385
+ dict[TModelName, dict[TChannelNo, dict[ParamsetKey, set[TParameterName]]]]
386
+ ] = defaultdict(lambda: defaultdict(lambda: defaultdict(set)))
387
+
388
+ # model, channel_no
389
+ self._relevant_master_paramsets_by_device: Final[dict[TModelName, set[TChannelNo]]] = defaultdict(set)
390
+ # Cache for resolving matching prefix key in _un_ignore_parameters_by_device_paramset_key
391
+ self._un_ignore_prefix_cache: dict[TModelName, str | None] = {}
392
+ # Cache for resolving matching prefix key in _relevant_master_paramsets_by_device
393
+ self._relevant_prefix_cache: dict[TModelName, str | None] = {}
394
+ # Per-instance memoization for repeated queries.
395
+ # Key: (model_l, channel_no, paramset_key, parameter)
396
+ self._param_ignored_cache: dict[tuple[TModelName, TChannelNo, ParamsetKey, TParameterName], bool] = {}
397
+ # Key: (model_l, channel_no, paramset_key, parameter, custom_only)
398
+ self._param_un_ignored_cache: dict[tuple[TModelName, TChannelNo, ParamsetKey, TParameterName, bool], bool] = {}
399
+ self._init()
400
+
401
+ def _init(self) -> None:
402
+ """Process cache initialisation."""
403
+ for (
404
+ model,
405
+ channels_parameter,
406
+ ) in _RELEVANT_MASTER_PARAMSETS_BY_DEVICE.items():
407
+ model_l = model.lower()
408
+ channel_nos, parameters = channels_parameter
409
+
410
+ def _add_channel(dt_l: str, params: frozenset[Parameter], ch_no: TChannelNo) -> None:
411
+ self._relevant_master_paramsets_by_device[dt_l].add(ch_no)
412
+ self._un_ignore_parameters_by_device_paramset_key[dt_l][ch_no][ParamsetKey.MASTER].update(params)
413
+
414
+ if channel_nos:
415
+ for channel_no in channel_nos:
416
+ _add_channel(dt_l=model_l, params=parameters, ch_no=channel_no)
417
+ else:
418
+ _add_channel(dt_l=model_l, params=parameters, ch_no=None)
419
+
420
+ self._process_un_ignore_entries(lines=self._raw_un_ignores)
421
+
422
+ def _resolve_prefix_key(
423
+ self, *, model_l: TModelName, mapping: Mapping[str, object], cache_dict: dict[TModelName, str | None]
424
+ ) -> str | None:
425
+ """Resolve and memoize the first key in mapping that prefixes model_l."""
426
+ if (dt_short_key := cache_dict.get(model_l)) is None and model_l not in cache_dict:
427
+ dt_short_key = next((k for k in mapping if model_l.startswith(k)), None)
428
+ cache_dict[model_l] = dt_short_key
429
+ return dt_short_key
430
+
431
+ def model_is_ignored(self, *, model: TModelName) -> bool:
432
+ """Check if a model should be ignored for custom data points."""
433
+ return element_matches_key(
434
+ search_elements=self._ignore_custom_device_definition_models,
435
+ compare_with=model,
436
+ )
437
+
438
+ def parameter_is_ignored(
439
+ self,
440
+ *,
441
+ channel: hmd.Channel,
442
+ paramset_key: ParamsetKey,
443
+ parameter: TParameterName,
444
+ ) -> bool:
445
+ """Check if parameter can be ignored."""
446
+ model_l = channel.device.model.lower()
447
+ # Fast path via per-instance memoization
448
+ if (cache_key := (model_l, channel.no, paramset_key, parameter)) in self._param_ignored_cache:
449
+ return self._param_ignored_cache[cache_key]
450
+
451
+ if paramset_key == ParamsetKey.VALUES:
452
+ if self.parameter_is_un_ignored(
453
+ channel=channel,
454
+ paramset_key=paramset_key,
455
+ parameter=parameter,
456
+ ):
457
+ return False
458
+
459
+ if (
460
+ (
461
+ (parameter in _IGNORED_PARAMETERS or _parameter_is_wildcard_ignored(parameter=parameter))
462
+ and parameter not in self._required_parameters
463
+ )
464
+ or hms.element_matches_key(
465
+ search_elements=_IGNORE_PARAMETERS_BY_DEVICE_LOWER.get(parameter, []),
466
+ compare_with=model_l,
467
+ )
468
+ or hms.element_matches_key(
469
+ search_elements=_IGNORE_DEVICES_FOR_DATA_POINT_EVENTS_LOWER,
470
+ compare_with=parameter,
471
+ search_key=model_l,
472
+ do_right_wildcard_search=False,
473
+ )
474
+ ):
475
+ return True
476
+
477
+ if (
478
+ accept_channel := _ACCEPT_PARAMETER_ONLY_ON_CHANNEL.get(parameter)
479
+ ) is not None and accept_channel != channel.no:
480
+ return True
481
+ if paramset_key == ParamsetKey.MASTER:
482
+ if (
483
+ parameters := _RELEVANT_MASTER_PARAMSETS_BY_CHANNEL.get(channel.no)
484
+ ) is not None and parameter in parameters:
485
+ return False
486
+
487
+ if parameter in self._custom_un_ignore_complex[model_l][channel.no][ParamsetKey.MASTER]:
488
+ return False
489
+
490
+ dt_short_key = self._resolve_prefix_key(
491
+ model_l=model_l,
492
+ mapping=self._un_ignore_parameters_by_device_paramset_key,
493
+ cache_dict=self._un_ignore_prefix_cache,
494
+ )
495
+ if (
496
+ dt_short_key is not None
497
+ and parameter
498
+ not in self._un_ignore_parameters_by_device_paramset_key[dt_short_key][channel.no][ParamsetKey.MASTER]
499
+ ):
500
+ result = True
501
+ self._param_ignored_cache[cache_key] = result
502
+ return result
503
+
504
+ result = False
505
+ self._param_ignored_cache[cache_key] = result
506
+ return result
507
+
508
+ def _parameter_is_un_ignored(
509
+ self,
510
+ *,
511
+ channel: hmd.Channel,
512
+ paramset_key: ParamsetKey,
513
+ parameter: TParameterName,
514
+ custom_only: bool = False,
515
+ ) -> bool:
516
+ """
517
+ Return if parameter is on an un_ignore list.
518
+
519
+ This can be either be the users un_ignore file, or in the
520
+ predefined _UN_IGNORE_PARAMETERS_BY_DEVICE.
521
+ """
522
+
523
+ # check if parameter is in custom_un_ignore
524
+ if paramset_key == ParamsetKey.VALUES and parameter in self._custom_un_ignore_values_parameters:
525
+ return True
526
+
527
+ # check if parameter is in custom_un_ignore with paramset_key
528
+ model_l = channel.device.model.lower()
529
+ # Fast path via per-instance memoization
530
+ if (cache_key := (model_l, channel.no, paramset_key, parameter, custom_only)) in self._param_un_ignored_cache:
531
+ return self._param_un_ignored_cache[cache_key]
532
+
533
+ search_matrix = (
534
+ (
535
+ (model_l, channel.no),
536
+ (model_l, UN_IGNORE_WILDCARD),
537
+ (UN_IGNORE_WILDCARD, channel.no),
538
+ (UN_IGNORE_WILDCARD, UN_IGNORE_WILDCARD),
539
+ )
540
+ if paramset_key == ParamsetKey.VALUES
541
+ else ((model_l, channel.no),)
542
+ )
543
+
544
+ for ml, cno in search_matrix:
545
+ if parameter in self._custom_un_ignore_complex[ml][cno][paramset_key]:
546
+ self._param_un_ignored_cache[cache_key] = True
547
+ return True
548
+
549
+ # check if parameter is in _UN_IGNORE_PARAMETERS_BY_DEVICE
550
+ result = bool(
551
+ not custom_only
552
+ and (un_ignore_parameters := _get_parameters_for_model_prefix(model_prefix=model_l))
553
+ and parameter in un_ignore_parameters
554
+ )
555
+ self._param_un_ignored_cache[cache_key] = result
556
+ return result
557
+
558
+ def parameter_is_un_ignored(
559
+ self,
560
+ *,
561
+ channel: hmd.Channel,
562
+ paramset_key: ParamsetKey,
563
+ parameter: TParameterName,
564
+ custom_only: bool = False,
565
+ ) -> bool:
566
+ """
567
+ Return if parameter is on an un_ignore list.
568
+
569
+ Additionally to _parameter_is_un_ignored these parameters
570
+ from _RELEVANT_MASTER_PARAMSETS_BY_DEVICE are un ignored.
571
+ """
572
+ if not custom_only:
573
+ model_l = channel.device.model.lower()
574
+ dt_short_key = self._resolve_prefix_key(
575
+ model_l=model_l,
576
+ mapping=self._un_ignore_parameters_by_device_paramset_key,
577
+ cache_dict=self._un_ignore_prefix_cache,
578
+ )
579
+
580
+ # check if parameter is in _RELEVANT_MASTER_PARAMSETS_BY_DEVICE
581
+ if (
582
+ dt_short_key is not None
583
+ and parameter
584
+ in self._un_ignore_parameters_by_device_paramset_key[dt_short_key][channel.no][paramset_key]
585
+ ):
586
+ return True
587
+
588
+ return self._parameter_is_un_ignored(
589
+ channel=channel,
590
+ paramset_key=paramset_key,
591
+ parameter=parameter,
592
+ custom_only=custom_only,
593
+ )
594
+
595
+ def should_skip_parameter(
596
+ self,
597
+ *,
598
+ channel: hmd.Channel,
599
+ paramset_key: ParamsetKey,
600
+ parameter: TParameterName,
601
+ parameter_is_un_ignored: bool,
602
+ ) -> bool:
603
+ """Determine if a parameter should be skipped."""
604
+ if self.parameter_is_ignored(
605
+ channel=channel,
606
+ paramset_key=paramset_key,
607
+ parameter=parameter,
608
+ ):
609
+ _LOGGER.debug(
610
+ "SHOULD_SKIP_PARAMETER: Ignoring parameter: %s [%s]",
611
+ parameter,
612
+ channel.address,
613
+ )
614
+ return True
615
+ if (
616
+ paramset_key == ParamsetKey.MASTER
617
+ and (parameters := _RELEVANT_MASTER_PARAMSETS_BY_CHANNEL.get(channel.no)) is not None
618
+ and parameter in parameters
619
+ ):
620
+ return False
621
+
622
+ return paramset_key == ParamsetKey.MASTER and not parameter_is_un_ignored
623
+
624
+ def _process_un_ignore_entries(self, *, lines: Iterable[str]) -> None:
625
+ """Batch process un_ignore entries into cache."""
626
+ for line in lines:
627
+ # ignore empty line
628
+ if not line.strip():
629
+ continue
630
+
631
+ if line_details := self._get_un_ignore_line_details(line=line):
632
+ if isinstance(line_details, str):
633
+ self._custom_un_ignore_values_parameters.add(line_details)
634
+ else:
635
+ self._add_complex_un_ignore_entry(
636
+ model=line_details[0],
637
+ channel_no=line_details[1],
638
+ parameter=line_details[2],
639
+ paramset_key=line_details[3],
640
+ )
641
+ else:
642
+ _LOGGER.warning(
643
+ "PROCESS_UN_IGNORE_ENTRY failed: No supported format detected for un ignore line '%s'. ",
644
+ line,
645
+ )
646
+
647
+ def _get_un_ignore_line_details(
648
+ self, *, line: str
649
+ ) -> tuple[TModelName, TUnIgnoreChannelNo, TParameterName, ParamsetKey] | str | None:
650
+ """
651
+ Check the format of the line for un_ignore file.
652
+
653
+ model, channel_no, paramset_key, parameter
654
+ """
655
+
656
+ model: TModelName | None = None
657
+ channel_no: TUnIgnoreChannelNo = None
658
+ paramset_key: ParamsetKey | None = None
659
+ parameter: TParameterName | None = None
660
+
661
+ if "@" in line:
662
+ data = line.split("@")
663
+ if len(data) == 2:
664
+ if ADDRESS_SEPARATOR in data[0]:
665
+ param_data = data[0].split(ADDRESS_SEPARATOR)
666
+ if len(param_data) == 2:
667
+ parameter = param_data[0]
668
+ paramset_key = ParamsetKey(param_data[1])
669
+ else:
670
+ _LOGGER.warning(
671
+ "GET_UN_IGNORE_LINE_DETAILS failed: Could not add line '%s' to un ignore cache. "
672
+ "Only one ':' expected in param_data",
673
+ line,
674
+ )
675
+ return None
676
+ else:
677
+ _LOGGER.warning(
678
+ "GET_UN_IGNORE_LINE_DETAILS failed: Could not add line '%s' to un ignore cache. "
679
+ "No ':' before '@'",
680
+ line,
681
+ )
682
+ return None
683
+ if ADDRESS_SEPARATOR in data[1]:
684
+ channel_data = data[1].split(ADDRESS_SEPARATOR)
685
+ if len(channel_data) == 2:
686
+ model = channel_data[0].lower()
687
+ _channel_no = channel_data[1]
688
+ channel_no = (
689
+ int(_channel_no) if _channel_no.isnumeric() else None if _channel_no == "" else _channel_no
690
+ )
691
+ else:
692
+ _LOGGER.warning(
693
+ "GET_UN_IGNORE_LINE_DETAILS failed: Could not add line '%s' to un ignore cache. "
694
+ "Only one ':' expected in channel_data",
695
+ line,
696
+ )
697
+ return None
698
+ else:
699
+ _LOGGER.warning(
700
+ "GET_UN_IGNORE_LINE_DETAILS failed: Could not add line '%s' to un ignore cache. "
701
+ "No ':' after '@'",
702
+ line,
703
+ )
704
+ return None
705
+ else:
706
+ _LOGGER.warning(
707
+ "GET_UN_IGNORE_LINE_DETAILS failed: Could not add line '%s' to un ignore cache. "
708
+ "Only one @ expected",
709
+ line,
710
+ )
711
+ return None
712
+ elif ADDRESS_SEPARATOR in line:
713
+ _LOGGER.warning(
714
+ "GET_UN_IGNORE_LINE_DETAILS failed: No supported format detected for un ignore line '%s'. ",
715
+ line,
716
+ )
717
+ return None
718
+ if model == UN_IGNORE_WILDCARD and channel_no == UN_IGNORE_WILDCARD and paramset_key == ParamsetKey.VALUES:
719
+ return parameter
720
+ if model is not None and parameter is not None and paramset_key is not None:
721
+ return model, channel_no, parameter, paramset_key
722
+ return line
723
+
724
+ def _add_complex_un_ignore_entry(
725
+ self,
726
+ *,
727
+ model: TModelName,
728
+ channel_no: TUnIgnoreChannelNo,
729
+ paramset_key: ParamsetKey,
730
+ parameter: TParameterName,
731
+ ) -> None:
732
+ """Add line to un ignore cache."""
733
+ if paramset_key == ParamsetKey.MASTER:
734
+ if isinstance(channel_no, int) or channel_no is None:
735
+ # add master channel for a device to fetch paramset descriptions
736
+ self._relevant_master_paramsets_by_device[model].add(channel_no)
737
+ else:
738
+ _LOGGER.warning(
739
+ "ADD_UN_IGNORE_ENTRY: channel_no '%s' must be an integer or None for paramset_key MASTER.",
740
+ channel_no,
741
+ )
742
+ return
743
+ if model == UN_IGNORE_WILDCARD:
744
+ _LOGGER.warning("ADD_UN_IGNORE_ENTRY: model must be set for paramset_key MASTER.")
745
+ return
746
+
747
+ self._custom_un_ignore_complex[model][channel_no][paramset_key].add(parameter)
748
+
749
+ def parameter_is_hidden(
750
+ self,
751
+ *,
752
+ channel: hmd.Channel,
753
+ paramset_key: ParamsetKey,
754
+ parameter: TParameterName,
755
+ ) -> bool:
756
+ """
757
+ Return if parameter should be hidden.
758
+
759
+ This is required to determine the data_point usage.
760
+ Return only hidden parameters, that are no defined in the un_ignore file.
761
+ """
762
+ return parameter in _HIDDEN_PARAMETERS and not self._parameter_is_un_ignored(
763
+ channel=channel,
764
+ paramset_key=paramset_key,
765
+ parameter=parameter,
766
+ )
767
+
768
+ def is_relevant_paramset(
769
+ self,
770
+ *,
771
+ channel: hmd.Channel,
772
+ paramset_key: ParamsetKey,
773
+ ) -> bool:
774
+ """
775
+ Return if a paramset is relevant.
776
+
777
+ Required to load MASTER paramsets, which are not initialized by default.
778
+ """
779
+ if paramset_key == ParamsetKey.VALUES:
780
+ return True
781
+ if paramset_key == ParamsetKey.MASTER:
782
+ if channel.no in _RELEVANT_MASTER_PARAMSETS_BY_CHANNEL:
783
+ return True
784
+ model_l = channel.device.model.lower()
785
+ # Resolve matching device type prefix once and cache per model
786
+ dt_short_key = self._resolve_prefix_key(
787
+ model_l=model_l,
788
+ mapping=self._relevant_master_paramsets_by_device,
789
+ cache_dict=self._relevant_prefix_cache,
790
+ )
791
+ if dt_short_key is not None and channel.no in self._relevant_master_paramsets_by_device[dt_short_key]:
792
+ return True
793
+ return False
794
+
795
+
796
+ def check_ignore_parameters_is_clean() -> bool:
797
+ """Check if a required parameter is in ignored parameters."""
798
+ un_ignore_parameters_by_device: list[str] = []
799
+ for params in _UN_IGNORE_PARAMETERS_BY_DEVICE.values():
800
+ un_ignore_parameters_by_device.extend(params)
801
+
802
+ return (
803
+ len(
804
+ [
805
+ parameter
806
+ for parameter in get_required_parameters()
807
+ if (parameter in _IGNORED_PARAMETERS or _parameter_is_wildcard_ignored(parameter=parameter))
808
+ and parameter not in un_ignore_parameters_by_device
809
+ ]
810
+ )
811
+ == 0
812
+ )