Coverage for src / bluetooth_sig / gatt / characteristics / base.py: 85%

640 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

1"""Base class for GATT characteristics. 

2 

3This module implements the core characteristic parsing and encoding system for 

4Bluetooth GATT characteristics, following official Bluetooth SIG specifications. 

5 

6Architecture 

7============ 

8 

9The implementation uses a multi-stage pipeline for parsing and encoding: 

10 

11**Parsing Pipeline (parse_value):** 

12 1. Length validation (pre-decode) 

13 2. Raw integer extraction (little-endian per Bluetooth spec) 

14 3. Special value detection (sentinel values like 0x8000) 

15 4. Value decoding (via template or subclass override) 

16 5. Range validation (post-decode) 

17 6. Type validation 

18 

19**Encoding Pipeline (build_value):** 

20 1. Type validation 

21 2. Range validation 

22 3. Value encoding (via template or subclass override) 

23 4. Length validation (post-encode) 

24 

25YAML Metadata Resolution 

26========================= 

27 

28Characteristic metadata is automatically resolved from Bluetooth SIG YAML specifications: 

29 

30- UUID, name, value type from assigned numbers registry 

31- Units, resolution, and scaling factors (M × 10^d + b formula) 

32- Special sentinel values (e.g., 0x8000 = "value is not known") 

33- Validation ranges and length constraints 

34 

35Manual overrides (_manual_unit, _special_values, etc.) should only be used for: 

36- Fixing incomplete or incorrect SIG specifications 

37- Custom characteristics not in official registry 

38- Performance optimizations 

39 

40Template Composition 

41==================== 

42 

43Characteristics use templates for reusable parsing logic via composition: 

44 

45 class TemperatureCharacteristic(BaseCharacteristic): 

46 _template = Sint16Template(resolution=0.01, unit="°C") 

47 # No need to override decode_value() - template handles it 

48 

49Subclasses only override decode_value() for custom logic that templates 

50cannot handle. Templates take priority over YAML-derived extractors. 

51 

52Validation Sources (Priority Order) 

53=================================== 

54 

551. **Descriptor Valid Range** - Device-reported constraints (highest priority) 

562. **Class-level Attributes** - Characteristic spec defaults (min_value, max_value) 

573. **YAML-derived Ranges** - Bluetooth SIG specification ranges (fallback) 

58 

59Special Values 

60============== 

61 

62Sentinel values (like 0x8000 for "unknown") bypass range and type validation 

63since they represent non-numeric states. The gss_special_values property 

64handles both unsigned (0x8000) and signed (-32768) interpretations for 

65compatibility with different parsing contexts. 

66 

67Byte Order 

68========== 

69 

70All multi-byte values use little-endian encoding per Bluetooth Core Specification. 

71""" 

72# pylint: disable=too-many-lines 

73 

74from __future__ import annotations 

75 

76import os 

77import re 

78from abc import ABC, ABCMeta 

79from functools import cached_property, lru_cache 

80from typing import Any, Generic, TypeVar 

81 

82import msgspec 

83 

84from ...registry.uuids.units import units_registry 

85from ...types import ( 

86 CharacteristicInfo, 

87 SpecialValueResult, 

88 SpecialValueRule, 

89 SpecialValueType, 

90 classify_special_value, 

91) 

92from ...types import ParseFieldError as FieldError 

93from ...types.data_types import ValidationAccumulator 

94from ...types.gatt_enums import CharacteristicName, DataType, GattProperty, ValueType 

95from ...types.registry import CharacteristicSpec 

96from ...types.registry.descriptor_types import DescriptorData 

97from ...types.uuid import BluetoothUUID 

98from ..context import CharacteristicContext 

99from ..descriptor_utils import enhance_error_message_with_descriptors as _enhance_error_message 

100from ..descriptor_utils import get_descriptor_from_context as _get_descriptor 

101from ..descriptor_utils import get_presentation_format_from_context as _get_presentation_format 

102from ..descriptor_utils import get_user_description_from_context as _get_user_description 

103from ..descriptor_utils import get_valid_range_from_context as _get_valid_range 

104from ..descriptor_utils import validate_value_against_descriptor_range as _validate_value_range 

105from ..descriptors import BaseDescriptor 

106from ..descriptors.cccd import CCCDDescriptor 

107from ..descriptors.characteristic_presentation_format import CharacteristicPresentationFormatData 

108from ..exceptions import ( 

109 CharacteristicEncodeError, 

110 CharacteristicParseError, 

111 ParseFieldError, 

112 SpecialValueDetected, 

113 UUIDResolutionError, 

114) 

115from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator 

116from ..special_values_resolver import SpecialValueResolver 

117from ..uuid_registry import uuid_registry 

118from .templates import CodingTemplate 

119from .utils.extractors import get_extractor 

120 

121# Type variable for generic characteristic return types 

122T = TypeVar("T") 

123 

124 

125class ValidationConfig(msgspec.Struct, kw_only=True): 

126 """Configuration for characteristic validation constraints. 

127 

128 Groups validation parameters into a single, optional configuration object 

129 to simplify BaseCharacteristic constructor signatures. 

130 """ 

131 

132 min_value: int | float | None = None 

133 max_value: int | float | None = None 

134 expected_length: int | None = None 

135 min_length: int | None = None 

136 max_length: int | None = None 

137 allow_variable_length: bool = False 

138 expected_type: type | None = None 

139 

140 

141class SIGCharacteristicResolver: 

142 """Resolves SIG characteristic information from YAML and registry. 

143 

144 This class handles all SIG characteristic resolution logic, separating 

145 concerns from the BaseCharacteristic constructor. Uses shared utilities 

146 from the resolver module to avoid code duplication. 

147 """ 

148 

149 camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name) 

150 

151 @staticmethod 

152 def resolve_for_class(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicInfo: 

153 """Resolve CharacteristicInfo for a SIG characteristic class. 

154 

155 Args: 

156 char_class: The characteristic class to resolve info for 

157 

158 Returns: 

159 CharacteristicInfo with resolved UUID, name, value_type, unit 

160 

161 Raises: 

162 UUIDResolutionError: If no UUID can be resolved for the class 

163 

164 """ 

165 # Try YAML resolution first 

166 yaml_spec = SIGCharacteristicResolver.resolve_yaml_spec_for_class(char_class) 

167 if yaml_spec: 

168 return SIGCharacteristicResolver._create_info_from_yaml(yaml_spec, char_class) 

169 

170 # Fallback to registry 

171 registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class) 

172 if registry_info: 

173 return registry_info 

174 

175 # No resolution found 

176 raise UUIDResolutionError(char_class.__name__, [char_class.__name__]) 

177 

178 @staticmethod 

179 def resolve_yaml_spec_for_class(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicSpec | None: 

180 """Resolve YAML spec for a characteristic class using shared name variant logic.""" 

181 # Get explicit name if set 

182 characteristic_name = getattr(char_class, "_characteristic_name", None) 

183 

184 # Generate all name variants using shared utility 

185 names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name) 

186 

187 # Try each name format with YAML resolution 

188 for try_name in names_to_try: 

189 spec = uuid_registry.resolve_characteristic_spec(try_name) 

190 if spec: 

191 return spec 

192 

193 return None 

194 

195 @staticmethod 

196 def _create_info_from_yaml( 

197 yaml_spec: CharacteristicSpec, char_class: type[BaseCharacteristic[Any]] 

198 ) -> CharacteristicInfo: 

199 """Create CharacteristicInfo from YAML spec, resolving metadata via registry classes.""" 

200 value_type = DataType.from_string(yaml_spec.data_type).to_value_type() 

201 

202 # Resolve unit via registry if present 

203 unit_info = None 

204 unit_name = getattr(yaml_spec, "unit_symbol", None) or getattr(yaml_spec, "unit", None) 

205 if unit_name: 

206 unit_info = units_registry.get_unit_info_by_name(unit_name) 

207 if unit_info: 

208 # Prefer symbol, fallback to name, always ensure string 

209 unit_symbol = str(getattr(unit_info, "symbol", getattr(unit_info, "name", unit_name))) 

