python-bsblan 6.1.7__py3-none-any.whl → 6.1.8__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.
bsblan/_validation.py CHANGED
@@ -75,8 +75,9 @@ class SectionValidator:
75
75
  self._api_validator: APIValidator | None = None
76
76
  # Cache of validated hot water parameter id -> name.
77
77
  self._hot_water_param_cache: dict[str, str] = {}
78
- # Track which hot water param groups have been validated.
78
+ # Track which hot water param groups have been fully validated.
79
79
  self._validated_hot_water_groups: set[str] = set()
80
+ self._validated_hot_water_parameters: dict[str, set[str]] = {}
80
81
  # Locks to prevent concurrent validation of the same section/group.
81
82
  self._section_locks: dict[str, asyncio.Lock] = {}
82
83
  self._hot_water_group_locks: dict[str, asyncio.Lock] = {}
@@ -192,6 +193,11 @@ class SectionValidator:
192
193
  raise BSBLANError(ErrorMsg.API_VALIDATOR_NOT_INITIALIZED)
193
194
 
194
195
  api_validator = self._api_validator
196
+ if api_validator.is_section_validated(section):
197
+ return
198
+
199
+ def _requested_parameter_ids() -> set[str]:
200
+ return set(self._get_section_params(section, include))
195
201
 
196
202
  async def _validate() -> None:
197
203
  logger.debug("Lazy loading section: %s", section)
@@ -205,7 +211,9 @@ class SectionValidator:
205
211
  await self._run_once_locked(
206
212
  section,
207
213
  self._section_locks,
208
- lambda: api_validator.is_section_validated(section),
214
+ lambda: api_validator.are_parameters_validated(
215
+ section, _requested_parameter_ids()
216
+ ),
209
217
  _validate,
210
218
  )
211
219
 
@@ -231,49 +239,38 @@ class SectionValidator:
231
239
  If provided, only these parameters will be validated.
232
240
 
