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

492 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""Base class for GATT characteristics.""" 

2# pylint: disable=too-many-lines 

3 

4from __future__ import annotations 

5 

6import os 

7import re 

8from abc import ABC, ABCMeta 

9from functools import lru_cache 

10from typing import Any 

11 

12import msgspec 

13 

14from ...registry import units_registry 

15from ...types import CharacteristicData, CharacteristicInfo, DescriptorData 

16from ...types import ParseFieldError as FieldError 

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

18from ...types.uuid import BluetoothUUID 

19from ..context import CharacteristicContext 

20from ..descriptors import BaseDescriptor 

21from ..descriptors.cccd import CCCDDescriptor 

22from ..descriptors.characteristic_presentation_format import ( 

23 CharacteristicPresentationFormatData, 

24 CharacteristicPresentationFormatDescriptor, 

25) 

26from ..descriptors.characteristic_user_description import ( 

27 CharacteristicUserDescriptionDescriptor, 

28) 

29from ..descriptors.valid_range import ValidRangeDescriptor 

30from ..exceptions import ( 

31 InsufficientDataError, 

32 ParseFieldError, 

33 UUIDResolutionError, 

34 ValueRangeError, 

35) 

36from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator 

37from ..uuid_registry import CharacteristicSpec, uuid_registry 

38from .templates import CodingTemplate 

39 

40 

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

42 """Configuration for characteristic validation constraints. 

43 

44 Groups validation parameters into a single, optional configuration object 

45 to simplify BaseCharacteristic constructor signatures. 

46 """ 

47 

48 min_value: int | float | None = None 

49 max_value: int | float | None = None 

50 expected_length: int | None = None 

51 min_length: int | None = None 

52 max_length: int | None = None 

53 allow_variable_length: bool = False 

54 expected_type: type | None = None 

55 

56 

57class SIGCharacteristicResolver: 

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

59 

60 This class handles all SIG characteristic resolution logic, separating 

61 concerns from the BaseCharacteristic constructor. Uses shared utilities 

62 from the resolver module to avoid code duplication. 

63 """ 

64 

65 camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name) 

66 

67 @staticmethod 

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

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

70 

71 Args: 

72 char_class: The characteristic class to resolve info for 

73 

74 Returns: 

75 CharacteristicInfo with resolved UUID, name, value_type, unit 

76 

77 Raises: 

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

79 

80 """ 

81 # Try YAML resolution first 

82 yaml_spec = SIGCharacteristicResolver.resolve_yaml_spec_for_class(char_class) 

83 if yaml_spec: 

84 return SIGCharacteristicResolver._create_info_from_yaml(yaml_spec, char_class) 

85 

86 # Fallback to registry 

87 registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class) 

88 if registry_info: 

89 return registry_info 

90 

91 # No resolution found 

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

93 

94 @staticmethod 

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

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

97 # Get explicit name if set 

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

99 

100 # Generate all name variants using shared utility 

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

102 

103 # Try each name format with YAML resolution 

104 for try_name in names_to_try: 

105 spec = uuid_registry.resolve_characteristic_spec(try_name) 

106 if spec: 

107 return spec 

108 

109 return None 

110 

111 @staticmethod 

112 def _create_info_from_yaml( 

113 yaml_spec: CharacteristicSpec, char_class: type[BaseCharacteristic] 

114 ) -> CharacteristicInfo: 

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

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

117 

118 # Resolve unit via registry if present 

119 unit_info = None 

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

121 if unit_name: 

122 unit_info = units_registry.get_unit_info_by_name(unit_name) 

123 if unit_info: 

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

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

126 else: 

127 unit_symbol = str(unit_name or "") 

128 

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

130 

131 return CharacteristicInfo( 

132 uuid=yaml_spec.uuid, 

133 name=yaml_spec.name or char_class.__name__, 

134 unit=unit_symbol, 

135 value_type=value_type, 

136 properties=[], # Properties will be resolved separately if needed 

137 ) 

138 

139 @staticmethod 

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

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

142 # Use shared registry search strategy 

143 search_strategy = CharacteristicRegistrySearch() 

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

145 return search_strategy.search(char_class, characteristic_name) 

146 

147 

148class CharacteristicMeta(ABCMeta): 

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

150 

151 def __new__( 

152 mcs, 

153 name: str, 

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

155 namespace: dict[str, Any], 

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

157 ) -> type: 

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

159 

160 This metaclass hook ensures template classes and concrete 

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

162 attribute before the class object is created. 

163 """ 

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

165 if bases: # Not the base class itself 

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

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

168 is_in_templates = "templates" in module_name 

169 

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

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

172 # Check if any parent has _is_template = True 

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

