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

1"""Base class for GATT characteristics. 

2 

3Implements the core parsing and encoding system for Bluetooth GATT 

4characteristics following official Bluetooth SIG specifications. 

5 

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""" 

11 

12from __future__ import annotations 

13 

14import logging 

15from abc import ABC 

16from functools import cached_property 

17from typing import Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin 

18 

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 

39 

40logger = logging.getLogger(__name__) 

41 

42# Type variable for generic characteristic return types 

43T = TypeVar("T") 

44 

45# Sentinel for per-class cache (distinguishes None from "not yet resolved") 

46_SENTINEL = object() 

47 

48 

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. 

51 

52 Generic over *T*, the return type of ``_decode_value()``. 

53 

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. 

57 

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 """ 

64 

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 

72 

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 

80 

81 _template: CodingTemplate[T] | None = None 

82 

83 _allows_sig_override = False 

84 

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 

89 

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 

94 

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 

99 

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 

104 

105 def __init__( 

106 self, 

107 info: CharacteristicInfo | None = None, 

108 validation: ValidationConfig | None = None, 

109 ) -> None: 

110 """Initialize characteristic with structured configuration. 

111 

112 Args: 

113 info: Complete characteristic information (optional for SIG characteristics) 

114 validation: Validation constraints configuration (optional) 

115 

116 """ 

117 # Store provided info or None (will be resolved in __post_init__) 

118 self._provided_info = info 

119 

120 # Instance variables (will be set in __post_init__) 

121 self._info: CharacteristicInfo 

122 self._spec: CharacteristicSpec | None = None 

123 

124 # Manual overrides with proper types (using explicit class attributes) 

125 self._manual_unit: str | None = self.__class__._manual_unit 

126 

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 

145 

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 

149 

150 # Descriptor support 

151 self._descriptors: dict[str, BaseDescriptor] = {} 

152 

153 # Last parsed value for caching/debugging 

154 self.last_parsed: T | None = None 

155 

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) 

160 

161 # Call post-init to resolve characteristic info 

162 self.__post_init__() 

163 

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)) 

172 

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 ) 

180 

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 ) 

187 

188 self._special_resolver = SpecialValueResolver(spec_rules=spec_rules, class_rules=class_rules) 

189 

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 

193 

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 

201 

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 

210 

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 

217 

218 @classmethod 

219 def _resolve_generic_python_type(cls) -> type | None: 

220 """Resolve python_type from the class generic parameter BaseCharacteristic[T]. 

221 

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] 

229 

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 

238 

239 arg = args[0] 

240 if arg is Any: 

241 continue 

242 

243 if isinstance(arg, type): 

244 resolved = arg 

245 break 

246 

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 

254 

255 cls._cached_generic_python_type = resolved # type: ignore[attr-defined] 

256 return resolved 

257 

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)) 

262 

263 @property 

264 def uuid(self) -> BluetoothUUID: 

265 """Get the characteristic UUID from _info.""" 

266 return self._info.uuid 

267 

268 @property 

269 def info(self) -> CharacteristicInfo: 

270 """Characteristic information.""" 

271 return self._info 

272 

273 @property 

274 def spec(self) -> CharacteristicSpec | None: 

275 """Get the full GSS specification with description and detailed metadata.""" 

276 return self._spec 

277 

278 @property 

279 def name(self) -> str: 

280 """Get the characteristic name from _info.""" 

281 return self._info.name 

282 

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 "" 

287 

288 @property 

289 def role(self) -> CharacteristicRole: 

290 """Classify the characteristic's purpose from SIG spec metadata. 

291 

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 

305 

306 @property 

307 def display_name(self) -> str: 

308 """Get the display name for this characteristic. 

309 

310 Uses explicit _characteristic_name if set, otherwise falls back 

311 to class name. 

312 """ 

313 return self._characteristic_name or self.__class__.__name__ 

314 

315 @cached_property 

316 def gss_special_values(self) -> dict[int, str]: 

317 """Get special values from GSS specification. 

