Coverage for src/bluetooth_sig/gatt/characteristics/base.py: 84%

332 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 01:26 +0000

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.base import BaseDescriptor 

31from ..resolver import NameNormalizer 

32from ..special_values_resolver import SpecialValueResolver 

33from .characteristic_meta import CharacteristicMeta, SIGCharacteristicResolver 

34from .characteristic_meta import ValidationConfig as ValidationConfig # noqa: PLC0414 # explicit re-export 

35from .context_lookup import ContextLookupMixin 

36from .descriptor_mixin import DescriptorMixin 

37from .pipeline import CharacteristicValidator, EncodePipeline, ParsePipeline 

38from .role_classifier import classify_role 

39from .templates import CodingTemplate 

40 

41logger = logging.getLogger(__name__) 

42 

43# Type variable for generic characteristic return types 

44T = TypeVar("T") 

45 

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

47_SENTINEL = object() 

48 

49 

50class BaseCharacteristic(ContextLookupMixin, DescriptorMixin, ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods 

51 """Base class for all GATT characteristics. 

52 

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

54 

55 Automatically resolves UUID, unit, and python_type from Bluetooth SIG YAML 

56 specifications. Supports manual overrides via ``_manual_unit`` and 

57 ``_python_type`` attributes. 

58 

59 Validation Attributes (optional class-level declarations): 

60 min_value / max_value: Allowed numeric range. 

61 expected_length / min_length / max_length: Byte-length constraints. 

62 allow_variable_length: Accept variable length data. 

63 expected_type: Expected Python type for parsed values. 

64 """ 

65 

66 # Explicit class attributes with defaults (replaces getattr usage) 

67 _characteristic_name: str | None = None 

68 _manual_unit: str | None = None 

69 _python_type: type | str | None = None 

70 _is_bitfield: bool = False 

71 _manual_size: int | None = None 

72 _is_template: bool = False 

73 

74 min_value: int | float | None = None 

75 max_value: int | float | None = None 

76 expected_length: int | None = None 

77 min_length: int | None = None 

78 max_length: int | None = None 

79 allow_variable_length: bool = False 

80 expected_type: type | None = None 

81 

82 _template: CodingTemplate[T] | None = None 

83 

84 _allows_sig_override = False 

85 

86 _required_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [] # Dependencies that MUST be present 

87 _optional_dependencies: ClassVar[ 

88 list[type[BaseCharacteristic[Any]]] 

89 ] = [] # Dependencies that enrich parsing when available 

90 

91 # Parse trace control (for performance tuning) 

92 # Can be configured via BLUETOOTH_SIG_ENABLE_PARSE_TRACE environment variable 

93 # Set to "0", "false", or "no" to disable trace collection 

94 _enable_parse_trace: bool = True # Default: enabled 

95 

96 # Role classification (computed once per concrete subclass) 

97 # Subclasses can set _manual_role to bypass the heuristic entirely. 

98 _manual_role: ClassVar[CharacteristicRole | None] = None 

99 _cached_role: ClassVar[CharacteristicRole | None] = None 

100 

101 # Special value handling (GSS-derived) 

102 # Manual override for special values when GSS spec is incomplete/wrong. 

103 # Format: {raw_value: meaning_string}. GSS values are used by default. 

104 _special_values: dict[int, str] | None = None 

105 

106 def __init__( 

107 self, 

108 info: CharacteristicInfo | None = None, 

109 validation: ValidationConfig | None = None, 

110 ) -> None: 

111 """Initialize characteristic with structured configuration. 

112 

113 Args: 

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

115 validation: Validation constraints configuration (optional) 

116 

117 """ 

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

119 self._provided_info = info 

120 

121 # Instance variables (will be set in __post_init__) 

122 self._info: CharacteristicInfo 

123 self._spec: CharacteristicSpec | None = None 

124 

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

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

127 

128 # Set validation attributes from ValidationConfig or class defaults 

129 if validation: 

130 self.min_value = validation.min_value 

131 self.max_value = validation.max_value 

132 self.expected_length = validation.expected_length 

133 self.min_length = validation.min_length 

134 self.max_length = validation.max_length 

135 self.allow_variable_length = validation.allow_variable_length 

136 self.expected_type = validation.expected_type 

137 else: 

138 # Fall back to class attributes for Progressive API Level 2 

139 self.min_value = self.__class__.min_value 

140 self.max_value = self.__class__.max_value 

141 self.expected_length = self.__class__.expected_length 

142 self.min_length = self.__class__.min_length 

143 self.max_length = self.__class__.max_length 

144 self.allow_variable_length = self.__class__.allow_variable_length 

145 self.expected_type = self.__class__.expected_type 

146 

147 # Dependency caches (resolved once per instance) 

148 self._resolved_required_dependencies: list[str] | None = None 

149 self._resolved_optional_dependencies: list[str] | None = None 

150 

151 # Descriptor support 

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

153 

154 # Last parsed value for caching/debugging 

155 self.last_parsed: T | None = None 

156 

157 # Optional User Description (0x2901) label from device discovery 

158 self.user_description: str | None = None 

159 

160 # Pipeline composition — validator is shared by parse and encode pipelines 

161 self._validator = CharacteristicValidator(self) 

162 self._parse_pipeline = ParsePipeline(self, self._validator) 

163 self._encode_pipeline = EncodePipeline(self, self._validator) 

164 

165 # Call post-init to resolve characteristic info 

166 self.__post_init__() 

167 

168 def __post_init__(self) -> None: 

169 """Initialize characteristic with resolved information.""" 

170 # Use provided info if available, otherwise resolve from SIG specs 

171 if self._provided_info: 

172 self._info = self._provided_info 

173 else: 

174 # Resolve characteristic information using proper resolver 

175 self._info = SIGCharacteristicResolver.resolve_for_class(type(self)) 

176 

177 # Resolve YAML spec for access to detailed metadata 

178 self._spec = self._resolve_yaml_spec() 

179 spec_rules: dict[int, SpecialValueRule] = {} 

180 for raw, meaning in self.gss_special_values.items(): 

181 spec_rules[raw] = SpecialValueRule( 

182 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning) 

183 ) 