210 else: 

211 unit_symbol = str(unit_name or "") 

212 

213 # TODO: Add similar logic for object types, service classes, etc. as needed 

214 

215 return CharacteristicInfo( 

216 uuid=yaml_spec.uuid, 

217 name=yaml_spec.name or char_class.__name__, 

218 unit=unit_symbol, 

219 value_type=value_type, 

220 ) 

221 

222 @staticmethod 

223 def resolve_from_registry(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicInfo | None: 

224 """Fallback to registry resolution using shared search strategy.""" 

225 # Use shared registry search strategy 

226 search_strategy = CharacteristicRegistrySearch() 

227 characteristic_name = getattr(char_class, "_characteristic_name", None) 

228 return search_strategy.search(char_class, characteristic_name) 

229 

230 

231class CharacteristicMeta(ABCMeta): 

232 """Metaclass to automatically handle template flags for characteristics.""" 

233 

234 def __new__( 

235 mcs, 

236 name: str, 

237 bases: tuple[type, ...], 

238 namespace: dict[str, Any], 

239 **kwargs: Any, # noqa: ANN401 # Metaclass receives arbitrary keyword arguments 

240 ) -> type: 

241 """Create the characteristic class and handle template markers. 

242 

243 This metaclass hook ensures template classes and concrete 

244 implementations are correctly annotated with the ``_is_template`` 

245 attribute before the class object is created. 

246 """ 

247 # Auto-handle template flags before class creation so attributes are part of namespace 

248 if bases: # Not the base class itself 

249 # Check if this class is in templates.py (template) or a concrete implementation 

250 module_name = namespace.get("__module__", "") 

251 is_in_templates = "templates" in module_name 

252 

253 # If it's NOT in templates.py and inherits from a template, mark as concrete 

254 if not is_in_templates and not namespace.get("_is_template_override", False): 

255 # Check if any parent has _is_template = True 

256 has_template_parent = any(getattr(base, "_is_template", False) for base in bases) 

257 if has_template_parent and "_is_template" not in namespace: 

258 namespace["_is_template"] = False # Mark as concrete characteristic 

259 

260 # Create the class normally 

261 new_class = super().__new__(mcs, name, bases, namespace, **kwargs) 

262 

263 return new_class 

264 

265 

266class BaseCharacteristic(ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods 

267 """Base class for all GATT characteristics. 

268 

269 Generic over T, the return type of _decode_value(). 

270 

271 Automatically resolves UUID, unit, and value_type from Bluetooth SIG YAML specifications. 

272 Supports manual overrides via _manual_unit and _manual_value_type attributes. 

273 

274 Note: This class intentionally has >20 public methods as it provides the complete 

275 characteristic API including parsing, validation, UUID resolution, registry interaction, 

276 and metadata access. The methods are well-organized by functionality. 

277 

278 Validation Attributes (optional class-level declarations): 

279 min_value: Minimum allowed value for parsed data 

280 max_value: Maximum allowed value for parsed data 

281 expected_length: Exact expected data length in bytes 

282 min_length: Minimum required data length in bytes 

283 max_length: Maximum allowed data length in bytes 

284 allow_variable_length: Whether variable length data is acceptable 

285 expected_type: Expected Python type for parsed values 

286 

287 Example usage in subclasses: 

288 class ExampleCharacteristic(BaseCharacteristic): 

289 '''Example showing validation attributes usage.''' 

290 

291 # Declare validation constraints as class attributes 

292 expected_length = 2 

293 min_value = 0 

294 max_value = 65535 # UINT16_MAX 

295 expected_type = int 

296 

297 def _decode_value(self, data: bytearray) -> int: 

298 # Just parse - validation happens automatically in parse_value 

299 return DataParser.parse_int16(data, 0, signed=False) 

300 

301 # Before: BatteryLevelCharacteristic with hardcoded validation 

302 # class BatteryLevelCharacteristic(BaseCharacteristic): 

303 # def _decode_value(self, data: bytearray) -> int: 

304 # if not data: 

305 # raise ValueError("Battery level data must be at least 1 byte") 

306 # level = data[0] 

307 # if not 0 <= level <= PERCENTAGE_MAX: 

308 # raise ValueError(f"Battery level must be 0-100, got {level}") 

309 # return level 

310 

311 # After: BatteryLevelCharacteristic with declarative validation 

312 # class BatteryLevelCharacteristic(BaseCharacteristic): 

313 # expected_length = 1 

314 # min_value = 0 

315 # max_value = 100 # PERCENTAGE_MAX 

316 # expected_type = int 

317 # 

318 # def _decode_value(self, data: bytearray) -> int: 

319 # return data[0] # Validation happens automatically 

320 """ 

321 

322 # Explicit class attributes with defaults (replaces getattr usage) 

323 _characteristic_name: str | None = None 

324 _manual_unit: str | None = None 

325 _manual_value_type: ValueType | str | None = None 

326 _manual_size: int | None = None 

327 _is_template: bool = False 

328 

329 min_value: int | float | None = None 

330 max_value: int | float | None = None 

331 expected_length: int | None = None 

332 min_length: int | None = None 

333 max_length: int | None = None 

334 allow_variable_length: bool = False 

335 expected_type: type | None = None 

336 

337 _template: CodingTemplate[T] | None = None 

338 

339 _allows_sig_override = False 

340 

341 _required_dependencies: list[type[BaseCharacteristic[Any]]] = [] # Dependencies that MUST be present 

342 _optional_dependencies: list[type[BaseCharacteristic[Any]]] = [] # Dependencies that enrich parsing when available 

343 

344 # Parse trace control (for performance tuning) 

345 # Can be configured via BLUETOOTH_SIG_ENABLE_PARSE_TRACE environment variable 

346 # Set to "0", "false", or "no" to disable trace collection 

347 _enable_parse_trace: bool = True # Default: enabled 

348 

349 # Special value handling (GSS-derived) 

350 # Manual override for special values when GSS spec is incomplete/wrong. 

351 # Format: {raw_value: meaning_string}. GSS values are used by default. 

352 _special_values: dict[int, str] | None = None 

353 

354 def __init__( 

355 self, 

356 info: CharacteristicInfo | None = None, 

357 validation: ValidationConfig | None = None, 

358 properties: list[GattProperty] | None = None, 

359 ) -> None: 

360 """Initialize characteristic with structured configuration. 

361 

362 Args: 

363 info: Complete characteristic information (optional for SIG characteristics) 

364 validation: Validation constraints configuration (optional) 

365 properties: Runtime BLE properties discovered from device (optional) 

366 

367 """ 

368 # Store provided info or None (will be resolved in __post_init__) 

369 self._provided_info = info 

370 

371 # Instance variables (will be set in __post_init__) 

372 self._info: CharacteristicInfo 

373 self._spec: CharacteristicSpec | None = None 

374 

375 # Runtime properties (from actual device, not YAML) 

376 self.properties: list[GattProperty] = properties if properties is not None else [] 

377 

378 # Manual overrides with proper types (using explicit class attributes) 

379 self._manual_unit: str | None = self.__class__._manual_unit 

380 self._manual_value_type: ValueType | str | None = self.__class__._manual_value_type 

381 self.value_type: ValueType = ValueType.UNKNOWN 

382 

383 # Set validation attributes from ValidationConfig or class defaults 

384 if validation: 

385 self.min_value = validation.min_value 

386 self.max_value = validation.max_value 

387 self.expected_length = validation.expected_length 

388 self.min_length = validation.min_length 

389 self.max_length = validation.max_length 

390 self.allow_variable_length = validation.allow_variable_length 

391 self.expected_type = validation.expected_type 

392 else: 

393 # Fall back to class attributes for Progressive API Level 2 

394 self.min_value = self.__class__.min_value 

395 self.max_value = self.__class__.max_value 

396 self.expected_length = self.__class__.expected_length 

397 self.min_length = self.__class__.min_length 

398 self.max_length = self.__class__.max_length 

399 self.allow_variable_length = self.__class__.allow_variable_length 

400 self.expected_type = self.__class__.expected_type 

401 

402 # Dependency caches (resolved once per instance) 

403 self._resolved_required_dependencies: list[str] | None = None 

404 self._resolved_optional_dependencies: list[str] | None = None 

405 

406 # Descriptor support 

407 self._descriptors: dict[str, BaseDescriptor] = {} 

408 

409 # Last parsed value for caching/debugging 

410 self.last_parsed: T | None = None 

411 

412 # Call post-init to resolve characteristic info 

413 self.__post_init__() 

414 

415 def __post_init__(self) -> None: 

416 """Initialize characteristic with resolved information.""" 

417 # Use provided info if available, otherwise resolve from SIG specs 

418 if self._provided_info: 

419 self._info = self._provided_info 

420 else: 

421 # Resolve characteristic information using proper resolver 

422 self._info = SIGCharacteristicResolver.resolve_for_class(type(self)) 

423 

424 # Resolve YAML spec for access to detailed metadata 

425 self._spec = self._resolve_yaml_spec() 

426 spec_rules: dict[int, SpecialValueRule] = {} 

427 for raw, meaning in self.gss_special_values.items(): 

428 spec_rules[raw] = SpecialValueRule( 

429 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning) 

430 ) 

431 

432 class_rules: dict[int, SpecialValueRule] = {} 

433 if self._special_values is not None: 

434 for raw, meaning in self._special_values.items(): 

435 class_rules[raw] = SpecialValueRule( 

436 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning) 

437 ) 

