Coverage for src / bluetooth_sig / gatt / characteristics / base.py: 84%
329 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:41 +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 import BaseDescriptor
31from ..special_values_resolver import SpecialValueResolver
32from .characteristic_meta import CharacteristicMeta, SIGCharacteristicResolver
33from .characteristic_meta import ValidationConfig as ValidationConfig # noqa: PLC0414 # explicit re-export
34from .context_lookup import ContextLookupMixin
35from .descriptor_mixin import DescriptorMixin
36from .pipeline import CharacteristicValidator, EncodePipeline, ParsePipeline
37from .role_classifier import classify_role
38from .templates import CodingTemplate
40logger = logging.getLogger(__name__)
42# Type variable for generic characteristic return types
43T = TypeVar("T")
45# Sentinel for per-class cache (distinguishes None from "not yet resolved")
46_SENTINEL = object()
49class BaseCharacteristic(ContextLookupMixin, DescriptorMixin, ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods
50 """Base class for all GATT characteristics.
52 Generic over *T*, the return type of ``_decode_value()``.
54 Automatically resolves UUID, unit, and python_type from Bluetooth SIG YAML
55 specifications. Supports manual overrides via ``_manual_unit`` and
56 ``_python_type`` attributes.
58 Validation Attributes (optional class-level declarations):
59 min_value / max_value: Allowed numeric range.
60 expected_length / min_length / max_length: Byte-length constraints.
61 allow_variable_length: Accept variable length data.
62 expected_type: Expected Python type for parsed values.
63 """
65 # Explicit class attributes with defaults (replaces getattr usage)
66 _characteristic_name: str | None = None
67 _manual_unit: str | None = None
68 _python_type: type | str | None = None
69 _is_bitfield: bool = False
70 _manual_size: int | None = None
71 _is_template: bool = False
73 min_value: int | float | None = None
74 max_value: int | float | None = None
75 expected_length: int | None = None
76 min_length: int | None = None
77 max_length: int | None = None
78 allow_variable_length: bool = False
79 expected_type: type | None = None
81 _template: CodingTemplate[T] | None = None
83 _allows_sig_override = False
85 _required_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [] # Dependencies that MUST be present
86 _optional_dependencies: ClassVar[
87 list[type[BaseCharacteristic[Any]]]
88 ] = [] # Dependencies that enrich parsing when available
90 # Parse trace control (for performance tuning)
91 # Can be configured via BLUETOOTH_SIG_ENABLE_PARSE_TRACE environment variable
92 # Set to "0", "false", or "no" to disable trace collection
93 _enable_parse_trace: bool = True # Default: enabled
95 # Role classification (computed once per concrete subclass)
96 # Subclasses can set _manual_role to bypass the heuristic entirely.
97 _manual_role: ClassVar[CharacteristicRole | None] = None
98 _cached_role: ClassVar[CharacteristicRole | None] = None
100 # Special value handling (GSS-derived)
101 # Manual override for special values when GSS spec is incomplete/wrong.
102 # Format: {raw_value: meaning_string}. GSS values are used by default.
103 _special_values: dict[int, str] | None = None
105 def __init__(
106 self,
107 info: CharacteristicInfo | None = None,
108 validation: ValidationConfig | None = None,
109 ) -> None:
110 """Initialize characteristic with structured configuration.
112 Args:
113 info: Complete characteristic information (optional for SIG characteristics)
114 validation: Validation constraints configuration (optional)
116 """
117 # Store provided info or None (will be resolved in __post_init__)
118 self._provided_info = info
120 # Instance variables (will be set in __post_init__)
121 self._info: CharacteristicInfo
122 self._spec: CharacteristicSpec | None = None
124 # Manual overrides with proper types (using explicit class attributes)
125 self._manual_unit: str | None = self.__class__._manual_unit
127 # Set validation attributes from ValidationConfig or class defaults
128 if validation:
129 self.min_value = validation.min_value
130 self.max_value = validation.max_value
131 self.expected_length = validation.expected_length
132 self.min_length = validation.min_length
133 self.max_length = validation.max_length
134 self.allow_variable_length = validation.allow_variable_length
135 self.expected_type = validation.expected_type
136 else:
137 # Fall back to class attributes for Progressive API Level 2
138 self.min_value = self.__class__.min_value
139 self.max_value = self.__class__.max_value
140 self.expected_length = self.__class__.expected_length
141 self.min_length = self.__class__.min_length
142 self.max_length = self.__class__.max_length
143 self.allow_variable_length = self.__class__.allow_variable_length
144 self.expected_type = self.__class__.expected_type
146 # Dependency caches (resolved once per instance)
147 self._resolved_required_dependencies: list[str] | None = None
148 self._resolved_optional_dependencies: list[str] | None = None
150 # Descriptor support
151 self._descriptors: dict[str, BaseDescriptor] = {}
153 # Last parsed value for caching/debugging
154 self.last_parsed: T | None = None
156 # Pipeline composition — validator is shared by parse and encode pipelines
157 self._validator = CharacteristicValidator(self)
158 self._parse_pipeline = ParsePipeline(self, self._validator)
159 self._encode_pipeline = EncodePipeline(self, self._validator)
161 # Call post-init to resolve characteristic info
162 self.__post_init__()
164 def __post_init__(self) -> None:
165 """Initialize characteristic with resolved information."""
166 # Use provided info if available, otherwise resolve from SIG specs
167 if self._provided_info:
168 self._info = self._provided_info
169 else:
170 # Resolve characteristic information using proper resolver
171 self._info = SIGCharacteristicResolver.resolve_for_class(type(self))
173 # Resolve YAML spec for access to detailed metadata
174 self._spec = self._resolve_yaml_spec()
175 spec_rules: dict[int, SpecialValueRule] = {}
176 for raw, meaning in self.gss_special_values.items():
177 spec_rules[raw] = SpecialValueRule(
178 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning)
179 )
181 class_rules: dict[int, SpecialValueRule] = {}
182 if self._special_values is not None:
183 for raw, meaning in self._special_values.items():
184 class_rules[raw] = SpecialValueRule(
185 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning)
186 )
188 self._special_resolver = SpecialValueResolver(spec_rules=spec_rules, class_rules=class_rules)
190 # Apply manual overrides to _info (single source of truth)
191 if self._manual_unit is not None:
192 self._info.unit = self._manual_unit
194 # Auto-resolve python_type from template generic parameter.
195 # Templates carry their decoded type (e.g. ScaledUint16Template → float),
196 # which is more accurate than the YAML wire type (uint16 → int).
197 if self._template is not None:
198 template_type = type(self._template).resolve_python_type()
199 if template_type is not None:
200 self._info.python_type = template_type
202 # Auto-resolve python_type from the class generic parameter.
203 # BaseCharacteristic[T] already declares the decoded type (e.g.
204 # BaseCharacteristic[PushbuttonStatus8Data]). This is the most
205 # authoritative source — it overrides both YAML and template since
206 # the class signature is the contract for what _decode_value returns.
207 generic_type = self._resolve_generic_python_type()
208 if generic_type is not None:
209 self._info.python_type = generic_type
211 # Manual _python_type override wins over all auto-resolution.
212 # Use sparingly — only when no other mechanism can express the correct type.
213 if self.__class__._python_type is not None:
214 self._info.python_type = self.__class__._python_type
215 if self.__class__._is_bitfield:
216 self._info.is_bitfield = True
218 @classmethod
219 def _resolve_generic_python_type(cls) -> type | None:
220 """Resolve python_type from the class generic parameter BaseCharacteristic[T].
222 Walks the MRO to find the concrete type bound to ``BaseCharacteristic[T]``.
223 Returns ``None`` for unbound TypeVars, ``Any``, or forward references.
224 Caches the result per-class in ``_cached_generic_python_type``.
225 """
226 cached = cls.__dict__.get("_cached_generic_python_type", _SENTINEL)
227 if cached is not _SENTINEL:
228 return cached # type: ignore[no-any-return]
230 resolved: type | None = None
231 for klass in cls.__mro__:
232 for base in getattr(klass, "__orig_bases__", ()):
233 origin = getattr(base, "__origin__", None)
234 if origin is BaseCharacteristic:
235 args = get_args(base)
236 if not args:
237 continue
239 arg = args[0]
240 if arg is Any:
241 continue
243 if isinstance(arg, type):
244 resolved = arg
245 break
247 # Support PEP 585/typing aliases like list[Foo] or tuple[Bar, ...].
248 generic_origin = get_origin(arg)
249 if isinstance(generic_origin, type):
250 resolved = generic_origin
251 break
252 if resolved is not None:
253 break
255 cls._cached_generic_python_type = resolved # type: ignore[attr-defined]
256 return resolved
258 def _resolve_yaml_spec(self) -> CharacteristicSpec | None:
259 """Resolve specification using YAML cross-reference system."""
260 # Delegate to static method
261 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(type(self))
263 @property
264 def uuid(self) -> BluetoothUUID:
265 """Get the characteristic UUID from _info."""
266 return self._info.uuid
268 @property
269 def info(self) -> CharacteristicInfo:
270 """Characteristic information."""
271 return self._info
273 @property
274 def spec(self) -> CharacteristicSpec | None:
275 """Get the full GSS specification with description and detailed metadata."""
276 return self._spec
278 @property
279 def name(self) -> str:
280 """Get the characteristic name from _info."""
281 return self._info.name
283 @property
284 def description(self) -> str:
285 """Get the characteristic description from GSS specification."""
286 return self._spec.description if self._spec and self._spec.description else ""
288 @property
289 def role(self) -> CharacteristicRole:
290 """Classify the characteristic's purpose from SIG spec metadata.
292 Override via ``_manual_role`` class variable, or the heuristic in
293 :func:`.role_classifier.classify_role` is used. Result is cached
294 per concrete subclass.
295 """
296 cls = type(self)
297 if cls._cached_role is None:
298 if cls._manual_role is not None:
299 cls._cached_role = cls._manual_role
300 else:
301 cls._cached_role = classify_role(
302 self.name, self._info.python_type, self._info.is_bitfield, self.unit, self._spec
303 )
304 return cls._cached_role
306 @property
307 def display_name(self) -> str:
308 """Get the display name for this characteristic.
310 Uses explicit _characteristic_name if set, otherwise falls back
311 to class name.
312 """
313 return self._characteristic_name or self.__class__.__name__
315 @cached_property
316 def gss_special_values(self) -> dict[int, str]:
317 """Get special values from GSS specification.
319 Extracts all special value definitions (e.g., 0x8000="value is not known")
320 from the GSS YAML specification for this characteristic.
322 GSS stores values as unsigned hex (e.g., 0x8000). For signed types,
323 this method also includes the signed interpretation so lookups work
324 with both parsed signed values and raw unsigned values.
326 Returns:
327 Dictionary mapping raw integer values to their human-readable meanings.
328 Includes both unsigned and signed interpretations for applicable values.
329 """
330 # extract special values from YAML
331 if not self._spec or not hasattr(self._spec, "structure") or not self._spec.structure:
332 return {}
334 result: dict[int, str] = {}
335 for field in self._spec.structure: # pylint: disable=too-many-nested-blocks # Spec requires nested iteration for special values
336 for sv in field.special_values:
337 unsigned_val = sv.raw_value
338 result[unsigned_val] = sv.meaning
340 # For signed types, add the signed equivalent based on common bit widths.
341 # This handles cases like 0x8000 (32768) -> -32768 for sint16.
342 if self.is_signed_from_yaml():
343 for bits in (8, 16, 24, 32):
344 max_unsigned = (1 << bits) - 1
345 sign_bit = 1 << (bits - 1)
346 if sign_bit <= unsigned_val <= max_unsigned:
347 # This value would be negative when interpreted as signed
348 signed_val = unsigned_val - (1 << bits)
349 if signed_val not in result:
350 result[signed_val] = sv.meaning
351 return result
353 def is_special_value(self, raw_value: int) -> bool:
354 """Check if a raw value is a special sentinel value.
356 Checks both manual overrides (_special_values class variable) and
357 GSS-derived special values, with manual taking precedence.
359 Args:
360 raw_value: The raw integer value to check.
362 Returns:
363 True if this is a special sentinel value, False otherwise.
364 """
365 return self._special_resolver.is_special(raw_value)
367 def get_special_value_meaning(self, raw_value: int) -> str | None:
368 """Get the human-readable meaning of a special value.
370 Args:
371 raw_value: The raw integer value to look up.
373 Returns:
374 The meaning string (e.g., "value is not known"), or None if not special.
375 """
376 res = self._special_resolver.resolve(raw_value)
377 return res.meaning if res is not None else None
379 def get_special_value_type(self, raw_value: int) -> SpecialValueType | None:
380 """Get the category of a special value.
382 Args:
383 raw_value: The raw integer value to classify.
385 Returns:
386 The SpecialValueType category, or None if not a special value.
387 """
388 res = self._special_resolver.resolve(raw_value)
389 return res.value_type if res is not None else None
391 @classmethod
392 def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic[Any]]) -> str | None:
393 """Resolve a dependency class to its canonical UUID string.
395 Args:
396 dep_class: The characteristic class to resolve
398 Returns:
399 Canonical UUID string or None if unresolvable
401 """
402 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None)
403 if configured_info is not None:
404 return str(configured_info.uuid)
406 try:
407 class_uuid = dep_class.get_class_uuid()
408 if class_uuid is not None:
409 return str(class_uuid)
410 except (ValueError, AttributeError, TypeError):
411 logger.warning("Failed to resolve class UUID for dependency %s", dep_class.__name__)
413 try:
414 temp_instance = dep_class()
415 return str(temp_instance.info.uuid)
416 except (ValueError, AttributeError, TypeError):
417 return None
419 def _resolve_dependencies(self, attr_name: str) -> list[str]:
420 """Resolve dependency class references to canonical UUID strings.
422 Performance: Returns list[str] instead of list[BluetoothUUID] because
423 these are compared against dict[str, ...] keys in hot paths.
424 """
425 dependency_classes: list[type[BaseCharacteristic[Any]]] = []
427 declared = getattr(self.__class__, attr_name, []) or []
428 dependency_classes.extend(declared)
430 resolved: list[str] = []
431 seen: set[str] = set()
433 for dep_class in dependency_classes:
434 uuid_str = self._normalize_dependency_class(dep_class)
435 if uuid_str and uuid_str not in seen:
436 seen.add(uuid_str)
437 resolved.append(uuid_str)
439 return resolved
441 @property
442 def required_dependencies(self) -> list[str]:
443 """Get resolved required dependency UUID strings.
445 Performance: Returns list[str] for efficient comparison with dict keys.
446 """
447 if self._resolved_required_dependencies is None:
448 self._resolved_required_dependencies = self._resolve_dependencies("_required_dependencies")
450 return list(self._resolved_required_dependencies)
452 @property
453 def optional_dependencies(self) -> list[str]:
454 """Get resolved optional dependency UUID strings.
456 Performance: Returns list[str] for efficient comparison with dict keys.
457 """
458 if self._resolved_optional_dependencies is None:
459 self._resolved_optional_dependencies = self._resolve_dependencies("_optional_dependencies")
461 return list(self._resolved_optional_dependencies)
463 @classmethod
464 def get_allows_sig_override(cls) -> bool:
465 """Check if this characteristic class allows overriding SIG characteristics.
467 Custom characteristics that need to override official Bluetooth SIG
468 characteristics must set _allows_sig_override = True as a class attribute.
470 Returns:
471 True if SIG override is allowed, False otherwise.
473 """
474 return cls._allows_sig_override
476 @classmethod
477 def get_configured_info(cls) -> CharacteristicInfo | None:
478 """Get the class-level configured CharacteristicInfo.
480 This provides public access to the _configured_info attribute that is set
481 by __init_subclass__ for custom characteristics.
483 Returns:
484 CharacteristicInfo if configured, None otherwise
486 """
487 return getattr(cls, "_configured_info", None)
489 @classmethod
490 def get_class_uuid(cls) -> BluetoothUUID | None:
491 """Get the characteristic UUID for this class without creating an instance.
493 This is the public API for registry and other modules to resolve UUIDs.
495 Returns:
496 BluetoothUUID if the class has a resolvable UUID, None otherwise.
498 """
499 return cls._resolve_class_uuid()
501 @classmethod
502 def _resolve_class_uuid(cls) -> BluetoothUUID | None:
503 """Resolve the characteristic UUID for this class without creating an instance."""
504 # Check for _info attribute first (custom characteristics)
505 try:
506 info = cast(Any, cls)._info
507 except AttributeError:
508 info = None
510 if info is not None:
511 if isinstance(info, CharacteristicInfo):
512 return info.uuid
513 logger.warning("_info attribute is not CharacteristicInfo for class %s", cls.__name__)
515 # Try cross-file resolution for SIG characteristics
516 yaml_spec = cls._resolve_yaml_spec_class()
517 if yaml_spec:
518 return yaml_spec.uuid
520 # Fallback to original registry resolution
521 return cls._resolve_from_basic_registry_class()
523 @classmethod
524 def _resolve_yaml_spec_class(cls) -> CharacteristicSpec | None:
525 """Resolve specification using YAML cross-reference system at class level."""
526 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(cls)
528 @classmethod
529 def _resolve_from_basic_registry_class(cls) -> BluetoothUUID | None:
530 """Fallback to basic registry resolution at class level."""
531 try:
532 registry_info = SIGCharacteristicResolver.resolve_from_registry(cls)
533 except (ValueError, KeyError, AttributeError, TypeError):
534 # Registry resolution can fail for various reasons:
535 # - ValueError: Invalid UUID format
536 # - KeyError: Characteristic not in registry
537 # - AttributeError: Missing expected attributes
538 # - TypeError: Type mismatch in resolution
539 return None
540 else:
541 return registry_info.uuid if registry_info else None
543 @classmethod
544 def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool:
545 """Check if this characteristic matches the given UUID."""
546 try:
547 class_uuid = cls._resolve_class_uuid()
548 if class_uuid is None:
549 return False
550 input_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid)
551 except ValueError:
552 return False
553 else:
554 return class_uuid == input_uuid
556 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> T:
557 """Decode raw bytes into the characteristic's typed value.
559 Called internally by :meth:`parse_value` after pipeline validation.
560 Uses *_template* when set; subclasses override for custom logic.
561 """
562 if self._template is not None:
563 return self._template.decode_value( # pylint: disable=protected-access
564 data, offset=0, ctx=ctx, validate=validate
565 )
566 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override decode_value()")
568 def parse_value(
569 self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True
570 ) -> T:
571 """Parse characteristic data.
573 Delegates to :class:`ParsePipeline` for the multi-stage pipeline
574 (length validation → raw int extraction → special value detection →
575 decode → range/type validation).
577 Returns:
578 Parsed value of type T.
580 Raises:
581 SpecialValueDetectedError: Special sentinel (0x8000="unknown", 0x7FFFFFFF="NaN")
582 CharacteristicParseError: Parse/validation failure
584 """
585 decoded: T = self._parse_pipeline.run(data, ctx, validate)
586 self.last_parsed = decoded
587 return decoded
589 def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401
590 """Encode a typed value into raw bytes (no validation).
592 Called internally by :meth:`build_value` after pipeline validation.
593 Uses *_template* when set; subclasses override for custom logic.
594 """
595 if self._template is not None:
596 return self._template.encode_value(data) # pylint: disable=protected-access
597 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override encode_value()")
599 def build_value(self, data: T | SpecialValueResult, validate: bool = True) -> bytearray:
600 """Encode value or special value to characteristic bytes.
602 Delegates to :class:`EncodePipeline` for the multi-stage pipeline
603 (type validation → range validation → encode → length validation).
605 Args:
606 data: Value to encode (type T) or :class:`SpecialValueResult`.
607 validate: Enable validation (type, range, length checks).
609 Returns:
610 Encoded bytes ready for BLE write.
612 Raises:
613 CharacteristicEncodeError: If encoding or validation fails.
615 """
616 return self._encode_pipeline.run(data, validate)
618 # -------------------- Encoding helpers for special values --------------------
619 def encode_special(self, value_type: SpecialValueType) -> bytearray:
620 """Encode a special value type to bytes (reverse lookup).
622 Raises ValueError if no raw value of that type is defined for this characteristic.
623 """
624 return self._encode_pipeline.encode_special(value_type)
626 def encode_special_by_meaning(self, meaning: str) -> bytearray:
627 """Encode a special value by a partial meaning string match.
629 Raises ValueError if no matching special value is found.
630 """
631 return self._encode_pipeline.encode_special_by_meaning(meaning)
633 @property
634 def unit(self) -> str:
635 """Get the unit of measurement from _info.
637 Returns empty string for characteristics without units (e.g., bitfields).
638 """
639 return self._info.unit or ""
641 @cached_property
642 def unit_symbol(self) -> str:
643 """Get the canonical SIG unit symbol for this characteristic.
645 Resolves via the ``UnitsRegistry`` using the YAML ``unit_id``
646 (e.g. ``org.bluetooth.unit.thermodynamic_temperature.degree_celsius``
647 → ``°C``). Falls back to :attr:`unit` when no symbol is available.
649 Returns:
650 SI symbol string (e.g. ``'°C'``, ``'%'``, ``'bpm'``),
651 or empty string if the characteristic has no unit.
653 """
654 from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415
656 unit_id = self.get_yaml_unit_id()
657 if unit_id:
658 symbol = resolve_unit_symbol(unit_id)
659 if symbol:
660 return symbol
662 return self._info.unit or ""
664 def get_field_unit(self, field_name: str) -> str:
665 """Get the resolved unit symbol for a specific struct field.
667 For struct-valued characteristics with per-field units (e.g.
668 Heart Rate Measurement: ``bpm`` for heart rate, ``J`` for
669 energy expended), this resolves the unit for a single field
670 via ``FieldSpec.unit_id`` → ``UnitsRegistry`` → ``.symbol``.
672 Args:
673 field_name: The Python-style field name (e.g. ``'heart_rate'``)
674 or raw GSS field name (e.g. ``'Heart Rate Measurement Value'``).
676 Returns:
677 Resolved unit symbol, or empty string if not found.
679 """
680 if not self._spec or not self._spec.structure:
681 return ""
683 for field in self._spec.structure:
684 if field_name in (field.python_name, field.field):
685 return field.unit_symbol
687 return ""
689 @property
690 def size(self) -> int | None:
691 """Get the size in bytes for this characteristic from YAML specifications.
693 Returns the field size from YAML automation if available, otherwise None.
694 This is useful for determining the expected data length for parsing
695 and encoding.
697 """
698 # First try manual size override if set
699 if self._manual_size is not None:
700 return self._manual_size
702 # Try field size from YAML cross-reference
703 field_size = self.get_yaml_field_size()
704 if field_size is not None:
705 return field_size
707 # For characteristics without YAML size info, return None
708 # indicating variable or unknown length
709 return None
711 @property
712 def python_type(self) -> type | str | None:
713 """Get the resolved Python type for this characteristic's values."""
714 return self._info.python_type
716 @property
717 def is_bitfield(self) -> bool:
718 """Whether this characteristic's value is a bitfield."""
719 return self._info.is_bitfield
721 # YAML automation helper methods
722 def get_yaml_data_type(self) -> str | None:
723 """Get the data type from YAML automation (e.g., 'sint16', 'uint8')."""
724 return self._spec.data_type if self._spec else None
726 def get_yaml_field_size(self) -> int | None:
727 """Get the field size in bytes from YAML automation."""
728 field_size = self._spec.field_size if self._spec else None
729 if field_size and isinstance(field_size, str) and field_size.isdigit():
730 return int(field_size)
731 return None
733 def get_yaml_unit_id(self) -> str | None:
734 """Get the Bluetooth SIG unit identifier from YAML automation."""
735 return self._spec.unit_id if self._spec else None
737 def get_yaml_resolution_text(self) -> str | None:
738 """Get the resolution description text from YAML automation."""
739 return self._spec.resolution_text if self._spec else None
741 def is_signed_from_yaml(self) -> bool:
742 """Determine if the data type is signed based on YAML automation."""
743 data_type = self.get_yaml_data_type()
744 if not data_type:
745 return False
746 # Check for signed types: signed integers, medical floats, and standard floats
747 return data_type.startswith("sint") or data_type in ("medfloat16", "medfloat32", "float32", "float64")
749 def get_byte_order_hint(self) -> str:
750 """Get byte order hint (Bluetooth SIG uses little-endian by convention)."""
751 return "little"