318 

319 Extracts all special value definitions (e.g., 0x8000="value is not known") 

320 from the GSS YAML specification for this characteristic. 

321 

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. 

325 

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 {} 

333 

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 

339 

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 

352 

353 def is_special_value(self, raw_value: int) -> bool: 

354 """Check if a raw value is a special sentinel value. 

355 

356 Checks both manual overrides (_special_values class variable) and 

357 GSS-derived special values, with manual taking precedence. 

358 

359 Args: 

360 raw_value: The raw integer value to check. 

361 

362 Returns: 

363 True if this is a special sentinel value, False otherwise. 

364 """ 

365 return self._special_resolver.is_special(raw_value) 

366 

367 def get_special_value_meaning(self, raw_value: int) -> str | None: 

368 """Get the human-readable meaning of a special value. 

369 

370 Args: 

371 raw_value: The raw integer value to look up. 

372 

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 

378 

379 def get_special_value_type(self, raw_value: int) -> SpecialValueType | None: 

380 """Get the category of a special value. 

381 

382 Args: 

383 raw_value: The raw integer value to classify. 

384 

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 

390 

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. 

394 

395 Args: 

396 dep_class: The characteristic class to resolve 

397 

398 Returns: 

399 Canonical UUID string or None if unresolvable 

400 

401 """ 

402 configured_info: CharacteristicInfo | None = getattr(dep_class, "_info", None) 

403 if configured_info is not None: 

404 return str(configured_info.uuid) 

405 

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__) 

412 

413 try: 

414 temp_instance = dep_class() 

415 return str(temp_instance.info.uuid) 

416 except (ValueError, AttributeError, TypeError): 

417 return None 

418 

419 def _resolve_dependencies(self, attr_name: str) -> list[str]: 

420 """Resolve dependency class references to canonical UUID strings. 

421 

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]]] = [] 

426 

427 declared = getattr(self.__class__, attr_name, []) or [] 

428 dependency_classes.extend(declared) 

429 

430 resolved: list[str] = [] 

431 seen: set[str] = set() 

432 

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) 

438 

439 return resolved 

440 

441 @property 

442 def required_dependencies(self) -> list[str]: 

443 """Get resolved required dependency UUID strings. 

444 

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") 

449 

450 return list(self._resolved_required_dependencies) 

451 

452 @property 

453 def optional_dependencies(self) -> list[str]: 

454 """Get resolved optional dependency UUID strings. 

455 

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") 

460 

461 return list(self._resolved_optional_dependencies) 

462 

463 @classmethod 

464 def get_allows_sig_override(cls) -> bool: 

465 """Check if this characteristic class allows overriding SIG characteristics. 

466 

467 Custom characteristics that need to override official Bluetooth SIG 

468 characteristics must set _allows_sig_override = True as a class attribute. 

469 

470 Returns: 

471 True if SIG override is allowed, False otherwise. 

472 

473 """ 

474 return cls._allows_sig_override 

475 

476 @classmethod 

477 def get_configured_info(cls) -> CharacteristicInfo | None: 

478 """Get the class-level configured CharacteristicInfo. 

479 

480 This provides public access to the _configured_info attribute that is set 

481 by __init_subclass__ for custom characteristics. 

482 

483 Returns: 

484 CharacteristicInfo if configured, None otherwise 

485 

486 """ 

487 return getattr(cls, "_configured_info", None) 

488 

489 @classmethod 

490 def get_class_uuid(cls) -> BluetoothUUID | None: 

491 """Get the characteristic UUID for this class without creating an instance. 

492 

493 This is the public API for registry and other modules to resolve UUIDs. 

494 

495 Returns: 

496 BluetoothUUID if the class has a resolvable UUID, None otherwise. 

497 

498 """ 

499 return cls._resolve_class_uuid() 

500 

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 

509 

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__) 

514 

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 

519 

520 # Fallback to original registry resolution 

