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

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, get_args 

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

241 

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

243 return resolved 

244 

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

249 

250 @property 

251 def uuid(self) -> BluetoothUUID: 

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

253 return self._info.uuid 

254 

255 @property 

256 def info(self) -> CharacteristicInfo: 

257 """Characteristic information.""" 

258 return self._info 

259 

260 @property 

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

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

263 return self._spec 

264 

265 @property 

266 def name(self) -> str: 

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

268 return self._info.name 

269 

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

274 

275 @property 

276 def role(self) -> CharacteristicRole: 

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

278 

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 

292 

293 @property 

294 def display_name(self) -> str: 

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

296 

297 Uses explicit _characteristic_name if set, otherwise falls back 

298 to class name. 

299 """ 

300 return self._characteristic_name or self.__class__.__name__ 

301 

302 @cached_property 

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

304 """Get special values from GSS specification. 

305 

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

307 from the GSS YAML specification for this characteristic. 

308 

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. 

312 

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

320 

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 

326 

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 

339 

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

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

342 

343 Checks both manual overrides (_special_values class variable) and 

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

345 

346 Args: 

347 raw_value: The raw integer value to check. 

348 

349 Returns: 

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

351 """ 

352 return self._special_resolver.is_special(raw_value) 

353 

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

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

356 

357 Args: 

358 raw_value: The raw integer value to look up. 

359 

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 

365 

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

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

368 

369 Args: 

370 raw_value: The raw integer value to classify. 

371 

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 

377 

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. 

381 

382 Args: 

383 dep_class: The characteristic class to resolve 

384 

385 Returns: 

386 Canonical UUID string or None if unresolvable 

387 

388 """ 

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

390 if configured_info is not None: 

391 return str(configured_info.uuid) 

392 

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

399 

400 try: 

401 temp_instance = dep_class() 

402 return str(temp_instance.info.uuid) 

403 except (ValueError, AttributeError, TypeError): 

404 return None 

405 

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

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

408 

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

413 

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

415 dependency_classes.extend(declared) 

416 

417 resolved: list[str] = [] 

418 seen: set[str] = set() 

419 

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) 

425 

426 return resolved 

427 

428 @property 

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

430 """Get resolved required dependency UUID strings. 

431 

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

436 

437 return list(self._resolved_required_dependencies) 

438 

439 @property 

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

441 """Get resolved optional dependency UUID strings. 

442 

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

447 

448 return list(self._resolved_optional_dependencies) 

449 

450 @classmethod 

451 def get_allows_sig_override(cls) -> bool: 

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

453 

454 Custom characteristics that need to override official Bluetooth SIG 

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

456 

457 Returns: 

458 True if SIG override is allowed, False otherwise. 

459 

460 """ 

461 return cls._allows_sig_override 

462 

463 @classmethod 

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

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

466 

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

468 by __init_subclass__ for custom characteristics. 

469 

470 Returns: 

471 CharacteristicInfo if configured, None otherwise 

472 

473 """ 

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

475 

476 @classmethod 

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

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

479 

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

481 

482 Returns: 

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

484 

485 """ 

486 return cls._resolve_class_uuid() 

487 

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

498 

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 

503 

504 # Fallback to original registry resolution 

505 return cls._resolve_from_basic_registry_class() 

506 

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) 

511 

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 

526 

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 

539 

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. 

542 

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

551 

552 def parse_value( 

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

554 ) -> T: 

555 """Parse characteristic data. 

556 

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

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

559 decode → range/type validation). 

560 

561 Returns: 

562 Parsed value of type T. 

563 

564 Raises: 

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

566 CharacteristicParseError: Parse/validation failure 

567 

568 """ 

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

570 self.last_parsed = decoded 

571 return decoded 

572 

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

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

575 

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

582 

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

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

585 

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

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

588 

589 Args: 

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

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

592 

593 Returns: 

594 Encoded bytes ready for BLE write. 

595 

596 Raises: 

597 CharacteristicEncodeError: If encoding or validation fails. 

598 

599 """ 

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

601 

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

605 

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) 

609 

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

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

612 

613 Raises ValueError if no matching special value is found. 

614 """ 

615 return self._encode_pipeline.encode_special_by_meaning(meaning) 

616 

617 @property 

618 def unit(self) -> str: 

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

620 

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

622 """ 

623 return self._info.unit or "" 

624 

625 @cached_property 

626 def unit_symbol(self) -> str: 

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

628 

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. 

632 

633 Returns: 

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

635 or empty string if the characteristic has no unit. 

636 

637 """ 

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

639 

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 

645 

646 return self._info.unit or "" 

647 

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

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

650 

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

655 

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

659 

660 Returns: 

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

662 

663 """ 

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

665 return "" 

666 

667 for field in self._spec.structure: 

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

669 return field.unit_symbol 

670 

671 return "" 

672 

673 @property 

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

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

676 

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. 

680 

681 """ 

682 # First try manual size override if set 

683 if self._manual_size is not None: 

684 return self._manual_size 

685 

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 

690 

691 # For characteristics without YAML size info, return None 

692 # indicating variable or unknown length 

693 return None 

694 

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 

699 

700 @property 

701 def is_bitfield(self) -> bool: 

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

703 return self._info.is_bitfield 

704 

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 

709 

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 

716 

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 

720 

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 

724 

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

732 

733 def get_byte_order_hint(self) -> str: 

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

735 return "little"