184 

185 class_rules: dict[int, SpecialValueRule] = {} 

186 if self._special_values is not None: 

187 for raw, meaning in self._special_values.items(): 

188 class_rules[raw] = SpecialValueRule( 

189 raw_value=raw, meaning=meaning, value_type=classify_special_value(meaning) 

190 ) 

191 

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

193 

194 # Apply manual overrides to _info (single source of truth) 

195 if self._manual_unit is not None: 

196 self._info.unit = self._manual_unit 

197 

198 # Auto-resolve python_type from template generic parameter. 

199 # Templates carry their decoded type (e.g. ScaledUint16Template → float), 

200 # which is more accurate than the YAML wire type (uint16 → int). 

201 if self._template is not None: 

202 template_type = type(self._template).resolve_python_type() 

203 if template_type is not None: 

204 self._info.python_type = template_type 

205 

206 # Auto-resolve python_type from the class generic parameter. 

207 # BaseCharacteristic[T] already declares the decoded type (e.g. 

208 # BaseCharacteristic[PushbuttonStatus8Data]). This is the most 

209 # authoritative source — it overrides both YAML and template since 

210 # the class signature is the contract for what _decode_value returns. 

211 generic_type = self._resolve_generic_python_type() 

212 if generic_type is not None: 

213 self._info.python_type = generic_type 

214 

215 # Manual _python_type override wins over all auto-resolution. 

216 # Use sparingly — only when no other mechanism can express the correct type. 

217 if self.__class__._python_type is not None: 

218 self._info.python_type = self.__class__._python_type 

219 if self.__class__._is_bitfield: 

220 self._info.is_bitfield = True 

221 

222 @classmethod 

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

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

225 

226 Walks the MRO to find the concrete type bound to ``BaseCharacteristic[T]``. 

227 Returns ``None`` for unbound TypeVars, ``Any``, or forward references. 