521 return cls._resolve_from_basic_registry_class() 

522 

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) 

527 

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 

542 

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 

555 

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. 

558 

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()") 

567 

568 def parse_value( 

569 self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True 

570 ) -> T: 

571 """Parse characteristic data. 

572 

573 Delegates to :class:`ParsePipeline` for the multi-stage pipeline 

574 (length validation → raw int extraction → special value detection → 

575 decode → range/type validation). 

576 

577 Returns: 

578 Parsed value of type T. 

579 

580 Raises: 

581 SpecialValueDetectedError: Special sentinel (0x8000="unknown", 0x7FFFFFFF="NaN") 

582 CharacteristicParseError: Parse/validation failure 

583 

584 """ 

585 decoded: T = self._parse_pipeline.run(data, ctx, validate) 

586 self.last_parsed = decoded 

587 return decoded 

588 

589 def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401 

590 """Encode a typed value into raw bytes (no validation). 

591 

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()") 

598 

599 def build_value(self, data: T | SpecialValueResult, validate: bool = True) -> bytearray: 

600 """Encode value or special value to characteristic bytes. 

601 

602 Delegates to :class:`EncodePipeline` for the multi-stage pipeline 

603 (type validation → range validation → encode → length validation). 

604 

605 Args: 

606 data: Value to encode (type T) or :class:`SpecialValueResult`. 

607 validate: Enable validation (type, range, length checks). 

608 

609 Returns: 

610 Encoded bytes ready for BLE write. 

611 

612 Raises: 

613 CharacteristicEncodeError: If encoding or validation fails. 

614 

615 """ 

616 return self._encode_pipeline.run(data, validate) 

617 

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). 

621 

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) 

625 

626 def encode_special_by_meaning(self, meaning: str) -> bytearray: 

627 """Encode a special value by a partial meaning string match. 

628 

629 Raises ValueError if no matching special value is found. 

630 """ 

631 return self._encode_pipeline.encode_special_by_meaning(meaning) 

632 

633 @property 

634 def unit(self) -> str: 

635 """Get the unit of measurement from _info. 

636 

637 Returns empty string for characteristics without units (e.g., bitfields). 

638 """ 

639 return self._info.unit or "" 

640 

641 @cached_property 

642 def unit_symbol(self) -> str: 

643 """Get the canonical SIG unit symbol for this characteristic. 

644 

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. 

648 

649 Returns: 

650 SI symbol string (e.g. ``'°C'``, ``'%'``, ``'bpm'``), 

651 or empty string if the characteristic has no unit. 

652 

653 """ 

654 from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415 

655 

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 

661 

662 return self._info.unit or "" 

663 

664 def get_field_unit(self, field_name: str) -> str: 

665 """Get the resolved unit symbol for a specific struct field. 

666 

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``. 

671 

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'``). 

675 

676 Returns: 

677 Resolved unit symbol, or empty string if not found. 

678 

679 """ 

680 if not self._spec or not self._spec.structure: 

681 return "" 

682 

683 for field in self._spec.structure: 

684 if field_name in (field.python_name, field.field): 

685 return field.unit_symbol 

686 

687 return "" 

688 

689 @property 

690 def size(self) -> int | None: 

691 """Get the size in bytes for this characteristic from YAML specifications. 

692 

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. 

696 

697 """ 

698 # First try manual size override if set 

699 if self._manual_size is not None: 

700 return self._manual_size 

701 

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 

706 

707 # For characteristics without YAML size info, return None 

708 # indicating variable or unknown length 

709 return None 

710 

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 

715 

716 @property 

717 def is_bitfield(self) -> bool: 

718 """Whether this characteristic's value is a bitfield.""" 

719 return self._info.is_bitfield 

720 

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 

725 

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 

732 

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 

736 

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 

740 

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") 

748 

749 def get_byte_order_hint(self) -> str: 

750 """Get byte order hint (Bluetooth SIG uses little-endian by convention).""" 

751 return "little"