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
« 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
4from __future__ import annotations
6import os
7import re
8from abc import ABC, ABCMeta
9from functools import lru_cache
10from typing import Any
12import msgspec
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
41class ValidationConfig(msgspec.Struct, kw_only=True):
42 """Configuration for characteristic validation constraints.
44 Groups validation parameters into a single, optional configuration object
45 to simplify BaseCharacteristic constructor signatures.
46 """
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
57class SIGCharacteristicResolver:
58 """Resolves SIG characteristic information from YAML and registry.
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 """
65 camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name)
67 @staticmethod
68 def resolve_for_class(char_class: type[BaseCharacteristic]) -> CharacteristicInfo:
69 """Resolve CharacteristicInfo for a SIG characteristic class.
71 Args:
72 char_class: The characteristic class to resolve info for
74 Returns:
75 CharacteristicInfo with resolved UUID, name, value_type, unit
77 Raises:
78 UUIDResolutionError: If no UUID can be resolved for the class
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)
86 # Fallback to registry
87 registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class)
88 if registry_info:
89 return registry_info
91 # No resolution found
92 raise UUIDResolutionError(char_class.__name__, [char_class.__name__])
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)
100 # Generate all name variants using shared utility
101 names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name)
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
109 return None
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()
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 "")
129 # TODO: Add similar logic for object types, service classes, etc. as needed
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 )
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)
148class CharacteristicMeta(ABCMeta):
149 """Metaclass to automatically handle template flags for characteristics."""
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.
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
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
177 # Create the class normally
178 new_class = super().__new__(mcs, name, bases, namespace, **kwargs)
180 return new_class
183class BaseCharacteristic(ABC, metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods
184 """Base class for all GATT characteristics.
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.
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.
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
202 Example usage in subclasses:
203 class ExampleCharacteristic(BaseCharacteristic):
204 '''Example showing validation attributes usage.'''
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
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)
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
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 """
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
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
253 # Template support (Progressive API Level 4)
254 _template: CodingTemplate | None = None # CodingTemplate instance for composition
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
262 _allows_sig_override = False
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
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
273 def __init__(
274 self,
275 info: CharacteristicInfo | None = None,
276 validation: ValidationConfig | None = None,
277 ) -> None:
278 """Initialize characteristic with structured configuration.
280 Args:
281 info: Complete characteristic information (optional for SIG characteristics)
282 validation: Validation constraints configuration (optional)
284 """
285 # Store provided info or None (will be resolved in __post_init__)
286 self._provided_info = info
288 # Instance variables (will be set in __post_init__)
289 self._info: CharacteristicInfo
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
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
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
319 # Descriptor support
320 self._descriptors: dict[str, BaseDescriptor] = {}
322 # Call post-init to resolve characteristic info
323 self.__post_init__()
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 }
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)
364 # Set value_type from resolved info
365 self.value_type = self._info.value_type
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
375 def _infer_value_type_from_patterns(self) -> ValueType:
376 """Infer value type from characteristic naming patterns and class structure.
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
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 ]
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
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
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
409 # Default fallback for complex characteristics
410 return ValueType.BYTES
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))
417 @property
418 def uuid(self) -> BluetoothUUID:
419 """Get the characteristic UUID from _info."""
420 return self._info.uuid
422 @property
423 def info(self) -> CharacteristicInfo:
424 """Characteristic information."""
425 return self._info
427 @property
428 def name(self) -> str:
429 """Get the characteristic name from _info."""
430 return self._info.name
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 ""
441 @property
442 def display_name(self) -> str:
443 """Get the display name for this characteristic.
445 Uses explicit _characteristic_name if set, otherwise falls back
446 to class name.
447 """
448 return self._characteristic_name or self.__class__.__name__
450 @classmethod
451 def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic]) -> str | None:
452 """Resolve a dependency class to its canonical UUID string.
454 Args:
455 dep_class: The characteristic class to resolve
457 Returns:
458 Canonical UUID string or None if unresolvable
460 """
461 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None)
462 if configured_info is not None:
463 return str(configured_info.uuid)
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
472 try:
473 temp_instance = dep_class()
474 return str(temp_instance.info.uuid)
475 except (ValueError, AttributeError, TypeError):
476 return None
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]] = []
482 declared = getattr(self.__class__, attr_name, []) or []
483 dependency_classes.extend(declared)
485 resolved: list[str] = []
486 seen: set[str] = set()
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)
494 return resolved
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")
502 return list(self._resolved_required_dependencies)
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")
510 return list(self._resolved_optional_dependencies)
512 @classmethod
513 def get_allows_sig_override(cls) -> bool:
514 """Check if this characteristic class allows overriding SIG characteristics.
516 Custom characteristics that need to override official Bluetooth SIG
517 characteristics must set _allows_sig_override = True as a class attribute.
519 Returns:
520 True if SIG override is allowed, False otherwise.
522 """
523 return cls._allows_sig_override
525 @classmethod
526 def get_configured_info(cls) -> CharacteristicInfo | None:
527 """Get the class-level configured CharacteristicInfo.
529 This provides public access to the _configured_info attribute that is set
530 by __init_subclass__ for custom characteristics.
532 Returns:
533 CharacteristicInfo if configured, None otherwise
535 """
536 return getattr(cls, "_configured_info", None)
538 @classmethod
539 def get_class_uuid(cls) -> BluetoothUUID | None:
540 """Get the characteristic UUID for this class without creating an instance.
542 This is the public API for registry and other modules to resolve UUIDs.
544 Returns:
545 BluetoothUUID if the class has a resolvable UUID, None otherwise.
547 """
548 return cls._resolve_class_uuid()
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
558 # Fallback to original registry resolution
559 return cls._resolve_from_basic_registry_class()
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)
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
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
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.
598 If _template is set, uses the template's decode_value method.
599 Otherwise, subclasses must override this method.
601 Args:
602 data: Raw bytes from the characteristic read
603 ctx: Optional context information for parsing
605 Returns:
606 Parsed value in the appropriate type
608 Raises:
609 NotImplementedError: If no template is set and subclass doesn't override
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()")
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)
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)
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__}")
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}")
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
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.
667 Args:
668 ctx: Context containing other characteristics.
669 characteristic_name: Enum, string name, or characteristic class.
671 Returns:
672 Characteristic data if found, None otherwise.
674 """
675 if not ctx or not ctx.other_characteristics:
676 return None
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
704 return ctx.other_characteristics.get(char_uuid)
706 def _is_parse_trace_enabled(self) -> bool:
707 """Check if parse trace is enabled via environment variable or instance attribute.
709 Returns:
710 True if parse tracing is enabled, False otherwise
712 Environment Variables:
713 BLUETOOTH_SIG_ENABLE_PARSE_TRACE: Set to "0", "false", or "no" to disable
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
723 if self._enable_parse_trace is False:
724 return False
726 # Default to enabled
727 return True
729 def parse_value(self, data: bytes | bytearray, ctx: CharacteristicContext | None = None) -> CharacteristicData:
730 """Parse raw characteristic data into structured value with validation.
732 Args:
733 data: Raw bytes from the characteristic read
734 ctx: Optional context with descriptors and other characteristics
736 Returns:
737 CharacteristicData object with parsed value
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] = []
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 )
798 def get_descriptors_from_context(self, ctx: CharacteristicContext | None) -> dict[str, Any]:
799 """Extract descriptor data from the parsing context.
801 Args:
802 ctx: The characteristic context containing descriptor information
804 Returns:
805 Dictionary mapping descriptor UUIDs to DescriptorData objects
806 """
807 if not ctx or not ctx.descriptors:
808 return {}
810 # Return a copy of the descriptors from context
811 return dict(ctx.descriptors)
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.
816 If _template is set , uses the template's encode_value method.
817 Otherwise, subclasses must override this method.
819 Args:
820 data: Dataclass instance or value to encode
822 Returns:
823 Encoded bytes for characteristic write
825 Raises:
826 ValueError: If data is invalid for encoding
827 NotImplementedError: If no template is set and subclass doesn't override
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()")
834 @property
835 def unit(self) -> str:
836 """Get the unit of measurement from _info."""
837 return self._info.unit
839 @property
840 def properties(self) -> list[GattProperty]:
841 """Get the GATT properties from _info."""
842 return self._info.properties
844 @property
845 def size(self) -> int | None:
846 """Get the size in bytes for this characteristic from YAML specifications.
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.
852 """
853 # First try manual size override if set
854 if self._manual_size is not None:
855 return self._manual_size
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
862 # For characteristics without YAML size info, return None
863 # indicating variable or unknown length
864 return None
866 @property
867 def value_type_resolved(self) -> ValueType:
868 """Get the value type from _info."""
869 return self._info.value_type
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
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
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
889 def get_yaml_resolution_text(self) -> str | None:
890 """Get the resolution description text from YAML automation."""
891 return self._yaml_resolution_text
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
909 # Descriptor support methods
911 def add_descriptor(self, descriptor: BaseDescriptor) -> None:
912 """Add a descriptor to this characteristic.
914 Args:
915 descriptor: The descriptor instance to add
916 """
917 self._descriptors[str(descriptor.uuid)] = descriptor
919 def get_descriptor(self, uuid: str | BluetoothUUID) -> BaseDescriptor | None:
920 """Get a descriptor by UUID.
922 Args:
923 uuid: Descriptor UUID (string or BluetoothUUID)
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
937 return self._descriptors.get(uuid_obj.dashed_form)
939 def get_descriptors(self) -> dict[str, BaseDescriptor]:
940 """Get all descriptors for this characteristic.
942 Returns:
943 Dict mapping descriptor UUID strings to descriptor instances
944 """
945 return self._descriptors.copy()
947 def get_cccd(self) -> BaseDescriptor | None:
948 """Get the Client Characteristic Configuration Descriptor (CCCD).
950 Returns:
951 CCCD descriptor instance if present, None otherwise
952 """
953 return self.get_descriptor(CCCDDescriptor().uuid)
955 def can_notify(self) -> bool:
956 """Check if this characteristic supports notifications.
958 Returns:
959 True if the characteristic has a CCCD descriptor, False otherwise
960 """
961 return self.get_cccd() is not None
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.
968 Args:
969 ctx: Characteristic context containing descriptors
970 descriptor_class: The descriptor class to look for (e.g., ValidRangeDescriptor)
972 Returns:
973 DescriptorData if found, None otherwise
974 """
975 if not ctx or not ctx.descriptors:
976 return None
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
986 return ctx.descriptors.get(descriptor_uuid)
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.
993 Args:
994 ctx: Characteristic context containing descriptors
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
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.
1009 Args:
1010 ctx: Characteristic context containing descriptors
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
1020 def get_user_description_from_context(self, ctx: CharacteristicContext | None = None) -> str | None:
1021 """Get user description from descriptor context if available.
1023 Args:
1024 ctx: Characteristic context containing descriptors
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
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.
1039 Args:
1040 value: Value to validate
1041 ctx: Characteristic context containing descriptors
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
1050 min_val, max_val = valid_range
1051 return min_val <= value <= max_val
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.
1058 Args:
1059 base_message: Original error message
1060 ctx: Characteristic context containing descriptors
1062 Returns:
1063 Enhanced error message with descriptor context
1064 """
1065 enhancements = []
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}")
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}")
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})")
1083 if enhancements:
1084 return f"{base_message} ({'; '.join(enhancements)})"
1085 return base_message
1087 def get_byte_order_hint(self) -> str:
1088 """Get byte order hint (Bluetooth SIG uses little-endian by convention)."""
1089 return "little"
1092class CustomBaseCharacteristic(BaseCharacteristic):
1093 """Helper base class for custom characteristic implementations.
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__.
1099 Progressive API Levels Supported:
1100 - Level 2: Class-level _info attribute (automatic binding)
1101 - Legacy: Manual info parameter (backwards compatibility)
1102 """
1104 _is_custom = True
1105 _configured_info: CharacteristicInfo | None = None # Stores class-level _info
1106 _allows_sig_override = False # Default: no SIG override permission
1108 @classmethod
1109 def get_configured_info(cls) -> CharacteristicInfo | None:
1110 """Get the class-level configured CharacteristicInfo.
1112 Returns:
1113 CharacteristicInfo if configured, None otherwise
1115 """
1116 return cls._configured_info
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.
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.
1131 Raises:
1132 ValueError: If class uses SIG UUID without override permission.
1134 """
1135 super().__init_subclass__(**kwargs)
1137 # Store override permission for registry validation
1138 cls._allows_sig_override = allow_sig_override
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 )
1151 cls._configured_info = info
1153 def __init__(
1154 self,
1155 info: CharacteristicInfo | None = None,
1156 ) -> None:
1157 """Initialize a custom characteristic with automatic _info resolution.
1159 Args:
1160 info: Optional override for class-configured _info
1162 Raises:
1163 ValueError: If no valid info available from class or parameter
1165 """
1166 # Use provided info, or fall back to class-configured _info
1167 final_info = info or self.__class__.get_configured_info()
1169 if not final_info:
1170 raise ValueError(f"{self.__class__.__name__} requires either 'info' parameter or '_info' class attribute")
1172 if not final_info.uuid or str(final_info.uuid) == "0000":
1173 raise ValueError("Valid UUID is required for custom characteristics")
1175 # Call parent constructor with our info to maintain consistency
1176 super().__init__(info=final_info)
1178 def __post_init__(self) -> None:
1179 """Override BaseCharacteristic.__post_init__ to use custom info management.
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")
1196class UnknownCharacteristic(CustomBaseCharacteristic):
1197 """Generic characteristic implementation for unknown/non-SIG characteristics.
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 """
1204 def __init__(self, info: CharacteristicInfo) -> None:
1205 """Initialize an unknown characteristic.
1207 Args:
1208 info: CharacteristicInfo object with UUID, name, unit, value_type, properties
1210 Raises:
1211 ValueError: If UUID is invalid
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 )
1224 super().__init__(info=info)
1226 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> bytes: # Context type varies
1227 """Return raw bytes for unknown characteristics.
1229 Args:
1230 data: Raw bytes from the characteristic read
1231 ctx: Optional context (ignored)
1233 Returns:
1234 Raw bytes as-is
1236 """
1237 return bytes(data)
1239 def encode_value(self, data: Any) -> bytearray: # noqa: ANN401 # Accepts bytes-like objects
1240 """Encode data to bytes for unknown characteristics.
1242 Args:
1243 data: Data to encode (must be bytes or bytearray)
1245 Returns:
1246 Encoded bytes
1248 Raises:
1249 ValueError: If data is not bytes/bytearray
1251 """
1252 if isinstance(data, (bytes, bytearray)):
1253 return bytearray(data)
1254 raise ValueError(f"Unknown characteristics require bytes data, got {type(data)}")