228 Caches the result per-class in ``_cached_generic_python_type``. 

229 """ 

230 cached = cls.__dict__.get("_cached_generic_python_type", _SENTINEL) 

231 if cached is not _SENTINEL: 

232 return cached # type: ignore[no-any-return] 

233 

234 resolved: type | None = None 

235 for klass in cls.__mro__: 

236 for base in getattr(klass, "__orig_bases__", ()): 

237 origin = getattr(base, "__origin__", None) 

238 if origin is BaseCharacteristic: 

239 args = get_args(base) 

240 if not args: 

241 continue 

242 

243 arg = args[0] 

244 if arg is Any: 

245 continue 

246 

247 if isinstance(arg, type): 

248 resolved = arg 

249 break 

250 

251 # Support PEP 585/typing aliases like list[Foo] or tuple[Bar, ...]. 

252 generic_origin = get_origin(arg) 

253 if isinstance(generic_origin, type): 

254 resolved = generic_origin 

255 break 

256 if resolved is not None: 

257 break 

258 

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

260 return resolved 

261 

262 def _resolve_yaml_spec(self) -> CharacteristicSpec | None: 

263 """Resolve specification using YAML cross-reference system.""" 

264 # Delegate to static method 

265 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(type(self)) 

266 

267 @property 

268 def uuid(self) -> BluetoothUUID: 

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

270 return self._info.uuid 

271 

272 @property 

273 def info(self) -> CharacteristicInfo: 

274 """Characteristic information.""" 

275 return self._info 

276 

277 @property 

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

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

280 return self._spec 

281 

282 @property 

283 def name(self) -> str: 

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

285 return self._info.name 

286 

287 @property 

288 def description(self) -> str: 

289 """Get the characteristic description from GSS specification.""" 

290 return self._spec.description if self._spec and self._spec.description else "" 

291 

292 @property 

293 def role(self) -> CharacteristicRole: 

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

295 

296 Override via ``_manual_role`` class variable, or the heuristic in 

297 :func:`.role_classifier.classify_role` is used. Result is cached 

298 per concrete subclass. 

299 """ 

300 cls = type(self) 

301 if cls._cached_role is None: 

302 if cls._manual_role is not None: 

303 cls._cached_role = cls._manual_role 

304 else: 

305 cls._cached_role = classify_role( 

306 self.name, self._info.python_type, self._info.is_bitfield, self.unit, self._spec 

307 ) 

308 return cls._cached_role 

309 

310 @property 

311 def display_name(self) -> str: 

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

313 

314 Uses the canonical SIG/YAML name for lookup fidelity, then strips 

315 supported display markup for human-readable output. 

316 """ 

317 raw_name = self._characteristic_name or self._info.name 

318 return NameNormalizer.sanitize_display_markup(raw_name) 

319 

320 @cached_property 

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

322 """Get special values from GSS specification. 

323 

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

325 from the GSS YAML specification for this characteristic. 

326 

327 GSS stores values as unsigned hex (e.g., 0x8000). For signed types, 

328 this method also includes the signed interpretation so lookups work 

329 with both parsed signed values and raw unsigned values. 

330 

331 Returns: 

332 Dictionary mapping raw integer values to their human-readable meanings. 

333 Includes both unsigned and signed interpretations for applicable values. 

334 """ 

335 # extract special values from YAML 

336 if not self._spec or not hasattr(self._spec, "structure") or not self._spec.structure: 

337 return {} 

338 

339 result: dict[int, str] = {} 

340 for field in self._spec.structure: # pylint: disable=too-many-nested-blocks # Spec requires nested iteration for special values 

341 for sv in field.special_values: 

342 unsigned_val = sv.raw_value 

343 result[unsigned_val] = sv.meaning 

344 

345 # For signed types, add the signed equivalent based on common bit widths. 

346 # This handles cases like 0x8000 (32768) -> -32768 for sint16. 

347 if self.is_signed_from_yaml(): 

348 for bits in (8, 16, 24, 32): 

349 max_unsigned = (1 << bits) - 1 

350 sign_bit = 1 << (bits - 1) 

351 if sign_bit <= unsigned_val <= max_unsigned: 

352 # This value would be negative when interpreted as signed 

353 signed_val = unsigned_val - (1 << bits) 

354 if signed_val not in result: 

355 result[signed_val] = sv.meaning 

356 return result 

357 

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

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

360 

361 Checks both manual overrides (_special_values class variable) and 

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

363 

364 Args: 

365 raw_value: The raw integer value to check. 

366 

367 Returns: 

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

369 """ 