233
241
  """
242
+ if group_name in self._validated_hot_water_groups:
243
+ return
234
244
 
235
- async def _validate() -> None:
236
- if not self._api_validator:
237
- raise BSBLANError(ErrorMsg.API_VALIDATOR_NOT_INITIALIZED)
245
+ def _group_params() -> dict[str, str]:
246
+ return self._get_hot_water_group_params(param_filter, include)
238
247
 
239
- api_data = self._get_api_data()
240
- if not api_data:
241
- raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
248
+ def _requested_parameter_ids() -> set[str]:
249
+ return set(_group_params())
242
250
 
251
+ async def _validate() -> None:
243
252
  logger.debug("Lazy loading hot water group: %s", group_name)
244
-
245
- # Get the base hot water params from API config
246
- section_data = api_data.get("hot_water", {})
247
-
248
- # Filter to only the params in this group
249
- group_params = {
250
- param_id: param_name
251
- for param_id, param_name in section_data.items()
252
- if param_id in param_filter
253
+ group_params = _group_params()
254
+ uncovered_params = {
255
+ param_id: name
256
+ for param_id, name in group_params.items()
257
+ if param_id
258
+ not in self._validated_hot_water_parameters.get(group_name, set())
253
259
  }
254
260
 
255
- # Apply include filter if specified - only validate requested params
256
- if include is not None:
257
- group_params = {
258
- param_id: name
259
- for param_id, name in group_params.items()
260
- if name in include
261
- }
262
-
263
- if not group_params:
261
+ if not uncovered_params:
264
262
  logger.debug("No parameters to validate for group %s", group_name)
265
- self._validated_hot_water_groups.add(group_name)
266
263
  return
267
264
 
268
265
  # Request only these specific parameters from the device
269
- params = self._extract_params_summary(group_params)
266
+ params = self._extract_params_summary(uncovered_params)
270
267
  response_data = await self._request(
271
268
  params={"Parameter": params["string_par"]}
272
269
  )
273
270
 
274
271
  # Validate and filter out unsupported params
275
272
  params_to_remove = []
276
- for param_id, param_name in group_params.items():
273
+ for param_id, param_name in uncovered_params.items():
277
274
  if param_id not in response_data:
278
275
  logger.info(
279
276
  "Hot water param %s (%s) not found in response",
@@ -294,26 +291,87 @@ class SectionValidator:
294
291
  params_to_remove.append(param_id)
295
292
 
296
293
  # Update the cache with validated params for this group
297
- for param_id, param_name in group_params.items():
294
+ for param_id, param_name in uncovered_params.items():
298
295
  if param_id not in params_to_remove:
299
296
  self._hot_water_param_cache[param_id] = param_name
300
297
 
301
- # Mark this group as validated
302
- self._validated_hot_water_groups.add(group_name)
298
+ validated_ids = self._validated_hot_water_parameters.setdefault(
299
+ group_name, set()
300
+ )
301
+ validated_ids.update(uncovered_params)
302
+ if (
303
+ self._get_hot_water_group_params(param_filter, None).keys()
304
+ <= validated_ids
305
+ ):
306
+ self._validated_hot_water_groups.add(group_name)
307
+ else:
308
+ self._validated_hot_water_groups.discard(group_name)
303
309
  logger.debug(
304
310
  "Validated hot water group '%s': %d params, removed %d unsupported",
305
311
  group_name,
306
- len(group_params),
312
+ len(uncovered_params),
307
313
  len(params_to_remove),
308
314
  )
309
315
 
310
316
  await self._run_once_locked(
311
317
  group_name,
312
318
  self._hot_water_group_locks,
313
- lambda: group_name in self._validated_hot_water_groups,
319
+ lambda: self._are_hot_water_parameters_validated(
320
+ group_name, _requested_parameter_ids()
321
+ ),
314
322
  _validate,
315
323
  )
316
324
 
325
+ def _get_section_params(
326
+ self, section: SectionLiteral, include: list[str] | None
327
+ ) -> dict[str, str]:
328
+ """Get the configured parameters selected for a section request."""
329
+ api_data = self._get_api_data()
330
+ if not api_data:
331
+ raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
332
+
333
+ try:
334
+ section_data = api_data[section]
335
+ except KeyError as err:
336
+ msg = ErrorMsg.SECTION_NOT_FOUND.format(section)
337
+ raise BSBLANError(msg) from err
338
+
339
+ if include is None:
340
+ return section_data
341
+ return {
342
+ param_id: name for param_id, name in section_data.items() if name in include
343
+ }
344
+
345
+ def _get_hot_water_group_params(
346
+ self, param_filter: set[str], include: list[str] | None
347
+ ) -> dict[str, str]:
348
+ """Get configured parameters selected for a hot water group request."""
349
+ if not self._api_validator:
350
+ raise BSBLANError(ErrorMsg.API_VALIDATOR_NOT_INITIALIZED)
351
+
352
+ api_data = self._get_api_data()
353
+ if not api_data:
354
+ raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
355
+
356
+ group_params = {
357
+ param_id: param_name
358
+ for param_id, param_name in api_data.get("hot_water", {}).items()
359
+ if param_id in param_filter
360
+ }
361
+ if include is None:
362
+ return group_params
363
+ return {
364
+ param_id: name for param_id, name in group_params.items() if name in include
365
+ }
366
+
367
+ def _are_hot_water_parameters_validated(
368
+ self, group_name: str, parameter_ids: set[str]
369
+ ) -> bool:
370
+ """Check whether every requested hot water parameter ID was validated."""
371
+ return group_name in self._validated_hot_water_groups or parameter_ids.issubset(
372
+ self._validated_hot_water_parameters.get(group_name, set())
373
+ )
374
+
317
375
  async def _validate_api_section(
318
376
  self, section: SectionLiteral, include: list[str] | None = None
319
377
  ) -> dict[str, Any] | None:
@@ -340,36 +398,31 @@ class SectionValidator:
340
398
  if not api_data:
341
399
  raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
342
400
 
343
- # Assign to local variable after asserting it's not None
344
401
  api_validator = self._api_validator
345
-
346
402
  if api_validator.is_section_validated(section):
347
403
  return None
348
404
 
349
- # Get parameters for the section
350
- try:
351
- section_data = api_data[section]
352
- except KeyError as err:
353
- msg = ErrorMsg.SECTION_NOT_FOUND.format(section)
354
- raise BSBLANError(msg) from err
405
+ section_data = self._get_section_params(section, include)
406
+ uncovered_params = {
407
+ param_id: name
408
+ for param_id, name in section_data.items()
409
+ if not api_validator.are_parameters_validated(section, {param_id})
410
+ }
355
411
 
356
- # Filter to only included params if specified
357
- if include is not None:
358
- section_data = {
359
- param_id: name
360
- for param_id, name in section_data.items()
361
- if name in include
362
- }
412
+ if not uncovered_params:
413
+ return None
363
414
 
364
415
  try:
365
416
  # Request data from device for validation
366
- params = self._extract_params_summary(section_data)
417
+ params = self._extract_params_summary(uncovered_params)
367
418
  response_data = await self._request(
368
419
  params={"Parameter": params["string_par"]}
369
420
  )
370
421
 
371
422
  # Validate the section against actual device response
372
- api_validator.validate_section(section, response_data, include)
423
+ api_validator.validate_section(
424
+ section, response_data, parameter_ids=set(uncovered_params)
425
+ )
373
426
  # Update API data with validated configuration
374
427
  api_data[section] = api_validator.get_section_params(section)
375
428
 
bsblan/models.py CHANGED
@@ -254,7 +254,11 @@ def _convert_bsblan_value(
254
254
  The converted value, or None if the value is inactive.
255
255
 
256
256
  """