438 

439 self._special_resolver = SpecialValueResolver(spec_rules=spec_rules, class_rules=class_rules) 

440 

441 # Apply manual overrides to _info (single source of truth) 

442 if self._manual_unit is not None: 

443 self._info.unit = self._manual_unit 

444 if self._manual_value_type is not None: 

445 # Handle both ValueType enum and string manual overrides 

446 if isinstance(self._manual_value_type, ValueType): 

447 self._info.value_type = self._manual_value_type 

448 else: 

449 # Map string value types to ValueType enum 

450 string_to_value_type_map = { 

451 "string": ValueType.STRING, 

452 "int": ValueType.INT, 

453 "float": ValueType.FLOAT, 

454 "bytes": ValueType.BYTES, 

455 "bool": ValueType.BOOL, 

456 "datetime": ValueType.DATETIME, 

457 "uuid": ValueType.UUID, 

458 "dict": ValueType.DICT, 

459 "various": ValueType.VARIOUS, 

460 "unknown": ValueType.UNKNOWN, 

461 # Custom type strings that should map to basic types 

462 "BarometricPressureTrend": ValueType.INT, # IntEnum -> int 

463 } 

464 

465 try: 

466 # First try direct ValueType enum construction 

467 self._info.value_type = ValueType(self._manual_value_type) 

468 except ValueError: 

469 # Fall back to string mapping 

470 self._info.value_type = string_to_value_type_map.get(self._manual_value_type, ValueType.VARIOUS) 

471 

472 # Set value_type from resolved info 

473 self.value_type = self._info.value_type 

474 

475 # If value_type is still UNKNOWN after resolution and no manual override, 

476 # try to infer from characteristic patterns 

477 if self.value_type == ValueType.UNKNOWN and self._manual_value_type is None: 

478 inferred_type = self._infer_value_type_from_patterns() 

479 if inferred_type != ValueType.UNKNOWN: 

480 self._info.value_type = inferred_type 

481 self.value_type = inferred_type 

482 

483 def _infer_value_type_from_patterns(self) -> ValueType: 

484 """Infer value type from characteristic naming patterns and class structure. 

485 

486 This provides a fallback when SIG resolution fails to determine proper value types. 

487 """ 

488 class_name = self.__class__.__name__ 

489 char_name = self._characteristic_name or class_name 

490 

491 # Feature characteristics are bitfields and should be BITFIELD 

492 if "Feature" in class_name or "Feature" in char_name: 

493 return ValueType.BITFIELD 

494 

495 # Check if this is a multi-field characteristic (complex structure) 

496 if self._spec and hasattr(self._spec, "structure") and len(self._spec.structure) > 1: 

497 return ValueType.VARIOUS 

498 

499 # Common simple value characteristics 

500 simple_int_patterns = ["Level", "Count", "Index", "ID", "Appearance"] 

501 if any(pattern in class_name or pattern in char_name for pattern in simple_int_patterns): 

502 return ValueType.INT 

503 

504 simple_string_patterns = ["Name", "Description", "Text", "String"] 

505 if any(pattern in class_name or pattern in char_name for pattern in simple_string_patterns): 

506 return ValueType.STRING 

507 

508 # Default fallback for complex characteristics 

509 return ValueType.VARIOUS 

510 

511 def _resolve_yaml_spec(self) -> CharacteristicSpec | None: 

512 """Resolve specification using YAML cross-reference system.""" 

513 # Delegate to static method 

514 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(type(self)) 

515 

516 @property 

517 def uuid(self) -> BluetoothUUID: 

518 """Get the characteristic UUID from _info.""" 

519 return self._info.uuid 

520 

521 @property 

522 def info(self) -> CharacteristicInfo: 

523 """Characteristic information.""" 

524 return self._info 

525 

526 @property 

527 def spec(self) -> CharacteristicSpec | None: 

528 """Get the full GSS specification with description and detailed metadata.""" 

529 return self._spec 

530 

531 @property 

532 def name(self) -> str: 

533 """Get the characteristic name from _info.""" 

534 return self._info.name 

535 

536 @property 

537 def description(self) -> str: 

538 """Get the characteristic description from GSS specification.""" 

539 return self._spec.description if self._spec and self._spec.description else "" 

540 

541 @property 

542 def display_name(self) -> str: 

543 """Get the display name for this characteristic. 

544 

545 Uses explicit _characteristic_name if set, otherwise falls back 

546 to class name. 

547 """ 

548 return self._characteristic_name or self.__class__.__name__ 

549 

550 @cached_property 

551 def gss_special_values(self) -> dict[int, str]: 

552 """Get special values from GSS specification. 

553 

554 Extracts all special value definitions (e.g., 0x8000="value is not known") 

555 from the GSS YAML specification for this characteristic. 

556 

557 GSS stores values as unsigned hex (e.g., 0x8000). For signed types, 

558 this method also includes the signed interpretation so lookups work 

559 with both parsed signed values and raw unsigned values. 

560 

561 Returns: 

562 Dictionary mapping raw integer values to their human-readable meanings. 

563 Includes both unsigned and signed interpretations for applicable values. 

564 """ 

565 # extract special values from YAML 

566 if not self._spec or not hasattr(self._spec, "structure") or not self._spec.structure: 

567 return {} 

568 

569 result: dict[int, str] = {} 

570 for field in self._spec.structure: 

571 for sv in field.special_values: 

572 unsigned_val = sv.raw_value 

573 result[unsigned_val] = sv.meaning 

574 

575 # For signed types, add the signed equivalent based on common bit widths. 

576 # This handles cases like 0x8000 (32768) -> -32768 for sint16. 

577 if self.is_signed_from_yaml(): 

578 for bits in (8, 16, 24, 32): 

579 max_unsigned = (1 << bits) - 1 

580 sign_bit = 1 << (bits - 1) 

581 if sign_bit <= unsigned_val <= max_unsigned: 

582 # This value would be negative when interpreted as signed 

583 signed_val = unsigned_val - (1 << bits) 

584 if signed_val not in result: 

585 result[signed_val] = sv.meaning 

586 return result 

587 

588 def is_special_value(self, raw_value: int) -> bool: 

589 """Check if a raw value is a special sentinel value. 

590 

591 Checks both manual overrides (_special_values class variable) and 

592 GSS-derived special values, with manual taking precedence. 

593 

594 Args: 

595 raw_value: The raw integer value to check. 

596 

597 Returns: 

598 True if this is a special sentinel value, False otherwise. 

599 """ 

600 return self._special_resolver.is_special(raw_value) 

601 

602 def get_special_value_meaning(self, raw_value: int) -> str | None: 

603 """Get the human-readable meaning of a special value. 

604 

605 Args: 

606 raw_value: The raw integer value to look up. 

607 

608 Returns: 

609 The meaning string (e.g., "value is not known"), or None if not special. 

610 """ 