370 return self._special_resolver.is_special(raw_value) 

371 

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

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

374 

375 Args: 

376 raw_value: The raw integer value to look up. 

377 

378 Returns: 

379 The meaning string (e.g., "value is not known"), or None if not special. 

380 """ 

381 res = self._special_resolver.resolve(raw_value) 

382 return res.meaning if res is not None else None 

383 

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

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

386 

387 Args: 

388 raw_value: The raw integer value to classify. 

389 

390 Returns: 

391 The SpecialValueType category, or None if not a special value. 

392 """ 

393 res = self._special_resolver.resolve(raw_value) 

394 return res.value_type if res is not None else None 

395 

396 @classmethod 

397 def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic[Any]]) -> str | None: 

398 """Resolve a dependency class to its canonical UUID string. 

399 

400 Args: 

401 dep_class: The characteristic class to resolve 

402 

403 Returns: 

404 Canonical UUID string or None if unresolvable 

405 

406 """ 

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

408 if configured_info is not None: 

409 return str(configured_info.uuid) 

410 

411 try: 

412 class_uuid = dep_class.get_class_uuid() 

413 if class_uuid is not None: 

414 return str(class_uuid) 

415 except (ValueError, AttributeError, TypeError): 

416 logger.warning("Failed to resolve class UUID for dependency %s", dep_class.__name__) 

417 

418 try: 

419 temp_instance = dep_class() 

420 return str(temp_instance.info.uuid) 

421 except (ValueError, AttributeError, TypeError): 

422 return None 

423 

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

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

426 

427 Performance: Returns list[str] instead of list[BluetoothUUID] because 

428 these are compared against dict[str, ...] keys in hot paths. 

429 """ 

430 dependency_classes: list[type[BaseCharacteristic[Any]]] = [] 

431 

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

433 dependency_classes.extend(declared) 

434 

435 resolved: list[str] = [] 

436 seen: set[str] = set() 

437 

438 for dep_class in dependency_classes: 

439 uuid_str = self._normalize_dependency_class(dep_class) 

440 if uuid_str and uuid_str not in seen: 

441 seen.add(uuid_str) 

442 resolved.append(uuid_str) 

443 

444 return resolved 

445 

446 @property 

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

448 """Get resolved required dependency UUID strings. 

449 

450 Performance: Returns list[str] for efficient comparison with dict keys. 

451 """ 

452 if self._resolved_required_dependencies is None: 

453 self._resolved_required_dependencies = self._resolve_dependencies("_required_dependencies") 

454 

455 return list(self._resolved_required_dependencies) 

456 

457 @property 

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

459 """Get resolved optional dependency UUID strings. 

460 

461 Performance: Returns list[str] for efficient comparison with dict keys. 

462 """ 

463 if self._resolved_optional_dependencies is None: 

464 self._resolved_optional_dependencies = self._resolve_dependencies("_optional_dependencies") 

465 

466 return list(self._resolved_optional_dependencies) 

467 

468 @classmethod 

469 def get_allows_sig_override(cls) -> bool: 

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

471 

472 Custom characteristics that need to override official Bluetooth SIG 

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

474 

475 Returns: 

476 True if SIG override is allowed, False otherwise. 

477 

478 """ 

479 return cls._allows_sig_override 

480 

481 @classmethod 

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

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

484 

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

486 by __init_subclass__ for custom characteristics. 

487 

488 Returns: 

489 CharacteristicInfo if configured, None otherwise 

490 