174 if has_template_parent and "_is_template" not in namespace: 

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

176 

177 # Create the class normally 

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

179 

180 return new_class 

181 

182 

183class BaseCharacteristic(ABC, metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods 

184 """Base class for all GATT characteristics. 

185 

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

187 Supports manual overrides via _manual_unit and _manual_value_type attributes. 

188 

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

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

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

192 

193 Validation Attributes (optional class-level declarations): 

194 min_value: Minimum allowed value for parsed data 

195 max_value: Maximum allowed value for parsed data 

196 expected_length: Exact expected data length in bytes 

197 min_length: Minimum required data length in bytes 

198 max_length: Maximum allowed data length in bytes 

199 allow_variable_length: Whether variable length data is acceptable 

200 expected_type: Expected Python type for parsed values 

201 

202 Example usage in subclasses: 

203 class ExampleCharacteristic(BaseCharacteristic): 

204 '''Example showing validation attributes usage.''' 

205 

206 # Declare validation constraints as class attributes 

207 expected_length = 2 

208 min_value = 0 

209 max_value = 65535 # UINT16_MAX 

210 expected_type = int 

211 

212 def decode_value(self, data: bytearray) -> int: 

213 # Just parse - validation happens automatically in parse_value 

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

215 

216 # Before: BatteryLevelCharacteristic with hardcoded validation 

217 # class BatteryLevelCharacteristic(BaseCharacteristic): 

218 # def decode_value(self, data: bytearray) -> int: 

219 # if not data: 

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

221 # level = data[0] 

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

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

224 # return level 

225 

226 # After: BatteryLevelCharacteristic with declarative validation 

227 # class BatteryLevelCharacteristic(BaseCharacteristic): 

228 # expected_length = 1 

229 # min_value = 0 

230 # max_value = 100 # PERCENTAGE_MAX 

231 # expected_type = int 

232 # 

233 # def decode_value(self, data: bytearray) -> int: 

234 # return data[0] # Validation happens automatically 

235 """ 

236 

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

238 _characteristic_name: str | None = None 

239 _manual_unit: str | None = None 

240 _manual_value_type: ValueType | str | None = None 

241 _manual_size: int | None = None 

242 _is_template: bool = False 

243 

244 # Validation attributes (Progressive API Level 2) 

245 min_value: int | float | None = None 

246 max_value: int | float | None = None 

247 expected_length: int | None = None 

248 min_length: int | None = None 

249 max_length: int | None = None 

250 allow_variable_length: bool = False 

251 expected_type: type | None = None 

252 

253 # Template support (Progressive API Level 4) 

254 _template: CodingTemplate | None = None # CodingTemplate instance for composition 

255 

256 # YAML automation attributes 

257 _yaml_data_type: str | None = None 

258 _yaml_field_size: int | str | None = None 

259 _yaml_unit_id: str | None = None 

260 _yaml_resolution_text: str | None = None 

261 

262 _allows_sig_override = False 

263 

264 # Multi-characteristic parsing support (Progressive API Level 5) 

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

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

267 

268 # Parse trace control (for performance tuning) 

269 # Can be configured via BLUETOOTH_SIG_ENABLE_PARSE_TRACE environment variable 

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

271 _enable_parse_trace: bool = True # Default: enabled 

272 

273 def __init__( 

274 self, 

275 info: CharacteristicInfo | None = None, 

276 validation: ValidationConfig | None = None, 

277 ) -> None: 

278 """Initialize characteristic with structured configuration. 

279 

280 Args: 

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

282 validation: Validation constraints configuration (optional) 

283 

284 """ 

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

286 self._provided_info = info 

287 

288 # Instance variables (will be set in __post_init__) 

289 self._info: CharacteristicInfo 

290 

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

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

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

294 self.value_type: ValueType = ValueType.UNKNOWN 

295 

296 # Set validation attributes from ValidationConfig or class defaults 

297 if validation: 

298 self.min_value = validation.min_value 

299 self.max_value = validation.max_value 

300 self.expected_length = validation.expected_length 

301 self.min_length = validation.min_length 

302 self.max_length = validation.max_length 

303 self.allow_variable_length = validation.allow_variable_length 

304 self.expected_type = validation.expected_type 

305 else: 

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

307 self.min_value = self.__class__.min_value 

308 self.max_value = self.__class__.max_value 

309 self.expected_length = self.__class__.expected_length 

310 self.min_length = self.__class__.min_length 

311 self.max_length = self.__class__.max_length 

312 self.allow_variable_length = self.__class__.allow_variable_length 

313 self.expected_type = self.__class__.expected_type 

314 

315 # Dependency caches (resolved once per instance) 

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

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

318 

319 # Descriptor support 

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

321 

322 # Call post-init to resolve characteristic info 

323 self.__post_init__() 

324 

325 def __post_init__(self) -> None: 

326 """Initialize characteristic with resolved information.""" 

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

328 if self._provided_info: 

329 self._info = self._provided_info 

330 else: 

331 # Resolve characteristic information using proper resolver 

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

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

334 if self._manual_unit is not None: 

335 self._info.unit = self._manual_unit 

336 if self._manual_value_type is not None: 

337 # Handle both ValueType enum and string manual overrides 

338 if isinstance(self._manual_value_type, ValueType): 

339 self._info.value_type = self._manual_value_type 

340 else: 

341 # Map string value types to ValueType enum 

342 string_to_value_type_map = { 

343 "string": ValueType.STRING, 

344 "int": ValueType.INT, 

345 "float": ValueType.FLOAT, 

346 "bytes": ValueType.BYTES, 

347 "bool": ValueType.BOOL, 

348 "datetime": ValueType.DATETIME, 

349 "uuid": ValueType.UUID, 

350 "dict": ValueType.DICT, 

351 "various": ValueType.VARIOUS, 

352 "unknown": ValueType.UNKNOWN, 

353 # Custom type strings that should map to basic types 

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

355 } 

356 

357 try: 

358 # First try direct ValueType enum construction 

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

360 except ValueError: 

361 # Fall back to string mapping 

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

363 

364 # Set value_type from resolved info 

365 self.value_type = self._info.value_type 

366 

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

368 # try to infer from characteristic patterns 

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

370 inferred_type = self._infer_value_type_from_patterns() 

371 if inferred_type != ValueType.UNKNOWN: 

372 self._info.value_type = inferred_type 

373 self.value_type = inferred_type 

374 

375 def _infer_value_type_from_patterns(self) -> ValueType: 

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

377 

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

379 """ 

380 class_name = self.__class__.__name__ 

381 char_name = self._characteristic_name or class_name 

382 

383 # Pattern-based inference for common characteristics 

384 measurement_patterns = [ 

385 "Measurement", 

386 "Data", 

387 "Reading", 

388 "Value", 

389 "Status", 

390 "Feature", 

391 "Capability", 

392 "Support", 

393 "Configuration", 

394 ] 

395 

396 # If it contains measurement/data patterns, likely returns complex data -> bytes 

397 if any(pattern in class_name or pattern in char_name for pattern in measurement_patterns): 

398 return ValueType.BYTES 

399 

400 # Common simple value characteristics 

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

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

403 return ValueType.INT 

404 

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

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

407 return ValueType.STRING 

408 

409 # Default fallback for complex characteristics 

410 return ValueType.BYTES 

411 

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

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

414 # Delegate to static method 

415 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(type(self)) 

416 

417 @property 

418 def uuid(self) -> BluetoothUUID: 

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

420 return self._info.uuid 

421 

422 @property 

423 def info(self) -> CharacteristicInfo: 

424 """Characteristic information.""" 

425 return self._info 

426 

427 @property 

428 def name(self) -> str: 

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

430 return self._info.name 

431 

432 @property 

433 def summary(self) -> str: 

434 """Get the characteristic summary.""" 

435 # NOTE: For single source of truth, we should use _info but CharacteristicInfo 

436 # doesn't currently include summary field. This is a temporary compromise 

437 # until CharacteristicInfo is enhanced with summary field 

438 info = uuid_registry.get_characteristic_info(self._info.uuid) 

439 return info.summary if info else "" 

440 

441 @property 

442 def display_name(self) -> str: 

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

444 

445 Uses explicit _characteristic_name if set, otherwise falls back 

446 to class name. 

447 """ 

448 return self._characteristic_name or self.__class__.__name__ 

449 

450 @classmethod 

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

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

453 

454 Args: 

455 dep_class: The characteristic class to resolve 

456 

457 Returns: 

458 Canonical UUID string or None if unresolvable 

459 

460 """ 

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

462 if configured_info is not None: 

463 return str(configured_info.uuid) 

464 

465 try: 

466 class_uuid = dep_class.get_class_uuid() 

467 if class_uuid is not None: 

468 return str(class_uuid) 

469 except (ValueError, AttributeError, TypeError): 

470 pass 

471 

472 try: 

473 temp_instance = dep_class() 

474 return str(temp_instance.info.uuid) 

475 except (ValueError, AttributeError, TypeError): 

476 return None 

477 

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

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

480 dependency_classes: list[type[BaseCharacteristic]] = [] 

481 

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

483 dependency_classes.extend(declared) 

484 

485 resolved: list[str] = [] 

486 seen: set[str] = set() 

487 

488 for dep_class in dependency_classes: 

489 uuid_str = self._normalize_dependency_class(dep_class) 

490 if uuid_str and uuid_str not in seen: 

491 seen.add(uuid_str) 

492 resolved.append(uuid_str) 

493 

494 return resolved 

495 

496 @property 

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

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

499 if self._resolved_required_dependencies is None: 

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

501 

502 return list(self._resolved_required_dependencies) 

503 

504 @property 

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

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

507 if self._resolved_optional_dependencies is None: 

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

509 

510 return list(self._resolved_optional_dependencies) 

511 

512 @classmethod 

513 def get_allows_sig_override(cls) -> bool: 

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

515 

516 Custom characteristics that need to override official Bluetooth SIG 

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

518 

519 Returns: 

520 True if SIG override is allowed, False otherwise. 

521 

522 """ 

523 return cls._allows_sig_override 

524 

525 @classmethod 

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

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

528 

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

530 by __init_subclass__ for custom characteristics. 

531 

532 Returns: 

533 CharacteristicInfo if configured, None otherwise 

534 

535 """ 

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

537 

538 @classmethod 

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

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

541 

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

543 

544 Returns: 

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

546 

547 """ 

548 return cls._resolve_class_uuid() 

549 

550 @classmethod 

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

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

553 # Try cross-file resolution first 

554 yaml_spec = cls._resolve_yaml_spec_class() 

555 if yaml_spec: 

556 return yaml_spec.uuid 

557 

558 # Fallback to original registry resolution 

559 return cls._resolve_from_basic_registry_class() 

560 

561 @classmethod 

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

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

564 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(cls) 

565 

566 @classmethod 

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

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

569 try: 

570 registry_info = SIGCharacteristicResolver.resolve_from_registry(cls) 

571 return registry_info.uuid if registry_info else None 

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

573 # Registry resolution can fail for various reasons: 

574 # - ValueError: Invalid UUID format 

575 # - KeyError: Characteristic not in registry 

576 # - AttributeError: Missing expected attributes 

577 # - TypeError: Type mismatch in resolution 

578 return None 

579 

580 @classmethod 

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

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

583 try: 

584 class_uuid = cls._resolve_class_uuid() 

585 if class_uuid is None: 

586 return False 

587 if isinstance(uuid, BluetoothUUID): 

588 input_uuid = uuid 

589 else: 

590 input_uuid = BluetoothUUID(uuid) 

591 return class_uuid == input_uuid 

592 except ValueError: 

593 return False 

594 

595 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> Any: # noqa: ANN401 # Context and return types vary by characteristic 

596 """Parse the characteristic's raw value. 

597 

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

599 Otherwise, subclasses must override this method. 

600 

601 Args: 

602 data: Raw bytes from the characteristic read 

603 ctx: Optional context information for parsing 

604 

605 Returns: 

606 Parsed value in the appropriate type 

607 

608 Raises: 

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

610 

611 """ 

612 if self._template is not None: 

613 return self._template.decode_value(data, offset=0, ctx=ctx) 

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

615 

616 def _validate_range(self, value: Any, ctx: CharacteristicContext | None = None) -> None: # noqa: ANN401 # Validates values of various numeric types 

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

618 # Check class-level validation attributes first 

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

620 raise ValueRangeError("value", value, self.min_value, self.max_value) 

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

622 raise ValueRangeError("value", value, self.min_value, self.max_value) 

623 

624 # Check descriptor-defined valid range if available 

625 if isinstance(value, (int, float)): 

626 valid_range = self.get_valid_range_from_context(ctx) 

627 if valid_range: 

628 min_val, max_val = valid_range 

629 if not min_val <= value <= max_val: 

630 raise ValueRangeError("value", value, min_val, max_val) 

631 

632 def _validate_type(self, value: Any) -> None: # noqa: ANN401 # Validates values of various types 

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

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

635 raise TypeError(f"expected type {self.expected_type.__name__}, got {type(value).__name__}") 

636 

637 def _validate_length(self, data: bytes | bytearray) -> None: 

638 """Validate data length meets requirements.""" 

639 length = len(data) 

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

641 raise InsufficientDataError("characteristic_data", data, self.expected_length) 

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

643 raise InsufficientDataError("characteristic_data", data, self.min_length) 

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

645 raise ValueError(f"Maximum {self.max_length} bytes allowed, got {length}") 

646 

647 @staticmethod 

648 @lru_cache(maxsize=32) 

649 def _get_characteristic_uuid_by_name( 

650 characteristic_name: CharacteristicName | CharacteristicName | str, 

651 ) -> str | None: 

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

653 # Convert enum to string value for registry lookup 

654 name_str = ( 

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

656 ) 

657 char_info = uuid_registry.get_characteristic_info(name_str) 

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

659 

660 def get_context_characteristic( 

661 self, 

662 ctx: CharacteristicContext | None, 

663 characteristic_name: CharacteristicName | str | type[BaseCharacteristic], 

664 ) -> Any | None: # noqa: ANN401 # Returns various characteristic types from context 

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

666 

667 Args: 

668 ctx: Context containing other characteristics. 

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

670 

671 Returns: 

672 Characteristic data if found, None otherwise. 

673 

674 """ 

675 if not ctx or not ctx.other_characteristics: 

676 return None 

677 

678 # Extract UUID from class if provided 

679 if isinstance(characteristic_name, type): 

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

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

682 if configured_info is not None: 

683 # Custom characteristic with explicit _configured_info 

684 char_uuid: str = str(configured_info.uuid) 

685 else: 

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

687 class_name: str = characteristic_name.__name__ 

688 # Remove 'Characteristic' suffix 

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

690 # Insert spaces before capital letters to get SIG name 

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

692 # Look up UUID via registry 

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

694 if resolved_uuid is None: 

695 return None 

696 char_uuid = resolved_uuid 

697 else: 

698 # Enum or string name 

699 resolved_uuid = self._get_characteristic_uuid_by_name(characteristic_name) 

700 if resolved_uuid is None: 

701 return None 

702 char_uuid = resolved_uuid 

703 

704 return ctx.other_characteristics.get(char_uuid) 

705 

706 def _is_parse_trace_enabled(self) -> bool: 

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

708 

709 Returns: 

710 True if parse tracing is enabled, False otherwise 

711 

712 Environment Variables: 

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

714 

715 Instance Attributes: 

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

717 """ 

718 # Check environment variable first 

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

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

721 return False 

722 

723 if self._enable_parse_trace is False: 

724 return False 

725 

726 # Default to enabled 

727 return True 

728 

729 def parse_value(self, data: bytes | bytearray, ctx: CharacteristicContext | None = None) -> CharacteristicData: 

730 """Parse raw characteristic data into structured value with validation. 

731 

732 Args: 

733 data: Raw bytes from the characteristic read 

734 ctx: Optional context with descriptors and other characteristics 

735 

736 Returns: 

737 CharacteristicData object with parsed value 

738 

739 """ 

740 # Convert to bytearray for internal processing 

741 data_bytes = bytearray(data) 

742 enable_trace = self._is_parse_trace_enabled() 

743 parse_trace: list[str] = [] 

744 if enable_trace: 

745 parse_trace = ["Starting parse"] 

746 field_errors: list[FieldError] = [] 

747 

748 try: 

749 if enable_trace: 

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

751 self._validate_length(data_bytes) 

752 if enable_trace: 

753 parse_trace.append("Decoding value") 

754 parsed_value = self.decode_value(data_bytes, ctx) 

755 if enable_trace: 

756 parse_trace.append("Validating range") 

757 self._validate_range(parsed_value, ctx) 

758 if enable_trace: 

759 parse_trace.append("Validating type") 

760 self._validate_type(parsed_value) 

761 if enable_trace: 

762 parse_trace.append("completed successfully") 

763 return CharacteristicData( 

764 info=self._info, 

765 value=parsed_value, 

766 raw_data=bytes(data), 

767 parse_success=True, 

768 error_message="", 

769 field_errors=field_errors, 

770 parse_trace=parse_trace, 

771 descriptors={}, 

772 ) 

773 except Exception as e: # pylint: disable=broad-exception-caught 

774 if enable_trace: 

775 if isinstance(e, ParseFieldError): 

776 parse_trace.append(f"Field error: {str(e)}") 

777 # Extract field error information 

778 field_error = FieldError( 

779 field=e.field, 

780 reason=e.field_reason, 

781 offset=e.offset, 

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

783 ) 

784 field_errors.append(field_error) 

785 else: 

786 parse_trace.append(f"Parse failed: {str(e)}") 

787 return CharacteristicData( 

788 info=self._info, 

789 value=None, 

790 raw_data=bytes(data), 

791 parse_success=False, 

792 error_message=str(e), 

793 field_errors=field_errors, 

794 parse_trace=parse_trace, 

795 descriptors={}, 

796 ) 

797 

798 def get_descriptors_from_context(self, ctx: CharacteristicContext | None) -> dict[str, Any]: 

799 """Extract descriptor data from the parsing context. 

800 

801 Args: 

802 ctx: The characteristic context containing descriptor information 

803 

804 Returns: 

805 Dictionary mapping descriptor UUIDs to DescriptorData objects 

806 """ 

807 if not ctx or not ctx.descriptors: 

808 return {} 

809 

810 # Return a copy of the descriptors from context 

811 return dict(ctx.descriptors) 

812 

813 def encode_value(self, data: Any) -> bytearray: # noqa: ANN401 # Encodes various value types (int, float, dataclass, etc.) 

814 """Encode the characteristic's value to raw bytes. 

815 

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

817 Otherwise, subclasses must override this method. 

818 

819 Args: 

820 data: Dataclass instance or value to encode 

821 

822 Returns: 

823 Encoded bytes for characteristic write 

824 

825 Raises: 

826 ValueError: If data is invalid for encoding 

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

828 

829 """ 

830 if self._template is not None: 

831 return self._template.encode_value(data) 

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

833 

834 @property 

835 def unit(self) -> str: 

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

837 return self._info.unit 

838 

839 @property 

840 def properties(self) -> list[GattProperty]: 

841 """Get the GATT properties from _info.""" 

842 return self._info.properties 

843 

844 @property 

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

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

847 

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

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

850 and encoding. 

851 

852 """ 

853 # First try manual size override if set 

854 if self._manual_size is not None: 

855 return self._manual_size 

856 

857 # Try field size from YAML cross-reference 

858 field_size = self.get_yaml_field_size() 

859 if field_size is not None: 

860 return field_size 

861 

862 # For characteristics without YAML size info, return None 

863 # indicating variable or unknown length 

864 return None 

865 

866 @property 

867 def value_type_resolved(self) -> ValueType: 

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

869 return self._info.value_type 

870 

871 # YAML automation helper methods 

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

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

874 return self._yaml_data_type 

875 

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

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

878 field_size = self._yaml_field_size 

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

880 return int(field_size) 

881 if isinstance(field_size, int): 

882 return field_size 

883 return None 

884 

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

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

887 return self._yaml_unit_id 

888 

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

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

891 return self._yaml_resolution_text 

892 

893 def is_signed_from_yaml(self) -> bool: 

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

895 data_type = self.get_yaml_data_type() 

896 if not data_type: 

897 return False 

898 # Check for signed integer types 

899 if data_type.startswith("sint"): 

900 return True 

901 # Check for IEEE-11073 medical float types (signed) 

902 if data_type in ("medfloat16", "medfloat32"): 

903 return True 

904 # Check for IEEE-754 floating point types (signed) 

905 if data_type in ("float32", "float64"): 

906 return True 

907 return False 

908 

909 # Descriptor support methods 

910 

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

912 """Add a descriptor to this characteristic. 

913 

914 Args: 

915 descriptor: The descriptor instance to add 

916 """ 

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

918 

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

920 """Get a descriptor by UUID. 

921 

922 Args: 

923 uuid: Descriptor UUID (string or BluetoothUUID) 

924 

925 Returns: 

926 Descriptor instance if found, None otherwise 

927 """ 

928 # Convert to BluetoothUUID for consistent handling 

929 if isinstance(uuid, str): 

930 try: 

931 uuid_obj = BluetoothUUID(uuid) 

932 except ValueError: 

933 return None 

934 else: 

935 uuid_obj = uuid 

936 

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

938 

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

940 """Get all descriptors for this characteristic. 

941 

942 Returns: 

943 Dict mapping descriptor UUID strings to descriptor instances 

944 """ 

945 return self._descriptors.copy() 

946 

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

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

949 

950 Returns: 

951 CCCD descriptor instance if present, None otherwise 

952 """ 

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

954 

955 def can_notify(self) -> bool: 

956 """Check if this characteristic supports notifications. 

957 

958 Returns: 

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

960 """ 

961 return self.get_cccd() is not None 

962 

963 def get_descriptor_from_context( 

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

965 ) -> DescriptorData | None: 

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

967 

968 Args: 

969 ctx: Characteristic context containing descriptors 

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

971 

972 Returns: 

973 DescriptorData if found, None otherwise 

974 """ 

975 if not ctx or not ctx.descriptors: 

976 return None 

977 

978 # Get the UUID from the descriptor class 

979 try: 

980 descriptor_instance = descriptor_class() 

981 descriptor_uuid = str(descriptor_instance.uuid) 

982 except (ValueError, TypeError, AttributeError): 

983 # If we can't create the descriptor instance, return None 

984 return None 

985 

986 return ctx.descriptors.get(descriptor_uuid) 

987 

988 def get_valid_range_from_context( 

989 self, ctx: CharacteristicContext | None = None 

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

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

992 

993 Args: 

994 ctx: Characteristic context containing descriptors 

995 

996 Returns: 

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

998 """ 

999 descriptor_data = self.get_descriptor_from_context(ctx, ValidRangeDescriptor) 

1000 if descriptor_data and descriptor_data.value: 

1001 return descriptor_data.value.min_value, descriptor_data.value.max_value 

1002 return None 

1003 

1004 def get_presentation_format_from_context( 

1005 self, ctx: CharacteristicContext | None = None 

1006 ) -> CharacteristicPresentationFormatData | None: 

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

1008 

1009 Args: 

1010 ctx: Characteristic context containing descriptors 

1011 

1012 Returns: 

1013 CharacteristicPresentationFormatData if present, None otherwise 

1014 """ 

1015 descriptor_data = self.get_descriptor_from_context(ctx, CharacteristicPresentationFormatDescriptor) 

1016 if descriptor_data and descriptor_data.value: 

1017 return descriptor_data.value # type: ignore[no-any-return] 

1018 return None 

1019 

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

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

1022 

1023 Args: 

1024 ctx: Characteristic context containing descriptors 

1025 

1026 Returns: 

1027 User description string if present, None otherwise 

1028 """ 

1029 descriptor_data = self.get_descriptor_from_context(ctx, CharacteristicUserDescriptionDescriptor) 

1030 if descriptor_data and descriptor_data.value: 

1031 return descriptor_data.value.description # type: ignore[no-any-return] 

1032 return None 

1033 

1034 def validate_value_against_descriptor_range( 

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

1036 ) -> bool: 

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

1038 

1039 Args: 

1040 value: Value to validate 

1041 ctx: Characteristic context containing descriptors 

1042 

1043 Returns: 

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

1045 """ 

1046 valid_range = self.get_valid_range_from_context(ctx) 

1047 if valid_range is None: 

1048 return True # No range constraint, value is valid 

1049 

1050 min_val, max_val = valid_range 

1051 return min_val <= value <= max_val 

1052 

1053 def enhance_error_message_with_descriptors( 

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

1055 ) -> str: 

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

1057 

1058 Args: 

1059 base_message: Original error message 

1060 ctx: Characteristic context containing descriptors 

1061 

1062 Returns: 

1063 Enhanced error message with descriptor context 

1064 """ 

1065 enhancements = [] 

1066 

1067 # Add valid range info if available 

1068 valid_range = self.get_valid_range_from_context(ctx) 

1069 if valid_range: 

1070 min_val, max_val = valid_range 

1071 enhancements.append(f"Valid range: {min_val}-{max_val}") 

1072 

1073 # Add user description if available 

1074 user_desc = self.get_user_description_from_context(ctx) 

1075 if user_desc: 

1076 enhancements.append(f"Description: {user_desc}") 

1077 

1078 # Add presentation format info if available 

1079 pres_format = self.get_presentation_format_from_context(ctx) 

1080 if pres_format: 

1081 enhancements.append(f"Format: {pres_format.format} ({pres_format.unit})") 

1082 

1083 if enhancements: 

1084 return f"{base_message} ({'; '.join(enhancements)})" 

1085 return base_message 

1086 

1087 def get_byte_order_hint(self) -> str: 

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

1089 return "little" 

1090 

1091 

1092class CustomBaseCharacteristic(BaseCharacteristic): 

1093 """Helper base class for custom characteristic implementations. 

1094 

1095 This class provides a wrapper around physical BLE characteristics that are not 

1096 defined in the Bluetooth SIG specification. It supports both manual info passing 

1097 and automatic class-level _info binding via __init_subclass__. 

1098 

1099 Progressive API Levels Supported: 

1100 - Level 2: Class-level _info attribute (automatic binding) 

1101 - Legacy: Manual info parameter (backwards compatibility) 

1102 """ 

1103 

1104 _is_custom = True 

1105 _configured_info: CharacteristicInfo | None = None # Stores class-level _info 

1106 _allows_sig_override = False # Default: no SIG override permission 

1107 

1108 @classmethod 

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

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

1111 

1112 Returns: 

1113 CharacteristicInfo if configured, None otherwise 

1114 

1115 """ 

1116 return cls._configured_info 

1117 

1118 # pylint: disable=duplicate-code 

1119 # NOTE: __init_subclass__ and __init__ patterns are intentionally similar to CustomBaseService. 

1120 # This is by design - both custom characteristic and service classes need identical validation 

1121 # and info management patterns. Consolidation not possible due to different base types and info types. 

1122 def __init_subclass__(cls, allow_sig_override: bool = False, **kwargs: Any) -> None: # noqa: ANN401 # Receives subclass kwargs 

1123 """Automatically set up _info if provided as class attribute. 

1124 

1125 Args: 

1126 allow_sig_override: Set to True when intentionally overriding SIG UUIDs. 

1127 **kwargs: Additional subclass keyword arguments passed by callers or 

1128 metaclasses; these are accepted for compatibility and ignored 

1129 unless explicitly handled. 

1130 

1131 Raises: 

1132 ValueError: If class uses SIG UUID without override permission. 

1133 

1134 """ 

1135 super().__init_subclass__(**kwargs) 

1136 

1137 # Store override permission for registry validation 

1138 cls._allows_sig_override = allow_sig_override 

1139 

1140 # If class has _info attribute, validate and store it 

1141 if hasattr(cls, "_info"): 

1142 info = getattr(cls, "_info", None) 

1143 if info is not None: 

1144 # Check for SIG UUID override (unless explicitly allowed) 

1145 if not allow_sig_override and info.uuid.is_sig_characteristic(): 

1146 raise ValueError( 

1147 f"{cls.__name__} uses SIG UUID {info.uuid} without override flag. " 

1148 "Use custom UUID or add allow_sig_override=True parameter." 

1149 ) 

1150 

1151 cls._configured_info = info 

1152 

1153 def __init__( 

1154 self, 

1155 info: CharacteristicInfo | None = None, 

1156 ) -> None: 

1157 """Initialize a custom characteristic with automatic _info resolution. 

1158 

1159 Args: 

1160 info: Optional override for class-configured _info 

1161 

1162 Raises: 

1163 ValueError: If no valid info available from class or parameter 

1164 

1165 """ 

1166 # Use provided info, or fall back to class-configured _info 

1167 final_info = info or self.__class__.get_configured_info() 

1168 

1169 if not final_info: 

1170 raise ValueError(f"{self.__class__.__name__} requires either 'info' parameter or '_info' class attribute") 

1171 

1172 if not final_info.uuid or str(final_info.uuid) == "0000": 

1173 raise ValueError("Valid UUID is required for custom characteristics") 

1174 

1175 # Call parent constructor with our info to maintain consistency 

1176 super().__init__(info=final_info) 

1177 

1178 def __post_init__(self) -> None: 

1179 """Override BaseCharacteristic.__post_init__ to use custom info management. 

1180 

1181 CustomBaseCharacteristic manages _info manually from provided or configured info, 

1182 bypassing SIG resolution that would fail for custom characteristics. 

1183 """ 

1184 # Use provided info if available (from manual override), otherwise use configured info 

1185 if hasattr(self, "_provided_info") and self._provided_info: 

1186 self._info = self._provided_info 

1187 else: 

1188 configured_info = self.__class__.get_configured_info() 

1189 if configured_info: 

1190 self._info = configured_info 

1191 else: 

1192 # This shouldn't happen if class setup is correct 

1193 raise ValueError(f"CustomBaseCharacteristic {self.__class__.__name__} has no valid info source") 

1194 

1195 

1196class UnknownCharacteristic(CustomBaseCharacteristic): 

1197 """Generic characteristic implementation for unknown/non-SIG characteristics. 

1198 

1199 This class provides basic functionality for characteristics that are not 

1200 defined in the Bluetooth SIG specification. It stores raw data without 

1201 attempting to parse it into structured types. 

1202 """ 

1203 

1204 def __init__(self, info: CharacteristicInfo) -> None: 

1205 """Initialize an unknown characteristic. 

1206 

1207 Args: 

1208 info: CharacteristicInfo object with UUID, name, unit, value_type, properties 

1209 

1210 Raises: 

1211 ValueError: If UUID is invalid 

1212 

1213 """ 

1214 # If no name provided, generate one from UUID 

1215 if not info.name: 

1216 info = CharacteristicInfo( 

1217 uuid=info.uuid, 

1218 name=f"Unknown Characteristic ({info.uuid})", 

1219 unit=info.unit or "", 

1220 value_type=info.value_type, 

1221 properties=info.properties or [], 

1222 ) 

1223 

1224 super().__init__(info=info) 

1225 

1226 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> bytes: # Context type varies 

1227 """Return raw bytes for unknown characteristics. 

1228 

1229 Args: 

1230 data: Raw bytes from the characteristic read 

1231 ctx: Optional context (ignored) 

1232 

1233 Returns: 

1234 Raw bytes as-is 

1235 

1236 """ 

1237 return bytes(data) 

1238 

1239 def encode_value(self, data: Any) -> bytearray: # noqa: ANN401 # Accepts bytes-like objects 

1240 """Encode data to bytes for unknown characteristics. 

1241 

1242 Args: 

1243 data: Data to encode (must be bytes or bytearray) 

1244 

1245 Returns: 

1246 Encoded bytes 

1247 

1248 Raises: 

1249 ValueError: If data is not bytes/bytearray 

1250 

1251 """ 

1252 if isinstance(data, (bytes, bytearray)): 

1253 return bytearray(data) 

1254 raise ValueError(f"Unknown characteristics require bytes data, got {type(data)}")