611 res = self._special_resolver.resolve(raw_value) 

612 return res.meaning if res is not None else None 

613 

614 def get_special_value_type(self, raw_value: int) -> SpecialValueType | None: 

615 """Get the category of a special value. 

616 

617 Args: 

618 raw_value: The raw integer value to classify. 

619 

620 Returns: 

621 The SpecialValueType category, or None if not a special value. 

622 """ 

623 res = self._special_resolver.resolve(raw_value) 

624 return res.value_type if res is not None else None 

625 

626 @classmethod 

627 def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic[Any]]) -> str | None: 

628 """Resolve a dependency class to its canonical UUID string. 

629 

630 Args: 

631 dep_class: The characteristic class to resolve 

632 

633 Returns: 

634 Canonical UUID string or None if unresolvable 

635 

636 """ 

637 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None) 

638 if configured_info is not None: 

639 return str(configured_info.uuid) 

640 

641 try: 

642 class_uuid = dep_class.get_class_uuid() 

643 if class_uuid is not None: 

644 return str(class_uuid) 

645 except (ValueError, AttributeError, TypeError): 

646 pass 

647 

648 try: 

649 temp_instance = dep_class() 

650 return str(temp_instance.info.uuid) 

651 except (ValueError, AttributeError, TypeError): 

652 return None 

653 

654 def _resolve_dependencies(self, attr_name: str) -> list[str]: 

655 """Resolve dependency class references to canonical UUID strings.""" 

656 dependency_classes: list[type[BaseCharacteristic[Any]]] = [] 

657 

658 declared = getattr(self.__class__, attr_name, []) or [] 

659 dependency_classes.extend(declared) 

660 

661 resolved: list[str] = [] 

662 seen: set[str] = set() 

663 

664 for dep_class in dependency_classes: 

665 uuid_str = self._normalize_dependency_class(dep_class) 

666 if uuid_str and uuid_str not in seen: 

667 seen.add(uuid_str) 

668 resolved.append(uuid_str) 

669 

670 return resolved 

671 

672 @property 

673 def required_dependencies(self) -> list[str]: 

674 """Get resolved required dependency UUID strings.""" 

675 if self._resolved_required_dependencies is None: 

676 self._resolved_required_dependencies = self._resolve_dependencies("_required_dependencies") 

677 

678 return list(self._resolved_required_dependencies) 

679 

680 @property 

681 def optional_dependencies(self) -> list[str]: 

682 """Get resolved optional dependency UUID strings.""" 

683 if self._resolved_optional_dependencies is None: 

684 self._resolved_optional_dependencies = self._resolve_dependencies("_optional_dependencies") 

685 

686 return list(self._resolved_optional_dependencies) 

687 

688 @classmethod 

689 def get_allows_sig_override(cls) -> bool: 

690 """Check if this characteristic class allows overriding SIG characteristics. 

691 

692 Custom characteristics that need to override official Bluetooth SIG 

693 characteristics must set _allows_sig_override = True as a class attribute. 

694 

695 Returns: 

696 True if SIG override is allowed, False otherwise. 

697 

698 """ 

699 return cls._allows_sig_override 

700 

701 @classmethod 

702 def get_configured_info(cls) -> CharacteristicInfo | None: 

703 """Get the class-level configured CharacteristicInfo. 

704 

705 This provides public access to the _configured_info attribute that is set 

706 by __init_subclass__ for custom characteristics. 

707 

708 Returns: 

709 CharacteristicInfo if configured, None otherwise 

710 

711 """ 

712 return getattr(cls, "_configured_info", None) 

713 

714 @classmethod 

715 def get_class_uuid(cls) -> BluetoothUUID | None: 

716 """Get the characteristic UUID for this class without creating an instance. 

717 

718 This is the public API for registry and other modules to resolve UUIDs. 

719 

720 Returns: 

721 BluetoothUUID if the class has a resolvable UUID, None otherwise. 

722 

723 """ 

724 return cls._resolve_class_uuid() 

725 

726 @classmethod 

727 def _resolve_class_uuid(cls) -> BluetoothUUID | None: 

728 """Resolve the characteristic UUID for this class without creating an instance.""" 

729 # Check for _info attribute first (custom characteristics) 

730 if hasattr(cls, "_info"): 

731 info: CharacteristicInfo = cls._info # Custom characteristics may have _info 

732 try: 

733 return info.uuid 

734 except AttributeError: 

735 pass 

736 

737 # Try cross-file resolution for SIG characteristics 

738 yaml_spec = cls._resolve_yaml_spec_class() 

739 if yaml_spec: 

740 return yaml_spec.uuid 

741 

742 # Fallback to original registry resolution 

743 return cls._resolve_from_basic_registry_class() 

744 

745 @classmethod 

746 def _resolve_yaml_spec_class(cls) -> CharacteristicSpec | None: 

747 """Resolve specification using YAML cross-reference system at class level.""" 

748 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(cls) 

749 

750 @classmethod 

751 def _resolve_from_basic_registry_class(cls) -> BluetoothUUID | None: 

752 """Fallback to basic registry resolution at class level.""" 

753 try: 

754 registry_info = SIGCharacteristicResolver.resolve_from_registry(cls) 

755 return registry_info.uuid if registry_info else None 

756 except (ValueError, KeyError, AttributeError, TypeError): 

757 # Registry resolution can fail for various reasons: 

758 # - ValueError: Invalid UUID format 

759 # - KeyError: Characteristic not in registry 

760 # - AttributeError: Missing expected attributes 

761 # - TypeError: Type mismatch in resolution 

762 return None 

763 

764 @classmethod 

765 def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool: 

766 """Check if this characteristic matches the given UUID.""" 

767 try: 

768 class_uuid = cls._resolve_class_uuid() 

769 if class_uuid is None: 

770 return False 

771 if isinstance(uuid, BluetoothUUID): 

772 input_uuid = uuid 

773 else: 

774 input_uuid = BluetoothUUID(uuid) 

775 return class_uuid == input_uuid 

776 except ValueError: 

777 return False 

778 

779 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> T: 

780 """Internal parse the characteristic's raw value with no validation. 

781 

782 This is expected to be called from parse_value() which handles validation. 

783 

784 If _template is set, uses the template's decode_value method. 

785 Otherwise, subclasses must override this method. 

786 

787 Args: 

788 data: Raw bytes from the characteristic read 

789 ctx: Optional context information for parsing 

790 

791 Returns: 

792 Parsed value in the appropriate type 

793 

794 Raises: 

795 NotImplementedError: If no template is set and subclass doesn't override 

796 

797 """ 

798 if self._template is not None: 

799 return self._template.decode_value( # pylint: disable=protected-access 

800 data, offset=0, ctx=ctx 

801 ) 

802 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override decode_value()") 

803 

804 def _validate_range( 

805 self, 

806 value: Any, # noqa: ANN401 # Validates values of various numeric types 

807 ctx: CharacteristicContext | None = None, 

808 ) -> ValidationAccumulator: # pylint: disable=too-many-branches # Complex validation with multiple precedence levels 

809 """Validate value is within min/max range from both class attributes and descriptors. 

810 

811 Validation precedence: 

812 1. Descriptor Valid Range (if present in context) - most specific, device-reported 

813 2. Class-level validation attributes (min_value, max_value) - characteristic spec defaults 

814 3. YAML-derived value range from structure - Bluetooth SIG specification 

815 

816 Args: 

817 value: The value to validate 

818 ctx: Optional characteristic context containing descriptors 

819 

820 Returns: 

821 ValidationReport with errors if validation fails 

822 """ 

823 result = ValidationAccumulator() 

824 

825 # Skip validation for SpecialValueResult 

826 if isinstance(value, SpecialValueResult): 

827 return result 

828 

829 # Skip validation for non-numeric types 

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

831 return result 

832 

833 # Check descriptor Valid Range first (takes precedence over class attributes) 

834 descriptor_range = self.get_valid_range_from_context(ctx) if ctx else None 

835 if descriptor_range is not None: 