257
- if raw_value is None or raw_value in {"---", ""}:
257
+ # BSB-LAN's `replaceDisabled` setting decides what the firmware sends for a
258
+ # deactivated/inactive numeric parameter. It defaults to "---", but its own
259
+ # BSB_LAN_config.h documents "None" as the value Home Assistant users should
260
+ # pick, so both spellings reach us in the wild.
261
+ if raw_value is None or raw_value in {"---", "", "None"}:
258
262
  return None
259
263
 
260
264
  raw = str(raw_value)
@@ -302,7 +306,8 @@ class EntityInfo(BaseModel, Generic[T]):
302
306
  - ``EntityInfo[time]`` for HH:MM time values
303
307
  - ``EntityInfo[str]`` for string / datetime values
304
308
 
305
- When the device returns ``"---"`` (sensor not in use), ``value`` is set
309
+ When the device returns ``"---"`` or ``"None"`` (sensor not in use, per
310
+ BSB-LAN's ``replaceDisabled`` setting), ``value`` is set
306
311
  to ``None``.
307
312
 
308
313
  Attributes:
bsblan/utility.py CHANGED
@@ -94,12 +94,14 @@ class APIValidator:
94
94
  # Flexible type for API data (accepts `APIConfig`, plain dicts, or None)
95
95
  api_config: Any # intentionally permissive to support tests and dynamic data
96
96
  validated_sections: set[str] = field(default_factory=set)
97
+ validated_parameters: dict[str, set[str]] = field(default_factory=dict)
97
98
 
98
99
  def validate_section(
99
100
  self,
100
101
  section: str,
101
102
  request_data: dict[str, Any],
102
103
  include: list[str] | None = None,
104
+ parameter_ids: set[str] | None = None,
103
105
  ) -> None:
104
106
  """Validate and update a section of API config based on actual device support.
105
107
 
@@ -109,6 +111,8 @@ class APIValidator:
109
111
  request_data: Response data from the device for validation
110
112
  include: Optional list of parameter names to validate. If None,
111
113
  validates all parameters for the section.
114
+ parameter_ids: Optional parameter IDs to validate. When supplied,
115
+ takes precedence over ``include``.
112
116
 
113
117
  """
114
118
  # Check if the section exists in the APIConfig object
@@ -116,19 +120,27 @@ class APIValidator:
116
120
  logger.warning("Unknown section '%s' in API configuration", section)
117
121
  return
118
122
 
119
- # Skip if section was already validated
120
123
  if section in self.validated_sections:
121
124
  logger.debug("Section '%s' was already validated", section)
122
125
  return
123
126
 
124
127
  section_config = self.api_config[section]
125
128
  params_to_remove = []
129
+ validated_ids = set()
126
130
 
127
131
  # Check each parameter in the section (filtered by include if specified)
128
- for param_id, param_name in section_config.items():
132
+ for param_id, param_name in list(section_config.items()):
129
133
  # Skip params not in include list (if include is specified)
130
- if include is not None and param_name not in include:
134
+ if parameter_ids is not None and param_id not in parameter_ids:
131
135
  continue
136
+ if (
137
+ parameter_ids is None
138
+ and include is not None
139
+ and param_name not in include
140
+ ):
141
+ continue
142
+
143
+ validated_ids.add(param_id)
132
144
 
133
145
  if param_id not in request_data:
134
146
  logger.info(
@@ -153,8 +165,11 @@ class APIValidator:
153
165
  for param_id in params_to_remove:
154
166
  section_config.pop(param_id)
155
167
 
156
- # Mark section as validated
157
- self.validated_sections.add(section)
168
+ self.validated_parameters.setdefault(section, set()).update(validated_ids)
169
+ if set(section_config).issubset(self.validated_parameters[section]):
170
+ self.validated_sections.add(section)
171
+ else:
172
+ self.validated_sections.discard(section)
158
173
 
159
174
  logger.debug(
160
175
  "Validated section '%s': removed %d unsupported parameters",
@@ -170,8 +185,14 @@ class APIValidator:
170
185
  """Get the parameter mapping for a section."""
171
186
  return (self.api_config or {}).get(section, {}).copy()
172
187
 
188
+ def are_parameters_validated(self, section: str, parameter_ids: set[str]) -> bool:
189
+ """Check whether every requested parameter ID was validated."""
190
+ return section in self.validated_sections or parameter_ids.issubset(
191
+ self.validated_parameters.get(section, set())
192
+ )
193
+
173
194
  def is_section_validated(self, section: str) -> bool:
174
- """Check if a section has been validated."""
195
+ """Check whether every current parameter in a section was validated."""
175
196
  return section in self.validated_sections
176
197
 
177
198
  def reset_validation(self, section: str | None = None) -> None:
@@ -183,5 +204,9 @@ class APIValidator:
183
204
  """
184
205
  if section is None:
185
206
  self.validated_sections.clear()
207
+ self.validated_parameters.clear()
186
208
  elif section in self.validated_sections:
187
209
  self.validated_sections.remove(section)
210
+ self.validated_parameters.pop(section, None)
211
+ else:
212
+ self.validated_parameters.pop(section, None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-bsblan
3
- Version: 6.1.7
3
+ Version: 6.1.8
4
4
  Summary: Asynchronous Python client for BSBLAN API
5
5
  Project-URL: Homepage, https://github.com/liudger/python-bsblan
6
6
  Project-URL: Repository, https://github.com/liudger/python-bsblan
@@ -5,15 +5,15 @@ bsblan/_schedules.py,sha256=Pb0TrhoZM3Wlf83zZOo2d31xDLI0178TNWRqhljJJmU,5273
5
5
  bsblan/_temperature.py,sha256=DsxFd6f1EMYKHcrf74ScQk6YqaYuUy0zBPHhiP-C0nY,11366
6
6
  bsblan/_thermostat.py,sha256=_Dgvt1iVLGJT2H5i4z9R1RUU44NZCJJwnP1D9rSXDHk,5052
7
7
  bsblan/_transport.py,sha256=qNtV_8uJrfYfHlbaExPln4jCaJxqE5sypq5OHyxG8KE,8266
8
- bsblan/_validation.py,sha256=PsS7IeQ_IiiNYF6iu1opJJCe6aHPo41kxvSjapV6fjc,15661
8
+ bsblan/_validation.py,sha256=JXXRRo95k9yIaErrHxui30e5b3CXJkRWvxo9Fy47Pgo,17742
9
9
  bsblan/_version.py,sha256=hpeIg9JUswHSorY3iSUERcMC1MzwM9Rpdo1wqeh5rc8,2799
10
10
  bsblan/bsblan.py,sha256=su_WvelM4FauTyn1RNTJYQZwfwXhSG0Mj6r7RIGNVIg,46887
11
11
  bsblan/constants.py,sha256=G0VEImKfc30W68c8RCH3AWRitCTMSVVgXwdhqHOQr7o,25589
12
12
  bsblan/exceptions.py,sha256=Z9pXtVc8zhy9FTBkT8leJ1UE9zdOdLHF7pE7C4GjBmg,2795
13
- bsblan/models.py,sha256=1uRHmDxvPyGYvIuEJczP1mdCR1IJafDrBL9yKDew8E8,22570
13
+ bsblan/models.py,sha256=nbQjmkLEdM_MtazTTDS1KISZs9h1iestoZg8byVB9kM,22932
14
14
  bsblan/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- bsblan/utility.py,sha256=IvDUIpa77lJ6GLFTOx34fDhMlTuIJsaz9HcG-3bncH4,6248
16
- python_bsblan-6.1.7.dist-info/METADATA,sha256=IXEEUQlZMsUdTijDyzPPUrOVzdQc6HfvNpmdoGF_0Xw,9585
17
- python_bsblan-6.1.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
- python_bsblan-6.1.7.dist-info/licenses/LICENSE.md,sha256=Shv8HPcD1WbZjBPvfb5r3h_cwaPeVaUZMUqU_XQGwGw,1092
19
- python_bsblan-6.1.7.dist-info/RECORD,,
15
+ bsblan/utility.py,sha256=5oD3f84ke_r7pTQN2jjhhgtVjLjq1Ohld3LlHN7m8xg,7421
16
+ python_bsblan-6.1.8.dist-info/METADATA,sha256=t4usBriYaK4FCTLbD_YI1zMN9Wye-Mfnylmd21mC-hQ,9585
17
+ python_bsblan-6.1.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
+ python_bsblan-6.1.8.dist-info/licenses/LICENSE.md,sha256=Shv8HPcD1WbZjBPvfb5r3h_cwaPeVaUZMUqU_XQGwGw,1092
19
+ python_bsblan-6.1.8.dist-info/RECORD,,