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

1"""Validation logic for characteristic values. 

2 

3Provides range, type, and length validation with a three-level precedence 

4system: descriptor Valid Range > class-level attributes > YAML-derived ranges. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import Any 

10 

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 

16 

17 

18class CharacteristicValidator: 

19 """Validates characteristic values against range, type, and length constraints. 

20 

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 """ 

26 

27 def __init__(self, char: Any) -> None: # noqa: ANN401 # Avoids circular BaseCharacteristic import 

28 """Initialise with a back-reference to the owning characteristic. 

29 

30 Args: 

31 char: BaseCharacteristic instance (typed as Any to avoid circular import) 

32 

33 """ 

34 self._char = char 

35 

36 # ------------------------------------------------------------------ 

37 # Range validation 

38 # ------------------------------------------------------------------ 

39 

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. 

46 

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 

51 

52 Args: 

53 value: The value to validate. 

54 ctx: Optional characteristic context containing descriptors. 

55 

56 Returns: 

57 ValidationAccumulator with errors if validation fails. 

58 

59 """ 

60 char = self._char 

61 result = ValidationAccumulator() 

62 

63 # Skip validation for SpecialValueResult 

64 if isinstance(value, SpecialValueResult): 

65 return result 

66 

67 # Skip validation for non-numeric types 

68 if not isinstance(value, (int, float)): 

69 return result 

70 

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 

85 

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) 

103 

104 # Fall back to YAML-derived value range from structure 

105 _validate_yaml_range(result, value, char) 

106 

107 return result 

108 

109 # ------------------------------------------------------------------ 

110 # Type validation 

111 # ------------------------------------------------------------------ 

112 

113 def validate_type(self, value: Any) -> ValidationAccumulator: # noqa: ANN401 

114 """Validate value type matches expected_type if specified. 

115 

116 Args: 

117 value: The value to validate. 

118 

119 Returns: 

120 ValidationAccumulator with errors if validation fails. 

121 

122 """ 

123 result = ValidationAccumulator() 

124 

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 

134 

135 # ------------------------------------------------------------------ 

136 # Length validation 

137 # ------------------------------------------------------------------ 

138 

139 def validate_length(self, data: bytes | bytearray) -> ValidationAccumulator: 

140 """Validate data length meets requirements. 

141 

142 Args: 

143 data: The data to validate. 

144 

145 Returns: 

146 ValidationAccumulator with errors if validation fails. 

147 

148 """ 

149 char = self._char 

150 result = ValidationAccumulator() 

151 length = len(data) 

152 

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__})" 

160 

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 

180 

181 

182# ------------------------------------------------------------------ 

183# Private helper 

184# ------------------------------------------------------------------ 

185 

186 

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

193 

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 

199 

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