836 min_val, max_val = descriptor_range 

837 if value < min_val or value > max_val: 

838 error_msg = ( 

839 f"Value {value} is outside valid range [{min_val}, {max_val}] " 

840 f"(source: Valid Range descriptor for {self.name})" 

841 ) 

842 if self.unit: 

843 error_msg += f" [unit: {self.unit}]" 

844 result.add_error(error_msg) 

845 # Descriptor validation checked - skip class-level checks 

846 return result 

847 

848 # Fall back to class-level validation attributes 

849 if self.min_value is not None and value < self.min_value: 

850 error_msg = ( 

851 f"Value {value} is below minimum {self.min_value} " 

852 f"(source: class-level constraint for {self.__class__.__name__})" 

853 ) 

854 if self.unit: 

855 error_msg += f" [unit: {self.unit}]" 

856 result.add_error(error_msg) 

857 if self.max_value is not None and value > self.max_value: 

858 error_msg = ( 

859 f"Value {value} is above maximum {self.max_value} " 

860 f"(source: class-level constraint for {self.__class__.__name__})" 

861 ) 

862 if self.unit: 

863 error_msg += f" [unit: {self.unit}]" 

864 result.add_error(error_msg) 

865 

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

867 # Use tolerance-based comparison for floating-point values due to precision loss in scaled types 

868 if self.min_value is None and self.max_value is None and self._spec and self._spec.structure: 

869 for field in self._spec.structure: 

870 yaml_range = field.value_range 

871 if yaml_range is not None: 

872 min_val, max_val = yaml_range 

873 # Use tolerance for floating-point comparison (common in scaled characteristics) 

874 tolerance = max(abs(max_val - min_val) * 1e-9, 1e-9) if isinstance(value, float) else 0 

875 if value < min_val - tolerance or value > max_val + tolerance: 

876 yaml_source = f"{self._spec.name}" if self._spec.name else "YAML specification" 

877 error_msg = ( 

878 f"Value {value} is outside allowed range [{min_val}, {max_val}] " 

879 f"(source: Bluetooth SIG {yaml_source})" 

880 ) 

881 if self.unit: 

882 error_msg += f" [unit: {self.unit}]" 

883 result.add_error(error_msg) 

884 break # Use first field with range found 

885 

886 return result 

887 

888 def _validate_type(self, value: Any) -> ValidationAccumulator: # noqa: ANN401 

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

890 

891 Args: 

892 value: The value to validate 

893 validate: Whether validation is enabled 

894 

895 Returns: 

896 ValidationReport with errors if validation fails 

897 """ 

898 result = ValidationAccumulator() 

899 

900 if self.expected_type is not None and not isinstance(value, (self.expected_type, SpecialValueResult)): 

901 error_msg = ( 

902 f"Type validation failed for {self.name}: " 

903 f"expected {self.expected_type.__name__}, got {type(value).__name__} " 

904 f"(value: {value})" 

905 ) 

906 result.add_error(error_msg) 

907 return result 

908 

909 def _validate_length(self, data: bytes | bytearray) -> ValidationAccumulator: 

910 """Validate data length meets requirements. 

911 

912 Args: 

913 data: The data to validate 

914 

915 Returns: 

916 ValidationReport with errors if validation fails 

917 """ 

918 result = ValidationAccumulator() 

919 

920 length = len(data) 

921 

922 # Determine validation source for error context 

923 yaml_size = self.get_yaml_field_size() 

924 source_context = "" 

925 if yaml_size is not None: 

926 source_context = f" (YAML specification: {yaml_size} bytes)" 

927 elif self.expected_length is not None or self.min_length is not None or self.max_length is not None: 

928 source_context = f" (class-level constraint for {self.__class__.__name__})" 

929 

930 if self.expected_length is not None and length != self.expected_length: 

931 error_msg = ( 

932 f"Length validation failed for {self.name}: " 

933 f"expected exactly {self.expected_length} bytes, got {length}{source_context}" 

934 ) 

935 result.add_error(error_msg) 

936 if self.min_length is not None and length < self.min_length: 

937 error_msg = ( 

938 f"Length validation failed for {self.name}: " 

939 f"expected at least {self.min_length} bytes, got {length}{source_context}" 

940 ) 

941 result.add_error(error_msg) 

942 if self.max_length is not None and length > self.max_length: 

943 error_msg = ( 

944 f"Length validation failed for {self.name}: " 

945 f"expected at most {self.max_length} bytes, got {length}{source_context}" 

946 ) 

947 result.add_error(error_msg) 

948 return result 

949 

950 def _extract_raw_int( 

951 self, 

952 data: bytearray, 

953 enable_trace: bool, 

954 parse_trace: list[str], 

955 ) -> int | None: 

956 """Extract raw integer from bytes using the extraction pipeline. 

957 

958 Tries extraction in order of precedence: 

959 1. Template extractor (if _template with extractor is set) 

960 2. YAML-derived extractor (based on get_yaml_data_type()) 

961 

962 Args: 

963 data: Raw bytes to extract from. 

964 enable_trace: Whether to log trace messages. 

965 parse_trace: List to append trace messages to. 

966 

967 Returns: 

968 Raw integer value, or None if no extractor is available. 

969 """ 

970 # Priority 1: Template extractor 

971 if self._template is not None and self._template.extractor is not None: 

972 if enable_trace: 

973 parse_trace.append("Extracting raw integer via template extractor") 

974 raw_int = self._template.extractor.extract(data, offset=0) 

975 if enable_trace: 

976 parse_trace.append(f"Extracted raw_int: {raw_int}") 

977 return raw_int 

978 

979 # Priority 2: YAML data type extractor 

980 yaml_type = self.get_yaml_data_type() 

981 if yaml_type is not None: 

982 extractor = get_extractor(yaml_type) 

983 if extractor is not None: 

984 if enable_trace: 

985 parse_trace.append(f"Extracting raw integer via YAML type '{yaml_type}'") 

986 raw_int = extractor.extract(data, offset=0) 

987 if enable_trace: 

988 parse_trace.append(f"Extracted raw_int: {raw_int}") 

989 return raw_int 

990 

991 # No extractor available 

992 if enable_trace: 

993 parse_trace.append("No extractor available for raw_int extraction") 

994 return None 

995 

996 def _pack_raw_int(self, raw: int) -> bytearray: 

997 """Pack a raw integer to bytes using template extractor or YAML extractor.""" 

998 # Priority 1: template extractor 

999 if self._template is not None: 

1000 extractor = getattr(self._template, "extractor", None) 

1001 if extractor is not None: 

1002 return bytearray(extractor.pack(raw)) 

1003 

1004 # Priority 2: YAML-derived extractor 

1005 yaml_type = self.get_yaml_data_type() 

1006 if yaml_type is not None: 

1007 extractor = get_extractor(yaml_type) 

1008 if extractor is not None: 

1009 return bytearray(extractor.pack(raw)) 

1010 

1011 raise ValueError("No extractor available to pack raw integer for this characteristic") 

1012 

1013 def _get_dependency_from_context( 

1014 self, 

1015 ctx: CharacteristicContext, 

1016 dep_class: type[BaseCharacteristic[Any]], 

1017 ) -> Any: # noqa: ANN401 # Dependency type determined by dep_class at runtime 

1018 """Get dependency from context using type-safe class reference. 

1019 

1020 Note: 

1021 Returns ``Any`` because the dependency type is determined at runtime 

1022 by ``dep_class``. For type-safe access, the caller should know the 

1023 expected type based on the class they pass in. 

1024 

1025 Args: 

1026 ctx: Characteristic context containing other characteristics 

1027 dep_class: Dependency characteristic class to look up 

1028 

1029 Returns: 

1030 Parsed characteristic value if found in context, None otherwise. 

1031 

