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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Base class for GATT characteristics.
3This module implements the core characteristic parsing and encoding system for
4Bluetooth GATT characteristics, following official Bluetooth SIG specifications.
6Architecture
7============
9The implementation uses a multi-stage pipeline for parsing and encoding:
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
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)
25YAML Metadata Resolution
26=========================
28Characteristic metadata is automatically resolved from Bluetooth SIG YAML specifications:
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
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
40Template Composition
41====================
43Characteristics use templates for reusable parsing logic via composition:
45 class TemperatureCharacteristic(BaseCharacteristic):
46 _template = Sint16Template(resolution=0.01, unit="°C")
47 # No need to override decode_value() - template handles it
49Subclasses only override decode_value() for custom logic that templates
50cannot handle. Templates take priority over YAML-derived extractors.
52Validation Sources (Priority Order)
53===================================
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)
59Special Values
60==============
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.
67Byte Order
68==========
70All multi-byte values use little-endian encoding per Bluetooth Core Specification.
71"""
72# pylint: disable=too-many-lines
74from __future__ import annotations
76import os
77import re
78from abc import ABC, ABCMeta
79from functools import cached_property, lru_cache
80from typing import Any, Generic, TypeVar
82import msgspec
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
121# Type variable for generic characteristic return types
122T = TypeVar("T")
125class ValidationConfig(msgspec.Struct, kw_only=True):
126 """Configuration for characteristic validation constraints.
128 Groups validation parameters into a single, optional configuration object
129 to simplify BaseCharacteristic constructor signatures.
130 """
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
141class SIGCharacteristicResolver:
142 """Resolves SIG characteristic information from YAML and registry.
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 """
149 camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name)
151 @staticmethod
152 def resolve_for_class(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicInfo:
153 """Resolve CharacteristicInfo for a SIG characteristic class.
155 Args:
156 char_class: The characteristic class to resolve info for
158 Returns:
159 CharacteristicInfo with resolved UUID, name, value_type, unit
161 Raises:
162 UUIDResolutionError: If no UUID can be resolved for the class
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)
170 # Fallback to registry
171 registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class)
172 if registry_info:
173 return registry_info
175 # No resolution found
176 raise UUIDResolutionError(char_class.__name__, [char_class.__name__])
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)
184 # Generate all name variants using shared utility
185 names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name)
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
193 return None
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()
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 "")
213 # TODO: Add similar logic for object types, service classes, etc. as needed
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 )
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)
231class CharacteristicMeta(ABCMeta):
232 """Metaclass to automatically handle template flags for characteristics."""
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.
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
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
260 # Create the class normally
261 new_class = super().__new__(mcs, name, bases, namespace, **kwargs)
263 return new_class
266class BaseCharacteristic(ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods
267 """Base class for all GATT characteristics.
269 Generic over T, the return type of _decode_value().
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.
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.
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
287 Example usage in subclasses:
288 class ExampleCharacteristic(BaseCharacteristic):
289 '''Example showing validation attributes usage.'''
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
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)
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
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 """
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
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
337 _template: CodingTemplate[T] | None = None
339 _allows_sig_override = False
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
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
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
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.
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)
367 """
368 # Store provided info or None (will be resolved in __post_init__)
369 self._provided_info = info
371 # Instance variables (will be set in __post_init__)
372 self._info: CharacteristicInfo
373 self._spec: CharacteristicSpec | None = None
375 # Runtime properties (from actual device, not YAML)
376 self.properties: list[GattProperty] = properties if properties is not None else []
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
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
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
406 # Descriptor support
407 self._descriptors: dict[str, BaseDescriptor] = {}
409 # Last parsed value for caching/debugging
410 self.last_parsed: T | None = None
412 # Call post-init to resolve characteristic info
413 self.__post_init__()
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))
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 )
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 )
439 self._special_resolver = SpecialValueResolver(spec_rules=spec_rules, class_rules=class_rules)
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 }
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)
472 # Set value_type from resolved info
473 self.value_type = self._info.value_type
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
483 def _infer_value_type_from_patterns(self) -> ValueType:
484 """Infer value type from characteristic naming patterns and class structure.
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
491 # Feature characteristics are bitfields and should be BITFIELD
492 if "Feature" in class_name or "Feature" in char_name:
493 return ValueType.BITFIELD
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
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
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
508 # Default fallback for complex characteristics
509 return ValueType.VARIOUS
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))
516 @property
517 def uuid(self) -> BluetoothUUID:
518 """Get the characteristic UUID from _info."""
519 return self._info.uuid
521 @property
522 def info(self) -> CharacteristicInfo:
523 """Characteristic information."""
524 return self._info
526 @property
527 def spec(self) -> CharacteristicSpec | None:
528 """Get the full GSS specification with description and detailed metadata."""
529 return self._spec
531 @property
532 def name(self) -> str:
533 """Get the characteristic name from _info."""
534 return self._info.name
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 ""
541 @property
542 def display_name(self) -> str:
543 """Get the display name for this characteristic.
545 Uses explicit _characteristic_name if set, otherwise falls back
546 to class name.
547 """
548 return self._characteristic_name or self.__class__.__name__
550 @cached_property
551 def gss_special_values(self) -> dict[int, str]:
552 """Get special values from GSS specification.
554 Extracts all special value definitions (e.g., 0x8000="value is not known")
555 from the GSS YAML specification for this characteristic.
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.
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 {}
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
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
588 def is_special_value(self, raw_value: int) -> bool:
589 """Check if a raw value is a special sentinel value.
591 Checks both manual overrides (_special_values class variable) and
592 GSS-derived special values, with manual taking precedence.
594 Args:
595 raw_value: The raw integer value to check.
597 Returns:
598 True if this is a special sentinel value, False otherwise.
599 """
600 return self._special_resolver.is_special(raw_value)
602 def get_special_value_meaning(self, raw_value: int) -> str | None:
603 """Get the human-readable meaning of a special value.
605 Args:
606 raw_value: The raw integer value to look up.
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
614 def get_special_value_type(self, raw_value: int) -> SpecialValueType | None:
615 """Get the category of a special value.
617 Args:
618 raw_value: The raw integer value to classify.
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
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.
630 Args:
631 dep_class: The characteristic class to resolve
633 Returns:
634 Canonical UUID string or None if unresolvable
636 """
637 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None)
638 if configured_info is not None:
639 return str(configured_info.uuid)
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
648 try:
649 temp_instance = dep_class()
650 return str(temp_instance.info.uuid)
651 except (ValueError, AttributeError, TypeError):
652 return None
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]]] = []
658 declared = getattr(self.__class__, attr_name, []) or []
659 dependency_classes.extend(declared)
661 resolved: list[str] = []
662 seen: set[str] = set()
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)
670 return resolved
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")
678 return list(self._resolved_required_dependencies)
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")
686 return list(self._resolved_optional_dependencies)
688 @classmethod
689 def get_allows_sig_override(cls) -> bool:
690 """Check if this characteristic class allows overriding SIG characteristics.
692 Custom characteristics that need to override official Bluetooth SIG
693 characteristics must set _allows_sig_override = True as a class attribute.
695 Returns:
696 True if SIG override is allowed, False otherwise.
698 """
699 return cls._allows_sig_override
701 @classmethod
702 def get_configured_info(cls) -> CharacteristicInfo | None:
703 """Get the class-level configured CharacteristicInfo.
705 This provides public access to the _configured_info attribute that is set
706 by __init_subclass__ for custom characteristics.
708 Returns:
709 CharacteristicInfo if configured, None otherwise
711 """
712 return getattr(cls, "_configured_info", None)
714 @classmethod
715 def get_class_uuid(cls) -> BluetoothUUID | None:
716 """Get the characteristic UUID for this class without creating an instance.
718 This is the public API for registry and other modules to resolve UUIDs.
720 Returns:
721 BluetoothUUID if the class has a resolvable UUID, None otherwise.
723 """
724 return cls._resolve_class_uuid()
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
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
742 # Fallback to original registry resolution
743 return cls._resolve_from_basic_registry_class()
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)
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
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
779 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> T:
780 """Internal parse the characteristic's raw value with no validation.
782 This is expected to be called from parse_value() which handles validation.
784 If _template is set, uses the template's decode_value method.
785 Otherwise, subclasses must override this method.
787 Args:
788 data: Raw bytes from the characteristic read
789 ctx: Optional context information for parsing
791 Returns:
792 Parsed value in the appropriate type
794 Raises:
795 NotImplementedError: If no template is set and subclass doesn't override
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()")
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.
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
816 Args:
817 value: The value to validate
818 ctx: Optional characteristic context containing descriptors
820 Returns:
821 ValidationReport with errors if validation fails
822 """
823 result = ValidationAccumulator()
825 # Skip validation for SpecialValueResult
826 if isinstance(value, SpecialValueResult):
827 return result
829 # Skip validation for non-numeric types
830 if not isinstance(value, (int, float)):
831 return result
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
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)
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
886 return result
888 def _validate_type(self, value: Any) -> ValidationAccumulator: # noqa: ANN401
889 """Validate value type matches expected_type if specified.
891 Args:
892 value: The value to validate
893 validate: Whether validation is enabled
895 Returns:
896 ValidationReport with errors if validation fails
897 """
898 result = ValidationAccumulator()
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
909 def _validate_length(self, data: bytes | bytearray) -> ValidationAccumulator:
910 """Validate data length meets requirements.
912 Args:
913 data: The data to validate
915 Returns:
916 ValidationReport with errors if validation fails
917 """
918 result = ValidationAccumulator()
920 length = len(data)
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__})"
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
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.
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())
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.
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
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
991 # No extractor available
992 if enable_trace:
993 parse_trace.append("No extractor available for raw_int extraction")
994 return None
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))
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))
1011 raise ValueError("No extractor available to pack raw integer for this characteristic")
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.
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.
1025 Args:
1026 ctx: Characteristic context containing other characteristics
1027 dep_class: Dependency characteristic class to look up
1029 Returns:
1030 Parsed characteristic value if found in context, None otherwise.
1032 """
1033 # Resolve class to UUID
1034 dep_uuid = dep_class.get_class_uuid()
1035 if not dep_uuid:
1036 return None
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))
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
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.
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.
1068 Args:
1069 ctx: Context containing other characteristics.
1070 characteristic_name: Enum, string name, or characteristic class.
1072 Returns:
1073 Parsed characteristic value if found, None otherwise.
1075 """
1076 if not ctx or not ctx.other_characteristics:
1077 return None
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
1105 return ctx.other_characteristics.get(char_uuid)
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.
1110 Args:
1111 raw_value: The raw integer value to check
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
1121 def _is_parse_trace_enabled(self) -> bool:
1122 """Check if parse trace is enabled via environment variable or instance attribute.
1124 Returns:
1125 True if parse tracing is enabled, False otherwise
1127 Environment Variables:
1128 BLUETOOTH_SIG_ENABLE_PARSE_TRACE: Set to "0", "false", or "no" to disable
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
1138 if self._enable_parse_trace is False:
1139 return False
1141 # Default to enabled
1142 return True
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))
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)
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")
1182 return raw_int, parsed_value
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.
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)
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
1218 def parse_value(
1219 self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True
1220 ) -> T:
1221 """Parse characteristic data.
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
1235 try:
1236 # Perform initial validation
1237 self._perform_parse_validation(data_bytes, enable_trace, parse_trace, validation, validate)
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)
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 )
1250 # Decode and validate value
1251 decoded_value = self._decode_and_validate_value(
1252 data_bytes, enable_trace, parse_trace, ctx, validation, validate
1253 )
1255 if enable_trace:
1256 parse_trace.append("Parse completed successfully")
1258 # Cache the parsed value for debugging/caching purposes
1259 self.last_parsed = decoded_value
1261 return decoded_value
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}")
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)
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
1292 def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401
1293 """Internal encode the characteristic's value to raw bytes with no validation.
1295 This is expected to called from build_value() after validation.
1297 If _template is set, uses the template's encode_value method.
1298 Otherwise, subclasses must override this method.
1300 This is a low-level method that performs no validation. For encoding
1301 with validation, use encode() instead.
1303 Args:
1304 data: Dataclass instance or value to encode
1306 Returns:
1307 Encoded bytes for characteristic write
1309 Raises:
1310 ValueError: If data is invalid for encoding
1311 NotImplementedError: If no template is set and subclass doesn't override
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()")
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.
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
1328 Returns:
1329 Encoded bytes ready for BLE write
1331 Raises:
1332 CharacteristicEncodeError: If encoding or validation fails
1334 Examples:
1335 # Normal value
1336 data = char.build_value(37.5) # Returns: bytearray([0xAA, 0x0E])
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])
1347 # With validation disabled (for debugging)
1348 data = char.build_value(200.0, validate=False) # Allows out-of-range
1350 # Error handling
1351 try:
1352 data = char.build_value(value)
1353 except CharacteristicEncodeError as e:
1354 print(f"Encode failed: {e}")
1356 """
1357 enable_trace = self._is_parse_trace_enabled()
1358 build_trace: list[str] = ["Starting build"] if enable_trace else []
1359 validation = ValidationAccumulator()
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
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))
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))
1397 # Encode
1398 if enable_trace:
1399 build_trace.append("Encoding value")
1400 encoded = self._encode_value(data)
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))
1412 if enable_trace:
1413 build_trace.append("Build completed successfully")
1415 return encoded
1417 except Exception as e:
1418 if enable_trace:
1419 build_trace.append(f"Build failed: {type(e).__name__}: {e}")
1421 raise CharacteristicEncodeError(
1422 message=str(e),
1423 name=self.name,
1424 uuid=self.uuid,
1425 value=data,
1426 validation=validation,
1427 ) from e
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).
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)
1440 def encode_special_by_meaning(self, meaning: str) -> bytearray:
1441 """Encode a special value by a partial meaning string match.
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)
1450 @property
1451 def unit(self) -> str:
1452 """Get the unit of measurement from _info.
1454 Returns empty string for characteristics without units (e.g., bitfields).
1455 """
1456 return self._info.unit or ""
1458 @property
1459 def size(self) -> int | None:
1460 """Get the size in bytes for this characteristic from YAML specifications.
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.
1466 """
1467 # First try manual size override if set
1468 if self._manual_size is not None:
1469 return self._manual_size
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
1476 # For characteristics without YAML size info, return None
1477 # indicating variable or unknown length
1478 return None
1480 @property
1481 def value_type_resolved(self) -> ValueType:
1482 """Get the value type from _info."""
1483 return self._info.value_type
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
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
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
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
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
1515 # Descriptor support methods
1517 def add_descriptor(self, descriptor: BaseDescriptor) -> None:
1518 """Add a descriptor to this characteristic.
1520 Args:
1521 descriptor: The descriptor instance to add
1522 """
1523 self._descriptors[str(descriptor.uuid)] = descriptor
1525 def get_descriptor(self, uuid: str | BluetoothUUID) -> BaseDescriptor | None:
1526 """Get a descriptor by UUID.
1528 Args:
1529 uuid: Descriptor UUID (string or BluetoothUUID)
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
1543 return self._descriptors.get(uuid_obj.dashed_form)
1545 def get_descriptors(self) -> dict[str, BaseDescriptor]:
1546 """Get all descriptors for this characteristic.
1548 Returns:
1549 Dict mapping descriptor UUID strings to descriptor instances
1550 """
1551 return self._descriptors.copy()
1553 def get_cccd(self) -> BaseDescriptor | None:
1554 """Get the Client Characteristic Configuration Descriptor (CCCD).
1556 Returns:
1557 CCCD descriptor instance if present, None otherwise
1558 """
1559 return self.get_descriptor(CCCDDescriptor().uuid)
1561 def can_notify(self) -> bool:
1562 """Check if this characteristic supports notifications.
1564 Returns:
1565 True if the characteristic has a CCCD descriptor, False otherwise
1566 """
1567 return self.get_cccd() is not None
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.
1574 Args:
1575 ctx: Characteristic context containing descriptors
1576 descriptor_class: The descriptor class to look for (e.g., ValidRangeDescriptor)
1578 Returns:
1579 DescriptorData if found, None otherwise
1580 """
1581 return _get_descriptor(ctx, descriptor_class)
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.
1588 Args:
1589 ctx: Characteristic context containing descriptors
1591 Returns:
1592 Tuple of (min, max) values if Valid Range descriptor present, None otherwise
1593 """
1594 return _get_valid_range(ctx)
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.
1601 Args:
1602 ctx: Characteristic context containing descriptors
1604 Returns:
1605 CharacteristicPresentationFormatData if present, None otherwise
1606 """
1607 return _get_presentation_format(ctx)
1609 def get_user_description_from_context(self, ctx: CharacteristicContext | None = None) -> str | None:
1610 """Get user description from descriptor context if available.
1612 Args:
1613 ctx: Characteristic context containing descriptors
1615 Returns:
1616 User description string if present, None otherwise
1617 """
1618 return _get_user_description(ctx)
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.
1625 Args:
1626 value: Value to validate
1627 ctx: Characteristic context containing descriptors
1629 Returns:
1630 True if value is within valid range or no range defined, False otherwise
1631 """
1632 return _validate_value_range(value, ctx)
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.
1639 Args:
1640 base_message: Original error message
1641 ctx: Characteristic context containing descriptors
1643 Returns:
1644 Enhanced error message with descriptor context
1645 """
1646 return _enhance_error_message(base_message, ctx)
1648 def get_byte_order_hint(self) -> str:
1649 """Get byte order hint (Bluetooth SIG uses little-endian by convention)."""
1650 return "little"