491 """ 

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

493 

494 @classmethod 

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

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

497 

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

499 

500 Returns: 

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

502 

503 """ 

504 return cls._resolve_class_uuid() 

505 

506 @classmethod 

507 def _resolve_class_uuid(cls) -> BluetoothUUID | None: 

508 """Resolve the characteristic UUID for this class without creating an instance.""" 

509 # Check for _info attribute first (custom characteristics) 

510 try: 

511 info = cast(Any, cls)._info 

512 except AttributeError: 

513 info = None 

514 

515 if info is not None: 

516 if isinstance(info, CharacteristicInfo): 

517 return info.uuid 

518 logger.warning("_info attribute is not CharacteristicInfo for class %s", cls.__name__) 

519 

520 # Try cross-file resolution for SIG characteristics 

521 yaml_spec = cls._resolve_yaml_spec_class() 

522 if yaml_spec: 

523 return yaml_spec.uuid 

524 

525 # Fallback to original registry resolution 

526 return cls._resolve_from_basic_registry_class() 

527 

528 @classmethod 

529 def _resolve_yaml_spec_class(cls) -> CharacteristicSpec | None: 

530 """Resolve specification using YAML cross-reference system at class level.""" 

531 return SIGCharacteristicResolver.resolve_yaml_spec_for_class(cls) 

532 

533 @classmethod 

534 def _resolve_from_basic_registry_class(cls) -> BluetoothUUID | None: 

535 """Fallback to basic registry resolution at class level.""" 

536 try: 

537 registry_info = SIGCharacteristicResolver.resolve_from_registry(cls) 

538 except (ValueError, KeyError, AttributeError, TypeError): 

539 # Registry resolution can fail for various reasons: 

540 # - ValueError: Invalid UUID format 

541 # - KeyError: Characteristic not in registry 

542 # - AttributeError: Missing expected attributes 

543 # - TypeError: Type mismatch in resolution 

544 return None 

545 else: 

546 return registry_info.uuid if registry_info else None 

547 

548 @classmethod 

549 def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool: 

550 """Check if this characteristic matches the given UUID.""" 

551 try: 

552 class_uuid = cls._resolve_class_uuid() 

553 if class_uuid is None: 

554 return False 

555 input_uuid = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid) 

556 except ValueError: 

557 return False 

558 else: 

559 return class_uuid == input_uuid 

560 

561 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> T: 

562 """Decode raw bytes into the characteristic's typed value. 

563 

564 Called internally by :meth:`parse_value` after pipeline validation. 

565 Uses *_template* when set; subclasses override for custom logic. 

566 """ 

567 if self._template is not None: 

568 return self._template.decode_value( # pylint: disable=protected-access 

569 data, offset=0, ctx=ctx, validate=validate 

570 ) 

571 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override decode_value()") 

572 

573 def parse_value( 

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

575 ) -> T: 

576 """Parse characteristic data. 

577 

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

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

580 decode → range/type validation). 

581 

582 Returns: 

583 Parsed value of type T. 

584 

585 Raises: 

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

587 CharacteristicParseError: Parse/validation failure 

588 

589 """ 

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

591 self.last_parsed = decoded 

592 return decoded 

593 

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

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

596 

597 Called internally by :meth:`build_value` after pipeline validation. 

598 Uses *_template* when set; subclasses override for custom logic. 

599 """ 

600 if self._template is not None: 

601 return self._template.encode_value(data) # pylint: disable=protected-access 

602 raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override encode_value()") 

603 

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

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

606 

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

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

609 

610 Args: 

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

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

613 

614 Returns: 

615 Encoded bytes ready for BLE write. 

616 

617 Raises: 

618 CharacteristicEncodeError: If encoding or validation fails. 

619 

620 """ 

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

622 

623 # -------------------- Encoding helpers for special values -------------------- 

624 def encode_special(self, value_type: SpecialValueType) -> bytearray: 

625 """Encode a special value type to bytes (reverse lookup). 

626 

627 Raises ValueError if no raw value of that type is defined for this characteristic. 

628 """ 