1032 """ 

1033 # Resolve class to UUID 

1034 dep_uuid = dep_class.get_class_uuid() 

1035 if not dep_uuid: 

1036 return None 

1037 

1038 # Lookup in context by UUID (string key) 

1039 if ctx.other_characteristics is None: 

1040 return None 

1041 return ctx.other_characteristics.get(str(dep_uuid)) 

1042 

1043 @staticmethod 

1044 @lru_cache(maxsize=32) 

1045 def _get_characteristic_uuid_by_name( 

1046 characteristic_name: CharacteristicName | CharacteristicName | str, 

1047 ) -> str | None: 

1048 """Get characteristic UUID by name using cached registry lookup.""" 

1049 # Convert enum to string value for registry lookup 

1050 name_str = ( 

1051 characteristic_name.value if isinstance(characteristic_name, CharacteristicName) else characteristic_name 

1052 ) 

1053 char_info = uuid_registry.get_characteristic_info(name_str) 

1054 return str(char_info.uuid) if char_info else None 

1055 

1056 def get_context_characteristic( 

1057 self, 

1058 ctx: CharacteristicContext | None, 

1059 characteristic_name: CharacteristicName | str | type[BaseCharacteristic[Any]], 

1060 ) -> Any: # noqa: ANN401 # Type determined by characteristic_name at runtime 

1061 """Find a characteristic in a context by name or class. 

1062 

1063 Note: 

1064 Returns ``Any`` because the characteristic type is determined at 

1065 runtime by ``characteristic_name``. For type-safe access, use direct 

1066 characteristic class instantiation instead of this lookup method. 

1067 

1068 Args: 

1069 ctx: Context containing other characteristics. 

1070 characteristic_name: Enum, string name, or characteristic class. 

1071 

1072 Returns: 

1073 Parsed characteristic value if found, None otherwise. 

1074 

1075 """ 

1076 if not ctx or not ctx.other_characteristics: 

1077 return None 

1078 

1079 # Extract UUID from class if provided 

1080 if isinstance(characteristic_name, type): 

1081 # Class reference provided - try to get class-level UUID 

1082 configured_info: CharacteristicInfo | None = getattr(characteristic_name, "_configured_info", None) 

1083 if configured_info is not None: 

1084 # Custom characteristic with explicit _configured_info 

1085 char_uuid: str = str(configured_info.uuid) 

1086 else: 

1087 # SIG characteristic: convert class name to SIG name and resolve via registry 

1088 class_name: str = characteristic_name.__name__ 

1089 # Remove 'Characteristic' suffix 

1090 name_without_suffix: str = class_name.replace("Characteristic", "") 

1091 # Insert spaces before capital letters to get SIG name 

1092 sig_name: str = re.sub(r"(?<!^)(?=[A-Z])", " ", name_without_suffix) 

1093 # Look up UUID via registry 

1094 resolved_uuid: str | None = self._get_characteristic_uuid_by_name(sig_name) 

1095 if resolved_uuid is None: 

1096 return None 

1097 char_uuid = resolved_uuid 

1098 else: 

1099 # Enum or string name 

1100 resolved_uuid = self._get_characteristic_uuid_by_name(characteristic_name) 

1101 if resolved_uuid is None: 

1102 return None 

1103 char_uuid = resolved_uuid 

1104 

1105 return ctx.other_characteristics.get(char_uuid) 

1106 

1107 def _check_special_value(self, raw_value: int) -> int | SpecialValueResult: 

1108 """Check if raw value is a special sentinel value and return appropriate result. 

1109 

1110 Args: 

1111 raw_value: The raw integer value to check 

1112 

1113 Returns: 

1114 SpecialValueResult if raw_value is special, otherwise raw_value unchanged 

1115 """ 

1116 res = self._special_resolver.resolve(raw_value) 

1117 if res is not None: 

1118 return res 

1119 return raw_value 

1120 

1121 def _is_parse_trace_enabled(self) -> bool: 

1122 """Check if parse trace is enabled via environment variable or instance attribute. 

1123 

1124 Returns: 

1125 True if parse tracing is enabled, False otherwise 

1126 

1127 Environment Variables: 

1128 BLUETOOTH_SIG_ENABLE_PARSE_TRACE: Set to "0", "false", or "no" to disable 

1129 

1130 Instance Attributes: 

1131 _enable_parse_trace: Set to False to disable tracing for this instance 

1132 """ 

1133 # Check environment variable first 

1134 env_value = os.getenv("BLUETOOTH_SIG_ENABLE_PARSE_TRACE", "").lower() 

1135 if env_value in ("0", "false", "no"): 

1136 return False 

1137 

1138 if self._enable_parse_trace is False: 

1139 return False 

1140 

1141 # Default to enabled 

1142 return True 

1143 

1144 def _perform_parse_validation( # pylint: disable=too-many-arguments,too-many-positional-arguments 

1145 self, 

1146 data_bytes: bytearray, 

1147 enable_trace: bool, 

1148 parse_trace: list[str], 

1149 validation: ValidationAccumulator, 

1150 validate: bool, 

1151 ) -> None: 

1152 """Perform initial validation on parse data.""" 

1153 if not validate: 

1154 return 

1155 if enable_trace: 

1156 parse_trace.append(f"Validating data length (got {len(data_bytes)} bytes)") 

1157 length_validation = self._validate_length(data_bytes) 

1158 validation.errors.extend(length_validation.errors) 

1159 validation.warnings.extend(length_validation.warnings) 

1160 if not length_validation.valid: 

1161 raise ValueError("; ".join(length_validation.errors)) 

1162 

1163 def _extract_and_check_special_value( # pylint: disable=unused-argument # ctx used in get_valid_range_from_context by callers 

1164 self, data_bytes: bytearray, enable_trace: bool, parse_trace: list[str], ctx: CharacteristicContext | None 

1165 ) -> tuple[int | None, int | SpecialValueResult | None]: 

1166 """Extract raw int and check for special values.""" 

1167 # Extract raw integer using the pipeline 

1168 raw_int = self._extract_raw_int(data_bytes, enable_trace, parse_trace) 

1169 

1170 # Check for special values if raw_int was extracted 

1171 parsed_value = None 

1172 if raw_int is not None: 

1173 if enable_trace: 

1174 parse_trace.append("Checking for special values") 

1175 parsed_value = self._check_special_value(raw_int) 

1176 if enable_trace: 

1177 if isinstance(parsed_value, SpecialValueResult): 

1178 parse_trace.append(f"Found special value: {parsed_value}") 

1179 else: 

1180 parse_trace.append("Not a special value, proceeding with decode") 

1181 

1182 return raw_int, parsed_value 

1183 

1184 def _decode_and_validate_value( # pylint: disable=too-many-arguments,too-many-positional-arguments # All parameters necessary for decode/validate pipeline 

1185 self, 

1186 data_bytes: bytearray, 

1187 enable_trace: bool, 

1188 parse_trace: list[str], 

1189 ctx: CharacteristicContext | None, 

1190 validation: ValidationAccumulator, 

1191 validate: bool, 

1192 ) -> T: 

1193 """Decode value and perform validation. 

1194 

1195 At this point, special values have already been handled by the caller. 

1196 """ 

1197 if enable_trace: 

1198 parse_trace.append("Decoding value") 

1199 decoded_value: T = self._decode_value(data_bytes, ctx) 

1200 

1201 if validate: 

1202 if enable_trace: 

1203 parse_trace.append("Validating range") 

1204 range_validation = self._validate_range(decoded_value, ctx) 

1205 validation.errors.extend(range_validation.errors) 

1206 validation.warnings.extend(range_validation.warnings) 

1207 if not range_validation.valid: 

1208 raise ValueError("; ".join(range_validation.errors)) 

1209 if enable_trace: 

1210 parse_trace.append("Validating type") 

1211 type_validation = self._validate_type(decoded_value) 

1212 validation.errors.extend(type_validation.errors) 

1213 validation.warnings.extend(type_validation.warnings) 

1214 if not type_validation.valid: 

1215 raise ValueError("; ".join(type_validation.errors)) 

1216 return decoded_value 

1217 

1218 def parse_value( 

1219 self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True 

1220 ) -> T: 

1221 """Parse characteristic data. 

1222 

1223 Returns: Parsed value of type T 

1224 Raises: 

1225 SpecialValueDetected: Special sentinel (0x8000="unknown", 0x7FFFFFFF="NaN") 

