Skip to content

Commit 33de21f

Browse files
committed
Refactor SectionValidator and APIValidator to enhance parameter validation logic
- Improved tracking of validated hot water parameters in SectionValidator. - Updated validation methods to ensure uncovered parameters are validated correctly. - Enhanced APIValidator to manage parameter validation states more effectively. - Added tests to verify validation behavior for uncovered parameters and include filters.
1 parent 0c1aa4d commit 33de21f

6 files changed

Lines changed: 415 additions & 67 deletions

File tree

src/bsblan/_validation.py

Lines changed: 104 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ def __init__(
7575
self._api_validator: APIValidator | None = None
7676
# Cache of validated hot water parameter id -> name.
7777
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.
7979
self._validated_hot_water_groups: set[str] = set()
80+
self._validated_hot_water_parameters: dict[str, set[str]] = {}
8081
# Locks to prevent concurrent validation of the same section/group.
8182
self._section_locks: dict[str, asyncio.Lock] = {}
8283
self._hot_water_group_locks: dict[str, asyncio.Lock] = {}
@@ -192,6 +193,11 @@ async def ensure_section_validated(
192193
raise BSBLANError(ErrorMsg.API_VALIDATOR_NOT_INITIALIZED)
193194

194195
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))
195201

196202
async def _validate() -> None:
197203
logger.debug("Lazy loading section: %s", section)
@@ -205,7 +211,9 @@ async def _validate() -> None:
205211
await self._run_once_locked(
206212
section,
207213
self._section_locks,
208-
lambda: api_validator.is_section_validated(section),
214+
lambda: api_validator.are_parameters_validated(
215+
section, _requested_parameter_ids()
216+
),
209217
_validate,
210218
)
211219

@@ -231,49 +239,38 @@ async def ensure_hot_water_group_validated(
231239
If provided, only these parameters will be validated.
232240
233241
"""
242+
if group_name in self._validated_hot_water_groups:
243+
return
234244

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)
238247

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())
242250

251+
async def _validate() -> None:
243252
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())
253259
}
254260

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:
264262
logger.debug("No parameters to validate for group %s", group_name)
265-
self._validated_hot_water_groups.add(group_name)
266263
return
267264

268265
# Request only these specific parameters from the device
269-
params = self._extract_params_summary(group_params)
266+
params = self._extract_params_summary(uncovered_params)
270267
response_data = await self._request(
271268
params={"Parameter": params["string_par"]}
272269
)
273270

274271
# Validate and filter out unsupported params
275272
params_to_remove = []
276-
for param_id, param_name in group_params.items():
273+
for param_id, param_name in uncovered_params.items():
277274
if param_id not in response_data:
278275
logger.info(
279276
"Hot water param %s (%s) not found in response",
@@ -294,26 +291,87 @@ async def _validate() -> None:
294291
params_to_remove.append(param_id)
295292

296293
# 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():
298295
if param_id not in params_to_remove:
299296
self._hot_water_param_cache[param_id] = param_name
300297

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)
303309
logger.debug(
304310
"Validated hot water group '%s': %d params, removed %d unsupported",
305311
group_name,
306-
len(group_params),
312+
len(uncovered_params),
307313
len(params_to_remove),
308314
)
309315

310316
await self._run_once_locked(
311317
group_name,
312318
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+
),
314322
_validate,
315323
)
316324

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+
317375
async def _validate_api_section(
318376
self, section: SectionLiteral, include: list[str] | None = None
319377
) -> dict[str, Any] | None:
@@ -340,36 +398,31 @@ async def _validate_api_section(
340398
if not api_data:
341399
raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
342400

343-
# Assign to local variable after asserting it's not None
344401
api_validator = self._api_validator
345-
346402
if api_validator.is_section_validated(section):
347403
return None
348404

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+
}
355411

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
363414

364415
try:
365416
# Request data from device for validation
366-
params = self._extract_params_summary(section_data)
417+
params = self._extract_params_summary(uncovered_params)
367418
response_data = await self._request(
368419
params={"Parameter": params["string_par"]}
369420
)
370421

371422
# 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+
)
373426
# Update API data with validated configuration
374427
api_data[section] = api_validator.get_section_params(section)
375428

src/bsblan/utility.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,14 @@ class APIValidator:
9494
# Flexible type for API data (accepts `APIConfig`, plain dicts, or None)
9595
api_config: Any # intentionally permissive to support tests and dynamic data
9696
validated_sections: set[str] = field(default_factory=set)
97+
validated_parameters: dict[str, set[str]] = field(default_factory=dict)
9798

9899
def validate_section(
99100
self,
100101
section: str,
101102
request_data: dict[str, Any],
102103
include: list[str] | None = None,
104+
parameter_ids: set[str] | None = None,
103105
) -> None:
104106
"""Validate and update a section of API config based on actual device support.
105107
@@ -109,26 +111,36 @@ def validate_section(
109111
request_data: Response data from the device for validation
110112
include: Optional list of parameter names to validate. If None,
111113
validates all parameters for the section.
114+
parameter_ids: Optional parameter IDs to validate. When supplied,
115+
takes precedence over ``include``.
112116
113117
"""
114118
# Check if the section exists in the APIConfig object
115119
if not self.api_config or section not in self.api_config:
116120
logger.warning("Unknown section '%s' in API configuration", section)
117121
return
118122

119-
# Skip if section was already validated
120123
if section in self.validated_sections:
121124
logger.debug("Section '%s' was already validated", section)
122125
return
123126

124127
section_config = self.api_config[section]
125128
params_to_remove = []
129+
validated_ids = set()
126130

127131
# 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()):
129133
# 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:
131135
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)
132144

133145
if param_id not in request_data:
134146
logger.info(
@@ -153,8 +165,11 @@ def validate_section(
153165
for param_id in params_to_remove:
154166
section_config.pop(param_id)
155167

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)
158173

159174
logger.debug(
160175
"Validated section '%s': removed %d unsupported parameters",
@@ -170,8 +185,14 @@ def get_section_params(self, section: str) -> Any:
170185
"""Get the parameter mapping for a section."""
171186
return (self.api_config or {}).get(section, {}).copy()
172187

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+
173194
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."""
175196
return section in self.validated_sections
176197

177198
def reset_validation(self, section: str | None = None) -> None:
@@ -183,5 +204,9 @@ def reset_validation(self, section: str | None = None) -> None:
183204
"""
184205
if section is None:
185206
self.validated_sections.clear()
207+
self.validated_parameters.clear()
186208
elif section in self.validated_sections:
187209
self.validated_sections.remove(section)
210+
self.validated_parameters.pop(section, None)
211+
else:
212+
self.validated_parameters.pop(section, None)

0 commit comments

Comments
 (0)