629 return self._encode_pipeline.encode_special(value_type) 

630 

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

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

633 

634 Raises ValueError if no matching special value is found. 

635 """ 

636 return self._encode_pipeline.encode_special_by_meaning(meaning) 

637 

638 @property 

639 def unit(self) -> str: 

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

641 

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

643 """ 

644 return self._info.unit or "" 

645 

646 @cached_property 

647 def unit_symbol(self) -> str: 

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

649 

650 Resolves via the ``UnitsRegistry`` using the YAML ``unit_id`` 

651 (e.g. ``org.bluetooth.unit.thermodynamic_temperature.degree_celsius`` 

652 → ``°C``). Falls back to :attr:`unit` when no symbol is available. 

653 

654 Returns: 

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

656 or empty string if the characteristic has no unit. 

657 

658 """ 

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

660 

661 unit_id = self.get_yaml_unit_id() 

662 if unit_id: 

663 symbol = resolve_unit_symbol(unit_id) 

664 if symbol: 

665 return symbol 

666 

667 return self._info.unit or "" 

668 

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

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

671 

672 For struct-valued characteristics with per-field units (e.g. 

673 Heart Rate Measurement: ``bpm`` for heart rate, ``J`` for 

674 energy expended), this resolves the unit for a single field 

675 via ``FieldSpec.unit_id`` → ``UnitsRegistry`` → ``.symbol``. 

676 

677 Args: 

678 field_name: The Python-style field name (e.g. ``'heart_rate'``) 

679 or raw GSS field name (e.g. ``'Heart Rate Measurement Value'``). 

680 

681 Returns: 

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

683 

684 """ 

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

686 return "" 

687 

688 for field in self._spec.structure: 

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

690 return field.unit_symbol 

691 

692 return "" 

693 

694 @property 

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

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

697 

698 Returns the field size from YAML automation if available, otherwise None. 

699 This is useful for determining the expected data length for parsing 

700 and encoding. 

701 

702 """ 

703 # First try manual size override if set 

704 if self._manual_size is not None: 

705 return self._manual_size 

706 

707 # Try field size from YAML cross-reference 

708 field_size = self.get_yaml_field_size() 

709 if field_size is not None: 

710 return field_size 

711 

712 # For characteristics without YAML size info, return None 

713 # indicating variable or unknown length 

714 return None 

715 

716 @property 

717 def python_type(self) -> type | str | None: 

718 """Get the resolved Python type for this characteristic's values.""" 

719 return self._info.python_type 

720 

721 @property 

722 def is_bitfield(self) -> bool: 

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

724 return self._info.is_bitfield 

725 

726 # YAML automation helper methods 

727 def get_yaml_data_type(self) -> str | None: 

728 """Get the data type from YAML automation (e.g., 'sint16', 'uint8').""" 

729 return self._spec.data_type if self._spec else None 

730 

731 def get_yaml_field_size(self) -> int | None: 

732 """Get the field size in bytes from YAML automation.""" 

733 field_size = self._spec.field_size if self._spec else None 

734 if field_size and isinstance(field_size, str) and field_size.isdigit(): 

735 return int(field_size) 

736 return None 

737 

738 def get_yaml_unit_id(self) -> str | None: 

739 """Get the Bluetooth SIG unit identifier from YAML automation.""" 

740 return self._spec.unit_id if self._spec else None 

741 

742 def get_yaml_resolution_text(self) -> str | None: 

743 """Get the resolution description text from YAML automation.""" 

744 return self._spec.resolution_text if self._spec else None 

745 

746 def is_signed_from_yaml(self) -> bool: 

747 """Determine if the data type is signed based on YAML automation.""" 

748 data_type = self.get_yaml_data_type() 

749 if not data_type: 

750 return False 

751 # Check for signed types: signed integers, medical floats, and standard floats 

752 return data_type.startswith("sint") or data_type in ("medfloat16", "medfloat32", "float32", "float64") 

753 

754 def get_byte_order_hint(self) -> str: 

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

756 return "little"