1226 CharacteristicParseError: Parse/validation failure 

1227 """ 

1228 data_bytes = bytearray(data) 

1229 enable_trace = self._is_parse_trace_enabled() 

1230 parse_trace: list[str] = ["Starting parse"] if enable_trace else [] 

1231 field_errors: list[FieldError] = [] 

1232 validation = ValidationAccumulator() 

1233 raw_int: int | None = None 

1234 

1235 try: 

1236 # Perform initial validation 

1237 self._perform_parse_validation(data_bytes, enable_trace, parse_trace, validation, validate) 

1238 

1239 # Extract raw int and check for special values 

1240 raw_int, parsed_value = self._extract_and_check_special_value(data_bytes, enable_trace, parse_trace, ctx) 

1241 

1242 # Special value detection - raise specialized exception 

1243 if isinstance(parsed_value, SpecialValueResult): 

1244 if enable_trace: 

1245 parse_trace.append(f"Detected special value: {parsed_value.meaning}") 

1246 raise SpecialValueDetected( 

1247 special_value=parsed_value, name=self.name, uuid=self.uuid, raw_data=bytes(data), raw_int=raw_int 

1248 ) 

1249 

1250 # Decode and validate value 

1251 decoded_value = self._decode_and_validate_value( 

1252 data_bytes, enable_trace, parse_trace, ctx, validation, validate 

1253 ) 

1254 

1255 if enable_trace: 

1256 parse_trace.append("Parse completed successfully") 

1257 

1258 # Cache the parsed value for debugging/caching purposes 

1259 self.last_parsed = decoded_value 

1260 

1261 return decoded_value 

1262 

1263 except SpecialValueDetected: 

1264 # Re-raise special value exceptions as-is 

1265 raise 

1266 except Exception as e: 

1267 if enable_trace: 

1268 parse_trace.append(f"Parse failed: {type(e).__name__}: {e}") 

1269 

1270 # Handle field errors 

1271 if isinstance(e, ParseFieldError): 

1272 field_error = FieldError( 

1273 field=e.field, 

1274 reason=e.field_reason, 

1275 offset=e.offset, 

1276 raw_slice=bytes(e.data) if hasattr(e, "data") else None, 

1277 ) 

1278 field_errors.append(field_error) 

1279 

1280 # Raise structured parse error 

1281 raise CharacteristicParseError( 

1282 message=str(e), 

1283 name=self.name, 

1284 uuid=self.uuid, 

1285 raw_data=bytes(data), 

1286 raw_int=raw_int if raw_int is not None else None, 

1287 field_errors=field_errors, 

1288 parse_trace=parse_trace, 

1289 validation=validation, 

1290 ) from e 

1291 

1292 def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401 

1293 """Internal encode the characteristic's value to raw bytes with no validation. 

1294 

1295 This is expected to called from build_value() after validation. 

1296 

1297 If _template is set, uses the template's encode_value method. 

1298 Otherwise, subclasses must override this method. 

1299 

1300 This is a low-level method that performs no validation. For encoding 

1301 with validation, use encode() instead. 

1302 

1303 Args: 

1304 data: Dataclass instance or value to encode 

1305 

1306 Returns: 

1307 Encoded bytes for characteristic write 

1308 

1309 Raises: 

1310 ValueError: If data is invalid for encoding 

1311 NotImplementedError: If no template is set and subclass doesn't override 

1312 

1313 """ 

1314 if self._template is not None: 

1315 return self._template.encode_value(data) # pylint: disable=protected-access 

1316 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override encode_value()") 

1317 

1318 def build_value( # pylint: disable=too-many-branches 

1319 self, data: T | SpecialValueResult, validate: bool = True 

1320 ) -> bytearray: 

1321 """Encode value or special value to characteristic bytes. 

1322 

1323 Args: 

1324 data: Value to encode (type T) or special value to encode 

1325 validate: Enable validation (type, range, length checks) 

1326 Note: Special values bypass validation 

1327 

1328 Returns: 

1329 Encoded bytes ready for BLE write 

1330 

1331 Raises: 

1332 CharacteristicEncodeError: If encoding or validation fails 

1333 

1334 Examples: 

1335 # Normal value 

1336 data = char.build_value(37.5) # Returns: bytearray([0xAA, 0x0E]) 

1337 

1338 # Special value (for testing/simulation) 

1339 from bluetooth_sig.types import SpecialValueResult, SpecialValueType 

1340 special = SpecialValueResult( 

1341 raw_value=0x8000, 

1342 meaning="value is not known", 

1343 value_type=SpecialValueType.NOT_KNOWN 

1344 ) 

1345 data = char.build_value(special) # Returns: bytearray([0x00, 0x80]) 

1346 

1347 # With validation disabled (for debugging) 

1348 data = char.build_value(200.0, validate=False) # Allows out-of-range 

1349 

1350 # Error handling 

1351 try: 

1352 data = char.build_value(value) 

1353 except CharacteristicEncodeError as e: 

1354 print(f"Encode failed: {e}") 

1355 

1356 """ 

1357 enable_trace = self._is_parse_trace_enabled() 

1358 build_trace: list[str] = ["Starting build"] if enable_trace else [] 

1359 validation = ValidationAccumulator() 

1360 

1361 # Special value encoding - bypass validation 

1362 if isinstance(data, SpecialValueResult): 

1363 if enable_trace: 

1364 build_trace.append(f"Encoding special value: {data.meaning}") 

1365 try: 

1366 return self._pack_raw_int(data.raw_value) 

1367 except Exception as e: 

1368 raise CharacteristicEncodeError( 

1369 message=f"Failed to encode special value: {e}", 

1370 name=self.name, 

1371 uuid=self.uuid, 

1372 value=data, 

1373 validation=None, 

1374 ) from e 

1375 

1376 try: 

1377 # Type validation 

1378 if validate: 

1379 if enable_trace: 

1380 build_trace.append("Validating type") 

1381 type_validation = self._validate_type(data) 

1382 validation.errors.extend(type_validation.errors) 

1383 validation.warnings.extend(type_validation.warnings) 

1384 if not type_validation.valid: 

1385 raise TypeError("; ".join(type_validation.errors)) 

1386 

1387 # Range validation for numeric types 

1388 if validate and isinstance(data, (int, float)): 

1389 if enable_trace: 

1390 build_trace.append("Validating range") 

1391 range_validation = self._validate_range(data, ctx=None) 

1392 validation.errors.extend(range_validation.errors) 

1393 validation.warnings.extend(range_validation.warnings) 

1394 if not range_validation.valid: 

1395 raise ValueError("; ".join(range_validation.errors)) 

1396 

1397 # Encode 

1398 if enable_trace: 

1399 build_trace.append("Encoding value") 

1400 encoded = self._encode_value(data) 

1401 

1402 # Length validation 

1403 if validate: 

1404 if enable_trace: 

1405 build_trace.append("Validating encoded length") 

1406 length_validation = self._validate_length(encoded) 

1407 validation.errors.extend(length_validation.errors) 

1408 validation.warnings.extend(length_validation.warnings) 

1409 if not length_validation.valid: 

1410 raise ValueError("; ".join(length_validation.errors)) 

1411 

1412 if enable_trace: 

1413 build_trace.append("Build completed successfully") 

1414 

1415 return encoded 

1416 

1417 except Exception as e: 

1418 if enable_trace: 

1419 build_trace.append(f"Build failed: {type(e).__name__}: {e}") 

1420 

1421 raise CharacteristicEncodeError( 

1422 message=str(e), 

1423 name=self.name, 

1424 uuid=self.uuid, 

1425 value=data, 

1426 validation=validation, 

1427 ) from e 

1428 

1429 # -------------------- Encoding helpers for special values -------------------- 

1430 def encode_special(self, value_type: SpecialValueType) -> bytearray: 

1431 """Encode a special value type to bytes (reverse lookup). 

1432 

1433 Raises ValueError if no raw value of that type is defined for this characteristic. 

