Coverage for src / bluetooth_sig / gatt / characteristics / pipeline / validation.py: 89%
81 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Validation logic for characteristic values.
3Provides range, type, and length validation with a three-level precedence
4system: descriptor Valid Range > class-level attributes > YAML-derived ranges.
5"""
7from __future__ import annotations
9from typing import Any
11from ....types import SpecialValueResult
12from ....types.data_types import ValidationAccumulator
13from ....types.registry import CharacteristicSpec
14from ...context import CharacteristicContext
15from ...descriptor_utils import get_valid_range_from_context as _get_valid_range
18class CharacteristicValidator:
19 """Validates characteristic values against range, type, and length constraints.
21 Uses a back-reference to the owning characteristic to access validation
22 attributes (``min_value``, ``max_value``, ``expected_length``, etc.) and
23 YAML-derived metadata. This class is an **internal** implementation detail
24 of ``BaseCharacteristic`` and should not be used directly.
25 """
27 def __init__(self, char: Any) -> None: # noqa: ANN401 # Avoids circular BaseCharacteristic import
28 """Initialise with a back-reference to the owning characteristic.
30 Args:
31 char: BaseCharacteristic instance (typed as Any to avoid circular import)
33 """
34 self._char = char
36 # ------------------------------------------------------------------
37 # Range validation
38 # ------------------------------------------------------------------
40 def validate_range( # pylint: disable=too-many-branches
41 self,
42 value: Any, # noqa: ANN401 # Validates values of various numeric types
43 ctx: CharacteristicContext | None = None,
44 ) -> ValidationAccumulator:
45 """Validate value is within min/max range.
47 Validation precedence:
48 1. Descriptor Valid Range (if present in context) — most specific, device-reported
49 2. Class-level validation attributes (min_value, max_value) — characteristic spec defaults
50 3. YAML-derived value range from structure — Bluetooth SIG specification
52 Args:
53 value: The value to validate.
54 ctx: Optional characteristic context containing descriptors.
56 Returns:
57 ValidationAccumulator with errors if validation fails.
59 """
60 char = self._char
61 result = ValidationAccumulator()
63 # Skip validation for SpecialValueResult
64 if isinstance(value, SpecialValueResult):
65 return result
67 # Skip validation for non-numeric types
68 if not isinstance(value, (int, float)):
69 return result
71 # Check descriptor Valid Range first (takes precedence over class attributes)
72 descriptor_range = _get_valid_range(ctx) if ctx else None
73 if descriptor_range is not None:
74 min_val, max_val = descriptor_range
75 if value < min_val or value > max_val:
76 error_msg = (
77 f"Value {value} is outside valid range [{min_val}, {max_val}] "
78 f"(source: Valid Range descriptor for {char.name})"
79 )
80 if char.unit:
81 error_msg += f" [unit: {char.unit}]"
82 result.add_error(error_msg)
83 # Descriptor validation checked — skip class-level checks
84 return result
86 # Fall back to class-level validation attributes
87 if char.min_value is not None and value < char.min_value:
88 error_msg = (
89 f"Value {value} is below minimum {char.min_value} "
90 f"(source: class-level constraint for {char.__class__.__name__})"
91 )
92 if char.unit:
93 error_msg += f" [unit: {char.unit}]"
94 result.add_error(error_msg)
95 if char.max_value is not None and value > char.max_value:
96 error_msg = (
97 f"Value {value} is above maximum {char.max_value} "
98 f"(source: class-level constraint for {char.__class__.__name__})"
99 )
100 if char.unit:
101 error_msg += f" [unit: {char.unit}]"
102 result.add_error(error_msg)
104 # Fall back to YAML-derived value range from structure
105 _validate_yaml_range(result, value, char)
107 return result
109 # ------------------------------------------------------------------
110 # Type validation
111 # ------------------------------------------------------------------
113 def validate_type(self, value: Any) -> ValidationAccumulator: # noqa: ANN401
114 """Validate value type matches expected_type if specified.
116 Args:
117 value: The value to validate.
119 Returns:
120 ValidationAccumulator with errors if validation fails.
122 """
123 result = ValidationAccumulator()
125 expected_type: type | None = self._char.expected_type
126 if expected_type is not None and not isinstance(value, (expected_type, SpecialValueResult)):
127 error_msg = (
128 f"Type validation failed for {self._char.name}: "
129 f"expected {expected_type.__name__}, got {type(value).__name__} "
130 f"(value: {value})"
131 )
132 result.add_error(error_msg)
133 return result
135 # ------------------------------------------------------------------
136 # Length validation
137 # ------------------------------------------------------------------
139 def validate_length(self, data: bytes | bytearray) -> ValidationAccumulator:
140 """Validate data length meets requirements.
142 Args:
143 data: The data to validate.
145 Returns:
146 ValidationAccumulator with errors if validation fails.
148 """
149 char = self._char
150 result = ValidationAccumulator()
151 length = len(data)
153 # Determine validation source for error context
154 yaml_size = char.get_yaml_field_size()
155 source_context = ""
156 if yaml_size is not None:
157 source_context = f" (YAML specification: {yaml_size} bytes)"
158 elif char.expected_length is not None or char.min_length is not None or char.max_length is not None:
159 source_context = f" (class-level constraint for {char.__class__.__name__})"
161 if char.expected_length is not None and length != char.expected_length:
162 error_msg = (
163 f"Length validation failed for {char.name}: "
164 f"expected exactly {char.expected_length} bytes, got {length}{source_context}"
165 )
166 result.add_error(error_msg)
167 if char.min_length is not None and length < char.min_length:
168 error_msg = (
169 f"Length validation failed for {char.name}: "
170 f"expected at least {char.min_length} bytes, got {length}{source_context}"
171 )
172 result.add_error(error_msg)
173 if char.max_length is not None and length > char.max_length:
174 error_msg = (
175 f"Length validation failed for {char.name}: "
176 f"expected at most {char.max_length} bytes, got {length}{source_context}"
177 )
178 result.add_error(error_msg)
179 return result
182# ------------------------------------------------------------------
183# Private helper
184# ------------------------------------------------------------------
187def _validate_yaml_range(
188 result: ValidationAccumulator,
189 value: int | float,
190 char: Any, # noqa: ANN401 # BaseCharacteristic back-reference
191) -> None:
192 """Add YAML-derived range validation errors to *result* (mutates in-place).
194 Only applies when no class-level min/max constraints are set.
195 """
196 spec: CharacteristicSpec | None = char._spec # Internal composition
197 if char.min_value is not None or char.max_value is not None or not spec or not spec.structure:
198 return
200 for field in spec.structure:
201 yaml_range = field.value_range
202 if yaml_range is not None:
203 min_val, max_val = yaml_range
204 # Use tolerance for floating-point comparison (common in scaled characteristics)
205 tolerance = max(abs(max_val - min_val) * 1e-9, 1e-9) if isinstance(value, float) else 0
206 if value < min_val - tolerance or value > max_val + tolerance:
207 yaml_source = f"{spec.name}" if spec.name else "YAML specification"
208 error_msg = (
209 f"Value {value} is outside allowed range [{min_val}, {max_val}] "
210 f"(source: Bluetooth SIG {yaml_source})"
211 )
212 if char.unit:
213 error_msg += f" [unit: {char.unit}]"
214 result.add_error(error_msg)
215 break # Use first field with range found