Coverage for src/bluetooth_sig/gatt/characteristics/base.py: 84%
332 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 01:26 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 01:26 +0000
1"""Base class for GATT characteristics.
3Implements the core parsing and encoding system for Bluetooth GATT
4characteristics following official Bluetooth SIG specifications.
6See :mod:`.characteristic_meta` for infrastructure classes
7(``SIGCharacteristicResolver``, ``CharacteristicMeta``, ``ValidationConfig``).
8See :mod:`.pipeline` for the multi-stage parse/encode pipeline.
9See :mod:`.role_classifier` for characteristic role inference.
10"""
12from __future__ import annotations
14import logging
15from abc import ABC
16from functools import cached_property
17from typing import Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
19from ...types import (
20 CharacteristicInfo,
21 SpecialValueResult,
22 SpecialValueRule,
23 SpecialValueType,
24 classify_special_value,
25)
26from ...types.gatt_enums import CharacteristicRole
27from ...types.registry import CharacteristicSpec
28from ...types.uuid import BluetoothUUID
29from ..context import CharacteristicContext
30from ..descriptors.base import BaseDescriptor
31from ..resolver import NameNormalizer
32from ..special_values_resolver import SpecialValueResolver
33from .characteristic_meta import CharacteristicMeta, SIGCharacteristicResolver
34from .characteristic_meta import ValidationConfig as ValidationConfig # noqa: PLC0414 # explicit re-export
35from .context_lookup import ContextLookupMixin
36from .descriptor_mixin import DescriptorMixin
37from .pipeline import CharacteristicValidator, EncodePipeline, ParsePipeline
38from .role_classifier import classify_role
39from .templates import CodingTemplate
41logger = logging.getLogger(__name__)
43# Type variable for generic characteristic return types
44T = TypeVar("T")
46# Sentinel for per-class cache (distinguishes None from "not yet resolved")
47_SENTINEL = object()
50class BaseCharacteristic(ContextLookupMixin, DescriptorMixin, ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods
51 """Base class for all GATT characteristics.
53 Generic over *T*, the return type of ``_decode_value()``.
55 Automatically resolves UUID, unit, and python_type from Bluetooth SIG YAML
56 specifications. Supports manual overrides via ``_manual_unit`` and
57 ``_python_type`` attributes.
59 Validation Attributes (optional class-level declarations):
60 min_value / max_value: Allowed numeric range.
61 expected_length / min_length / max_length: Byte-length constraints.
62 allow_variable_length: Accept variable length data.
63 expected_type: Expected Python type for parsed values.
64 """
66 # Explicit class attributes with defaults (replaces getattr usage)
67 _characteristic_name: str | None = None
68 _manual_unit: str | None = None
69 _python_type: type | str | None = None
70 _is_bitfield: bool = False
71 _manual_size: int | None = None
72 _is_template: bool = False
74 min_value: int | float | None = None
75 max_value: int | float | None = None
76 expected_length: int | None = None
77 min_length: int | None = None
78 max_length: int | None = None
79 allow_variable_length: bool = False
80 expected_type: type | None = None
82 _template: CodingTemplate[T] | None = None
84 _allows_sig_override = False
86 _required_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [] # Dependencies that MUST be present
87 _optional_dependencies: ClassVar[
88 list[type[BaseCharacteristic[Any]]]
89 ] = [] # Dependencies that enrich parsing when available
91 # Parse trace control (for performance tuning)
92 # Can be configured via BLUETOOTH_SIG_ENABLE_PARSE_TRACE environment variable
93 # Set to "0", "false", or "no" to disable trace collection
94 _enable_parse_trace: bool = True # Default: enabled
96 # Role classification (computed once per concrete subclass)
97 # Subclasses can set _manual_role to bypass the heuristic entirely.
98 _manual_role: ClassVar[CharacteristicRole | None] = None
99 _cached_role: ClassVar[CharacteristicRole | None] = None
101 # Special value handling (GSS-derived)
102 # Manual override for special values when GSS spec is incomplete/wrong.
103 # Format: {raw_value: meaning_string}. GSS values are used by default.
104 _special_values: dict[int, str] | None = None
106 def __init__(
107 self,
108 info: CharacteristicInfo | None = None,
109 validation: ValidationConfig | None = None,
110 ) -> None:
111 """Initialize characteristic with structured configuration.
113 Args:
114 info: Complete characteristic information (optional for SIG characteristics)
115 validation: Validation constraints configuration (optional)
117 """
118 # Store provided info or None (will be resolved in __post_init__)
119 self._provided_info = info
121 # Instance variables (will be set in __post_init__)
122 self._info: CharacteristicInfo
123 self._spec: CharacteristicSpec | None = None
125 # Manual overrides with proper types (using explicit class attributes)
126 self._manual_unit: str | None = self.__class__._manual_unit
128 # Set validation attributes from ValidationConfig or class defaults
129 if validation:
130 self.min_value = validation.min_value
131 self.max_value = validation.max_value
132 self.expected_length = validation.expected_length
133 self.min_length = validation.min_length
134 self.max_length = validation.max_length
135 self.allow_variable_length = validation.allow_variable_length
136 self.expected_type = validation.expected_type
137 else:
138 # Fall back to class attributes for Progressive API Level 2
139 self.min_value = self.__class__.min_value
140 self.max_value = self.__class__.max_value
141 self.expected_length = self.__class__.expected_length
142 self.min_length = self.__class__.min_length
143 self.max_length = self.__class__.max_length
144 self.allow_variable_length = self.__class__.allow_variable_length
145 self.expected_type = self.__class__.expected_type
147 # Dependency caches (resolved once per instance)
148 self._resolved_required_dependencies: list[str] | None = None
149 self._resolved_optional_dependencies: list[str] | None = None
151 # Descriptor support
152 self._descriptors: dict[str, BaseDescriptor] = {}
154 # Last parsed value for caching/debugging
155 self.last_parsed: T | None = None
157 # Optional User Description (0x2901) label from device discovery
158 self.user_description: str | None = None
160 # Pipeline composition — validator is shared by parse and encode pipelines
161 self._validator = CharacteristicValidator(self)
162 self._parse_pipeline = ParsePipeline(self, self._validator)
163 self._encode_pipeline = EncodePipeline(self, self._validator)
165 # Call post-init to resolve characteristic info
166 self.__post_init__()
168 def __post_init__(self) -> None:
169 """Initialize characteristic with resolved information."""
170 # Use provided info if available, otherwise resolve from SIG specs
171 if self._provided_info:
172 self._info = self._provided_info
173 else:
174 # Resolve characteristic information using proper resolver
175 self._info = SIGCharacteristicResolver.resolve_for_class(type(self))
177 # Resolve YAML spec for access to detailed metadata
178 self._spec = self._resolve_yaml_spec()
179 spec_rules: dict[int, SpecialValueRule] = {}
180 for raw, meaning in self.gss_special_values.items():
181 spec_rules[raw] = SpecialValueRule(
182 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning)
183 )
185 class_rules: dict[int, SpecialValueRule] = {}
186 if self._special_values is not None:
187 for raw, meaning in self._special_values.items():
188 class_rules[raw] = SpecialValueRule(
189 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning)
190 )
192 self._special_resolver = SpecialValueResolver(spec_rules=spec_rules, class_rules=class_rules)
194 # Apply manual overrides to _info (single source of truth)
195 if self._manual_unit is not None:
196 self._info.unit = self._manual_unit
198 # Auto-resolve python_type from template generic parameter.
199 # Templates carry their decoded type (e.g. ScaledUint16Template → float),
200 # which is more accurate than the YAML wire type (uint16 → int).
201 if self._template is not None:
202 template_type = type(self._template).resolve_python_type()
203 if template_type is not None:
204 self._info.python_type = template_type
206 # Auto-resolve python_type from the class generic parameter.
207 # BaseCharacteristic[T] already declares the decoded type (e.g.
208 # BaseCharacteristic[PushbuttonStatus8Data]). This is the most
209 # authoritative source — it overrides both YAML and template since
210 # the class signature is the contract for what _decode_value returns.
211 generic_type = self._resolve_generic_python_type()
212 if generic_type is not None:
213 self._info.python_type = generic_type
215 # Manual _python_type override wins over all auto-resolution.
216 # Use sparingly — only when no other mechanism can express the correct type.
217 if self.__class__._python_type is not None:
218 self._info.python_type = self.__class__._python_type
219 if self.__class__._is_bitfield:
220 self._info.is_bitfield = True
222 @classmethod
223 def _resolve_generic_python_type(cls) -> type | None:
224 """Resolve python_type from the class generic parameter BaseCharacteristic[T].
226 Walks the MRO to find the concrete type bound to ``BaseCharacteristic[T]``.
227 Returns ``None`` for unbound TypeVars, ``Any``, or forward references.
228 Caches the result per-class in ``_cached_generic_python_type``.
229 """
230 cached = cls.__dict__.get("_cached_generic_python_type", _SENTINEL)
231 if cached is not _SENTINEL:
232 return cached # type: ignore[no-any-return]
234 resolved: type | None = None
235 for klass in cls.__mro__:
236 for base in getattr(klass, "__orig_bases__", ()):
237 origin = getattr(base, "__origin__", None)
238 if origin is BaseCharacteristic:
239 args = get_args(base)
240 if not args:
241 continue
243 arg = args[0]
244 if arg is Any:
245 continue
247 if isinstance(arg, type):
248 resolved = arg
249 break
251 # Support PEP 585/typing aliases like list[Foo] or tuple[Bar, ...].
252 generic_origin = get_origin(arg)
253 if isinstance(generic_origin, type):
254 resolved = generic_origin
255 break
256 if resolved is not None:
257 break
259 cls._cached_generic_python_type = resolved # type: ignore[attr-defined]
260 return resolved
262 def _resolve_yaml_spec(self) -> CharacteristicSpec | None:
263 """Resolve specification using YAML cross-reference system."""
264 # Delegate to static method
265 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(type(self))
267 @property
268 def uuid(self) -> BluetoothUUID:
269 """Get the characteristic UUID from _info."""
270 return self._info.uuid
272 @property
273 def info(self) -> CharacteristicInfo:
274 """Characteristic information."""
275 return self._info
277 @property
278 def spec(self) -> CharacteristicSpec | None:
279 """Get the full GSS specification with description and detailed metadata."""
280 return self._spec
282 @property
283 def name(self) -> str:
284 """Get the characteristic name from _info."""
285 return self._info.name
287 @property
288 def description(self) -> str:
289 """Get the characteristic description from GSS specification."""
290 return self._spec.description if self._spec and self._spec.description else ""
292 @property
293 def role(self) -> CharacteristicRole:
294 """Classify the characteristic's purpose from SIG spec metadata.
296 Override via ``_manual_role`` class variable, or the heuristic in
297 :func:`.role_classifier.classify_role` is used. Result is cached
298 per concrete subclass.
299 """
300 cls = type(self)
301 if cls._cached_role is None:
302 if cls._manual_role is not None:
303 cls._cached_role = cls._manual_role
304 else:
305 cls._cached_role = classify_role(
306 self.name, self._info.python_type, self._info.is_bitfield, self.unit, self._spec
307 )
308 return cls._cached_role
310 @property
311 def display_name(self) -> str:
312 """Get the display name for this characteristic.
314 Uses the canonical SIG/YAML name for lookup fidelity, then strips
315 supported display markup for human-readable output.
316 """
317 raw_name = self._characteristic_name or self._info.name
318 return NameNormalizer.sanitize_display_markup(raw_name)
320 @cached_property
321 def gss_special_values(self) -> dict[int, str]:
322 """Get special values from GSS specification.
324 Extracts all special value definitions (e.g., 0x8000="value is not known")
325 from the GSS YAML specification for this characteristic.
327 GSS stores values as unsigned hex (e.g., 0x8000). For signed types,
328 this method also includes the signed interpretation so lookups work
329 with both parsed signed values and raw unsigned values.
331 Returns:
332 Dictionary mapping raw integer values to their human-readable meanings.
333 Includes both unsigned and signed interpretations for applicable values.
334 """
335 # extract special values from YAML
336 if not self._spec or not hasattr(self._spec, "structure") or not self._spec.structure:
337 return {}
339 result: dict[int, str] = {}
340 for field in self._spec.structure: # pylint: disable=too-many-nested-blocks # Spec requires nested iteration for special values
341 for sv in field.special_values:
342 unsigned_val = sv.raw_value
343 result[unsigned_val] = sv.meaning
345 # For signed types, add the signed equivalent based on common bit widths.
346 # This handles cases like 0x8000 (32768) -> -32768 for sint16.
347 if self.is_signed_from_yaml():
348 for bits in (8, 16, 24, 32):
349 max_unsigned = (1 << bits) - 1
350 sign_bit = 1 << (bits - 1)
351 if sign_bit <= unsigned_val <= max_unsigned:
352 # This value would be negative when interpreted as signed
353 signed_val = unsigned_val - (1 << bits)
354 if signed_val not in result:
355 result[signed_val] = sv.meaning
356 return result
358 def is_special_value(self, raw_value: int) -> bool:
359 """Check if a raw value is a special sentinel value.
361 Checks both manual overrides (_special_values class variable) and
362 GSS-derived special values, with manual taking precedence.
364 Args:
365 raw_value: The raw integer value to check.
367 Returns:
368 True if this is a special sentinel value, False otherwise.
369 """
370 return self._special_resolver.is_special(raw_value)
372 def get_special_value_meaning(self, raw_value: int) -> str | None:
373 """Get the human-readable meaning of a special value.
375 Args:
376 raw_value: The raw integer value to look up.
378 Returns:
379 The meaning string (e.g., "value is not known"), or None if not special.
380 """
381 res = self._special_resolver.resolve(raw_value)
382 return res.meaning if res is not None else None
384 def get_special_value_type(self, raw_value: int) -> SpecialValueType | None:
385 """Get the category of a special value.
387 Args:
388 raw_value: The raw integer value to classify.
390 Returns:
391 The SpecialValueType category, or None if not a special value.
392 """
393 res = self._special_resolver.resolve(raw_value)
394 return res.value_type if res is not None else None
396 @classmethod
397 def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic[Any]]) -> str | None:
398 """Resolve a dependency class to its canonical UUID string.
400 Args:
401 dep_class: The characteristic class to resolve
403 Returns:
404 Canonical UUID string or None if unresolvable
406 """
407 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None)
408 if configured_info is not None:
409 return str(configured_info.uuid)
411 try:
412 class_uuid = dep_class.get_class_uuid()
413 if class_uuid is not None:
414 return str(class_uuid)
415 except (ValueError, AttributeError, TypeError):
416 logger.warning("Failed to resolve class UUID for dependency %s", dep_class.__name__)
418 try:
419 temp_instance = dep_class()
420 return str(temp_instance.info.uuid)
421 except (ValueError, AttributeError, TypeError):
422 return None
424 def _resolve_dependencies(self, attr_name: str) -> list[str]:
425 """Resolve dependency class references to canonical UUID strings.
427 Performance: Returns list[str] instead of list[BluetoothUUID] because
428 these are compared against dict[str, ...] keys in hot paths.
429 """
430 dependency_classes: list[type[BaseCharacteristic[Any]]] = []
432 declared = getattr(self.__class__, attr_name, []) or []
433 dependency_classes.extend(declared)
435 resolved: list[str] = []
436 seen: set[str] = set()
438 for dep_class in dependency_classes:
439 uuid_str = self._normalize_dependency_class(dep_class)
440 if uuid_str and uuid_str not in seen:
441 seen.add(uuid_str)
442 resolved.append(uuid_str)
444 return resolved
446 @property
447 def required_dependencies(self) -> list[str]:
448 """Get resolved required dependency UUID strings.
450 Performance: Returns list[str] for efficient comparison with dict keys.
451 """
452 if self._resolved_required_dependencies is None:
453 self._resolved_required_dependencies = self._resolve_dependencies("_required_dependencies")
455 return list(self._resolved_required_dependencies)
457 @property
458 def optional_dependencies(self) -> list[str]:
459 """Get resolved optional dependency UUID strings.
461 Performance: Returns list[str] for efficient comparison with dict keys.
462 """
463 if self._resolved_optional_dependencies is None:
464 self._resolved_optional_dependencies = self._resolve_dependencies("_optional_dependencies")
466 return list(self._resolved_optional_dependencies)
468 @classmethod
469 def get_allows_sig_override(cls) -> bool:
470 """Check if this characteristic class allows overriding SIG characteristics.
472 Custom characteristics that need to override official Bluetooth SIG
473 characteristics must set _allows_sig_override = True as a class attribute.
475 Returns:
476 True if SIG override is allowed, False otherwise.
478 """
479 return cls._allows_sig_override
481 @classmethod
482 def get_configured_info(cls) -> CharacteristicInfo | None:
483 """Get the class-level configured CharacteristicInfo.
485 This provides public access to the _configured_info attribute that is set
486 by __init_subclass__ for custom characteristics.
488 Returns:
489 CharacteristicInfo if configured, None otherwise
491 """
492 return getattr(cls, "_configured_info", None)
494 @classmethod
495 def get_class_uuid(cls) -> BluetoothUUID | None:
496 """Get the characteristic UUID for this class without creating an instance.
498 This is the public API for registry and other modules to resolve UUIDs.
500 Returns:
501 BluetoothUUID if the class has a resolvable UUID, None otherwise.
503 """
504 return cls._resolve_class_uuid()
506 @classmethod
507 def _resolve_class_uuid(cls) -> BluetoothUUID | None:
508 """Resolve the characteristic UUID for this class without creating an instance."""
509 # Check for _info attribute first (custom characteristics)
510 try:
511 info = cast(Any, cls)._info
512 except AttributeError:
513 info = None
515 if info is not None:
516 if isinstance(info, CharacteristicInfo):
517 return info.uuid
518 logger.warning("_info attribute is not CharacteristicInfo for class %s", cls.__name__)
520 # Try cross-file resolution for SIG characteristics
521 yaml_spec = cls._resolve_yaml_spec_class()
522 if yaml_spec:
523 return yaml_spec.uuid
525 # Fallback to original registry resolution
526 return cls._resolve_from_basic_registry_class()
528 @classmethod
529 def _resolve_yaml_spec_class(cls) -> CharacteristicSpec | None:
530 """Resolve specification using YAML cross-reference system at class level."""
531 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(cls)
533 @classmethod
534 def _resolve_from_basic_registry_class(cls) -> BluetoothUUID | None:
535 """Fallback to basic registry resolution at class level."""
536 try:
537 registry_info = SIGCharacteristicResolver.resolve_from_registry(cls)
538 except (ValueError, KeyError, AttributeError, TypeError):
539 # Registry resolution can fail for various reasons:
540 # - ValueError: Invalid UUID format
541 # - KeyError: Characteristic not in registry
542 # - AttributeError: Missing expected attributes
543 # - TypeError: Type mismatch in resolution
544 return None
545 else:
546 return registry_info.uuid if registry_info else None
548 @classmethod
549 def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool:
550 """Check if this characteristic matches the given UUID."""
551 try:
552 class_uuid = cls._resolve_class_uuid()
553 if class_uuid is None:
554 return False
555 input_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid)
556 except ValueError:
557 return False
558 else:
559 return class_uuid == input_uuid
561 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> T:
562 """Decode raw bytes into the characteristic's typed value.
564 Called internally by :meth:`parse_value` after pipeline validation.
565 Uses *_template* when set; subclasses override for custom logic.
566 """
567 if self._template is not None:
568 return self._template.decode_value( # pylint: disable=protected-access
569 data, offset=0, ctx=ctx, validate=validate
570 )
571 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override decode_value()")
573 def parse_value(
574 self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True
575 ) -> T:
576 """Parse characteristic data.
578 Delegates to :class:`ParsePipeline` for the multi-stage pipeline
579 (length validation → raw int extraction → special value detection →
580 decode → range/type validation).
582 Returns:
583 Parsed value of type T.
585 Raises:
586 SpecialValueDetectedError: Special sentinel (0x8000="unknown", 0x7FFFFFFF="NaN")
587 CharacteristicParseError: Parse/validation failure
589 """
590 decoded: T = self._parse_pipeline.run(data, ctx, validate)
591 self.last_parsed = decoded
592 return decoded
594 def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401
595 """Encode a typed value into raw bytes (no validation).
597 Called internally by :meth:`build_value` after pipeline validation.
598 Uses *_template* when set; subclasses override for custom logic.
599 """
600 if self._template is not None:
601 return self._template.encode_value(data) # pylint: disable=protected-access
602 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override encode_value()")
604 def build_value(self, data: T | SpecialValueResult, validate: bool = True) -> bytearray:
605 """Encode value or special value to characteristic bytes.
607 Delegates to :class:`EncodePipeline` for the multi-stage pipeline
608 (type validation → range validation → encode → length validation).
610 Args:
611 data: Value to encode (type T) or :class:`SpecialValueResult`.
612 validate: Enable validation (type, range, length checks).
614 Returns:
615 Encoded bytes ready for BLE write.
617 Raises:
618 CharacteristicEncodeError: If encoding or validation fails.
620 """
621 return self._encode_pipeline.run(data, validate)
623 # -------------------- Encoding helpers for special values --------------------
624 def encode_special(self, value_type: SpecialValueType) -> bytearray:
625 """Encode a special value type to bytes (reverse lookup).
627 Raises ValueError if no raw value of that type is defined for this characteristic.
628 """
629 return self._encode_pipeline.encode_special(value_type)
631 def encode_special_by_meaning(self, meaning: str) -> bytearray:
632 """Encode a special value by a partial meaning string match.
634 Raises ValueError if no matching special value is found.
635 """
636 return self._encode_pipeline.encode_special_by_meaning(meaning)
638 @property
639 def unit(self) -> str:
640 """Get the unit of measurement from _info.
642 Returns empty string for characteristics without units (e.g., bitfields).
643 """
644 return self._info.unit or ""
646 @cached_property
647 def unit_symbol(self) -> str:
648 """Get the canonical SIG unit symbol for this characteristic.
650 Resolves via the ``UnitsRegistry`` using the YAML ``unit_id``
651 (e.g. ``org.bluetooth.unit.thermodynamic_temperature.degree_celsius``
652 → ``°C``). Falls back to :attr:`unit` when no symbol is available.
654 Returns:
655 SI symbol string (e.g. ``'°C'``, ``'%'``, ``'bpm'``),
656 or empty string if the characteristic has no unit.
658 """
659 from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415
661 unit_id = self.get_yaml_unit_id()
662 if unit_id:
663 symbol = resolve_unit_symbol(unit_id)
664 if symbol:
665 return symbol
667 return self._info.unit or ""
669 def get_field_unit(self, field_name: str) -> str:
670 """Get the resolved unit symbol for a specific struct field.
672 For struct-valued characteristics with per-field units (e.g.
673 Heart Rate Measurement: ``bpm`` for heart rate, ``J`` for
674 energy expended), this resolves the unit for a single field
675 via ``FieldSpec.unit_id`` → ``UnitsRegistry`` → ``.symbol``.
677 Args:
678 field_name: The Python-style field name (e.g. ``'heart_rate'``)
679 or raw GSS field name (e.g. ``'Heart Rate Measurement Value'``).
681 Returns:
682 Resolved unit symbol, or empty string if not found.
684 """
685 if not self._spec or not self._spec.structure:
686 return ""
688 for field in self._spec.structure:
689 if field_name in (field.python_name, field.field):
690 return field.unit_symbol
692 return ""
694 @property
695 def size(self) -> int | None:
696 """Get the size in bytes for this characteristic from YAML specifications.
698 Returns the field size from YAML automation if available, otherwise None.
699 This is useful for determining the expected data length for parsing
700 and encoding.
702 """
703 # First try manual size override if set
704 if self._manual_size is not None:
705 return self._manual_size
707 # Try field size from YAML cross-reference
708 field_size = self.get_yaml_field_size()
709 if field_size is not None:
710 return field_size
712 # For characteristics without YAML size info, return None
713 # indicating variable or unknown length
714 return None
716 @property
717 def python_type(self) -> type | str | None:
718 """Get the resolved Python type for this characteristic's values."""
719 return self._info.python_type
721 @property
722 def is_bitfield(self) -> bool:
723 """Whether this characteristic's value is a bitfield."""
724 return self._info.is_bitfield
726 # YAML automation helper methods
727 def get_yaml_data_type(self) -> str | None:
728 """Get the data type from YAML automation (e.g., 'sint16', 'uint8')."""
729 return self._spec.data_type if self._spec else None
731 def get_yaml_field_size(self) -> int | None:
732 """Get the field size in bytes from YAML automation."""
733 field_size = self._spec.field_size if self._spec else None
734 if field_size and isinstance(field_size, str) and field_size.isdigit():
735 return int(field_size)
736 return None
738 def get_yaml_unit_id(self) -> str | None:
739 """Get the Bluetooth SIG unit identifier from YAML automation."""
740 return self._spec.unit_id if self._spec else None
742 def get_yaml_resolution_text(self) -> str | None:
743 """Get the resolution description text from YAML automation."""
744 return self._spec.resolution_text if self._spec else None
746 def is_signed_from_yaml(self) -> bool:
747 """Determine if the data type is signed based on YAML automation."""
748 data_type = self.get_yaml_data_type()
749 if not data_type:
750 return False
751 # Check for signed types: signed integers, medical floats, and standard floats
752 return data_type.startswith("sint") or data_type in ("medfloat16", "medfloat32", "float32", "float64")
754 def get_byte_order_hint(self) -> str:
755 """Get byte order hint (Bluetooth SIG uses little-endian by convention)."""
756 return "little"