1434 """ 

1435 raw = self._special_resolver.get_raw_for_type(value_type) 

1436 if raw is None: 

1437 raise ValueError(f"No special value of type {value_type.name} defined for this characteristic") 

1438 return self._pack_raw_int(raw) 

1439 

1440 def encode_special_by_meaning(self, meaning: str) -> bytearray: 

1441 """Encode a special value by a partial meaning string match. 

1442 

1443 Raises ValueError if no matching special value is found. 

1444 """ 

1445 raw = self._special_resolver.get_raw_for_meaning(meaning) 

1446 if raw is None: 

1447 raise ValueError(f"No special value matching '{meaning}' defined for this characteristic") 

1448 return self._pack_raw_int(raw) 

1449 

1450 @property 

1451 def unit(self) -> str: 

1452 """Get the unit of measurement from _info. 

1453 

1454 Returns empty string for characteristics without units (e.g., bitfields). 

1455 """ 

1456 return self._info.unit or "" 

1457 

1458 @property 

1459 def size(self) -> int | None: 

1460 """Get the size in bytes for this characteristic from YAML specifications. 

1461 

1462 Returns the field size from YAML automation if available, otherwise None. 

1463 This is useful for determining the expected data length for parsing 

1464 and encoding. 

1465 

1466 """ 

1467 # First try manual size override if set 

1468 if self._manual_size is not None: 

1469 return self._manual_size 

1470 

1471 # Try field size from YAML cross-reference 

1472 field_size = self.get_yaml_field_size() 

1473 if field_size is not None: 

1474 return field_size 

1475 

1476 # For characteristics without YAML size info, return None 

1477 # indicating variable or unknown length 

1478 return None 

1479 

1480 @property 

1481 def value_type_resolved(self) -> ValueType: 

1482 """Get the value type from _info.""" 

1483 return self._info.value_type 

1484 

1485 # YAML automation helper methods 

1486 def get_yaml_data_type(self) -> str | None: 

1487 """Get the data type from YAML automation (e.g., 'sint16', 'uint8').""" 

1488 return self._spec.data_type if self._spec else None 

1489 

1490 def get_yaml_field_size(self) -> int | None: 

1491 """Get the field size in bytes from YAML automation.""" 

1492 field_size = self._spec.field_size if self._spec else None 

1493 if field_size and isinstance(field_size, str) and field_size.isdigit(): 

1494 return int(field_size) 

1495 return None 

1496 

1497 def get_yaml_unit_id(self) -> str | None: 

1498 """Get the Bluetooth SIG unit identifier from YAML automation.""" 

1499 return self._spec.unit_id if self._spec else None 

1500 

1501 def get_yaml_resolution_text(self) -> str | None: 

1502 """Get the resolution description text from YAML automation.""" 

1503 return self._spec.resolution_text if self._spec else None 

1504 

1505 def is_signed_from_yaml(self) -> bool: 

1506 """Determine if the data type is signed based on YAML automation.""" 

1507 data_type = self.get_yaml_data_type() 

1508 if not data_type: 

1509 return False 

1510 # Check for signed types: signed integers, medical floats, and standard floats 

1511 if data_type.startswith("sint") or data_type in ("medfloat16", "medfloat32", "float32", "float64"): 

1512 return True 

1513 return False 

1514 

1515 # Descriptor support methods 

1516 

1517 def add_descriptor(self, descriptor: BaseDescriptor) -> None: 

1518 """Add a descriptor to this characteristic. 

1519 

1520 Args: 

1521 descriptor: The descriptor instance to add 

1522 """ 

1523 self._descriptors[str(descriptor.uuid)] = descriptor 

1524 

1525 def get_descriptor(self, uuid: str | BluetoothUUID) -> BaseDescriptor | None: 

1526 """Get a descriptor by UUID. 

1527 

1528 Args: 

1529 uuid: Descriptor UUID (string or BluetoothUUID) 

1530 

1531 Returns: 

1532 Descriptor instance if found, None otherwise 

1533 """ 

1534 # Convert to BluetoothUUID for consistent handling 

1535 if isinstance(uuid, str): 

1536 try: 

1537 uuid_obj = BluetoothUUID(uuid) 

1538 except ValueError: 

1539 return None 

1540 else: 

1541 uuid_obj = uuid 

1542 

1543 return self._descriptors.get(uuid_obj.dashed_form) 

1544 

1545 def get_descriptors(self) -> dict[str, BaseDescriptor]: 

1546 """Get all descriptors for this characteristic. 

1547 

1548 Returns: 

1549 Dict mapping descriptor UUID strings to descriptor instances 

1550 """ 

1551 return self._descriptors.copy() 

1552 

1553 def get_cccd(self) -> BaseDescriptor | None: 

1554 """Get the Client Characteristic Configuration Descriptor (CCCD). 

1555 

1556 Returns: 

1557 CCCD descriptor instance if present, None otherwise 

1558 """ 

1559 return self.get_descriptor(CCCDDescriptor().uuid) 

1560 

1561 def can_notify(self) -> bool: 

1562 """Check if this characteristic supports notifications. 

1563 

1564 Returns: 

1565 True if the characteristic has a CCCD descriptor, False otherwise 

1566 """ 

1567 return self.get_cccd() is not None 

1568 

1569 def get_descriptor_from_context( 

1570 self, ctx: CharacteristicContext | None, descriptor_class: type[BaseDescriptor] 

1571 ) -> DescriptorData | None: 

1572 """Get a descriptor of the specified type from the context. 

1573 

1574 Args: 

1575 ctx: Characteristic context containing descriptors 

1576 descriptor_class: The descriptor class to look for (e.g., ValidRangeDescriptor) 

1577 

1578 Returns: 

1579 DescriptorData if found, None otherwise 

1580 """ 

1581 return _get_descriptor(ctx, descriptor_class) 

1582 

1583 def get_valid_range_from_context( 

1584 self, ctx: CharacteristicContext | None = None 

1585 ) -> tuple[int | float, int | float] | None: 

1586 """Get valid range from descriptor context if available. 

1587 

1588 Args: 

1589 ctx: Characteristic context containing descriptors 

1590 

1591 Returns: 

1592 Tuple of (min, max) values if Valid Range descriptor present, None otherwise 

1593 """ 

1594 return _get_valid_range(ctx) 

1595 

1596 def get_presentation_format_from_context( 

1597 self, ctx: CharacteristicContext | None = None 

1598 ) -> CharacteristicPresentationFormatData | None: 

1599 """Get presentation format from descriptor context if available. 

1600 

1601 Args: 

1602 ctx: Characteristic context containing descriptors 

1603 

1604 Returns: 

1605 CharacteristicPresentationFormatData if present, None otherwise 

1606 """ 

1607 return _get_presentation_format(ctx) 

1608 

1609 def get_user_description_from_context(self, ctx: CharacteristicContext | None = None) -> str | None: 

1610 """Get user description from descriptor context if available. 

1611 

1612 Args: 

1613 ctx: Characteristic context containing descriptors 

1614 

1615 Returns: 

1616 User description string if present, None otherwise 

1617 """ 

1618 return _get_user_description(ctx) 

1619 

1620 def validate_value_against_descriptor_range( 

1621 self, value: int | float, ctx: CharacteristicContext | None = None 

1622 ) -> bool: 

1623 """Validate a value against descriptor-defined valid range. 

1624 

1625 Args: 

1626 value: Value to validate 

1627 ctx: Characteristic context containing descriptors 

1628 

1629 Returns: 

1630 True if value is within valid range or no range defined, False otherwise 

1631 """ 

1632 return _validate_value_range(value, ctx) 

1633 

1634 def enhance_error_message_with_descriptors( 

1635 self, base_message: str, ctx: CharacteristicContext | None = None 

1636 ) -> str: 

1637 """Enhance error message with descriptor information for better debugging. 

1638 

1639 Args: 

1640 base_message: Original error message 

1641 ctx: Characteristic context containing descriptors 

1642 

1643 Returns: 

1644 Enhanced error message with descriptor context 

1645 """ 

1646 return _enhance_error_message(base_message, ctx) 

1647 

1648 def get_byte_order_hint(self) -> str: 

1649 """Get byte order hint (Bluetooth SIG uses little-endian by convention).""" 

1650 return "little"