Coverage for src / bluetooth_sig / gatt / characteristics / base.py: 83%
318 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +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, get_args
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 args and isinstance(args[0], type) and args[0] is not Any:
237 resolved = args[0]
238 break
239 if resolved is not None:
240 break
242 cls._cached_generic_python_type = resolved # type: ignore[attr-defined]
243 return resolved
245 def _resolve_yaml_spec(self) -> CharacteristicSpec | None:
246 """Resolve specification using YAML cross-reference system."""
247 # Delegate to static method
248 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(type(self))
250 @property
251 def uuid(self) -> BluetoothUUID:
252 """Get the characteristic UUID from _info."""
253 return self._info.uuid
255 @property
256 def info(self) -> CharacteristicInfo:
257 """Characteristic information."""
258 return self._info
260 @property
261 def spec(self) -> CharacteristicSpec | None:
262 """Get the full GSS specification with description and detailed metadata."""
263 return self._spec
265 @property
266 def name(self) -> str:
267 """Get the characteristic name from _info."""
268 return self._info.name
270 @property
271 def description(self) -> str:
272 """Get the characteristic description from GSS specification."""
273 return self._spec.description if self._spec and self._spec.description else ""
275 @property
276 def role(self) -> CharacteristicRole:
277 """Classify the characteristic's purpose from SIG spec metadata.
279 Override via ``_manual_role`` class variable, or the heuristic in
280 :func:`.role_classifier.classify_role` is used. Result is cached
281 per concrete subclass.
282 """
283 cls = type(self)
284 if cls._cached_role is None:
285 if cls._manual_role is not None:
286 cls._cached_role = cls._manual_role
287 else:
288 cls._cached_role = classify_role(
289 self.name, self._info.python_type, self._info.is_bitfield, self.unit, self._spec
290 )
291 return cls._cached_role
293 @property
294 def display_name(self) -> str:
295 """Get the display name for this characteristic.
297 Uses explicit _characteristic_name if set, otherwise falls back
298 to class name.
299 """
300 return self._characteristic_name or self.__class__.__name__
302 @cached_property
303 def gss_special_values(self) -> dict[int, str]:
304 """Get special values from GSS specification.
306 Extracts all special value definitions (e.g., 0x8000="value is not known")
307 from the GSS YAML specification for this characteristic.
309 GSS stores values as unsigned hex (e.g., 0x8000). For signed types,
310 this method also includes the signed interpretation so lookups work
311 with both parsed signed values and raw unsigned values.
313 Returns:
314 Dictionary mapping raw integer values to their human-readable meanings.
315 Includes both unsigned and signed interpretations for applicable values.
316 """
317 # extract special values from YAML
318 if not self._spec or not hasattr(self._spec, "structure") or not self._spec.structure:
319 return {}
321 result: dict[int, str] = {}
322 for field in self._spec.structure: # pylint: disable=too-many-nested-blocks # Spec requires nested iteration for special values
323 for sv in field.special_values:
324 unsigned_val = sv.raw_value
325 result[unsigned_val] = sv.meaning
327 # For signed types, add the signed equivalent based on common bit widths.
328 # This handles cases like 0x8000 (32768) -> -32768 for sint16.
329 if self.is_signed_from_yaml():
330 for bits in (8, 16, 24, 32):
331 max_unsigned = (1 << bits) - 1
332 sign_bit = 1 << (bits - 1)
333 if sign_bit <= unsigned_val <= max_unsigned:
334 # This value would be negative when interpreted as signed
335 signed_val = unsigned_val - (1 << bits)
336 if signed_val not in result:
337 result[signed_val] = sv.meaning
338 return result
340 def is_special_value(self, raw_value: int) -> bool:
341 """Check if a raw value is a special sentinel value.
343 Checks both manual overrides (_special_values class variable) and
344 GSS-derived special values, with manual taking precedence.
346 Args:
347 raw_value: The raw integer value to check.
349 Returns:
350 True if this is a special sentinel value, False otherwise.
351 """
352 return self._special_resolver.is_special(raw_value)
354 def get_special_value_meaning(self, raw_value: int) -> str | None:
355 """Get the human-readable meaning of a special value.
357 Args:
358 raw_value: The raw integer value to look up.
360 Returns:
361 The meaning string (e.g., "value is not known"), or None if not special.
362 """
363 res = self._special_resolver.resolve(raw_value)
364 return res.meaning if res is not None else None
366 def get_special_value_type(self, raw_value: int) -> SpecialValueType | None:
367 """Get the category of a special value.
369 Args:
370 raw_value: The raw integer value to classify.
372 Returns:
373 The SpecialValueType category, or None if not a special value.
374 """
375 res = self._special_resolver.resolve(raw_value)
376 return res.value_type if res is not None else None
378 @classmethod
379 def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic[Any]]) -> str | None:
380 """Resolve a dependency class to its canonical UUID string.
382 Args:
383 dep_class: The characteristic class to resolve
385 Returns:
386 Canonical UUID string or None if unresolvable
388 """
389 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None)
390 if configured_info is not None:
391 return str(configured_info.uuid)
393 try:
394 class_uuid = dep_class.get_class_uuid()
395 if class_uuid is not None:
396 return str(class_uuid)
397 except (ValueError, AttributeError, TypeError):
398 logger.warning("Failed to resolve class UUID for dependency %s", dep_class.__name__)
400 try:
401 temp_instance = dep_class()
402 return str(temp_instance.info.uuid)
403 except (ValueError, AttributeError, TypeError):
404 return None
406 def _resolve_dependencies(self, attr_name: str) -> list[str]:
407 """Resolve dependency class references to canonical UUID strings.
409 Performance: Returns list[str] instead of list[BluetoothUUID] because
410 these are compared against dict[str, ...] keys in hot paths.
411 """
412 dependency_classes: list[type[BaseCharacteristic[Any]]] = []
414 declared = getattr(self.__class__, attr_name, []) or []
415 dependency_classes.extend(declared)
417 resolved: list[str] = []
418 seen: set[str] = set()
420 for dep_class in dependency_classes:
421 uuid_str = self._normalize_dependency_class(dep_class)
422 if uuid_str and uuid_str not in seen:
423 seen.add(uuid_str)
424 resolved.append(uuid_str)
426 return resolved
428 @property
429 def required_dependencies(self) -> list[str]:
430 """Get resolved required dependency UUID strings.
432 Performance: Returns list[str] for efficient comparison with dict keys.
433 """
434 if self._resolved_required_dependencies is None:
435 self._resolved_required_dependencies = self._resolve_dependencies("_required_dependencies")
437 return list(self._resolved_required_dependencies)
439 @property
440 def optional_dependencies(self) -> list[str]:
441 """Get resolved optional dependency UUID strings.
443 Performance: Returns list[str] for efficient comparison with dict keys.
444 """
445 if self._resolved_optional_dependencies is None:
446 self._resolved_optional_dependencies = self._resolve_dependencies("_optional_dependencies")
448 return list(self._resolved_optional_dependencies)
450 @classmethod
451 def get_allows_sig_override(cls) -> bool:
452 """Check if this characteristic class allows overriding SIG characteristics.
454 Custom characteristics that need to override official Bluetooth SIG
455 characteristics must set _allows_sig_override = True as a class attribute.
457 Returns:
458 True if SIG override is allowed, False otherwise.
460 """
461 return cls._allows_sig_override
463 @classmethod
464 def get_configured_info(cls) -> CharacteristicInfo | None:
465 """Get the class-level configured CharacteristicInfo.
467 This provides public access to the _configured_info attribute that is set
468 by __init_subclass__ for custom characteristics.
470 Returns:
471 CharacteristicInfo if configured, None otherwise
473 """
474 return getattr(cls, "_configured_info", None)
476 @classmethod
477 def get_class_uuid(cls) -> BluetoothUUID | None:
478 """Get the characteristic UUID for this class without creating an instance.
480 This is the public API for registry and other modules to resolve UUIDs.
482 Returns:
483 BluetoothUUID if the class has a resolvable UUID, None otherwise.
485 """
486 return cls._resolve_class_uuid()
488 @classmethod
489 def _resolve_class_uuid(cls) -> BluetoothUUID | None:
490 """Resolve the characteristic UUID for this class without creating an instance."""
491 # Check for _info attribute first (custom characteristics)
492 if hasattr(cls, "_info"):
493 info: CharacteristicInfo = cls._info # Custom characteristics may have _info
494 try:
495 return info.uuid
496 except AttributeError:
497 logger.warning("_info attribute has no uuid for class %s", cls.__name__)
499 # Try cross-file resolution for SIG characteristics
500 yaml_spec = cls._resolve_yaml_spec_class()
501 if yaml_spec:
502 return yaml_spec.uuid
504 # Fallback to original registry resolution
505 return cls._resolve_from_basic_registry_class()
507 @classmethod
508 def _resolve_yaml_spec_class(cls) -> CharacteristicSpec | None:
509 """Resolve specification using YAML cross-reference system at class level."""
510 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(cls)
512 @classmethod
513 def _resolve_from_basic_registry_class(cls) -> BluetoothUUID | None:
514 """Fallback to basic registry resolution at class level."""
515 try:
516 registry_info = SIGCharacteristicResolver.resolve_from_registry(cls)
517 except (ValueError, KeyError, AttributeError, TypeError):
518 # Registry resolution can fail for various reasons:
519 # - ValueError: Invalid UUID format
520 # - KeyError: Characteristic not in registry
521 # - AttributeError: Missing expected attributes
522 # - TypeError: Type mismatch in resolution
523 return None
524 else:
525 return registry_info.uuid if registry_info else None
527 @classmethod
528 def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool:
529 """Check if this characteristic matches the given UUID."""
530 try:
531 class_uuid = cls._resolve_class_uuid()
532 if class_uuid is None:
533 return False
534 input_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid)
535 except ValueError:
536 return False
537 else:
538 return class_uuid == input_uuid
540 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> T:
541 """Decode raw bytes into the characteristic's typed value.
543 Called internally by :meth:`parse_value` after pipeline validation.
544 Uses *_template* when set; subclasses override for custom logic.
545 """
546 if self._template is not None:
547 return self._template.decode_value( # pylint: disable=protected-access
548 data, offset=0, ctx=ctx, validate=validate
549 )
550 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override decode_value()")
552 def parse_value(
553 self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True
554 ) -> T:
555 """Parse characteristic data.
557 Delegates to :class:`ParsePipeline` for the multi-stage pipeline
558 (length validation → raw int extraction → special value detection →
559 decode → range/type validation).
561 Returns:
562 Parsed value of type T.
564 Raises:
565 SpecialValueDetectedError: Special sentinel (0x8000="unknown", 0x7FFFFFFF="NaN")
566 CharacteristicParseError: Parse/validation failure
568 """
569 decoded: T = self._parse_pipeline.run(data, ctx, validate)
570 self.last_parsed = decoded
571 return decoded
573 def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401
574 """Encode a typed value into raw bytes (no validation).
576 Called internally by :meth:`build_value` after pipeline validation.
577 Uses *_template* when set; subclasses override for custom logic.
578 """
579 if self._template is not None:
580 return self._template.encode_value(data) # pylint: disable=protected-access
581 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override encode_value()")
583 def build_value(self, data: T | SpecialValueResult, validate: bool = True) -> bytearray:
584 """Encode value or special value to characteristic bytes.
586 Delegates to :class:`EncodePipeline` for the multi-stage pipeline
587 (type validation → range validation → encode → length validation).
589 Args:
590 data: Value to encode (type T) or :class:`SpecialValueResult`.
591 validate: Enable validation (type, range, length checks).
593 Returns:
594 Encoded bytes ready for BLE write.
596 Raises:
597 CharacteristicEncodeError: If encoding or validation fails.
599 """
600 return self._encode_pipeline.run(data, validate)
602 # -------------------- Encoding helpers for special values --------------------
603 def encode_special(self, value_type: SpecialValueType) -> bytearray:
604 """Encode a special value type to bytes (reverse lookup).
606 Raises ValueError if no raw value of that type is defined for this characteristic.
607 """
608 return self._encode_pipeline.encode_special(value_type)
610 def encode_special_by_meaning(self, meaning: str) -> bytearray:
611 """Encode a special value by a partial meaning string match.
613 Raises ValueError if no matching special value is found.
614 """
615 return self._encode_pipeline.encode_special_by_meaning(meaning)
617 @property
618 def unit(self) -> str:
619 """Get the unit of measurement from _info.
621 Returns empty string for characteristics without units (e.g., bitfields).
622 """
623 return self._info.unit or ""
625 @cached_property
626 def unit_symbol(self) -> str:
627 """Get the canonical SIG unit symbol for this characteristic.
629 Resolves via the ``UnitsRegistry`` using the YAML ``unit_id``
630 (e.g. ``org.bluetooth.unit.thermodynamic_temperature.degree_celsius``
631 → ``°C``). Falls back to :attr:`unit` when no symbol is available.
633 Returns:
634 SI symbol string (e.g. ``'°C'``, ``'%'``, ``'bpm'``),
635 or empty string if the characteristic has no unit.
637 """
638 from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415
640 unit_id = self.get_yaml_unit_id()
641 if unit_id:
642 symbol = resolve_unit_symbol(unit_id)
643 if symbol:
644 return symbol
646 return self._info.unit or ""
648 def get_field_unit(self, field_name: str) -> str:
649 """Get the resolved unit symbol for a specific struct field.
651 For struct-valued characteristics with per-field units (e.g.
652 Heart Rate Measurement: ``bpm`` for heart rate, ``J`` for
653 energy expended), this resolves the unit for a single field
654 via ``FieldSpec.unit_id`` → ``UnitsRegistry`` → ``.symbol``.
656 Args:
657 field_name: The Python-style field name (e.g. ``'heart_rate'``)
658 or raw GSS field name (e.g. ``'Heart Rate Measurement Value'``).
660 Returns:
661 Resolved unit symbol, or empty string if not found.
663 """
664 if not self._spec or not self._spec.structure:
665 return ""
667 for field in self._spec.structure:
668 if field_name in (field.python_name, field.field):
669 return field.unit_symbol
671 return ""
673 @property
674 def size(self) -> int | None:
675 """Get the size in bytes for this characteristic from YAML specifications.
677 Returns the field size from YAML automation if available, otherwise None.
678 This is useful for determining the expected data length for parsing
679 and encoding.
681 """
682 # First try manual size override if set
683 if self._manual_size is not None:
684 return self._manual_size
686 # Try field size from YAML cross-reference
687 field_size = self.get_yaml_field_size()
688 if field_size is not None:
689 return field_size
691 # For characteristics without YAML size info, return None
692 # indicating variable or unknown length
693 return None
695 @property
696 def python_type(self) -> type | str | None:
697 """Get the resolved Python type for this characteristic's values."""
698 return self._info.python_type
700 @property
701 def is_bitfield(self) -> bool:
702 """Whether this characteristic's value is a bitfield."""
703 return self._info.is_bitfield
705 # YAML automation helper methods
706 def get_yaml_data_type(self) -> str | None:
707 """Get the data type from YAML automation (e.g., 'sint16', 'uint8')."""
708 return self._spec.data_type if self._spec else None
710 def get_yaml_field_size(self) -> int | None:
711 """Get the field size in bytes from YAML automation."""
712 field_size = self._spec.field_size if self._spec else None
713 if field_size and isinstance(field_size, str) and field_size.isdigit():
714 return int(field_size)
715 return None
717 def get_yaml_unit_id(self) -> str | None:
718 """Get the Bluetooth SIG unit identifier from YAML automation."""
719 return self._spec.unit_id if self._spec else None
721 def get_yaml_resolution_text(self) -> str | None:
722 """Get the resolution description text from YAML automation."""
723 return self._spec.resolution_text if self._spec else None
725 def is_signed_from_yaml(self) -> bool:
726 """Determine if the data type is signed based on YAML automation."""
727 data_type = self.get_yaml_data_type()
728 if not data_type:
729 return False
730 # Check for signed types: signed integers, medical floats, and standard floats
731 return data_type.startswith("sint") or data_type in ("medfloat16", "medfloat32", "float32", "float64")
733 def get_byte_order_hint(self) -> str:
734 """Get byte order hint (Bluetooth SIG uses little-endian by convention)."""
735 return "little"