diff --git a/.github/workflows/README.md b/.github/workflows/README.md index db25499a..4ab33c74 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -7,7 +7,7 @@ This directory contains GitHub Actions workflows for automated testing and code ### Test and Coverage (`test-coverage.yml`) - **Triggers**: Push to `main`, Pull Requests to `main` -- **Matrix**: Python 3.9, 3.12 +- **Matrix**: Python 3.10, 3.12 - **Purpose**: Run comprehensive test suite with coverage reporting - **Features**: - Automatic git submodule initialization for `bluetooth_sig` dependency diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index cbac7ede..f03516c2 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -28,7 +28,7 @@ jobs: contents: read strategy: matrix: - python-version: ["3.9", "3.12"] + python-version: ["3.10", "3.12"] steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index f66cf807..c82ff148 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Bluetooth SIG Standards Library [![Coverage Status](https://img.shields.io/endpoint?url=https://ronanb96.github.io/bluetooth-sig-python/coverage/coverage-badge.json)](https://ronanb96.github.io/bluetooth-sig-python/coverage/) -[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) [![PyPI version](https://img.shields.io/pypi/v/bluetooth-sig.svg)](https://pypi.org/project/bluetooth-sig/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Documentation](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://ronanb96.github.io/bluetooth-sig-python/) @@ -14,7 +14,7 @@ A pure Python library for Bluetooth SIG standards interpretation, providing comp - ✅ **Standards-Based**: Official Bluetooth SIG YAML registry with automatic UUID resolution - ✅ **Type-Safe**: Characteristic classes provide compile-time type checking; UUID strings return dynamic types -- ✅ **Modern Python**: msgspec-based design with Python 3.9+ compatibility +- ✅ **Modern Python**: msgspec-based design with Python 3.10+ compatibility - ✅ **Comprehensive**: Support for 200+ GATT characteristics across multiple service categories - ✅ **Flexible Validation**: Enable/disable validation per-characteristic for testing or debugging - ✅ **Framework Agnostic**: Works with any BLE library (bleak, simplepyble, etc.) diff --git a/docs/source/how-to/contributing.md b/docs/source/how-to/contributing.md index 0b9120e3..8594959b 100644 --- a/docs/source/how-to/contributing.md +++ b/docs/source/how-to/contributing.md @@ -92,7 +92,7 @@ Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 1. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.md. -1. The pull request should work for Python 3.9+. Tests run in GitHub Actions on every pull request to the main branch, make sure that the tests pass for all supported Python versions. +1. The pull request should work for Python 3.10+. Tests run in GitHub Actions on every pull request to the main branch, make sure that the tests pass for all supported Python versions. ## Tips diff --git a/docs/source/how-to/testing.md b/docs/source/how-to/testing.md index a513ec6d..8eab45e6 100644 --- a/docs/source/how-to/testing.md +++ b/docs/source/how-to/testing.md @@ -529,7 +529,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/examples/connection_managers/bleak_retry.py b/examples/connection_managers/bleak_retry.py index 1fdf27d2..d635ca12 100644 --- a/examples/connection_managers/bleak_retry.py +++ b/examples/connection_managers/bleak_retry.py @@ -11,8 +11,8 @@ import inspect import logging import sys -from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import AsyncIterator, Callable +from typing import TYPE_CHECKING, Any from bleak import BleakClient, BleakScanner from bleak.args.bluez import AdvertisementDataType, OrPattern # type: ignore[attr-defined] diff --git a/examples/connection_managers/bluepy.py b/examples/connection_managers/bluepy.py index 154fd692..ca4431ec 100644 --- a/examples/connection_managers/bluepy.py +++ b/examples/connection_managers/bluepy.py @@ -8,9 +8,9 @@ import asyncio import inspect -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from concurrent.futures import ThreadPoolExecutor -from typing import TYPE_CHECKING, Any, Callable, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from bluepy.btle import ( ADDR_TYPE_RANDOM, diff --git a/examples/utils/models.py b/examples/utils/models.py index e90a91d1..f3786a46 100644 --- a/examples/utils/models.py +++ b/examples/utils/models.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any, Union +from typing import Any import msgspec @@ -46,7 +46,7 @@ class DeviceInfo(msgspec.Struct): __all__ = ["DeviceInfo", "ReadResult"] -ComparisonData = Union[dict[str, ReadResult], dict[str, Any]] +ComparisonData = dict[str, ReadResult] | dict[str, Any] class LibraryComparisonResult(msgspec.Struct): diff --git a/pyproject.toml b/pyproject.toml index 4010bbc6..615cc4cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,14 +12,13 @@ maintainers = [ description = "Pure Bluetooth SIG standards library for characteristic and service interpretation" readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = ["bluetooth", "bluetooth-sig", "gatt", "standards"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -154,7 +153,7 @@ line-length = 120 # explicit `--no-fix`/`--fix` flags. Individual CI or developer workflows may # still override this via the command-line. fix = true -target-version = "py39" +target-version = "py310" exclude = [".git", "__pycache__", "build", "dist", ".venv", ".venv*", ".eggs", "src/bluetooth_sig/_version.py"] [tool.ruff.lint] diff --git a/src/bluetooth_sig/advertising/ead_decryptor.py b/src/bluetooth_sig/advertising/ead_decryptor.py index 7ab2e448..ab91b3d3 100644 --- a/src/bluetooth_sig/advertising/ead_decryptor.py +++ b/src/bluetooth_sig/advertising/ead_decryptor.py @@ -356,7 +356,8 @@ def decrypt( session_key = key_material.session_key else: # Static key guaranteed non-None by __init__ validation - session_key = self._static_key # type: ignore[assignment] + assert self._static_key is not None, "EADDecryptor requires either a key provider or static key" + session_key = self._static_key # Parse raw data if len(raw_ead_data) < EAD_MIN_SIZE: diff --git a/src/bluetooth_sig/advertising/encryption.py b/src/bluetooth_sig/advertising/encryption.py index 97501504..7a9ad747 100644 --- a/src/bluetooth_sig/advertising/encryption.py +++ b/src/bluetooth_sig/advertising/encryption.py @@ -12,14 +12,11 @@ import logging from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import Protocol, TypeAlias, runtime_checkable from bluetooth_sig.types.ead import EADKeyMaterial -if TYPE_CHECKING: - from typing_extensions import TypeAlias - - AsyncKeyLookup: TypeAlias = Callable[[str], Awaitable[bytes | None]] +AsyncKeyLookup: TypeAlias = Callable[[str], Awaitable[bytes | None]] logger = logging.getLogger(__name__) diff --git a/src/bluetooth_sig/core/encoder.py b/src/bluetooth_sig/core/encoder.py new file mode 100644 index 00000000..732cc1d2 --- /dev/null +++ b/src/bluetooth_sig/core/encoder.py @@ -0,0 +1,282 @@ +"""Characteristic encoding, value creation, and data validation. + +Provides encode_characteristic, create_value, validate_characteristic_data, +and type introspection for characteristic value types. +""" + +from __future__ import annotations + +import inspect +import logging +import struct +import typing +from typing import Any, TypeVar, overload + +from ..gatt.characteristics import templates +from ..gatt.characteristics.base import BaseCharacteristic +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.exceptions import ( + CharacteristicError, + CharacteristicParseError, +) +from ..types import ( + ValidationResult, +) +from ..types.gatt_enums import ValueType +from ..types.uuid import BluetoothUUID +from .parser import CharacteristicParser + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +class CharacteristicEncoder: + """Handles characteristic encoding, value creation, and data validation. + + Takes a CharacteristicParser reference for validate_characteristic_data, + which needs to attempt a parse to check data format validity. + """ + + def __init__(self, parser: CharacteristicParser) -> None: + """Initialise with a parser reference for validation support. + + Args: + parser: CharacteristicParser instance for data validation + + """ + self._parser = parser + + @overload + def encode_characteristic( + self, + char: type[BaseCharacteristic[T]], + value: T, + validate: bool = ..., + ) -> bytes: ... + + @overload + def encode_characteristic( + self, + char: str, + value: Any, # noqa: ANN401 # Runtime UUID dispatch cannot be type-safe + validate: bool = ..., + ) -> bytes: ... + + def encode_characteristic( + self, + char: str | type[BaseCharacteristic[T]], + value: T | Any, # Runtime UUID dispatch cannot be type-safe + validate: bool = True, + ) -> bytes: + r"""Encode a value for writing to a characteristic. + + Args: + char: Characteristic class (type-safe) or UUID string (not type-safe). + value: The value to encode. Type is checked when using characteristic class. + validate: If True, validates the value before encoding (default: True) + + Returns: + Encoded bytes ready to write to the characteristic + + Raises: + ValueError: If UUID is invalid, characteristic not found, or value is invalid + TypeError: If value type doesn't match characteristic's expected type + CharacteristicEncodeError: If encoding fails + + """ + # Handle characteristic class input (type-safe path) + if isinstance(char, type) and issubclass(char, BaseCharacteristic): + char_instance = char() + logger.debug("Encoding characteristic class=%s, value=%s", char.__name__, value) + try: + if validate: + encoded = char_instance.build_value(value) + logger.debug("Successfully encoded %s with validation", char_instance.name) + else: + encoded = char_instance._encode_value(value) # pylint: disable=protected-access + logger.debug("Successfully encoded %s without validation", char_instance.name) + return bytes(encoded) + except Exception: + logger.exception("Encoding failed for %s", char_instance.name) + raise + + # Handle string UUID input (not type-safe path) + logger.debug("Encoding characteristic UUID=%s, value=%s", char, value) + + characteristic = CharacteristicRegistry.get_characteristic(char) + if not characteristic: + raise ValueError(f"No encoder available for characteristic UUID: {char}") + + logger.debug("Found encoder for UUID=%s: %s", char, type(characteristic).__name__) + + # Handle dict input - convert to proper type + if isinstance(value, dict): + value_type = self._get_characteristic_value_type_class(characteristic) + if value_type and hasattr(value_type, "__init__") and not isinstance(value_type, str): + try: + value = value_type(**value) + logger.debug("Converted dict to %s", value_type.__name__) + except (TypeError, ValueError) as e: + type_name = getattr(value_type, "__name__", str(value_type)) + raise TypeError(f"Failed to convert dict to {type_name} for characteristic {char}: {e}") from e + + # Encode using build_value (with validation) or encode_value (without) + try: + if validate: + encoded = characteristic.build_value(value) + logger.debug("Successfully encoded %s with validation", characteristic.name) + else: + encoded = characteristic._encode_value(value) # pylint: disable=protected-access + logger.debug("Successfully encoded %s without validation", characteristic.name) + return bytes(encoded) + except Exception: + logger.exception("Encoding failed for %s", characteristic.name) + raise + + def _get_characteristic_value_type_class( # pylint: disable=too-many-return-statements,too-many-branches + self, characteristic: BaseCharacteristic[Any] + ) -> type[Any] | None: + """Get the Python type class that a characteristic expects. + + Args: + characteristic: The characteristic instance + + Returns: + The type class, or None if it can't be determined + + """ + # Try to infer from decode_value return type annotation + if hasattr(characteristic, "_decode_value"): + try: + module = inspect.getmodule(characteristic.__class__) + globalns = getattr(module, "__dict__", {}) if module else {} + type_hints = typing.get_type_hints(characteristic._decode_value, globalns=globalns) # pylint: disable=protected-access + return_type = type_hints.get("return") + if return_type and return_type is not type(None): + return return_type # type: ignore[no-any-return] # Dynamic introspection of _decode_value return annotation + except (TypeError, AttributeError, NameError): + return_type = inspect.signature(characteristic._decode_value).return_annotation # pylint: disable=protected-access + sig = inspect.signature(characteristic._decode_value) # pylint: disable=protected-access + return_annotation = sig.return_annotation + if ( + return_annotation + and return_annotation != inspect.Parameter.empty + and not isinstance(return_annotation, str) + ): + return return_annotation # type: ignore[no-any-return] # Dynamic introspection fallback via inspect.signature + + # Try to get from _manual_value_type attribute + if hasattr(characteristic, "_manual_value_type"): + manual_type = characteristic._manual_value_type # pylint: disable=protected-access + if manual_type and isinstance(manual_type, str) and hasattr(templates, manual_type): + return getattr(templates, manual_type) # type: ignore[no-any-return] # Runtime template lookup by string name + + # Try to get from template + if hasattr(characteristic, "_template") and characteristic._template: # pylint: disable=protected-access + template = characteristic._template # pylint: disable=protected-access + if hasattr(template, "__orig_class__"): + args = typing.get_args(template.__orig_class__) + if args: + return args[0] # type: ignore[no-any-return] # Generic type arg extraction from __orig_class__ + + # For simple types, check info.value_type + info = characteristic.info + if info.value_type == ValueType.INT: + return int + if info.value_type == ValueType.FLOAT: + return float + if info.value_type == ValueType.STRING: + return str + if info.value_type == ValueType.BOOL: + return bool + if info.value_type == ValueType.BYTES: + return bytes + + return None + + def validate_characteristic_data(self, uuid: str, data: bytes) -> ValidationResult: + """Validate characteristic data format against SIG specifications. + + Args: + uuid: The characteristic UUID + data: Raw data bytes to validate + + Returns: + ValidationResult with validation details + + """ + try: + self._parser.parse_characteristic(uuid, data) + try: + bt_uuid = BluetoothUUID(uuid) + char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) + expected = char_class.expected_length if char_class else None + except (ValueError, AttributeError): + expected = None + return ValidationResult( + is_valid=True, + actual_length=len(data), + expected_length=expected, + error_message="", + ) + except (CharacteristicParseError, ValueError, TypeError, struct.error, CharacteristicError) as e: + try: + bt_uuid = BluetoothUUID(uuid) + char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) + expected = char_class.expected_length if char_class else None + except (ValueError, AttributeError): + expected = None + return ValidationResult( + is_valid=False, + actual_length=len(data), + expected_length=expected, + error_message=str(e), + ) + + def create_value(self, uuid: str, **kwargs: Any) -> Any: # noqa: ANN401 + """Create a properly typed value instance for a characteristic. + + Args: + uuid: The characteristic UUID + **kwargs: Field values for the characteristic's type + + Returns: + Properly typed value instance + + Raises: + ValueError: If UUID is invalid or characteristic not found + TypeError: If kwargs don't match the characteristic's expected fields + + """ + characteristic = CharacteristicRegistry.get_characteristic(uuid) + if not characteristic: + raise ValueError(f"No characteristic found for UUID: {uuid}") + + value_type = self._get_characteristic_value_type_class(characteristic) + + if not value_type: + if len(kwargs) == 1: + return next(iter(kwargs.values())) + raise ValueError( + f"Cannot determine value type for characteristic {uuid}. " + "Try passing a dict to encode_characteristic() instead." + ) + + # Handle simple primitive types + if value_type in (int, float, str, bool, bytes): + if len(kwargs) == 1: + value = next(iter(kwargs.values())) + if not isinstance(value, value_type): + type_name = getattr(value_type, "__name__", str(value_type)) + raise TypeError(f"Expected {type_name}, got {type(value).__name__}") + return value + type_name = getattr(value_type, "__name__", str(value_type)) + raise TypeError(f"Simple type {type_name} expects a single value") + + # Construct complex type from kwargs + try: + return value_type(**kwargs) + except (TypeError, ValueError) as e: + type_name = getattr(value_type, "__name__", str(value_type)) + raise TypeError(f"Failed to create {type_name} for characteristic {uuid}: {e}") from e diff --git a/src/bluetooth_sig/core/parser.py b/src/bluetooth_sig/core/parser.py new file mode 100644 index 00000000..0325d61c --- /dev/null +++ b/src/bluetooth_sig/core/parser.py @@ -0,0 +1,299 @@ +"""Characteristic parsing with dependency-aware batch support. + +Provides single and batch characteristic parsing, including topological +dependency ordering for multi-characteristic reads. Stateless. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from graphlib import TopologicalSorter +from typing import Any, TypeVar, overload + +from ..gatt.characteristics.base import BaseCharacteristic +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.exceptions import ( + CharacteristicParseError, + MissingDependencyError, + SpecialValueDetectedError, +) +from ..types import CharacteristicContext +from ..types.uuid import BluetoothUUID + +T = TypeVar("T") + +logger = logging.getLogger(__name__) + + +class CharacteristicParser: + """Stateless parser for single and batch characteristic data. + + Handles parse_characteristic (with @overload support), parse_characteristics + (batch with dependency ordering), and all private batch helpers. + """ + + @overload + def parse_characteristic( + self, + char: type[BaseCharacteristic[T]], + raw_data: bytes | bytearray, + ctx: CharacteristicContext | None = ..., + ) -> T: ... + + @overload + def parse_characteristic( + self, + char: str, + raw_data: bytes | bytearray, + ctx: CharacteristicContext | None = ..., + ) -> Any: ... # noqa: ANN401 # Runtime UUID dispatch cannot be type-safe + + def parse_characteristic( + self, + char: str | type[BaseCharacteristic[T]], + raw_data: bytes | bytearray, + ctx: CharacteristicContext | None = None, + ) -> T | Any: # Runtime UUID dispatch cannot be type-safe + r"""Parse a characteristic's raw data using Bluetooth SIG standards. + + Args: + char: Characteristic class (type-safe) or UUID string (not type-safe). + raw_data: Raw bytes from the characteristic (bytes or bytearray) + ctx: Optional CharacteristicContext providing device-level info + + Returns: + Parsed value. Return type is inferred when passing characteristic class. + + Raises: + SpecialValueDetectedError: Special sentinel value detected + CharacteristicParseError: Parse/validation failure + + """ + # Handle characteristic class input (type-safe path) + if isinstance(char, type) and issubclass(char, BaseCharacteristic): + char_instance = char() + logger.debug("Parsing characteristic class=%s, data_len=%d", char.__name__, len(raw_data)) + try: + value = char_instance.parse_value(raw_data, ctx) + logger.debug("Successfully parsed %s: %s", char_instance.name, value) + except SpecialValueDetectedError as e: + logger.debug("Special value detected for %s: %s", char_instance.name, e.special_value.meaning) + raise + except CharacteristicParseError as e: + logger.warning("Parse failed for %s: %s", char_instance.name, e) + raise + else: + return value + + # Handle string UUID input (not type-safe path) + logger.debug("Parsing characteristic UUID=%s, data_len=%d", char, len(raw_data)) + + characteristic = CharacteristicRegistry.get_characteristic(char) + + if characteristic: + logger.debug("Found parser for UUID=%s: %s", char, type(characteristic).__name__) + try: + value = characteristic.parse_value(raw_data, ctx) + logger.debug("Successfully parsed %s: %s", characteristic.name, value) + except SpecialValueDetectedError as e: + logger.debug("Special value detected for %s: %s", characteristic.name, e.special_value.meaning) + raise + except CharacteristicParseError as e: + logger.warning("Parse failed for %s: %s", characteristic.name, e) + raise + else: + return value + else: + logger.info("No parser available for UUID=%s", char) + raise CharacteristicParseError( + message=f"No parser available for characteristic UUID: {char}", + name="Unknown", + uuid=BluetoothUUID(char), + raw_data=bytes(raw_data), + ) + + def parse_characteristics( + self, + char_data: dict[str, bytes], + ctx: CharacteristicContext | None = None, + ) -> dict[str, Any]: + r"""Parse multiple characteristics at once with dependency-aware ordering. + + Args: + char_data: Dictionary mapping UUIDs to raw data bytes + ctx: Optional CharacteristicContext used as the starting context + + Returns: + Dictionary mapping UUIDs to parsed values + + Raises: + ValueError: If circular dependencies are detected + CharacteristicParseError: If parsing fails for any characteristic + + """ + return self._parse_characteristics_batch(char_data, ctx) + + def _parse_characteristics_batch( + self, + char_data: dict[str, bytes], + ctx: CharacteristicContext | None, + ) -> dict[str, Any]: + """Parse multiple characteristics using dependency-aware ordering.""" + logger.debug("Batch parsing %d characteristics", len(char_data)) + + uuid_to_characteristic, uuid_to_required_deps, uuid_to_optional_deps = ( + self._prepare_characteristic_dependencies(char_data) + ) + + sorted_uuids = self._resolve_dependency_order(char_data, uuid_to_required_deps, uuid_to_optional_deps) + + base_context = ctx + + results: dict[str, Any] = {} + for uuid_str in sorted_uuids: + raw_data = char_data[uuid_str] + characteristic = uuid_to_characteristic.get(uuid_str) + + missing_required = self._find_missing_required_dependencies( + uuid_str, + uuid_to_required_deps.get(uuid_str, []), + results, + base_context, + ) + + if missing_required: + raise MissingDependencyError(characteristic.name if characteristic else "Unknown", missing_required) + + self._log_optional_dependency_gaps( + uuid_str, + uuid_to_optional_deps.get(uuid_str, []), + results, + base_context, + ) + + parse_context = self._build_parse_context(base_context, results) + + value = self.parse_characteristic(uuid_str, raw_data, ctx=parse_context) + results[uuid_str] = value + + logger.debug("Batch parsing complete: %d results", len(results)) + return results + + def _prepare_characteristic_dependencies( + self, characteristic_data: Mapping[str, bytes] + ) -> tuple[dict[str, BaseCharacteristic[Any]], dict[str, list[str]], dict[str, list[str]]]: + """Instantiate characteristics once and collect declared dependencies.""" + uuid_to_characteristic: dict[str, BaseCharacteristic[Any]] = {} + uuid_to_required_deps: dict[str, list[str]] = {} + uuid_to_optional_deps: dict[str, list[str]] = {} + + for uuid in characteristic_data: + characteristic = CharacteristicRegistry.get_characteristic(uuid) + if characteristic is None: + continue + + uuid_to_characteristic[uuid] = characteristic + + required = characteristic.required_dependencies + optional = characteristic.optional_dependencies + + if required: + uuid_to_required_deps[uuid] = required + logger.debug("Characteristic %s has required dependencies: %s", uuid, required) + if optional: + uuid_to_optional_deps[uuid] = optional + logger.debug("Characteristic %s has optional dependencies: %s", uuid, optional) + + return uuid_to_characteristic, uuid_to_required_deps, uuid_to_optional_deps + + @staticmethod + def _resolve_dependency_order( + characteristic_data: Mapping[str, bytes], + uuid_to_required_deps: Mapping[str, list[str]], + uuid_to_optional_deps: Mapping[str, list[str]], + ) -> list[str]: + """Topologically sort characteristics based on declared dependencies.""" + try: + sorter: TopologicalSorter[str] = TopologicalSorter() + for uuid in characteristic_data: + all_deps = uuid_to_required_deps.get(uuid, []) + uuid_to_optional_deps.get(uuid, []) + batch_deps = [dep for dep in all_deps if dep in characteristic_data] + sorter.add(uuid, *batch_deps) + + sorted_sequence = sorter.static_order() + sorted_uuids = list(sorted_sequence) + logger.debug("Dependency-sorted parsing order: %s", sorted_uuids) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.warning("Dependency sorting failed: %s. Using original order.", exc) + return list(characteristic_data.keys()) + else: + return sorted_uuids + + @staticmethod + def _find_missing_required_dependencies( + uuid: str, + required_deps: list[str], + results: Mapping[str, Any], + base_context: CharacteristicContext | None, + ) -> list[str]: + """Determine which required dependencies are unavailable for a characteristic.""" + if not required_deps: + return [] + + missing: list[str] = [] + other_characteristics = ( + base_context.other_characteristics if base_context and base_context.other_characteristics else None + ) + + for dep_uuid in required_deps: + if dep_uuid in results: + continue + + if other_characteristics and dep_uuid in other_characteristics: + continue + + missing.append(dep_uuid) + + if missing: + logger.debug("Characteristic %s missing required dependencies: %s", uuid, missing) + + return missing + + @staticmethod + def _log_optional_dependency_gaps( + uuid: str, + optional_deps: list[str], + results: Mapping[str, Any], + base_context: CharacteristicContext | None, + ) -> None: + """Emit debug logs when optional dependencies are unavailable.""" + if not optional_deps: + return + + other_characteristics = ( + base_context.other_characteristics if base_context and base_context.other_characteristics else None + ) + + for dep_uuid in optional_deps: + if dep_uuid in results: + continue + if other_characteristics and dep_uuid in other_characteristics: + continue + logger.debug("Optional dependency %s not available for %s", dep_uuid, uuid) + + @staticmethod + def _build_parse_context( + base_context: CharacteristicContext | None, + results: Mapping[str, Any], + ) -> CharacteristicContext: + """Construct the context passed to per-characteristic parsers.""" + if base_context is not None: + return CharacteristicContext( + device_info=base_context.device_info, + advertisement=base_context.advertisement, + other_characteristics=results, + raw_service=base_context.raw_service, + ) + + return CharacteristicContext(other_characteristics=results) diff --git a/src/bluetooth_sig/core/query.py b/src/bluetooth_sig/core/query.py new file mode 100644 index 00000000..e2685e57 --- /dev/null +++ b/src/bluetooth_sig/core/query.py @@ -0,0 +1,310 @@ +"""Characteristic and service query engine. + +Provides read-only lookup and metadata retrieval for characteristics and services +using the SIG registries. Stateless — no mutable state. +""" + +from __future__ import annotations + +import logging + +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.services import ServiceName +from ..gatt.services.registry import GattServiceRegistry +from ..gatt.uuid_registry import uuid_registry +from ..types import ( + CharacteristicInfo, + ServiceInfo, + SIGInfo, +) +from ..types.gatt_enums import CharacteristicName, ValueType +from ..types.uuid import BluetoothUUID + +logger = logging.getLogger(__name__) + + +class CharacteristicQueryEngine: + """Stateless query engine for characteristic and service metadata. + + Provides all read-only lookup operations: supports, get_value_type, + get_*_info_*, list_supported_*, get_service_characteristics, get_sig_info_*. + """ + + def supports(self, uuid: str) -> bool: + """Check if a characteristic UUID is supported. + + Args: + uuid: The characteristic UUID to check + + Returns: + True if the characteristic has a parser/encoder, False otherwise + + """ + try: + bt_uuid = BluetoothUUID(uuid) + char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) + except (ValueError, TypeError): + return False + else: + return char_class is not None + + def get_value_type(self, uuid: str) -> ValueType | None: + """Get the expected value type for a characteristic. + + Args: + uuid: The characteristic UUID (16-bit short form or full 128-bit) + + Returns: + ValueType enum if characteristic is found, None otherwise + + """ + info = self.get_characteristic_info_by_uuid(uuid) + return info.value_type if info else None + + def get_characteristic_info_by_uuid(self, uuid: str) -> CharacteristicInfo | None: + """Get information about a characteristic by UUID. + + Args: + uuid: The characteristic UUID (16-bit short form or full 128-bit) + + Returns: + CharacteristicInfo with metadata or None if not found + + """ + try: + bt_uuid = BluetoothUUID(uuid) + except ValueError: + return None + + char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) + if not char_class: + return None + + try: + temp_char = char_class() + except (TypeError, ValueError, AttributeError): + return None + else: + return temp_char.info + + def get_characteristic_uuid_by_name(self, name: CharacteristicName) -> BluetoothUUID | None: + """Get the UUID for a characteristic name enum. + + Args: + name: CharacteristicName enum + + Returns: + Characteristic UUID or None if not found + + """ + info = self.get_characteristic_info_by_name(name) + return info.uuid if info else None + + def get_service_uuid_by_name(self, name: str | ServiceName) -> BluetoothUUID | None: + """Get the UUID for a service name or enum. + + Args: + name: Service name or enum + + Returns: + Service UUID or None if not found + + """ + name_str = name.value if isinstance(name, ServiceName) else name + info = self.get_service_info_by_name(name_str) + return info.uuid if info else None + + def get_characteristic_info_by_name(self, name: CharacteristicName) -> CharacteristicInfo | None: + """Get characteristic info by enum name. + + Args: + name: CharacteristicName enum + + Returns: + CharacteristicInfo if found, None otherwise + + """ + char_class = CharacteristicRegistry.get_characteristic_class(name) + if not char_class: + return None + + info = char_class.get_configured_info() + if info: + return info + + try: + temp_char = char_class() + except (TypeError, ValueError, AttributeError): + return None + else: + return temp_char.info + + def get_service_info_by_name(self, name: str | ServiceName) -> ServiceInfo | None: + """Get service info by name or enum instead of UUID. + + Args: + name: Service name string or ServiceName enum + + Returns: + ServiceInfo if found, None otherwise + + """ + name_str = name.value if isinstance(name, ServiceName) else name + + try: + uuid_info = uuid_registry.get_service_info(name_str) + if uuid_info: + return ServiceInfo(uuid=uuid_info.uuid, name=uuid_info.name, characteristics=[]) + except Exception: # pylint: disable=broad-exception-caught + pass + + return None + + def get_service_info_by_uuid(self, uuid: str) -> ServiceInfo | None: + """Get information about a service by UUID. + + Args: + uuid: The service UUID + + Returns: + ServiceInfo with metadata or None if not found + + """ + service_class = GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID(uuid)) + if not service_class: + return None + + try: + temp_service = service_class() + char_infos: list[CharacteristicInfo] = [] + for _, char_instance in temp_service.characteristics.items(): + char_infos.append(char_instance.info) + return ServiceInfo( + uuid=temp_service.uuid, + name=temp_service.name, + characteristics=char_infos, + ) + except (TypeError, ValueError, AttributeError): + return None + + def list_supported_characteristics(self) -> dict[str, str]: + """List all supported characteristics with their names and UUIDs. + + Returns: + Dictionary mapping characteristic names to UUIDs + + """ + result: dict[str, str] = {} + for name, char_class in CharacteristicRegistry.get_all_characteristics().items(): + configured_info = char_class.get_configured_info() + if configured_info: + name_str = name.value if hasattr(name, "value") else str(name) + result[name_str] = str(configured_info.uuid) + return result + + def list_supported_services(self) -> dict[str, str]: + """List all supported services with their names and UUIDs. + + Returns: + Dictionary mapping service names to UUIDs + + """ + result: dict[str, str] = {} + for service_class in GattServiceRegistry.get_all_services(): + try: + temp_service = service_class() + service_name = getattr(temp_service, "_service_name", service_class.__name__) + result[service_name] = str(temp_service.uuid) + except Exception: # pylint: disable=broad-exception-caught + continue + return result + + def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, CharacteristicInfo | None]: + """Get information about multiple characteristics by UUID. + + Args: + uuids: List of characteristic UUIDs + + Returns: + Dictionary mapping UUIDs to CharacteristicInfo (or None if not found) + + """ + results: dict[str, CharacteristicInfo | None] = {} + for uuid in uuids: + results[uuid] = self.get_characteristic_info_by_uuid(uuid) + return results + + def get_service_characteristics(self, service_uuid: str) -> list[str]: + """Get the characteristic UUIDs associated with a service. + + Args: + service_uuid: The service UUID + + Returns: + List of characteristic UUIDs for this service + + """ + service_class = GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID(service_uuid)) + if not service_class: + return [] + + try: + temp_service = service_class() + required_chars = temp_service.get_required_characteristics() + return [str(k) for k in required_chars] + except Exception: # pylint: disable=broad-exception-caught + return [] + + def get_sig_info_by_name(self, name: str) -> SIGInfo | None: + """Get Bluetooth SIG information for a characteristic or service by name. + + Args: + name: Characteristic or service name + + Returns: + CharacteristicInfo or ServiceInfo if found, None otherwise + + """ + try: + char_info = uuid_registry.get_characteristic_info(name) + if char_info: + value_type = ValueType.UNKNOWN + if char_info.value_type: + try: + value_type = ValueType(char_info.value_type) + except (ValueError, KeyError): + value_type = ValueType.UNKNOWN + return CharacteristicInfo( + uuid=char_info.uuid, + name=char_info.name, + value_type=value_type, + unit=char_info.unit or "", + ) + except (KeyError, ValueError, AttributeError): + logger.warning("Failed to look up SIG info by name: %s", name) + + service_info = self.get_service_info_by_name(name) + if service_info: + return service_info + + return None + + def get_sig_info_by_uuid(self, uuid: str) -> SIGInfo | None: + """Get Bluetooth SIG information for a UUID. + + Args: + uuid: UUID string (with or without dashes) + + Returns: + CharacteristicInfo or ServiceInfo if found, None otherwise + + """ + char_info = self.get_characteristic_info_by_uuid(uuid) + if char_info: + return char_info + + service_info = self.get_service_info_by_uuid(uuid) + if service_info: + return service_info + + return None diff --git a/src/bluetooth_sig/core/registration.py b/src/bluetooth_sig/core/registration.py new file mode 100644 index 00000000..853f223a --- /dev/null +++ b/src/bluetooth_sig/core/registration.py @@ -0,0 +1,89 @@ +"""Custom characteristic and service registration manager. + +Provides runtime registration of custom characteristic and service classes +into the SIG registries. Stateless — writes to global registries. +""" + +from __future__ import annotations + +from typing import Any + +from ..gatt.characteristics.base import BaseCharacteristic +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.services.base import BaseGattService +from ..gatt.services.registry import GattServiceRegistry +from ..gatt.uuid_registry import uuid_registry +from ..types import ( + CharacteristicInfo, + ServiceInfo, +) + + +class RegistrationManager: + """Handles runtime registration of custom characteristics and services. + + All registrations write to the global CharacteristicRegistry, GattServiceRegistry, + and uuid_registry singletons. + """ + + @staticmethod + def register_custom_characteristic_class( + uuid_or_name: str, + cls: type[BaseCharacteristic[Any]], + info: CharacteristicInfo | None = None, + override: bool = False, + ) -> None: + """Register a custom characteristic class at runtime. + + Args: + uuid_or_name: The characteristic UUID or name + cls: The characteristic class to register + info: Optional CharacteristicInfo with metadata (name, unit, value_type) + override: Whether to override existing registrations + + Raises: + TypeError: If cls does not inherit from BaseCharacteristic + ValueError: If UUID conflicts with existing registration and override=False + + """ + CharacteristicRegistry.register_characteristic_class(uuid_or_name, cls, override) + + if info: + uuid_registry.register_characteristic( + uuid=info.uuid, + name=info.name or cls.__name__, + identifier=info.id, + unit=info.unit, + value_type=info.value_type, + override=override, + ) + + @staticmethod + def register_custom_service_class( + uuid_or_name: str, + cls: type[BaseGattService], + info: ServiceInfo | None = None, + override: bool = False, + ) -> None: + """Register a custom service class at runtime. + + Args: + uuid_or_name: The service UUID or name + cls: The service class to register + info: Optional ServiceInfo with metadata (name) + override: Whether to override existing registrations + + Raises: + TypeError: If cls does not inherit from BaseGattService + ValueError: If UUID conflicts with existing registration and override=False + + """ + GattServiceRegistry.register_service_class(uuid_or_name, cls, override) + + if info: + uuid_registry.register_service( + uuid=info.uuid, + name=info.name or cls.__name__, + identifier=info.id, + override=override, + ) diff --git a/src/bluetooth_sig/core/service_manager.py b/src/bluetooth_sig/core/service_manager.py new file mode 100644 index 00000000..cbdff317 --- /dev/null +++ b/src/bluetooth_sig/core/service_manager.py @@ -0,0 +1,86 @@ +"""Discovered service lifecycle management. + +Owns the only mutable state from the original translator: the _services dict. +Handles process_services, get_service_by_uuid, discovered_services, clear_services. +""" + +from __future__ import annotations + +from typing import Any + +from ..gatt.services.base import BaseGattService +from ..gatt.services.registry import GattServiceRegistry +from ..types import CharacteristicInfo +from ..types.gatt_enums import ValueType +from ..types.uuid import BluetoothUUID + +# Type alias for characteristic data in process_services +CharacteristicDataDict = dict[str, Any] + + +class ServiceManager: + """Manages discovered GATT services. + + This is the **only** delegate that holds mutable state — the dict of + discovered services keyed by normalised UUID strings. + """ + + def __init__(self) -> None: + """Initialise with an empty services dict.""" + # Performance: Use str keys (normalised UUIDs) for fast dict lookups + self._services: dict[str, BaseGattService] = {} + + def process_services(self, services: dict[str, dict[str, CharacteristicDataDict]]) -> None: + """Process discovered services and their characteristics. + + Args: + services: Dictionary of service UUIDs to their characteristics + + """ + for uuid_str, service_data in services.items(): + uuid = BluetoothUUID(uuid_str) + characteristics: dict[BluetoothUUID, CharacteristicInfo] = {} + for char_uuid_str, char_data in service_data.get("characteristics", {}).items(): + char_uuid = BluetoothUUID(char_uuid_str) + vtype_raw = char_data.get("value_type", "bytes") + if isinstance(vtype_raw, str): + value_type = ValueType(vtype_raw) + elif isinstance(vtype_raw, ValueType): + value_type = vtype_raw + else: + value_type = ValueType.BYTES + characteristics[char_uuid] = CharacteristicInfo( + uuid=char_uuid, + name=char_data.get("name", ""), + unit=char_data.get("unit", ""), + value_type=value_type, + ) + service = GattServiceRegistry.create_service(uuid, characteristics) + if service: + self._services[str(uuid)] = service + + def get_service_by_uuid(self, uuid: str) -> BaseGattService | None: + """Get a service instance by UUID. + + Args: + uuid: The service UUID + + Returns: + Service instance if found, None otherwise + + """ + return self._services.get(uuid) + + @property + def discovered_services(self) -> list[BaseGattService]: + """Get list of discovered service instances. + + Returns: + List of discovered service instances + + """ + return list(self._services.values()) + + def clear_services(self) -> None: + """Clear all discovered services.""" + self._services.clear() diff --git a/src/bluetooth_sig/core/translator.py b/src/bluetooth_sig/core/translator.py index 68e13d1e..2600127e 100644 --- a/src/bluetooth_sig/core/translator.py +++ b/src/bluetooth_sig/core/translator.py @@ -1,29 +1,26 @@ -# pylint: disable=too-many-lines # TODO split up Comprehensive translator with many methods -"""Core Bluetooth SIG standards translator functionality.""" +"""Core Bluetooth SIG standards translator — thin composition facade. + +This module provides the public ``BluetoothSIGTranslator`` class, which +delegates all work to five focused components: + +* :class:`~.query.CharacteristicQueryEngine` — read-only metadata lookups +* :class:`~.parser.CharacteristicParser` — single + batch parse +* :class:`~.encoder.CharacteristicEncoder` — encode, validate, create_value +* :class:`~.registration.RegistrationManager` — custom class registration +* :class:`~.service_manager.ServiceManager` — discovered-service lifecycle + +The facade preserves every public method signature, ``@overload`` +decorator, async wrapper, and the singleton pattern from the original +monolithic implementation. +""" from __future__ import annotations -import inspect -import logging -import struct -import typing -from collections.abc import Mapping -from graphlib import TopologicalSorter from typing import Any, TypeVar, overload -from ..gatt.characteristics import templates from ..gatt.characteristics.base import BaseCharacteristic -from ..gatt.characteristics.registry import CharacteristicRegistry -from ..gatt.exceptions import ( - CharacteristicError, - CharacteristicParseError, - MissingDependencyError, - SpecialValueDetectedError, -) from ..gatt.services import ServiceName from ..gatt.services.base import BaseGattService -from ..gatt.services.registry import GattServiceRegistry -from ..gatt.uuid_registry import uuid_registry from ..types import ( CharacteristicContext, CharacteristicInfo, @@ -33,16 +30,17 @@ ) from ..types.gatt_enums import CharacteristicName, ValueType from ..types.uuid import BluetoothUUID +from .encoder import CharacteristicEncoder +from .parser import CharacteristicParser +from .query import CharacteristicQueryEngine +from .registration import RegistrationManager +from .service_manager import CharacteristicDataDict, ServiceManager -# Type alias for characteristic data in process_services -# Performance: str keys (normalized UUIDs) instead of BluetoothUUID for fast lookups -CharacteristicDataDict = dict[str, Any] +# Re-export for backward compatibility +__all__ = ["BluetoothSIGTranslator", "BluetoothSIG", "CharacteristicDataDict"] -# Type variable for generic characteristic return types T = TypeVar("T") -logger = logging.getLogger(__name__) - class BluetoothSIGTranslator: # pylint: disable=too-many-public-methods """Pure Bluetooth SIG standards translator for characteristic and service interpretation. @@ -54,7 +52,7 @@ class BluetoothSIGTranslator: # pylint: disable=too-many-public-methods Singleton Pattern: This class is implemented as a singleton to provide a global registry for custom characteristics and services. Access the singleton instance using - `BluetoothSIGTranslator.get_instance()` or the module-level `translator` variable. + ``BluetoothSIGTranslator.get_instance()`` or the module-level ``translator`` variable. Key features: - Parse raw BLE characteristic data using Bluetooth SIG specifications @@ -97,18 +95,25 @@ def get_instance(cls) -> BluetoothSIGTranslator: def __init__(self) -> None: """Initialize the SIG translator (singleton pattern).""" - # Only initialize once if self.__class__._instance_lock: return self.__class__._instance_lock = True - # Performance: Use str keys (normalized UUIDs) for fast dict lookups - self._services: dict[str, BaseGattService] = {} + # Compose delegates + self._query = CharacteristicQueryEngine() + self._parser = CharacteristicParser() + self._encoder = CharacteristicEncoder(self._parser) + self._registration = RegistrationManager() + self._services = ServiceManager() def __str__(self) -> str: """Return string representation of the translator.""" return "BluetoothSIGTranslator(pure SIG standards)" + # ------------------------------------------------------------------------- + # Parse + # ------------------------------------------------------------------------- + @overload def parse_characteristic( self, @@ -130,7 +135,7 @@ def parse_characteristic( char: str | type[BaseCharacteristic[T]], raw_data: bytes | bytearray, ctx: CharacteristicContext | None = None, - ) -> T | Any: # Runtime UUID dispatch cannot be type-safe + ) -> T | Any: r"""Parse a characteristic's raw data using Bluetooth SIG standards. Args: @@ -141,10 +146,6 @@ def parse_characteristic( Returns: Parsed value. Return type is inferred when passing characteristic class. - - Primitives: ``int``, ``float``, ``str``, ``bool`` - - Dataclasses: ``NavigationData``, ``HeartRateMeasurement``, etc. - - Special values: ``SpecialValueResult`` (via exception) - Raises: SpecialValueDetectedError: Special sentinel value detected CharacteristicParseError: Parse/validation failure @@ -163,51 +164,46 @@ def parse_characteristic( value = translator.parse_characteristic("2A19", b"\\x64") """ - # Handle characteristic class input (type-safe path) - if isinstance(char, type) and issubclass(char, BaseCharacteristic): - char_instance = char() - logger.debug("Parsing characteristic class=%s, data_len=%d", char.__name__, len(raw_data)) - try: - value = char_instance.parse_value(raw_data, ctx) - logger.debug("Successfully parsed %s: %s", char_instance.name, value) - except SpecialValueDetectedError as e: - logger.debug("Special value detected for %s: %s", char_instance.name, e.special_value.meaning) - raise - except CharacteristicParseError as e: - logger.warning("Parse failed for %s: %s", char_instance.name, e) - raise - else: - return value + return self._parser.parse_characteristic(char, raw_data, ctx) + + def parse_characteristics( + self, + char_data: dict[str, bytes], + ctx: CharacteristicContext | None = None, + ) -> dict[str, Any]: + r"""Parse multiple characteristics at once with dependency-aware ordering. + + Args: + char_data: Dictionary mapping UUIDs to raw data bytes + ctx: Optional CharacteristicContext used as the starting context + + Returns: + Dictionary mapping UUIDs to parsed values - # Handle string UUID input (not type-safe path) - logger.debug("Parsing characteristic UUID=%s, data_len=%d", char, len(raw_data)) + Raises: + ValueError: If circular dependencies are detected + CharacteristicParseError: If parsing fails for any characteristic + + Example:: - # Get characteristic instance for parsing - characteristic = CharacteristicRegistry.get_characteristic(char) + from bluetooth_sig import BluetoothSIGTranslator - if characteristic: - logger.debug("Found parser for UUID=%s: %s", char, type(characteristic).__name__) - # Use the parse_value method which raises exceptions on failure + translator = BluetoothSIGTranslator() + data = { + "2A6E": b"\\x0A\\x00", # Temperature + "2A6F": b"\\x32\\x00", # Humidity + } try: - value = characteristic.parse_value(raw_data, ctx) - logger.debug("Successfully parsed %s: %s", characteristic.name, value) - except SpecialValueDetectedError as e: - logger.debug("Special value detected for %s: %s", characteristic.name, e.special_value.meaning) - raise + results = translator.parse_characteristics(data) except CharacteristicParseError as e: - logger.warning("Parse failed for %s: %s", characteristic.name, e) - raise - else: - return value - else: - # No parser found, raise an error - logger.info("No parser available for UUID=%s", char) - raise CharacteristicParseError( - message=f"No parser available for characteristic UUID: {char}", - name="Unknown", - uuid=BluetoothUUID(char), - raw_data=bytes(raw_data), - ) + print(f"Parse failed: {e}") + + """ + return self._parser.parse_characteristics(char_data, ctx) + + # ------------------------------------------------------------------------- + # Encode + # ------------------------------------------------------------------------- @overload def encode_characteristic( @@ -228,7 +224,7 @@ def encode_characteristic( def encode_characteristic( self, char: str | type[BaseCharacteristic[T]], - value: T | Any, # Runtime UUID dispatch cannot be type-safe + value: T | Any, validate: bool = True, ) -> bytes: r"""Encode a value for writing to a characteristic. @@ -261,152 +257,61 @@ def encode_characteristic( data = translator.encode_characteristic("2A06", 2) """ - # Handle characteristic class input (type-safe path) - if isinstance(char, type) and issubclass(char, BaseCharacteristic): - char_instance = char() - logger.debug("Encoding characteristic class=%s, value=%s", char.__name__, value) - try: - if validate: - encoded = char_instance.build_value(value) - logger.debug("Successfully encoded %s with validation", char_instance.name) - else: - encoded = char_instance._encode_value(value) # pylint: disable=protected-access - logger.debug("Successfully encoded %s without validation", char_instance.name) - return bytes(encoded) - except Exception: - logger.exception("Encoding failed for %s", char_instance.name) - raise - - # Handle string UUID input (not type-safe path) - logger.debug("Encoding characteristic UUID=%s, value=%s", char, value) - - # Get characteristic instance - characteristic = CharacteristicRegistry.get_characteristic(char) - if not characteristic: - raise ValueError(f"No encoder available for characteristic UUID: {char}") - - logger.debug("Found encoder for UUID=%s: %s", char, type(characteristic).__name__) - - # Handle dict input - convert to proper type - if isinstance(value, dict): - # Get the expected value type for this characteristic - value_type = self._get_characteristic_value_type_class(characteristic) - if value_type and hasattr(value_type, "__init__") and not isinstance(value_type, str): - try: - # Try to construct the dataclass from dict - value = value_type(**value) - logger.debug("Converted dict to %s", value_type.__name__) - except (TypeError, ValueError) as e: - type_name = getattr(value_type, "__name__", str(value_type)) - raise TypeError(f"Failed to convert dict to {type_name} for characteristic {char}: {e}") from e - - # Encode using build_value (with validation) or encode_value (without) - try: - if validate: - encoded = characteristic.build_value(value) - logger.debug("Successfully encoded %s with validation", characteristic.name) - else: - encoded = characteristic._encode_value(value) # pylint: disable=protected-access - logger.debug("Successfully encoded %s without validation", characteristic.name) - return bytes(encoded) - except Exception: - logger.exception("Encoding failed for %s", characteristic.name) - raise - - def _get_characteristic_value_type_class( # pylint: disable=too-many-return-statements,too-many-branches - self, characteristic: BaseCharacteristic[Any] - ) -> type[Any] | None: - """Get the Python type class that a characteristic expects. + return self._encoder.encode_characteristic(char, value, validate) + + def validate_characteristic_data(self, uuid: str, data: bytes) -> ValidationResult: + """Validate characteristic data format against SIG specifications. Args: - characteristic: The characteristic instance + uuid: The characteristic UUID + data: Raw data bytes to validate Returns: - The type class, or None if it can't be determined + ValidationResult with validation details """ - # Try to infer from decode_value return type annotation (resolve string annotations) - if hasattr(characteristic, "_decode_value"): - try: - # Use get_type_hints to resolve string annotations - # Need to pass the characteristic's module globals to resolve forward references - module = inspect.getmodule(characteristic.__class__) - globalns = getattr(module, "__dict__", {}) if module else {} - type_hints = typing.get_type_hints(characteristic._decode_value, globalns=globalns) # pylint: disable=protected-access # Need to inspect decode method signature - return_type = type_hints.get("return") - if return_type and return_type is not type(None): - return return_type # type: ignore[no-any-return] - except (TypeError, AttributeError, NameError): - # Fallback to direct signature inspection if type hints fail - return_type = inspect.signature(characteristic._decode_value).return_annotation # pylint: disable=protected-access # Need signature access - sig = inspect.signature(characteristic._decode_value) # pylint: disable=protected-access # Duplicate for clarity - return_annotation = sig.return_annotation - if ( - return_annotation - and return_annotation != inspect.Parameter.empty - and not isinstance(return_annotation, str) - ): - # Return non-string annotation - return return_annotation # type: ignore[no-any-return] - - # Try to get from _manual_value_type attribute - # pylint: disable=protected-access # Need to inspect manual type info - if hasattr(characteristic, "_manual_value_type"): - manual_type = characteristic._manual_value_type - if manual_type and isinstance(manual_type, str) and hasattr(templates, manual_type): - # Resolve string annotation from templates module - return getattr(templates, manual_type) # type: ignore[no-any-return] - - # Try to get from template first - # pylint: disable=protected-access # Need to inspect template for type info - if hasattr(characteristic, "_template") and characteristic._template: - template = characteristic._template - # Check if template has a value_type annotation - if hasattr(template, "__orig_class__"): - # Extract type from Generic - args = typing.get_args(template.__orig_class__) - if args: - return args[0] # type: ignore[no-any-return] - - # For simple types, check info.value_type - info = characteristic.info - if info.value_type == ValueType.INT: - return int - if info.value_type == ValueType.FLOAT: - return float - if info.value_type == ValueType.STRING: - return str - if info.value_type == ValueType.BOOL: - return bool - if info.value_type == ValueType.BYTES: - return bytes - - return None + return self._encoder.validate_characteristic_data(uuid, data) - def get_value_type(self, uuid: str) -> ValueType | None: - """Get the expected value type for a characteristic. - - Retrieves the ValueType enum indicating what type of data this - characteristic produces (int, float, string, bytes, etc.). + def create_value(self, uuid: str, **kwargs: Any) -> Any: # noqa: ANN401 + """Create a properly typed value instance for a characteristic. Args: - uuid: The characteristic UUID (16-bit short form or full 128-bit) + uuid: The characteristic UUID + **kwargs: Field values for the characteristic's type Returns: - ValueType enum if characteristic is found, None otherwise + Properly typed value instance + + Raises: + ValueError: If UUID is invalid or characteristic not found + TypeError: If kwargs don't match the characteristic's expected fields Example:: - Check what type a characteristic returns:: - from bluetooth_sig import BluetoothSIGTranslator + from bluetooth_sig import BluetoothSIGTranslator - translator = BluetoothSIGTranslator() - value_type = translator.get_value_type("2A19") - print(value_type) # ValueType.INT + translator = BluetoothSIGTranslator() + accel = translator.create_value("2C1D", x_axis=1.5, y_axis=0.5, z_axis=9.8) + data = translator.encode_characteristic("2C1D", accel) """ - info = self.get_characteristic_info_by_uuid(uuid) - return info.value_type if info else None + return self._encoder.create_value(uuid, **kwargs) + + # ------------------------------------------------------------------------- + # Query / Info + # ------------------------------------------------------------------------- + + def get_value_type(self, uuid: str) -> ValueType | None: + """Get the expected value type for a characteristic. + + Args: + uuid: The characteristic UUID (16-bit short form or full 128-bit) + + Returns: + ValueType enum if characteristic is found, None otherwise + + """ + return self._query.get_value_type(uuid) def supports(self, uuid: str) -> bool: """Check if a characteristic UUID is supported. @@ -417,64 +322,20 @@ def supports(self, uuid: str) -> bool: Returns: True if the characteristic has a parser/encoder, False otherwise - Example:: - Check if characteristic is supported:: - - from bluetooth_sig import BluetoothSIGTranslator - - translator = BluetoothSIGTranslator() - if translator.supports("2A19"): - result = translator.parse_characteristic("2A19", data) - """ - try: - bt_uuid = BluetoothUUID(uuid) - char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) - except (ValueError, TypeError): - return False - else: - return char_class is not None + return self._query.supports(uuid) def get_characteristic_info_by_uuid(self, uuid: str) -> CharacteristicInfo | None: """Get information about a characteristic by UUID. - Retrieve metadata for a Bluetooth characteristic using its UUID. This includes - the characteristic's name, description, value type, unit, and properties. - Args: uuid: The characteristic UUID (16-bit short form or full 128-bit) Returns: - [CharacteristicInfo][bluetooth_sig.CharacteristicInfo] with metadata or None if not found - - Example:: - Get battery level characteristic info:: - - from bluetooth_sig import BluetoothSIGTranslator - - translator = BluetoothSIGTranslator() - info = translator.get_characteristic_info_by_uuid("2A19") - if info: - print(f"Name: {info.name}") # Name: Battery Level + CharacteristicInfo with metadata or None if not found """ - try: - bt_uuid = BluetoothUUID(uuid) - except ValueError: - return None - - char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) - if not char_class: - return None - - # Create temporary instance to get metadata (no parameters needed for auto-resolution) - try: - temp_char = char_class() - except (TypeError, ValueError, AttributeError): - # Instantiation may fail if characteristic requires parameters or has missing dependencies - return None - else: - return temp_char.info + return self._query.get_characteristic_info_by_uuid(uuid) def get_characteristic_uuid_by_name(self, name: CharacteristicName) -> BluetoothUUID | None: """Get the UUID for a characteristic name enum. @@ -486,8 +347,7 @@ def get_characteristic_uuid_by_name(self, name: CharacteristicName) -> Bluetooth Characteristic UUID or None if not found """ - info = self.get_characteristic_info_by_name(name) - return info.uuid if info else None + return self._query.get_characteristic_uuid_by_name(name) def get_service_uuid_by_name(self, name: str | ServiceName) -> BluetoothUUID | None: """Get the UUID for a service name or enum. @@ -499,9 +359,7 @@ def get_service_uuid_by_name(self, name: str | ServiceName) -> BluetoothUUID | N Service UUID or None if not found """ - name_str = name.value if isinstance(name, ServiceName) else name - info = self.get_service_info_by_name(name_str) - return info.uuid if info else None + return self._query.get_service_uuid_by_name(name) def get_characteristic_info_by_name(self, name: CharacteristicName) -> CharacteristicInfo | None: """Get characteristic info by enum name. @@ -513,26 +371,10 @@ def get_characteristic_info_by_name(self, name: CharacteristicName) -> Character CharacteristicInfo if found, None otherwise """ - char_class = CharacteristicRegistry.get_characteristic_class(name) - if not char_class: - return None - - # Try get_configured_info first (for custom characteristics) - info = char_class.get_configured_info() - if info: - return info - - # For SIG characteristics, create temporary instance to get metadata - try: - temp_char = char_class() - except (TypeError, ValueError, AttributeError): - # Instantiation may fail if characteristic requires parameters or has missing dependencies - return None - else: - return temp_char.info + return self._query.get_characteristic_info_by_name(name) def get_service_info_by_name(self, name: str | ServiceName) -> ServiceInfo | None: - """Get service info by name or enum instead of UUID. + """Get service info by name or enum. Args: name: Service name string or ServiceName enum @@ -541,18 +383,7 @@ def get_service_info_by_name(self, name: str | ServiceName) -> ServiceInfo | Non ServiceInfo if found, None otherwise """ - # Convert enum to string value if needed - name_str = name.value if isinstance(name, ServiceName) else name - - # Use UUID registry for name-based lookup - try: - uuid_info = uuid_registry.get_service_info(name_str) - if uuid_info: - return ServiceInfo(uuid=uuid_info.uuid, name=uuid_info.name, characteristics=[]) - except Exception: # pylint: disable=broad-exception-caught - pass - - return None + return self._query.get_service_info_by_name(name) def get_service_info_by_uuid(self, uuid: str) -> ServiceInfo | None: """Get information about a service by UUID. @@ -564,25 +395,7 @@ def get_service_info_by_uuid(self, uuid: str) -> ServiceInfo | None: ServiceInfo with metadata or None if not found """ - service_class = GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID(uuid)) - if not service_class: - return None - - try: - temp_service = service_class() - # Convert characteristics dict to list of CharacteristicInfo - char_infos: list[CharacteristicInfo] = [] - for _, char_instance in temp_service.characteristics.items(): - # Use public info property - char_infos.append(char_instance.info) - return ServiceInfo( - uuid=temp_service.uuid, - name=temp_service.name, - characteristics=char_infos, - ) - except (TypeError, ValueError, AttributeError): - # Service instantiation may fail if it requires parameters or has missing data - return None + return self._query.get_service_info_by_uuid(uuid) def list_supported_characteristics(self) -> dict[str, str]: """List all supported characteristics with their names and UUIDs. @@ -591,15 +404,7 @@ def list_supported_characteristics(self) -> dict[str, str]: Dictionary mapping characteristic names to UUIDs """ - result: dict[str, str] = {} - for name, char_class in CharacteristicRegistry.get_all_characteristics().items(): - # Try to get configured_info from class using public accessor - configured_info = char_class.get_configured_info() - if configured_info: - # Convert CharacteristicName enum to string for dict key - name_str = name.value if hasattr(name, "value") else str(name) - result[name_str] = str(configured_info.uuid) - return result + return self._query.list_supported_characteristics() def list_supported_services(self) -> dict[str, str]: """List all supported services with their names and UUIDs. @@ -608,72 +413,31 @@ def list_supported_services(self) -> dict[str, str]: Dictionary mapping service names to UUIDs """ - result: dict[str, str] = {} - for service_class in GattServiceRegistry.get_all_services(): - try: - temp_service = service_class() - service_name = getattr(temp_service, "_service_name", service_class.__name__) - result[service_name] = str(temp_service.uuid) - except Exception: # pylint: disable=broad-exception-caught - continue - return result + return self._query.list_supported_services() - def process_services(self, services: dict[str, dict[str, CharacteristicDataDict]]) -> None: - """Process discovered services and their characteristics. - - Args: - services: Dictionary of service UUIDs to their characteristics - - """ - for uuid_str, service_data in services.items(): - uuid = BluetoothUUID(uuid_str) - # Convert dict[str, dict] to ServiceDiscoveryData - characteristics: dict[BluetoothUUID, CharacteristicInfo] = {} - for char_uuid_str, char_data in service_data.get("characteristics", {}).items(): - char_uuid = BluetoothUUID(char_uuid_str) - # Create CharacteristicInfo from dict - vtype_raw = char_data.get("value_type", "bytes") - if isinstance(vtype_raw, str): - value_type = ValueType(vtype_raw) - elif isinstance(vtype_raw, ValueType): - value_type = vtype_raw - else: - value_type = ValueType.BYTES - characteristics[char_uuid] = CharacteristicInfo( - uuid=char_uuid, - name=char_data.get("name", ""), - unit=char_data.get("unit", ""), - value_type=value_type, - ) - service = GattServiceRegistry.create_service(uuid, characteristics) - if service: - self._services[str(uuid)] = service - - def get_service_by_uuid(self, uuid: str) -> BaseGattService | None: - """Get a service instance by UUID. + def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, CharacteristicInfo | None]: + """Get information about multiple characteristics by UUID. Args: - uuid: The service UUID + uuids: List of characteristic UUIDs Returns: - Service instance if found, None otherwise + Dictionary mapping UUIDs to CharacteristicInfo (or None if not found) """ - return self._services.get(uuid) + return self._query.get_characteristics_info_by_uuids(uuids) - @property - def discovered_services(self) -> list[BaseGattService]: - """Get list of discovered service instances. + def get_service_characteristics(self, service_uuid: str) -> list[str]: + """Get the characteristic UUIDs associated with a service. + + Args: + service_uuid: The service UUID Returns: - List of discovered service instances + List of characteristic UUIDs for this service """ - return list(self._services.values()) - - def clear_services(self) -> None: - """Clear all discovered services.""" - self._services.clear() + return self._query.get_service_characteristics(service_uuid) def get_sig_info_by_name(self, name: str) -> SIGInfo | None: """Get Bluetooth SIG information for a characteristic or service by name. @@ -685,32 +449,7 @@ def get_sig_info_by_name(self, name: str) -> SIGInfo | None: CharacteristicInfo or ServiceInfo if found, None otherwise """ - # Use the UUID registry for name-based lookups (string inputs). - try: - char_info = uuid_registry.get_characteristic_info(name) - if char_info: - # Build CharacteristicInfo from registry data - value_type = ValueType.UNKNOWN - if char_info.value_type: - try: - value_type = ValueType(char_info.value_type) - except (ValueError, KeyError): - value_type = ValueType.UNKNOWN - return CharacteristicInfo( - uuid=char_info.uuid, - name=char_info.name, - value_type=value_type, - unit=char_info.unit or "", - ) - except (KeyError, ValueError, AttributeError): # Registry lookups may fail, fall through to service lookup - pass - - # Try service - service_info = self.get_service_info_by_name(name) - if service_info: - return service_info - - return None + return self._query.get_sig_info_by_name(name) def get_sig_info_by_uuid(self, uuid: str) -> SIGInfo | None: """Get Bluetooth SIG information for a UUID. @@ -722,317 +461,50 @@ def get_sig_info_by_uuid(self, uuid: str) -> SIGInfo | None: CharacteristicInfo or ServiceInfo if found, None otherwise """ - # Try characteristic first - char_info = self.get_characteristic_info_by_uuid(uuid) - if char_info: - return char_info + return self._query.get_sig_info_by_uuid(uuid) - # Try service - service_info = self.get_service_info_by_uuid(uuid) - if service_info: - return service_info + # ------------------------------------------------------------------------- + # Service lifecycle + # ------------------------------------------------------------------------- - return None - - def parse_characteristics( - self, - char_data: dict[str, bytes], - ctx: CharacteristicContext | None = None, - ) -> dict[str, Any]: - r"""Parse multiple characteristics at once with dependency-aware ordering. - - This method automatically handles multi-characteristic dependencies by parsing - independent characteristics first, then parsing characteristics that depend on them. - The parsing order is determined by the `required_dependencies` and `optional_dependencies` - attributes declared on characteristic classes. - - Required dependencies MUST be present and successfully parsed; missing required - dependencies result in parse failure with MissingDependencyError. Optional dependencies - enrich parsing when available but are not mandatory. + def process_services(self, services: dict[str, dict[str, CharacteristicDataDict]]) -> None: + """Process discovered services and their characteristics. Args: - char_data: Dictionary mapping UUIDs to raw data bytes - ctx: Optional CharacteristicContext used as the starting context - - Returns: - Dictionary mapping UUIDs to parsed values - - Raises: - ValueError: If circular dependencies are detected - CharacteristicParseError: If parsing fails for any characteristic - - Example:: - Parse multiple environmental characteristics:: - - from bluetooth_sig import BluetoothSIGTranslator - - translator = BluetoothSIGTranslator() - data = { - "2A6E": b"\\x0A\\x00", # Temperature - "2A6F": b"\\x32\\x00", # Humidity - } - try: - results = translator.parse_characteristics(data) - for uuid, value in results.items(): - print(f"{uuid}: {value}") - except CharacteristicParseError as e: - print(f"Parse failed: {e}") + services: Dictionary of service UUIDs to their characteristics """ - return self._parse_characteristics_batch(char_data, ctx) - - def _parse_characteristics_batch( - self, - char_data: dict[str, bytes], - ctx: CharacteristicContext | None, - ) -> dict[str, Any]: - """Parse multiple characteristics using dependency-aware ordering.""" - logger.debug("Batch parsing %d characteristics", len(char_data)) - - # Prepare characteristics and dependencies - uuid_to_characteristic, uuid_to_required_deps, uuid_to_optional_deps = ( - self._prepare_characteristic_dependencies(char_data) - ) - - # Resolve dependency order - sorted_uuids = self._resolve_dependency_order(char_data, uuid_to_required_deps, uuid_to_optional_deps) - - # Build base context - base_context = ctx - - results: dict[str, Any] = {} - for uuid_str in sorted_uuids: - raw_data = char_data[uuid_str] - characteristic = uuid_to_characteristic.get(uuid_str) - - missing_required = self._find_missing_required_dependencies( - uuid_str, - uuid_to_required_deps.get(uuid_str, []), - results, - base_context, - ) - - if missing_required: - raise MissingDependencyError(characteristic.name if characteristic else "Unknown", missing_required) - - self._log_optional_dependency_gaps( - uuid_str, - uuid_to_optional_deps.get(uuid_str, []), - results, - base_context, - ) - - parse_context = self._build_parse_context(base_context, results) - - # Let parse errors propagate to caller - value = self.parse_characteristic(uuid_str, raw_data, ctx=parse_context) - results[uuid_str] = value - - logger.debug("Batch parsing complete: %d results", len(results)) - return results - - def _prepare_characteristic_dependencies( - self, characteristic_data: Mapping[str, bytes] - ) -> tuple[dict[str, BaseCharacteristic[Any]], dict[str, list[str]], dict[str, list[str]]]: - """Instantiate characteristics once and collect declared dependencies.""" - # Performance: All dicts use str keys (UUID strings) for O(1) lookups in hot paths - uuid_to_characteristic: dict[str, BaseCharacteristic[Any]] = {} - uuid_to_required_deps: dict[str, list[str]] = {} - uuid_to_optional_deps: dict[str, list[str]] = {} - - for uuid in characteristic_data: - characteristic = CharacteristicRegistry.get_characteristic(uuid) - if characteristic is None: - continue - - uuid_to_characteristic[uuid] = characteristic - - required = characteristic.required_dependencies - optional = characteristic.optional_dependencies - - if required: - uuid_to_required_deps[uuid] = required - logger.debug("Characteristic %s has required dependencies: %s", uuid, required) - if optional: - uuid_to_optional_deps[uuid] = optional - logger.debug("Characteristic %s has optional dependencies: %s", uuid, optional) - - return uuid_to_characteristic, uuid_to_required_deps, uuid_to_optional_deps - - def _resolve_dependency_order( - self, - characteristic_data: Mapping[str, bytes], - uuid_to_required_deps: Mapping[str, list[str]], - uuid_to_optional_deps: Mapping[str, list[str]], - ) -> list[str]: - """Topologically sort characteristics based on declared dependencies.""" - try: - sorter: TopologicalSorter[str] = TopologicalSorter() - for uuid in characteristic_data: - all_deps = uuid_to_required_deps.get(uuid, []) + uuid_to_optional_deps.get(uuid, []) - batch_deps = [dep for dep in all_deps if dep in characteristic_data] - sorter.add(uuid, *batch_deps) - - sorted_sequence = sorter.static_order() - sorted_uuids = list(sorted_sequence) - logger.debug("Dependency-sorted parsing order: %s", sorted_uuids) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.warning("Dependency sorting failed: %s. Using original order.", exc) - return list(characteristic_data.keys()) - else: - return sorted_uuids - - def _find_missing_required_dependencies( - self, - uuid: str, - required_deps: list[str], - results: Mapping[str, Any], - base_context: CharacteristicContext | None, - ) -> list[str]: - """Determine which required dependencies are unavailable for a characteristic.""" - if not required_deps: - return [] - - missing: list[str] = [] - other_characteristics = ( - base_context.other_characteristics if base_context and base_context.other_characteristics else None - ) + self._services.process_services(services) - for dep_uuid in required_deps: - if dep_uuid in results: - # If it's in results, it was successfully parsed - continue - - if other_characteristics and dep_uuid in other_characteristics: - # If it's in context, assume it's available - continue - - missing.append(dep_uuid) - - if missing: - logger.debug("Characteristic %s missing required dependencies: %s", uuid, missing) - - return missing - - def _log_optional_dependency_gaps( - self, - uuid: str, - optional_deps: list[str], - results: Mapping[str, Any], - base_context: CharacteristicContext | None, - ) -> None: - """Emit debug logs when optional dependencies are unavailable.""" - if not optional_deps: - return - - other_characteristics = ( - base_context.other_characteristics if base_context and base_context.other_characteristics else None - ) - - for dep_uuid in optional_deps: - if dep_uuid in results: - continue - if other_characteristics and dep_uuid in other_characteristics: - continue - logger.debug("Optional dependency %s not available for %s", dep_uuid, uuid) - - def _build_parse_context( - self, - base_context: CharacteristicContext | None, - results: Mapping[str, Any], - ) -> CharacteristicContext: - """Construct the context passed to per-characteristic parsers.""" - if base_context is not None: - return CharacteristicContext( - device_info=base_context.device_info, - advertisement=base_context.advertisement, - other_characteristics=results, - raw_service=base_context.raw_service, - ) - - return CharacteristicContext(other_characteristics=results) - - def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, CharacteristicInfo | None]: - """Get information about multiple characteristics by UUID. + def get_service_by_uuid(self, uuid: str) -> BaseGattService | None: + """Get a service instance by UUID. Args: - uuids: List of characteristic UUIDs + uuid: The service UUID Returns: - Dictionary mapping UUIDs to CharacteristicInfo - (or None if not found) + Service instance if found, None otherwise """ - results: dict[str, CharacteristicInfo | None] = {} - for uuid in uuids: - results[uuid] = self.get_characteristic_info_by_uuid(uuid) - return results + return self._services.get_service_by_uuid(uuid) - def validate_characteristic_data(self, uuid: str, data: bytes) -> ValidationResult: - """Validate characteristic data format against SIG specifications. - - Args: - uuid: The characteristic UUID - data: Raw data bytes to validate + @property + def discovered_services(self) -> list[BaseGattService]: + """Get list of discovered service instances. Returns: - ValidationResult with validation details + List of discovered service instances """ - try: - # Attempt to parse the data - if it succeeds, format is valid - self.parse_characteristic(uuid, data) - # Try to get expected_length - try: - bt_uuid = BluetoothUUID(uuid) - char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) - expected = char_class.expected_length if char_class else None - except (ValueError, AttributeError): - # UUID parsing or class lookup may fail - expected = None - return ValidationResult( - is_valid=True, - actual_length=len(data), - expected_length=expected, - error_message="", - ) - except (CharacteristicParseError, ValueError, TypeError, struct.error, CharacteristicError) as e: - # Parsing failed - data format is invalid - # Try to get expected_length even on failure for better error reporting - try: - bt_uuid = BluetoothUUID(uuid) - char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(bt_uuid) - expected = char_class.expected_length if char_class else None - except (ValueError, AttributeError): - # UUID parsing or class lookup may fail - expected = None - return ValidationResult( - is_valid=False, - actual_length=len(data), - expected_length=expected, - error_message=str(e), - ) - - def get_service_characteristics(self, service_uuid: str) -> list[str]: # pylint: disable=too-many-return-statements - """Get the characteristic UUIDs associated with a service. + return self._services.discovered_services - Args: - service_uuid: The service UUID - - Returns: - List of characteristic UUIDs for this service - - """ - service_class = GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID(service_uuid)) - if not service_class: - return [] + def clear_services(self) -> None: + """Clear all discovered services.""" + self._services.clear_services() - try: - temp_service = service_class() - required_chars = temp_service.get_required_characteristics() - return [str(k) for k in required_chars] - except Exception: # pylint: disable=broad-exception-caught - return [] + # ------------------------------------------------------------------------- + # Registration + # ------------------------------------------------------------------------- def register_custom_characteristic_class( self, @@ -1065,22 +537,10 @@ def register_custom_characteristic_class( unit="°C", value_type=ValueType.FLOAT, ) - translator.register_custom_characteristic_class(str(info.uuid), MyCustomCharacteristic, info=info) + translator.register_custom_characteristic_class(str(info.uuid), MyCustomChar, info=info) """ - # Register the class - CharacteristicRegistry.register_characteristic_class(uuid_or_name, cls, override) - - # Register metadata in uuid_registry if provided - if info: - uuid_registry.register_characteristic( - uuid=info.uuid, - name=info.name or cls.__name__, - identifier=info.id, - unit=info.unit, - value_type=info.value_type, - override=override, - ) + self._registration.register_custom_characteristic_class(uuid_or_name, cls, info, override) def register_custom_service_class( self, @@ -1107,23 +567,15 @@ def register_custom_service_class( from bluetooth_sig.types import BluetoothUUID translator = BluetoothSIGTranslator() - info = ServiceInfo(uuid=BluetoothUUID("12345678-1234-1234-1234-123456789abc"), name="Custom Service") - translator.register_custom_service_class(str(info.uuid), MyCustomService, info=info) + info = ServiceInfo(uuid=BluetoothUUID("12345678-..."), name="Custom Service") + translator.register_custom_service_class(str(info.uuid), MyService, info=info) """ - # Register the class - GattServiceRegistry.register_service_class(uuid_or_name, cls, override) - - # Register metadata in uuid_registry if provided - if info: - uuid_registry.register_service( - uuid=info.uuid, - name=info.name or cls.__name__, - identifier=info.id, - override=override, - ) + self._registration.register_custom_service_class(uuid_or_name, cls, info, override) - # Async methods for non-blocking operation in async contexts + # ------------------------------------------------------------------------- + # Async wrappers + # ------------------------------------------------------------------------- @overload async def parse_characteristic_async( @@ -1146,15 +598,11 @@ async def parse_characteristic_async( char: str | BluetoothUUID | type[BaseCharacteristic[T]], raw_data: bytes, ctx: CharacteristicContext | None = None, - ) -> T | Any: # Runtime UUID dispatch cannot be type-safe + ) -> T | Any: """Parse characteristic data in an async-compatible manner. - This is an async wrapper that allows characteristic parsing to be used - in async contexts. The actual parsing is performed synchronously as it's - a fast, CPU-bound operation that doesn't benefit from async I/O. - Args: - char: Characteristic class (type-safe) or UUID string/BluetoothUUID (not type-safe). + char: Characteristic class (type-safe) or UUID string/BluetoothUUID. raw_data: Raw bytes from the characteristic ctx: Optional context providing device-level info @@ -1165,29 +613,12 @@ async def parse_characteristic_async( SpecialValueDetectedError: Special sentinel value detected CharacteristicParseError: Parse/validation failure - Example:: - - async with BleakClient(address) as client: - data = await client.read_gatt_char("2A19") - - # Type-safe: pass characteristic class - from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic - - level: int = await translator.parse_characteristic_async(BatteryLevelCharacteristic, data) - - # Not type-safe: pass UUID string - value = await translator.parse_characteristic_async("2A19", data) - """ - # Handle characteristic class input (type-safe path) if isinstance(char, type) and issubclass(char, BaseCharacteristic): - return self.parse_characteristic(char, raw_data, ctx) + return self._parser.parse_characteristic(char, raw_data, ctx) - # Convert to string for consistency with sync API uuid_str = str(char) if isinstance(char, BluetoothUUID) else char - - # Delegate to sync implementation - return self.parse_characteristic(uuid_str, raw_data, ctx) + return self._parser.parse_characteristic(uuid_str, raw_data, ctx) async def parse_characteristics_async( self, @@ -1196,10 +627,6 @@ async def parse_characteristics_async( ) -> dict[str, Any]: """Parse multiple characteristics in an async-compatible manner. - This is an async wrapper for batch characteristic parsing. The parsing - is performed synchronously as it's a fast, CPU-bound operation. This method - allows batch parsing to be used naturally in async workflows. - Args: char_data: Dictionary mapping UUIDs to raw data bytes ctx: Optional context @@ -1207,22 +634,8 @@ async def parse_characteristics_async( Returns: Dictionary mapping UUIDs to parsed values - Example:: - - async with BleakClient(address) as client: - # Read multiple characteristics - char_data = {} - for uuid in ["2A19", "2A6E", "2A6F"]: - char_data[uuid] = await client.read_gatt_char(uuid) - - # Parse all asynchronously - results = await translator.parse_characteristics_async(char_data) - for uuid, value in results.items(): - print(f"{uuid}: {value}") """ - # Delegate directly to sync implementation - # The sync implementation already handles dependency ordering - return self.parse_characteristics(char_data, ctx) + return self._parser.parse_characteristics(char_data, ctx) @overload async def encode_characteristic_async( @@ -1243,115 +656,25 @@ async def encode_characteristic_async( async def encode_characteristic_async( self, char: str | BluetoothUUID | type[BaseCharacteristic[T]], - value: T | Any, # Runtime UUID dispatch cannot be type-safe + value: T | Any, validate: bool = True, ) -> bytes: """Encode characteristic value in an async-compatible manner. - This is an async wrapper that allows characteristic encoding to be used - in async contexts. The actual encoding is performed synchronously as it's - a fast, CPU-bound operation that doesn't benefit from async I/O. - Args: - char: Characteristic class (type-safe) or UUID string/BluetoothUUID (not type-safe). - value: The value to encode (dataclass, dict, or primitive). - Type is checked when using characteristic class. + char: Characteristic class (type-safe) or UUID string/BluetoothUUID. + value: The value to encode. validate: If True, validates before encoding (default: True) Returns: Encoded bytes ready to write - Example:: - - async with BleakClient(address) as client: - from bluetooth_sig.gatt.characteristics import AlertLevelCharacteristic - from bluetooth_sig.gatt.characteristics.alert_level import AlertLevel - - # Type-safe: pass characteristic class - data: bytes = await translator.encode_characteristic_async(AlertLevelCharacteristic, AlertLevel.HIGH) - await client.write_gatt_char(str(AlertLevelCharacteristic().uuid), data) - - # Not type-safe: pass UUID string - data = await translator.encode_characteristic_async("2A06", 2) - await client.write_gatt_char("2A06", data) """ - # Handle characteristic class input (type-safe path) if isinstance(char, type) and issubclass(char, BaseCharacteristic): - return self.encode_characteristic(char, value, validate) + return self._encoder.encode_characteristic(char, value, validate) - # Convert to string for consistency with sync API uuid_str = str(char) if isinstance(char, BluetoothUUID) else char - - # Delegate to sync implementation - return self.encode_characteristic(uuid_str, value, validate) - - def create_value(self, uuid: str, **kwargs: Any) -> Any: # noqa: ANN401 - """Create a properly typed value instance for a characteristic. - - This is a convenience method that constructs the appropriate dataclass - or value type for a characteristic, which can then be passed to - encode_characteristic() or used directly. - - Args: - uuid: The characteristic UUID - **kwargs: Field values for the characteristic's type - - Returns: - Properly typed value instance - - Raises: - ValueError: If UUID is invalid or characteristic not found - TypeError: If kwargs don't match the characteristic's expected fields - - Example:: - Create complex characteristic values:: - - from bluetooth_sig import BluetoothSIGTranslator - - translator = BluetoothSIGTranslator() - - # Create acceleration data - accel = translator.create_value("2C1D", x_axis=1.5, y_axis=0.5, z_axis=9.8) - - # Encode and write - data = translator.encode_characteristic("2C1D", accel) - await client.write_gatt_char("2C1D", data) - - """ - # Get characteristic instance - characteristic = CharacteristicRegistry.get_characteristic(uuid) - if not characteristic: - raise ValueError(f"No characteristic found for UUID: {uuid}") - - # Get the value type - value_type = self._get_characteristic_value_type_class(characteristic) - - if not value_type: - # For simple types, just return the single value if provided - if len(kwargs) == 1: - return next(iter(kwargs.values())) - raise ValueError( - f"Cannot determine value type for characteristic {uuid}. " - "Try passing a dict to encode_characteristic() instead." - ) - - # Handle simple primitive types - if value_type in (int, float, str, bool, bytes): - if len(kwargs) == 1: - value = next(iter(kwargs.values())) - if not isinstance(value, value_type): - type_name = getattr(value_type, "__name__", str(value_type)) - raise TypeError(f"Expected {type_name}, got {type(value).__name__}") - return value - type_name = getattr(value_type, "__name__", str(value_type)) - raise TypeError(f"Simple type {type_name} expects a single value") - - # Construct complex type from kwargs - try: - return value_type(**kwargs) - except (TypeError, ValueError) as e: - type_name = getattr(value_type, "__name__", str(value_type)) - raise TypeError(f"Failed to create {type_name} for characteristic {uuid}: {e}") from e + return self._encoder.encode_characteristic(uuid_str, value, validate) # Global instance diff --git a/src/bluetooth_sig/device/__init__.py b/src/bluetooth_sig/device/__init__.py index d71bec1a..e44280b4 100644 --- a/src/bluetooth_sig/device/__init__.py +++ b/src/bluetooth_sig/device/__init__.py @@ -18,16 +18,19 @@ DeviceEncryption, DeviceService, ) -from bluetooth_sig.device.device import Device, SIGTranslatorProtocol +from bluetooth_sig.device.dependency_resolver import DependencyResolutionMode +from bluetooth_sig.device.device import Device from bluetooth_sig.device.peripheral import ( CharacteristicDefinition, PeripheralManagerProtocol, ServiceDefinition, ) +from bluetooth_sig.device.protocols import SIGTranslatorProtocol __all__ = [ "CharacteristicDefinition", "ClientManagerProtocol", + "DependencyResolutionMode", "Device", "DeviceAdvertising", "DeviceConnected", diff --git a/src/bluetooth_sig/device/advertising.py b/src/bluetooth_sig/device/advertising.py index 8b2327b5..d002782d 100644 --- a/src/bluetooth_sig/device/advertising.py +++ b/src/bluetooth_sig/device/advertising.py @@ -19,7 +19,8 @@ from __future__ import annotations import logging -from typing import Callable, TypeVar, overload +from collections.abc import Callable +from typing import TypeVar, overload from bluetooth_sig.advertising import AdvertisingPDUParser from bluetooth_sig.advertising.base import AdvertisingData, PayloadInterpreter @@ -198,7 +199,7 @@ def process( interp_instance = interpreter(self._mac_address) cached_name = interp_instance.info.name or interpreter.__name__ # Cache for future auto-detection (variance: T is subtype of object) - self._interpreters[cached_name] = interp_instance # type: ignore[assignment] + self._interpreters[cached_name] = interp_instance # type: ignore[assignment] # Covariant T stored in dict; safe because interpreters are read-only after caching return self._run_interpreter(interp_instance, advertising_data) # Try registered interpreters first (returns object for dynamic dispatch) diff --git a/src/bluetooth_sig/device/characteristic_io.py b/src/bluetooth_sig/device/characteristic_io.py new file mode 100644 index 00000000..c0194c94 --- /dev/null +++ b/src/bluetooth_sig/device/characteristic_io.py @@ -0,0 +1,350 @@ +"""Characteristic I/O operations for BLE devices. + +Encapsulates read, write, and notification operations for GATT characteristics, +including type-safe overloads for class-based and string/enum-based access. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar, cast, overload + +from ..gatt.characteristics import CharacteristicName +from ..gatt.characteristics.base import BaseCharacteristic +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.context import CharacteristicContext, DeviceInfo +from ..types.uuid import BluetoothUUID +from .client import ClientManagerProtocol +from .dependency_resolver import DependencyResolutionMode, DependencyResolver + +if TYPE_CHECKING: + from .protocols import SIGTranslatorProtocol + +logger = logging.getLogger(__name__) + +# Type variable for generic characteristic return types +T = TypeVar("T") + + +class CharacteristicIO: + """Read, write, and notification operations for GATT characteristics. + + Encapsulates the I/O logic extracted from Device, handling both type-safe + (class-based) and dynamic (string/enum-based) characteristic access patterns. + + Uses ``DependencyResolver`` for automatic dependency resolution before reads, + and a ``device_info_factory`` callable to get current ``DeviceInfo`` without + a back-reference to the owning Device. + """ + + def __init__( + self, + connection_manager: ClientManagerProtocol, + translator: SIGTranslatorProtocol, + dep_resolver: DependencyResolver, + device_info_factory: Callable[[], DeviceInfo], + ) -> None: + """Initialise with connection manager, translator, resolver, and info factory. + + Args: + connection_manager: Connection manager for BLE I/O + translator: Translator for parsing/encoding characteristics + dep_resolver: Resolver for characteristic dependencies + device_info_factory: Callable returning current DeviceInfo + + """ + self._connection_manager = connection_manager + self._translator = translator + self._dep_resolver = dep_resolver + self._device_info_factory = device_info_factory + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + @overload + async def read( + self, + char: type[BaseCharacteristic[T]], + resolution_mode: DependencyResolutionMode = ..., + ) -> T | None: ... + + @overload + async def read( + self, + char: str | CharacteristicName, + resolution_mode: DependencyResolutionMode = ..., + ) -> Any | None: ... # noqa: ANN401 # Runtime UUID dispatch cannot be type-safe + + async def read( + self, + char: str | CharacteristicName | type[BaseCharacteristic[T]], + resolution_mode: DependencyResolutionMode = DependencyResolutionMode.NORMAL, + ) -> T | Any | None: # Runtime UUID dispatch cannot be type-safe + """Read a characteristic value from the device. + + Args: + char: Name, enum, or characteristic class to read. + Passing the class enables type-safe return values. + resolution_mode: How to handle automatic dependency resolution: + - NORMAL: Auto-resolve dependencies, use cache when available (default) + - SKIP_DEPENDENCIES: Skip dependency resolution and validation + - FORCE_REFRESH: Re-read dependencies from device, ignoring cache + + Returns: + Parsed characteristic value or None if read fails. + Return type is inferred from characteristic class when provided. + + Raises: + RuntimeError: If no connection manager is attached + ValueError: If required dependencies cannot be resolved + + """ + # Handle characteristic class input (type-safe path) + if isinstance(char, type) and issubclass(char, BaseCharacteristic): + char_class: type[BaseCharacteristic[Any]] = char + char_instance = char_class() + resolved_uuid = char_instance.uuid + + ctx: CharacteristicContext | None = None + if resolution_mode != DependencyResolutionMode.SKIP_DEPENDENCIES: + device_info = self._device_info_factory() + ctx = await self._dep_resolver.resolve(char_class, resolution_mode, device_info) + + raw = await self._connection_manager.read_gatt_char(resolved_uuid) + return char_instance.parse_value(raw, ctx=ctx) + + # Handle string/enum input (not type-safe path) + resolved_uuid = self._resolve_characteristic_name(char) + + char_class_lookup = CharacteristicRegistry.get_characteristic_class_by_uuid(resolved_uuid) + + # Resolve dependencies if characteristic class is known + ctx = None + if char_class_lookup and resolution_mode != DependencyResolutionMode.SKIP_DEPENDENCIES: + device_info = self._device_info_factory() + ctx = await self._dep_resolver.resolve(char_class_lookup, resolution_mode, device_info) + + # Read the characteristic + raw = await self._connection_manager.read_gatt_char(resolved_uuid) + return self._translator.parse_characteristic(str(resolved_uuid), raw, ctx=ctx) + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + @overload + async def write( + self, + char: type[BaseCharacteristic[T]], + data: T, + response: bool = ..., + ) -> None: ... + + @overload + async def write( + self, + char: str | CharacteristicName, + data: bytes, + response: bool = ..., + ) -> None: ... + + async def write( + self, + char: str | CharacteristicName | type[BaseCharacteristic[T]], + data: bytes | T, + response: bool = True, + ) -> None: + r"""Write data to a characteristic on the device. + + Args: + char: Name, enum, or characteristic class to write to. + Passing the class enables type-safe value encoding. + data: Raw bytes (for string/enum) or typed value (for characteristic class). + When using characteristic class, the value is encoded using build_value(). + response: If True, use write-with-response (wait for acknowledgment). + If False, use write-without-response (faster but no confirmation). + Default is True for reliability. + + Raises: + RuntimeError: If no connection manager is attached + CharacteristicEncodeError: If encoding fails (when using characteristic class) + + """ + # Handle characteristic class input (type-safe path) + if isinstance(char, type) and issubclass(char, BaseCharacteristic): + char_instance = char() + resolved_uuid = char_instance.uuid + # data is typed value T, encode it + encoded = char_instance.build_value(data) # type: ignore[arg-type] # T is erased at runtime; overload ensures type safety at call site + await self._connection_manager.write_gatt_char(resolved_uuid, bytes(encoded), response=response) + return + + # Handle string/enum input (not type-safe path) + # data must be bytes in this path + if not isinstance(data, (bytes, bytearray)): + raise TypeError(f"When using string/enum char_name, data must be bytes, got {type(data).__name__}") + + resolved_uuid = self._resolve_characteristic_name(char) + # cast is safe: isinstance check above ensures data is bytes/bytearray + await self._connection_manager.write_gatt_char(resolved_uuid, cast("bytes", data), response=response) + + # ------------------------------------------------------------------ + # Notifications + # ------------------------------------------------------------------ + + @overload + async def start_notify( + self, + char: type[BaseCharacteristic[T]], + callback: Callable[[T], None], + ) -> None: ... + + @overload + async def start_notify( + self, + char: str | CharacteristicName, + callback: Callable[[Any], None], + ) -> None: ... + + async def start_notify( + self, + char: str | CharacteristicName | type[BaseCharacteristic[T]], + callback: Callable[[T], None] | Callable[[Any], None], + ) -> None: + """Start notifications for a characteristic. + + Args: + char: Name, enum, or characteristic class to monitor. + Passing the class enables type-safe callbacks. + callback: Function to call when notifications are received. + Callback parameter type is inferred from characteristic class. + + Raises: + RuntimeError: If no connection manager is attached + + """ + # Handle characteristic class input (type-safe path) + if isinstance(char, type) and issubclass(char, BaseCharacteristic): + char_instance = char() + resolved_uuid = char_instance.uuid + + def _typed_cb(sender: str, data: bytes) -> None: + del sender # Required by callback interface + parsed = char_instance.parse_value(data) + try: + callback(parsed) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Notification callback raised an exception") + + await self._connection_manager.start_notify(resolved_uuid, _typed_cb) + return + + # Handle string/enum input (not type-safe path) + resolved_uuid = self._resolve_characteristic_name(char) + translator = self._translator + + def _internal_cb(sender: str, data: bytes) -> None: + parsed = translator.parse_characteristic(sender, data) + try: + callback(parsed) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Notification callback raised an exception") + + await self._connection_manager.start_notify(resolved_uuid, _internal_cb) + + async def stop_notify(self, char_name: str | CharacteristicName) -> None: + """Stop notifications for a characteristic. + + Args: + char_name: Characteristic name or UUID + + """ + resolved_uuid = self._resolve_characteristic_name(char_name) + await self._connection_manager.stop_notify(resolved_uuid) + + # ------------------------------------------------------------------ + # Batch operations + # ------------------------------------------------------------------ + + async def read_multiple(self, char_names: list[str | CharacteristicName]) -> dict[str, Any | None]: + """Read multiple characteristics in batch. + + Args: + char_names: List of characteristic names or enums to read + + Returns: + Dictionary mapping characteristic UUIDs to parsed values + + """ + results: dict[str, Any | None] = {} + for char_name in char_names: + try: + value = await self.read(char_name) + resolved_uuid = self._resolve_characteristic_name(char_name) + results[str(resolved_uuid)] = value + except Exception as exc: # pylint: disable=broad-exception-caught + resolved_uuid = self._resolve_characteristic_name(char_name) + results[str(resolved_uuid)] = None + logger.warning("Failed to read characteristic %s: %s", char_name, exc) + + return results + + async def write_multiple( + self, data_map: dict[str | CharacteristicName, bytes], response: bool = True + ) -> dict[str, bool]: + """Write to multiple characteristics in batch. + + Args: + data_map: Dictionary mapping characteristic names/enums to data bytes + response: If True, use write-with-response for all writes. + If False, use write-without-response for all writes. + + Returns: + Dictionary mapping characteristic UUIDs to success status + + """ + results: dict[str, bool] = {} + for char_name, data in data_map.items(): + try: + await self.write(char_name, data, response=response) + resolved_uuid = self._resolve_characteristic_name(char_name) + results[str(resolved_uuid)] = True + except Exception as exc: # pylint: disable=broad-exception-caught + resolved_uuid = self._resolve_characteristic_name(char_name) + results[str(resolved_uuid)] = False + logger.warning("Failed to write characteristic %s: %s", char_name, exc) + + return results + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _resolve_characteristic_name(self, identifier: str | CharacteristicName) -> BluetoothUUID: + """Resolve a characteristic name or enum to its UUID. + + Args: + identifier: Characteristic name string or enum + + Returns: + Characteristic UUID string + + Raises: + ValueError: If the characteristic name cannot be resolved + + """ + if isinstance(identifier, CharacteristicName): + # For enum inputs, ask the translator for the UUID + uuid = self._translator.get_characteristic_uuid_by_name(identifier) + if uuid: + return uuid + norm = identifier.value.strip() + else: + norm = identifier + stripped = norm.replace("-", "") + if len(stripped) in (4, 8, 32) and all(c in "0123456789abcdefABCDEF" for c in stripped): + return BluetoothUUID(norm) + + raise ValueError(f"Unknown characteristic name: '{identifier}'") diff --git a/src/bluetooth_sig/device/client.py b/src/bluetooth_sig/device/client.py index 48aa47a7..9a6cb841 100644 --- a/src/bluetooth_sig/device/client.py +++ b/src/bluetooth_sig/device/client.py @@ -14,21 +14,19 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import AsyncIterator -from typing import TYPE_CHECKING, Callable, ClassVar +from collections.abc import AsyncIterator, Callable +from typing import ClassVar from bluetooth_sig.types.advertising.result import AdvertisementData from bluetooth_sig.types.device_types import ( DeviceService, + ScanDetectionCallback, ScanFilter, ScannedDevice, ScanningMode, ) from bluetooth_sig.types.uuid import BluetoothUUID -if TYPE_CHECKING: - from bluetooth_sig.types.device_types import ScanDetectionCallback - class ClientManagerProtocol(ABC): """Abstract base class describing the transport operations Device expects. diff --git a/src/bluetooth_sig/device/dependency_resolver.py b/src/bluetooth_sig/device/dependency_resolver.py new file mode 100644 index 00000000..983a8a6a --- /dev/null +++ b/src/bluetooth_sig/device/dependency_resolver.py @@ -0,0 +1,170 @@ +"""Dependency resolution for characteristic reads. + +Resolves required and optional dependencies before reading a characteristic, +building a ``CharacteristicContext`` will all resolved dependency values. +""" + +from __future__ import annotations + +import logging +from enum import Enum +from typing import Any + +from ..gatt.characteristics.base import BaseCharacteristic +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.characteristics.unknown import UnknownCharacteristic +from ..gatt.context import CharacteristicContext, DeviceInfo +from ..types import CharacteristicInfo +from ..types.uuid import BluetoothUUID +from .client import ClientManagerProtocol +from .connected import DeviceConnected + +logger = logging.getLogger(__name__) + + +class DependencyResolver: + """Resolves characteristic dependencies by reading them from the device. + + Encapsulates the logic for: + - Discovering which dependencies a characteristic declares + - Reading dependency values from the device (with caching) + - Building a ``CharacteristicContext`` for the target characteristic + + Uses ``DeviceConnected`` for characteristic instance caching and + ``ClientManagerProtocol`` for BLE reads. + """ + + def __init__( + self, + connection_manager: ClientManagerProtocol, + connected: DeviceConnected, + ) -> None: + """Initialise with connection manager and connected subsystem. + + Args: + connection_manager: Connection manager for BLE reads + connected: Connected subsystem for characteristic cache + + """ + self._connection_manager = connection_manager + self._connected = connected + + async def resolve( + self, + char_class: type[BaseCharacteristic[Any]], + resolution_mode: DependencyResolutionMode, + device_info: DeviceInfo, + ) -> CharacteristicContext: + """Ensure all dependencies for a characteristic are resolved. + + Automatically reads feature characteristics needed for validation + of measurement characteristics. Feature characteristics are cached + after first read. + + Args: + char_class: The characteristic class to resolve dependencies for + resolution_mode: How to handle dependency resolution + device_info: Current device info for context construction + + Returns: + CharacteristicContext with resolved dependencies + + Raises: + RuntimeError: If no connection manager is attached + + """ + optional_deps = getattr(char_class, "_optional_dependencies", []) + required_deps = getattr(char_class, "_required_dependencies", []) + + context_chars: dict[str, Any] = {} + + for dep_class in required_deps + optional_deps: + is_required = dep_class in required_deps + + dep_uuid = dep_class.get_class_uuid() + if not dep_uuid: + if is_required: + raise ValueError(f"Required dependency {dep_class.__name__} has no UUID") + continue + + dep_uuid_str = str(dep_uuid) + + if resolution_mode == DependencyResolutionMode.SKIP_DEPENDENCIES: + continue + + # Check cache (unless force refresh) + if resolution_mode != DependencyResolutionMode.FORCE_REFRESH: + cached_char = self._connected.get_cached_characteristic(dep_uuid) + if cached_char is not None and cached_char.last_parsed is not None: + context_chars[dep_uuid_str] = cached_char.last_parsed + continue + + parsed_data = await self._resolve_single(dep_uuid, is_required, dep_class) + if parsed_data is not None: + context_chars[dep_uuid_str] = parsed_data + + return CharacteristicContext( + device_info=device_info, + other_characteristics=context_chars, + ) + + async def _resolve_single( + self, + dep_uuid: BluetoothUUID, + is_required: bool, + dep_class: type[BaseCharacteristic[Any]], + ) -> Any | None: # noqa: ANN401 # Dependency can be any characteristic type + """Read and parse a single dependency characteristic. + + Args: + dep_uuid: UUID of the dependency characteristic + is_required: Whether this is a required dependency + dep_class: The dependency characteristic class + + Returns: + Parsed characteristic data, or None if optional and failed + + Raises: + ValueError: If required dependency fails to read + + """ + dep_uuid_str = str(dep_uuid) + + try: + raw_data = await self._connection_manager.read_gatt_char(dep_uuid) + + char_instance = self._connected.get_cached_characteristic(dep_uuid) + if char_instance is None: + char_class_or_none = CharacteristicRegistry.get_characteristic_class_by_uuid(dep_uuid) + if char_class_or_none: + char_instance = char_class_or_none() + else: + char_info = CharacteristicInfo(uuid=dep_uuid, name=f"Unknown-{dep_uuid_str}") + char_instance = UnknownCharacteristic(info=char_info) + + self._connected.cache_characteristic(dep_uuid, char_instance) + + return char_instance.parse_value(raw_data) + + except Exception as e: # pylint: disable=broad-exception-caught + if is_required: + raise ValueError( + f"Failed to read required dependency {dep_class.__name__} ({dep_uuid_str}): {e}" + ) from e + logger.warning("Failed to read optional dependency %s: %s", dep_class.__name__, e) + return None + + +class DependencyResolutionMode(Enum): + """Mode for automatic dependency resolution during characteristic reads. + + Attributes: + NORMAL: Auto-resolve dependencies, use cache when available + SKIP_DEPENDENCIES: Skip dependency resolution and validation + FORCE_REFRESH: Re-read dependencies from device, ignoring cache + + """ + + NORMAL = "normal" + SKIP_DEPENDENCIES = "skip_dependencies" + FORCE_REFRESH = "force_refresh" diff --git a/src/bluetooth_sig/device/device.py b/src/bluetooth_sig/device/device.py index 0d39c52f..fbd77633 100644 --- a/src/bluetooth_sig/device/device.py +++ b/src/bluetooth_sig/device/device.py @@ -1,31 +1,23 @@ """Device class for grouping BLE device services, characteristics, encryption, and advertiser data. -This module provides a high-level Device abstraction that groups all -services, characteristics, encryption requirements, and advertiser data -for a BLE device. It integrates with the BluetoothSIGTranslator for -parsing while providing a unified view of device state. +Provides a high-level Device abstraction that groups all services, +characteristics, encryption requirements, and advertiser data for a BLE +device. """ -# pylint: disable=too-many-lines # Device abstraction is a cohesive module with related classes -# TODO split into multiple files from __future__ import annotations -import logging -from abc import abstractmethod -from enum import Enum -from typing import Any, Callable, Protocol, TypeVar, cast, overload +from collections.abc import Callable +from typing import Any, TypeVar, overload from ..advertising.registry import PayloadInterpreterRegistry from ..gatt.characteristics import CharacteristicName from ..gatt.characteristics.base import BaseCharacteristic -from ..gatt.characteristics.registry import CharacteristicRegistry -from ..gatt.characteristics.unknown import UnknownCharacteristic -from ..gatt.context import CharacteristicContext, DeviceInfo +from ..gatt.context import DeviceInfo from ..gatt.descriptors.base import BaseDescriptor from ..gatt.descriptors.registry import DescriptorRegistry from ..gatt.services import ServiceName from ..types import ( - CharacteristicInfo, DescriptorData, DescriptorInfo, ) @@ -33,8 +25,11 @@ from ..types.device_types import ScannedDevice from ..types.uuid import BluetoothUUID from .advertising import DeviceAdvertising +from .characteristic_io import CharacteristicIO from .client import ClientManagerProtocol from .connected import DeviceConnected, DeviceEncryption, DeviceService +from .dependency_resolver import DependencyResolutionMode, DependencyResolver +from .protocols import SIGTranslatorProtocol # Type variable for generic characteristic return types T = TypeVar("T") @@ -48,93 +43,18 @@ ] -class DependencyResolutionMode(Enum): - """Mode for automatic dependency resolution during characteristic reads. - - Attributes: - NORMAL: Auto-resolve dependencies, use cache when available - SKIP_DEPENDENCIES: Skip dependency resolution and validation - FORCE_REFRESH: Re-read dependencies from device, ignoring cache - """ - - NORMAL = "normal" - SKIP_DEPENDENCIES = "skip_dependencies" - FORCE_REFRESH = "force_refresh" - - -class SIGTranslatorProtocol(Protocol): # pylint: disable=too-few-public-methods - """Protocol for SIG translator interface.""" - - @abstractmethod - def parse_characteristics( - self, - char_data: dict[str, bytes], - ctx: CharacteristicContext | None = None, - ) -> dict[str, Any]: - """Parse multiple characteristics at once.""" - - @abstractmethod - def parse_characteristic( - self, - uuid: str, - raw_data: bytes, - ctx: CharacteristicContext | None = None, - ) -> Any: # noqa: ANN401 # Runtime UUID dispatch cannot be type-safe - """Parse a single characteristic's raw bytes.""" - - @abstractmethod - def get_characteristic_uuid_by_name(self, name: CharacteristicName) -> BluetoothUUID | None: - """Get the UUID for a characteristic name enum (enum-only API).""" - - @abstractmethod - def get_service_uuid_by_name(self, name: str | ServiceName) -> BluetoothUUID | None: - """Get the UUID for a service name or enum.""" - - def get_characteristic_info_by_name(self, name: CharacteristicName) -> Any | None: # noqa: ANN401 # Adapter-specific characteristic info - """Get characteristic info by enum name (optional method).""" - - class Device: # pylint: disable=too-many-instance-attributes,too-many-public-methods r"""High-level BLE device abstraction using composition pattern. - This class coordinates between connected GATT operations and advertising - packet interpretation through two subsystems: - - `device.connected` - GATT connection, services, characteristics - - `device.advertising` - Vendor-specific advertising interpretation - - Key features: - - Parse advertising data via `device.advertising` subsystem - - Manage GATT connections via `device.connected` subsystem - - Convenience methods delegate to appropriate subsystem - - Integrates with [BluetoothSIGTranslator][bluetooth_sig.BluetoothSIGTranslator] - - Attributes: - advertising: DeviceAdvertising subsystem for vendor-specific interpretation - connected: DeviceConnected subsystem for GATT operations - translator: SIG translator for parsing characteristics - - Example:: - Create and use device with composition:: - - from bluetooth_sig import BluetoothSIGTranslator - from bluetooth_sig.device import Device - - translator = BluetoothSIGTranslator() - device = Device(connection_manager, translator) - - # Use connected subsystem for GATT operations - await device.connected.connect() - await device.connected.discover_services() - battery = await device.connected.read("2A19") - - # Use advertising subsystem for packet interpretation - device.advertising.set_bindkey(b"\\x01\\x02...") - result = device.advertising.process(advertising_data) + Coordinates between connected GATT operations and advertising packet + interpretation through two subsystems: - # Convenience methods delegate to subsystems - await device.connect() # → device.connected.connect() - await device.read("battery_level") # → device.connected.read() + - ``device.connected`` — GATT connection, services, characteristics + - ``device.advertising`` — Vendor-specific advertising interpretation + Convenience methods delegate to the appropriate subsystem, so callers + can use ``await device.read("battery_level")`` without knowing which + subsystem handles it. """ def __init__(self, connection_manager: ClientManagerProtocol, translator: SIGTranslatorProtocol) -> None: @@ -160,6 +80,12 @@ def __init__(self, connection_manager: ClientManagerProtocol, translator: SIGTra # Set up registry for auto-detection self.advertising.set_registry(PayloadInterpreterRegistry()) + # Dependency resolution delegate + self._dep_resolver = DependencyResolver(connection_manager, self.connected) + + # Characteristic I/O delegate + self._char_io = CharacteristicIO(connection_manager, translator, self._dep_resolver, lambda: self.device_info) + # Cache for device_info property and last advertisement self._device_info_cache: DeviceInfo | None = None self._last_advertisement: AdvertisementData | None = None @@ -265,172 +191,9 @@ async def disconnect(self) -> None: """ await self.connected.disconnect() - def _get_cached_characteristic(self, char_uuid: BluetoothUUID) -> BaseCharacteristic[Any] | None: - """Get cached characteristic instance from services. - - Delegates to device.connected.get_cached_characteristic(). - - Args: - char_uuid: UUID of the characteristic to find - - Returns: BaseCharacteristic[Any] instance if found, None otherwise - - """ - return self.connected.get_cached_characteristic(char_uuid) - - def _cache_characteristic(self, char_uuid: BluetoothUUID, char_instance: BaseCharacteristic[Any]) -> None: - """Store characteristic instance in services cache. - - Delegates to device.connected.cache_characteristic(). - - Args: - char_uuid: UUID of the characteristic - char_instance: BaseCharacteristic[Any] instance to cache - - """ - self.connected.cache_characteristic(char_uuid, char_instance) - - def _create_unknown_characteristic(self, dep_uuid: BluetoothUUID) -> BaseCharacteristic[Any]: - """Create an unknown characteristic instance for a UUID not in registry. - - Args: - dep_uuid: UUID of the unknown characteristic - - Returns: - UnknownCharacteristic instance - - """ - dep_uuid_str = str(dep_uuid) - char_info = CharacteristicInfo(uuid=dep_uuid, name=f"Unknown-{dep_uuid_str}") - return UnknownCharacteristic(info=char_info) - - async def _resolve_single_dependency( - self, - dep_uuid: BluetoothUUID, - is_required: bool, - dep_class: type[BaseCharacteristic[Any]], - ) -> Any | None: # noqa: ANN401 # Dependency can be any characteristic type - """Resolve a single dependency by reading and parsing it. - - Args: - dep_uuid: UUID of the dependency characteristic - is_required: Whether this is a required dependency - dep_class: The dependency characteristic class - - Returns: - Parsed characteristic data, or None if optional and failed - - Raises: - ValueError: If required dependency fails to read - - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached") - - dep_uuid_str = str(dep_uuid) - - try: - raw_data = await self.connection_manager.read_gatt_char(dep_uuid) - - # Get or create characteristic instance - char_instance = self._get_cached_characteristic(dep_uuid) - if char_instance is None: - # Create a new characteristic instance using registry - char_class_or_none = CharacteristicRegistry.get_characteristic_class_by_uuid(dep_uuid) - if char_class_or_none: - char_instance = char_class_or_none() - else: - char_instance = self._create_unknown_characteristic(dep_uuid) - - # Cache the instance - self._cache_characteristic(dep_uuid, char_instance) - - # Parse using the characteristic instance - return char_instance.parse_value(raw_data) - - except Exception as e: # pylint: disable=broad-exception-caught - if is_required: - raise ValueError( - f"Failed to read required dependency {dep_class.__name__} ({dep_uuid_str}): {e}" - ) from e - # Optional dependency failed, log and continue - logging.warning("Failed to read optional dependency %s: %s", dep_class.__name__, e) - return None - - async def _ensure_dependencies_resolved( - self, - char_class: type[BaseCharacteristic[Any]], - resolution_mode: DependencyResolutionMode, - ) -> CharacteristicContext: - """Ensure all dependencies for a characteristic are resolved. - - This method automatically reads feature characteristics needed for validation - of measurement characteristics. Feature characteristics are cached after first read. - - Args: - char_class: The characteristic class to resolve dependencies for - resolution_mode: How to handle dependency resolution - - Returns: - CharacteristicContext with resolved dependencies - - Raises: - RuntimeError: If no connection manager is attached - - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached to Device") - - # Get dependency declarations from characteristic class - optional_deps = getattr(char_class, "_optional_dependencies", []) - required_deps = getattr(char_class, "_required_dependencies", []) - - # Build context with resolved dependencies - context_chars: dict[str, Any] = {} - - for dep_class in required_deps + optional_deps: - is_required = dep_class in required_deps - - # Get UUID for dependency characteristic - dep_uuid = dep_class.get_class_uuid() - if not dep_uuid: - if is_required: - raise ValueError(f"Required dependency {dep_class.__name__} has no UUID") - continue - - dep_uuid_str = str(dep_uuid) - - # Check resolution mode - if resolution_mode == DependencyResolutionMode.SKIP_DEPENDENCIES: - continue # Skip all dependency resolution - - # Check cache (unless force refresh) - if resolution_mode != DependencyResolutionMode.FORCE_REFRESH: - cached_char = self._get_cached_characteristic(dep_uuid) - if cached_char is not None and cached_char.last_parsed is not None: - # Use the last_parsed data from the cached characteristic - context_chars[dep_uuid_str] = cached_char.last_parsed - continue - - # Read and parse dependency from device - parsed_data = await self._resolve_single_dependency(dep_uuid, is_required, dep_class) - if parsed_data is not None: - context_chars[dep_uuid_str] = parsed_data - - # Create context with device info and resolved dependencies - device_info = DeviceInfo( - address=self.address, - name=self.name, - manufacturer_data=self._last_advertisement.ad_structures.core.manufacturer_data - if self._last_advertisement - else {}, - service_uuids=self._last_advertisement.ad_structures.core.service_uuids if self._last_advertisement else [], - ) - - return CharacteristicContext( - device_info=device_info, - other_characteristics=context_chars, - ) + # ------------------------------------------------------------------ + # Characteristic I/O (delegated to CharacteristicIO) + # ------------------------------------------------------------------ @overload async def read( @@ -453,63 +216,21 @@ async def read( ) -> T | Any | None: # Runtime UUID dispatch cannot be type-safe """Read a characteristic value from the device. + Delegates to :class:`CharacteristicIO`. + Args: char: Name, enum, or characteristic class to read. - Passing the class enables type-safe return values. - resolution_mode: How to handle automatic dependency resolution: - - NORMAL: Auto-resolve dependencies, use cache when available (default) - - SKIP_DEPENDENCIES: Skip dependency resolution and validation - - FORCE_REFRESH: Re-read dependencies from device, ignoring cache + resolution_mode: How to handle automatic dependency resolution. Returns: Parsed characteristic value or None if read fails. - Return type is inferred from characteristic class when provided. Raises: RuntimeError: If no connection manager is attached ValueError: If required dependencies cannot be resolved - Example:: - - # Type-safe: pass characteristic class, return type is inferred - from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic - - level: int | None = await device.read(BatteryLevelCharacteristic) - - # Not type-safe: pass string/enum, returns Any - level = await device.read(CharacteristicName.BATTERY_LEVEL) - level = await device.read("2A19") - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached to Device") - - # Handle characteristic class input (type-safe path) - if isinstance(char, type) and issubclass(char, BaseCharacteristic): - char_class: type[BaseCharacteristic[Any]] = char - char_instance = char_class() - resolved_uuid = char_instance.uuid - - ctx: CharacteristicContext | None = None - if resolution_mode != DependencyResolutionMode.SKIP_DEPENDENCIES: - ctx = await self._ensure_dependencies_resolved(char_class, resolution_mode) - - raw = await self.connection_manager.read_gatt_char(resolved_uuid) - return char_instance.parse_value(raw, ctx=ctx) - - # Handle string/enum input (not type-safe path) - resolved_uuid = self._resolve_characteristic_name(char) - - char_class_lookup = CharacteristicRegistry.get_characteristic_class_by_uuid(resolved_uuid) - - # Resolve dependencies if characteristic class is known - ctx = None - if char_class_lookup and resolution_mode != DependencyResolutionMode.SKIP_DEPENDENCIES: - ctx = await self._ensure_dependencies_resolved(char_class_lookup, resolution_mode) - - # Read the characteristic - raw = await self.connection_manager.read_gatt_char(resolved_uuid) - return self.translator.parse_characteristic(str(resolved_uuid), raw, ctx=ctx) + return await self._char_io.read(char, resolution_mode) @overload async def write( @@ -535,51 +256,19 @@ async def write( ) -> None: r"""Write data to a characteristic on the device. + Delegates to :class:`CharacteristicIO`. + Args: char: Name, enum, or characteristic class to write to. - Passing the class enables type-safe value encoding. data: Raw bytes (for string/enum) or typed value (for characteristic class). - When using characteristic class, the value is encoded using build_value(). - response: If True, use write-with-response (wait for acknowledgment). - If False, use write-without-response (faster but no confirmation). - Default is True for reliability. + response: If True, use write-with-response. Default is True. Raises: RuntimeError: If no connection manager is attached CharacteristicEncodeError: If encoding fails (when using characteristic class) - Example:: - - # Type-safe: pass characteristic class and typed value - from bluetooth_sig.gatt.characteristics import AlertLevelCharacteristic - - await device.write(AlertLevelCharacteristic, AlertLevel.HIGH) - - # Not type-safe: pass raw bytes - await device.write("2A06", b"\x02") - await device.write(CharacteristicName.ALERT_LEVEL, b"\x02") - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached to Device") - - # Handle characteristic class input (type-safe path) - if isinstance(char, type) and issubclass(char, BaseCharacteristic): - char_instance = char() - resolved_uuid = char_instance.uuid - # data is typed value T, encode it - encoded = char_instance.build_value(data) # type: ignore[arg-type] - await self.connection_manager.write_gatt_char(resolved_uuid, bytes(encoded), response=response) - return - - # Handle string/enum input (not type-safe path) - # data must be bytes in this path - if not isinstance(data, (bytes, bytearray)): - raise TypeError(f"When using string/enum char_name, data must be bytes, got {type(data).__name__}") - - resolved_uuid = self._resolve_characteristic_name(char) - # cast is safe: isinstance check above ensures data is bytes/bytearray - await self.connection_manager.write_gatt_char(resolved_uuid, cast("bytes", data), response=response) + await self._char_io.write(char, data, response=response) # type: ignore[arg-type, misc] # Union narrowing handled by overloads; mypy can't infer across delegation @overload async def start_notify( @@ -602,101 +291,28 @@ async def start_notify( ) -> None: """Start notifications for a characteristic. + Delegates to :class:`CharacteristicIO`. + Args: char: Name, enum, or characteristic class to monitor. - Passing the class enables type-safe callbacks. callback: Function to call when notifications are received. - Callback parameter type is inferred from characteristic class. Raises: RuntimeError: If no connection manager is attached - Example:: - - # Type-safe: callback receives typed value - from bluetooth_sig.gatt.characteristics import HeartRateMeasurementCharacteristic - - - def on_heart_rate(value: HeartRateMeasurementData) -> None: - print(f"Heart rate: {value.heart_rate}") - - - await device.start_notify(HeartRateMeasurementCharacteristic, on_heart_rate) - - # Not type-safe: callback receives Any - await device.start_notify("2A37", lambda v: print(v)) - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached to Device") - - # Handle characteristic class input (type-safe path) - if isinstance(char, type) and issubclass(char, BaseCharacteristic): - char_instance = char() - resolved_uuid = char_instance.uuid - - def _typed_cb(sender: str, data: bytes) -> None: - del sender # Required by callback interface - parsed = char_instance.parse_value(data) - try: - callback(parsed) - except Exception: # pylint: disable=broad-exception-caught - logging.exception("Notification callback raised an exception") - - await self.connection_manager.start_notify(resolved_uuid, _typed_cb) - return - - # Handle string/enum input (not type-safe path) - resolved_uuid = self._resolve_characteristic_name(char) - - def _internal_cb(sender: str, data: bytes) -> None: - parsed = self.translator.parse_characteristic(sender, data) - try: - callback(parsed) - except Exception: # pylint: disable=broad-exception-caught - logging.exception("Notification callback raised an exception") - - await self.connection_manager.start_notify(resolved_uuid, _internal_cb) - - def _resolve_characteristic_name(self, identifier: str | CharacteristicName) -> BluetoothUUID: - """Resolve a characteristic name or enum to its UUID. - - Args: - identifier: Characteristic name string or enum - - Returns: - Characteristic UUID string - - Raises: - ValueError: If the characteristic name cannot be resolved - - """ - if isinstance(identifier, CharacteristicName): - # For enum inputs, ask the translator for the UUID - uuid = self.translator.get_characteristic_uuid_by_name(identifier) - if uuid: - return uuid - norm = identifier.value.strip() - else: - norm = identifier - stripped = norm.replace("-", "") - if len(stripped) in (4, 8, 32) and all(c in "0123456789abcdefABCDEF" for c in stripped): - return BluetoothUUID(norm) - - raise ValueError(f"Unknown characteristic name: '{identifier}'") + await self._char_io.start_notify(char, callback) async def stop_notify(self, char_name: str | CharacteristicName) -> None: """Stop notifications for a characteristic. + Delegates to :class:`CharacteristicIO`. + Args: char_name: Characteristic name or UUID """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached") - - resolved_uuid = self._resolve_characteristic_name(char_name) - await self.connection_manager.stop_notify(resolved_uuid) + await self._char_io.stop_notify(char_name) async def read_descriptor(self, desc_uuid: BluetoothUUID | BaseDescriptor) -> DescriptorData: """Read a descriptor value from the device. @@ -820,28 +436,13 @@ async def refresh_advertisement(self, refresh: bool = False) -> AdvertisementDat """Get advertisement data from the connection manager. Args: - refresh: If True, perform an active scan to get fresh advertisement - data from the device. If False, return the last cached value. + refresh: If ``True``, perform an active scan for fresh data. Returns: - Interpreted AdvertisementData if available, None if no advertisement - has been received by the connection manager yet. + Interpreted :class:`AdvertisementData`, or ``None`` if unavailable. Raises: - RuntimeError: If no connection manager is attached - - Example:: - - device.attach_connection_manager(manager) - - # Get cached advertisement (fast, no BLE activity) - ad = await device.refresh_advertisement() - - # Force fresh scan (slower, active BLE scan) - ad = await device.refresh_advertisement(refresh=True) - - if ad and ad.interpreted_data: - print(f"Sensor: {ad.interpreted_data}") + RuntimeError: If no connection manager is attached. """ if not self.connection_manager: @@ -864,22 +465,12 @@ async def refresh_advertisement(self, refresh: bool = False) -> AdvertisementDat def parse_raw_advertisement(self, raw_data: bytes, rssi: int = 0) -> AdvertisementData: """Parse raw advertising PDU bytes directly. - Use this method when you have raw BLE advertising PDU bytes (e.g., from - a custom BLE stack or packet capture). For framework-integrated scanning, - use the connection manager's convert_advertisement() followed by - update_advertisement() instead. - Args: - raw_data: Raw BLE advertising PDU bytes - rssi: Received signal strength in dBm + raw_data: Raw BLE advertising PDU bytes. + rssi: Received signal strength in dBm. Returns: - AdvertisementData with parsed AD structures and vendor interpretation - - Example:: - # Parse raw PDU bytes directly - result = device.parse_raw_advertisement(pdu_bytes, rssi=-65) - print(result.manufacturer_data) + AdvertisementData with parsed AD structures and vendor interpretation. """ # Delegate to advertising subsystem @@ -893,25 +484,16 @@ def parse_raw_advertisement(self, raw_data: bytes, rssi: int = 0) -> Advertiseme return advertisement def get_characteristic_data(self, char_uuid: BluetoothUUID) -> Any | None: # noqa: ANN401 # Heterogeneous cache - """Get parsed characteristic data - single source of truth via characteristic.last_parsed. - - Searches across all services to find the characteristic by UUID. + """Get parsed characteristic data via ``characteristic.last_parsed``. Args: - char_uuid: UUID of the characteristic + char_uuid: UUID of the characteristic. Returns: - Parsed characteristic value if found, None otherwise. - - Example:: - - # Search for characteristic across all services - battery_data = device.get_characteristic_data(BluetoothUUID("2A19")) - if battery_data is not None: - print(f"Battery: {battery_data}%") + Parsed characteristic value if found, ``None`` otherwise. """ - char_instance = self._get_cached_characteristic(char_uuid) + char_instance = self.connected.get_cached_characteristic(char_uuid) if char_instance is not None: return char_instance.last_parsed return None @@ -919,46 +501,15 @@ def get_characteristic_data(self, char_uuid: BluetoothUUID) -> Any | None: # no async def discover_services(self) -> dict[str, Any]: """Discover services and characteristics from the connected BLE device. - This method performs BLE service discovery using the attached connection - manager, retrieving the device's service structure with characteristics - and their runtime properties (READ, WRITE, NOTIFY, etc.). - - The discovered services are stored in `self.services` as DeviceService - objects with properly instantiated characteristic classes from the registry. - - This implements the standard BLE workflow: - 1. await device.connect() - 2. await device.discover_services() # This method - 3. value = await device.read("battery_level") - - Note: - - This method discovers the SERVICE STRUCTURE (what services/characteristics - exist and their properties), but does NOT read characteristic VALUES. - - Use `read()` to retrieve actual characteristic values after discovery. - - Services are cached in `self.services` keyed by service UUID string. + Performs BLE service discovery via the connection manager. The + discovered :class:`DeviceService` objects (with characteristic + instances and runtime properties) are stored in ``self.services``. Returns: - Dictionary mapping service UUIDs to DeviceService objects + Dictionary mapping service UUIDs to DeviceService objects. Raises: - RuntimeError: If no connection manager is attached - - Example:: - - device = Device(address, translator) - device.attach_connection_manager(manager) - - await device.connect() - services = await device.discover_services() # Discover structure - - # Now services are available - for service_uuid, device_service in services.items(): - print(f"Service: {service_uuid}") - for char_uuid, char_instance in device_service.characteristics.items(): - print(f" Characteristic: {char_uuid}") - - # Read characteristic values - battery = await device.read("battery_level") + RuntimeError: If no connection manager is attached. """ # Delegate to connected subsystem @@ -996,64 +547,33 @@ async def get_characteristic_info(self, char_uuid: str) -> Any | None: # noqa: async def read_multiple(self, char_names: list[str | CharacteristicName]) -> dict[str, Any | None]: """Read multiple characteristics in batch. + Delegates to :class:`CharacteristicIO`. + Args: char_names: List of characteristic names or enums to read Returns: Dictionary mapping characteristic UUIDs to parsed values - Raises: - RuntimeError: If no connection manager is attached - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached to Device") - - results: dict[str, Any | None] = {} - for char_name in char_names: - try: - value = await self.read(char_name) - resolved_uuid = self._resolve_characteristic_name(char_name) - results[str(resolved_uuid)] = value - except Exception as exc: # pylint: disable=broad-exception-caught - resolved_uuid = self._resolve_characteristic_name(char_name) - results[str(resolved_uuid)] = None - logging.warning("Failed to read characteristic %s: %s", char_name, exc) - - return results + return await self._char_io.read_multiple(char_names) async def write_multiple( self, data_map: dict[str | CharacteristicName, bytes], response: bool = True ) -> dict[str, bool]: """Write to multiple characteristics in batch. + Delegates to :class:`CharacteristicIO`. + Args: data_map: Dictionary mapping characteristic names/enums to data bytes response: If True, use write-with-response for all writes. - If False, use write-without-response for all writes. Returns: Dictionary mapping characteristic UUIDs to success status - Raises: - RuntimeError: If no connection manager is attached - """ - if not self.connection_manager: - raise RuntimeError("No connection manager attached to Device") - - results: dict[str, bool] = {} - for char_name, data in data_map.items(): - try: - await self.write(char_name, data, response=response) - resolved_uuid = self._resolve_characteristic_name(char_name) - results[str(resolved_uuid)] = True - except Exception as exc: # pylint: disable=broad-exception-caught - resolved_uuid = self._resolve_characteristic_name(char_name) - results[str(resolved_uuid)] = False - logging.warning("Failed to write characteristic %s: %s", char_name, exc) - - return results + return await self._char_io.write_multiple(data_map, response=response) @property def device_info(self) -> DeviceInfo: diff --git a/src/bluetooth_sig/device/protocols.py b/src/bluetooth_sig/device/protocols.py new file mode 100644 index 00000000..392ef1e9 --- /dev/null +++ b/src/bluetooth_sig/device/protocols.py @@ -0,0 +1,43 @@ +"""Protocol definitions for the device subsystem.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any, Protocol + +from ..gatt.characteristics import CharacteristicName +from ..gatt.context import CharacteristicContext +from ..gatt.services import ServiceName +from ..types.uuid import BluetoothUUID + + +class SIGTranslatorProtocol(Protocol): # pylint: disable=too-few-public-methods + """Protocol for SIG translator interface.""" + + @abstractmethod + def parse_characteristics( + self, + char_data: dict[str, bytes], + ctx: CharacteristicContext | None = None, + ) -> dict[str, Any]: + """Parse multiple characteristics at once.""" + + @abstractmethod + def parse_characteristic( + self, + uuid: str, + raw_data: bytes, + ctx: CharacteristicContext | None = None, + ) -> Any: # noqa: ANN401 # Runtime UUID dispatch cannot be type-safe + """Parse a single characteristic's raw bytes.""" + + @abstractmethod + def get_characteristic_uuid_by_name(self, name: CharacteristicName) -> BluetoothUUID | None: + """Get the UUID for a characteristic name enum (enum-only API).""" + + @abstractmethod + def get_service_uuid_by_name(self, name: str | ServiceName) -> BluetoothUUID | None: + """Get the UUID for a service name or enum.""" + + def get_characteristic_info_by_name(self, name: CharacteristicName) -> Any | None: # noqa: ANN401 # Adapter-specific characteristic info + """Get characteristic info by enum name (optional method).""" diff --git a/src/bluetooth_sig/gatt/characteristics/base.py b/src/bluetooth_sig/gatt/characteristics/base.py index 140c6c67..3b77497a 100644 --- a/src/bluetooth_sig/gatt/characteristics/base.py +++ b/src/bluetooth_sig/gatt/characteristics/base.py @@ -1,87 +1,21 @@ """Base class for GATT characteristics. -This module implements the core characteristic parsing and encoding system for -Bluetooth GATT characteristics, following official Bluetooth SIG specifications. +Implements the core parsing and encoding system for Bluetooth GATT +characteristics following official Bluetooth SIG specifications. -Architecture -============ - -The implementation uses a multi-stage pipeline for parsing and encoding: - -**Parsing Pipeline (parse_value):** - 1. Length validation (pre-decode) - 2. Raw integer extraction (little-endian per Bluetooth spec) - 3. Special value detection (sentinel values like 0x8000) - 4. Value decoding (via template or subclass override) - 5. Range validation (post-decode) - 6. Type validation - -**Encoding Pipeline (build_value):** - 1. Type validation - 2. Range validation - 3. Value encoding (via template or subclass override) - 4. Length validation (post-encode) - -YAML Metadata Resolution -========================= - -Characteristic metadata is automatically resolved from Bluetooth SIG YAML specifications: - -- UUID, name, value type from assigned numbers registry -- Units, resolution, and scaling factors (M * 10^d + b formula) -- Special sentinel values (e.g., 0x8000 = "value is not known") -- Validation ranges and length constraints - -Manual overrides (_manual_unit, _special_values, etc.) should only be used for: -- Fixing incomplete or incorrect SIG specifications -- Custom characteristics not in official registry -- Performance optimizations - -Template Composition -==================== - -Characteristics use templates for reusable parsing logic via composition: - - class TemperatureCharacteristic(BaseCharacteristic): - _template = Sint16Template(resolution=0.01, unit="°C") - # No need to override decode_value() - template handles it - -Subclasses only override decode_value() for custom logic that templates -cannot handle. Templates take priority over YAML-derived extractors. - -Validation Sources (Priority Order) -=================================== - -1. **Descriptor Valid Range** - Device-reported constraints (highest priority) -2. **Class-level Attributes** - Characteristic spec defaults (min_value, max_value) -3. **YAML-derived Ranges** - Bluetooth SIG specification ranges (fallback) - -Special Values -============== - -Sentinel values (like 0x8000 for "unknown") bypass range and type validation -since they represent non-numeric states. The gss_special_values property -handles both unsigned (0x8000) and signed (-32768) interpretations for -compatibility with different parsing contexts. - -Byte Order -========== - -All multi-byte values use little-endian encoding per Bluetooth Core Specification. +See :mod:`.characteristic_meta` for infrastructure classes +(``SIGCharacteristicResolver``, ``CharacteristicMeta``, ``ValidationConfig``). +See :mod:`.pipeline` for the multi-stage parse/encode pipeline. +See :mod:`.role_classifier` for characteristic role inference. """ -# pylint: disable=too-many-lines from __future__ import annotations -import os -import re -from abc import ABC, ABCMeta -from functools import cached_property, lru_cache +import logging +from abc import ABC +from functools import cached_property from typing import Any, ClassVar, Generic, TypeVar -import msgspec - -from ...registry.uuids.units import units_registry from ...types import ( CharacteristicInfo, SpecialValueResult, @@ -89,232 +23,40 @@ class TemperatureCharacteristic(BaseCharacteristic): SpecialValueType, classify_special_value, ) -from ...types import ParseFieldError as FieldError -from ...types.data_types import ValidationAccumulator -from ...types.gatt_enums import CharacteristicName, DataType, GattProperty, ValueType +from ...types.gatt_enums import CharacteristicRole, GattProperty, ValueType from ...types.registry import CharacteristicSpec -from ...types.registry.descriptor_types import DescriptorData from ...types.uuid import BluetoothUUID from ..context import CharacteristicContext -from ..descriptor_utils import enhance_error_message_with_descriptors as _enhance_error_message -from ..descriptor_utils import get_descriptor_from_context as _get_descriptor -from ..descriptor_utils import get_presentation_format_from_context as _get_presentation_format -from ..descriptor_utils import get_user_description_from_context as _get_user_description -from ..descriptor_utils import get_valid_range_from_context as _get_valid_range -from ..descriptor_utils import validate_value_against_descriptor_range as _validate_value_range from ..descriptors import BaseDescriptor -from ..descriptors.cccd import CCCDDescriptor -from ..descriptors.characteristic_presentation_format import CharacteristicPresentationFormatData -from ..exceptions import ( - CharacteristicEncodeError, - CharacteristicParseError, - ParseFieldError, - SpecialValueDetectedError, - UUIDResolutionError, -) -from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator from ..special_values_resolver import SpecialValueResolver -from ..uuid_registry import uuid_registry +from .characteristic_meta import CharacteristicMeta, SIGCharacteristicResolver +from .characteristic_meta import ValidationConfig as ValidationConfig # noqa: PLC0414 # explicit re-export +from .context_lookup import ContextLookupMixin +from .descriptor_mixin import DescriptorMixin +from .pipeline import CharacteristicValidator, EncodePipeline, ParsePipeline +from .role_classifier import classify_role from .templates import CodingTemplate -from .utils.extractors import get_extractor + +logger = logging.getLogger(__name__) # Type variable for generic characteristic return types T = TypeVar("T") -class ValidationConfig(msgspec.Struct, kw_only=True): - """Configuration for characteristic validation constraints. - - Groups validation parameters into a single, optional configuration object - to simplify BaseCharacteristic constructor signatures. - """ - - min_value: int | float | None = None - max_value: int | float | None = None - expected_length: int | None = None - min_length: int | None = None - max_length: int | None = None - allow_variable_length: bool = False - expected_type: type | None = None - - -class SIGCharacteristicResolver: - """Resolves SIG characteristic information from YAML and registry. - - This class handles all SIG characteristic resolution logic, separating - concerns from the BaseCharacteristic constructor. Uses shared utilities - from the resolver module to avoid code duplication. - """ - - camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name) - - @staticmethod - def resolve_for_class(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicInfo: - """Resolve CharacteristicInfo for a SIG characteristic class. - - Args: - char_class: The characteristic class to resolve info for - - Returns: - CharacteristicInfo with resolved UUID, name, value_type, unit - - Raises: - UUIDResolutionError: If no UUID can be resolved for the class - - """ - # Try YAML resolution first - yaml_spec = SIGCharacteristicResolver.resolve_yaml_spec_for_class(char_class) - if yaml_spec: - return SIGCharacteristicResolver._create_info_from_yaml(yaml_spec, char_class) - - # Fallback to registry - registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class) - if registry_info: - return registry_info - - # No resolution found - raise UUIDResolutionError(char_class.__name__, [char_class.__name__]) - - @staticmethod - def resolve_yaml_spec_for_class(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicSpec | None: - """Resolve YAML spec for a characteristic class using shared name variant logic.""" - # Get explicit name if set - characteristic_name = getattr(char_class, "_characteristic_name", None) - - # Generate all name variants using shared utility - names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name) - - # Try each name format with YAML resolution - for try_name in names_to_try: - spec = uuid_registry.resolve_characteristic_spec(try_name) - if spec: - return spec - - return None - - @staticmethod - def _create_info_from_yaml( - yaml_spec: CharacteristicSpec, char_class: type[BaseCharacteristic[Any]] - ) -> CharacteristicInfo: - """Create CharacteristicInfo from YAML spec, resolving metadata via registry classes.""" - value_type = DataType.from_string(yaml_spec.data_type).to_value_type() - - # Resolve unit via registry if present - unit_info = None - unit_name = getattr(yaml_spec, "unit_symbol", None) or getattr(yaml_spec, "unit", None) - if unit_name: - unit_info = units_registry.get_unit_info_by_name(unit_name) - if unit_info: - # Prefer symbol, fallback to name, always ensure string - unit_symbol = str(getattr(unit_info, "symbol", getattr(unit_info, "name", unit_name))) - else: - unit_symbol = str(unit_name or "") - - # TODO: Add similar logic for object types, service classes, etc. as needed - - return CharacteristicInfo( - uuid=yaml_spec.uuid, - name=yaml_spec.name or char_class.__name__, - unit=unit_symbol, - value_type=value_type, - ) - - @staticmethod - def resolve_from_registry(char_class: type[BaseCharacteristic[Any]]) -> CharacteristicInfo | None: - """Fallback to registry resolution using shared search strategy.""" - # Use shared registry search strategy - search_strategy = CharacteristicRegistrySearch() - characteristic_name = getattr(char_class, "_characteristic_name", None) - return search_strategy.search(char_class, characteristic_name) - - -class CharacteristicMeta(ABCMeta): - """Metaclass to automatically handle template flags for characteristics.""" - - def __new__( - mcs, - name: str, - bases: tuple[type, ...], - namespace: dict[str, Any], - **kwargs: Any, # noqa: ANN401 # Metaclass receives arbitrary keyword arguments - ) -> type: - """Create the characteristic class and handle template markers. - - This metaclass hook ensures template classes and concrete - implementations are correctly annotated with the ``_is_template`` - attribute before the class object is created. - """ - # Auto-handle template flags before class creation so attributes are part of namespace - if bases: # Not the base class itself - # Check if this class is in templates.py (template) or a concrete implementation - module_name = namespace.get("__module__", "") - is_in_templates = "templates" in module_name - - # If it's NOT in templates.py and inherits from a template, mark as concrete - if not is_in_templates and not namespace.get("_is_template_override", False): - # Check if any parent has _is_template = True - has_template_parent = any(getattr(base, "_is_template", False) for base in bases) - if has_template_parent and "_is_template" not in namespace: - namespace["_is_template"] = False # Mark as concrete characteristic - - # Create the class normally - return super().__new__(mcs, name, bases, namespace, **kwargs) - - -class BaseCharacteristic(ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods +class BaseCharacteristic(ContextLookupMixin, DescriptorMixin, ABC, Generic[T], metaclass=CharacteristicMeta): # pylint: disable=too-many-instance-attributes,too-many-public-methods """Base class for all GATT characteristics. - Generic over T, the return type of _decode_value(). + Generic over *T*, the return type of ``_decode_value()``. - Automatically resolves UUID, unit, and value_type from Bluetooth SIG YAML specifications. - Supports manual overrides via _manual_unit and _manual_value_type attributes. - - Note: This class intentionally has >20 public methods as it provides the complete - characteristic API including parsing, validation, UUID resolution, registry interaction, - and metadata access. The methods are well-organized by functionality. + Automatically resolves UUID, unit, and value_type from Bluetooth SIG YAML + specifications. Supports manual overrides via ``_manual_unit`` and + ``_manual_value_type`` attributes. Validation Attributes (optional class-level declarations): - min_value: Minimum allowed value for parsed data - max_value: Maximum allowed value for parsed data - expected_length: Exact expected data length in bytes - min_length: Minimum required data length in bytes - max_length: Maximum allowed data length in bytes - allow_variable_length: Whether variable length data is acceptable - expected_type: Expected Python type for parsed values - - Example usage in subclasses: - class ExampleCharacteristic(BaseCharacteristic): - '''Example showing validation attributes usage.''' - - # Declare validation constraints as class attributes - expected_length = 2 - min_value = 0 - max_value = 65535 # UINT16_MAX - expected_type = int - - def _decode_value(self, data: bytearray) -> int: - # Just parse - validation happens automatically in parse_value - return DataParser.parse_int16(data, 0, signed=False) - - # Before: BatteryLevelCharacteristic with hardcoded validation - # class BatteryLevelCharacteristic(BaseCharacteristic): - # def _decode_value(self, data: bytearray) -> int: - # if not data: - # raise ValueError("Battery level data must be at least 1 byte") - # level = data[0] - # if not 0 <= level <= PERCENTAGE_MAX: - # raise ValueError(f"Battery level must be 0-100, got {level}") - # return level - - # After: BatteryLevelCharacteristic with declarative validation - # class BatteryLevelCharacteristic(BaseCharacteristic): - # expected_length = 1 - # min_value = 0 - # max_value = 100 # PERCENTAGE_MAX - # expected_type = int - # - # def _decode_value(self, data: bytearray) -> int: - # return data[0] # Validation happens automatically + min_value / max_value: Allowed numeric range. + expected_length / min_length / max_length: Byte-length constraints. + allow_variable_length: Accept variable length data. + expected_type: Expected Python type for parsed values. """ # Explicit class attributes with defaults (replaces getattr usage) @@ -346,6 +88,11 @@ def _decode_value(self, data: bytearray) -> int: # Set to "0", "false", or "no" to disable trace collection _enable_parse_trace: bool = True # Default: enabled + # Role classification (computed once per concrete subclass) + # Subclasses can set _manual_role to bypass the heuristic entirely. + _manual_role: ClassVar[CharacteristicRole | None] = None + _cached_role: ClassVar[CharacteristicRole | None] = None + # Special value handling (GSS-derived) # Manual override for special values when GSS spec is incomplete/wrong. # Format: {raw_value: meaning_string}. GSS values are used by default. @@ -409,6 +156,11 @@ def __init__( # Last parsed value for caching/debugging self.last_parsed: T | None = None + # Pipeline composition — validator is shared by parse and encode pipelines + self._validator = CharacteristicValidator(self) + self._parse_pipeline = ParsePipeline(self, self._validator) + self._encode_pipeline = EncodePipeline(self, self._validator) + # Call post-init to resolve characteristic info self.__post_init__() @@ -538,6 +290,22 @@ def description(self) -> str: """Get the characteristic description from GSS specification.""" return self._spec.description if self._spec and self._spec.description else "" + @property + def role(self) -> CharacteristicRole: + """Classify the characteristic's purpose from SIG spec metadata. + + Override via ``_manual_role`` class variable, or the heuristic in + :func:`.role_classifier.classify_role` is used. Result is cached + per concrete subclass. + """ + cls = type(self) + if cls._cached_role is None: + if cls._manual_role is not None: + cls._cached_role = cls._manual_role + else: + cls._cached_role = classify_role(self.name, self.value_type, self.unit, self._spec) + return cls._cached_role + @property def display_name(self) -> str: """Get the display name for this characteristic. @@ -643,7 +411,7 @@ def _normalize_dependency_class(cls, dep_class: type[BaseCharacteristic[Any]]) - if class_uuid is not None: return str(class_uuid) except (ValueError, AttributeError, TypeError): - pass + logger.warning("Failed to resolve class UUID for dependency %s", dep_class.__name__) try: temp_instance = dep_class() @@ -742,7 +510,7 @@ def _resolve_class_uuid(cls) -> BluetoothUUID | None: try: return info.uuid except AttributeError: - pass + logger.warning("_info attribute has no uuid for class %s", cls.__name__) # Try cross-file resolution for SIG characteristics yaml_spec = cls._resolve_yaml_spec_class() @@ -786,25 +554,10 @@ def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool: return class_uuid == input_uuid def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> T: - """Internal parse the characteristic's raw value with no validation. - - This is expected to be called from parse_value() which handles validation. - - If _template is set, uses the template's decode_value method. - Otherwise, subclasses must override this method. - - Args: - data: Raw bytes from the characteristic read - ctx: Optional context information for parsing - validate: Whether to validate ranges (default True) - validate: Whether to validate ranges (default True) - - Returns: - Parsed value in the appropriate type - - Raises: - NotImplementedError: If no template is set and subclass doesn't override + """Decode raw bytes into the characteristic's typed value. + Called internally by :meth:`parse_value` after pipeline validation. + Uses *_template* when set; subclasses override for custom logic. """ if self._template is not None: return self._template.decode_value( # pylint: disable=protected-access @@ -812,628 +565,55 @@ def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = Non ) raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override decode_value()") - def _validate_range( - self, - value: Any, # noqa: ANN401 # Validates values of various numeric types - ctx: CharacteristicContext | None = None, - ) -> ValidationAccumulator: # pylint: disable=too-many-branches # Multiple validation precedence levels per spec - """Validate value is within min/max range from both class attributes and descriptors. - - Validation precedence: - 1. Descriptor Valid Range (if present in context) - most specific, device-reported - 2. Class-level validation attributes (min_value, max_value) - characteristic spec defaults - 3. YAML-derived value range from structure - Bluetooth SIG specification - - Args: - value: The value to validate - ctx: Optional characteristic context containing descriptors - - Returns: - ValidationReport with errors if validation fails - """ - result = ValidationAccumulator() - - # Skip validation for SpecialValueResult - if isinstance(value, SpecialValueResult): - return result - - # Skip validation for non-numeric types - if not isinstance(value, (int, float)): - return result - - # Check descriptor Valid Range first (takes precedence over class attributes) - descriptor_range = self.get_valid_range_from_context(ctx) if ctx else None - if descriptor_range is not None: - min_val, max_val = descriptor_range - if value < min_val or value > max_val: - error_msg = ( - f"Value {value} is outside valid range [{min_val}, {max_val}] " - f"(source: Valid Range descriptor for {self.name})" - ) - if self.unit: - error_msg += f" [unit: {self.unit}]" - result.add_error(error_msg) - # Descriptor validation checked - skip class-level checks - return result - - # Fall back to class-level validation attributes - if self.min_value is not None and value < self.min_value: - error_msg = ( - f"Value {value} is below minimum {self.min_value} " - f"(source: class-level constraint for {self.__class__.__name__})" - ) - if self.unit: - error_msg += f" [unit: {self.unit}]" - result.add_error(error_msg) - if self.max_value is not None and value > self.max_value: - error_msg = ( - f"Value {value} is above maximum {self.max_value} " - f"(source: class-level constraint for {self.__class__.__name__})" - ) - if self.unit: - error_msg += f" [unit: {self.unit}]" - result.add_error(error_msg) - - # Fall back to YAML-derived value range from structure - # Use tolerance-based comparison for floating-point values due to precision loss in scaled types - if self.min_value is None and self.max_value is None and self._spec and self._spec.structure: - for field in self._spec.structure: - yaml_range = field.value_range - if yaml_range is not None: - min_val, max_val = yaml_range - # Use tolerance for floating-point comparison (common in scaled characteristics) - tolerance = max(abs(max_val - min_val) * 1e-9, 1e-9) if isinstance(value, float) else 0 - if value < min_val - tolerance or value > max_val + tolerance: - yaml_source = f"{self._spec.name}" if self._spec.name else "YAML specification" - error_msg = ( - f"Value {value} is outside allowed range [{min_val}, {max_val}] " - f"(source: Bluetooth SIG {yaml_source})" - ) - if self.unit: - error_msg += f" [unit: {self.unit}]" - result.add_error(error_msg) - break # Use first field with range found - - return result - - def _validate_type(self, value: Any) -> ValidationAccumulator: # noqa: ANN401 - """Validate value type matches expected_type if specified. - - Args: - value: The value to validate - validate: Whether validation is enabled - - Returns: - ValidationReport with errors if validation fails - """ - result = ValidationAccumulator() - - if self.expected_type is not None and not isinstance(value, (self.expected_type, SpecialValueResult)): - error_msg = ( - f"Type validation failed for {self.name}: " - f"expected {self.expected_type.__name__}, got {type(value).__name__} " - f"(value: {value})" - ) - result.add_error(error_msg) - return result - - def _validate_length(self, data: bytes | bytearray) -> ValidationAccumulator: - """Validate data length meets requirements. - - Args: - data: The data to validate - - Returns: - ValidationReport with errors if validation fails - """ - result = ValidationAccumulator() - - length = len(data) - - # Determine validation source for error context - yaml_size = self.get_yaml_field_size() - source_context = "" - if yaml_size is not None: - source_context = f" (YAML specification: {yaml_size} bytes)" - elif self.expected_length is not None or self.min_length is not None or self.max_length is not None: - source_context = f" (class-level constraint for {self.__class__.__name__})" - - if self.expected_length is not None and length != self.expected_length: - error_msg = ( - f"Length validation failed for {self.name}: " - f"expected exactly {self.expected_length} bytes, got {length}{source_context}" - ) - result.add_error(error_msg) - if self.min_length is not None and length < self.min_length: - error_msg = ( - f"Length validation failed for {self.name}: " - f"expected at least {self.min_length} bytes, got {length}{source_context}" - ) - result.add_error(error_msg) - if self.max_length is not None and length > self.max_length: - error_msg = ( - f"Length validation failed for {self.name}: " - f"expected at most {self.max_length} bytes, got {length}{source_context}" - ) - result.add_error(error_msg) - return result - - def _extract_raw_int( - self, - data: bytearray, - enable_trace: bool, - parse_trace: list[str], - ) -> int | None: - """Extract raw integer from bytes using the extraction pipeline. - - Tries extraction in order of precedence: - 1. Template extractor (if _template with extractor is set) - 2. YAML-derived extractor (based on get_yaml_data_type()) - - Args: - data: Raw bytes to extract from. - enable_trace: Whether to log trace messages. - parse_trace: List to append trace messages to. - - Returns: - Raw integer value, or None if no extractor is available. - """ - # Priority 1: Template extractor - if self._template is not None and self._template.extractor is not None: - if enable_trace: - parse_trace.append("Extracting raw integer via template extractor") - raw_int = self._template.extractor.extract(data, offset=0) - if enable_trace: - parse_trace.append(f"Extracted raw_int: {raw_int}") - return raw_int - - # Priority 2: YAML data type extractor - yaml_type = self.get_yaml_data_type() - if yaml_type is not None: - extractor = get_extractor(yaml_type) - if extractor is not None: - if enable_trace: - parse_trace.append(f"Extracting raw integer via YAML type '{yaml_type}'") - raw_int = extractor.extract(data, offset=0) - if enable_trace: - parse_trace.append(f"Extracted raw_int: {raw_int}") - return raw_int - - # No extractor available - if enable_trace: - parse_trace.append("No extractor available for raw_int extraction") - return None - - def _pack_raw_int(self, raw: int) -> bytearray: - """Pack a raw integer to bytes using template extractor or YAML extractor.""" - # Priority 1: template extractor - if self._template is not None: - extractor = getattr(self._template, "extractor", None) - if extractor is not None: - return bytearray(extractor.pack(raw)) - - # Priority 2: YAML-derived extractor - yaml_type = self.get_yaml_data_type() - if yaml_type is not None: - extractor = get_extractor(yaml_type) - if extractor is not None: - return bytearray(extractor.pack(raw)) - - raise ValueError("No extractor available to pack raw integer for this characteristic") - - def _get_dependency_from_context( - self, - ctx: CharacteristicContext, - dep_class: type[BaseCharacteristic[Any]], - ) -> Any: # noqa: ANN401 # Dependency type determined by dep_class at runtime - """Get dependency from context using type-safe class reference. - - Note: - Returns ``Any`` because the dependency type is determined at runtime - by ``dep_class``. For type-safe access, the caller should know the - expected type based on the class they pass in. - - Args: - ctx: Characteristic context containing other characteristics - dep_class: Dependency characteristic class to look up - - Returns: - Parsed characteristic value if found in context, None otherwise. - - """ - # Resolve class to UUID - dep_uuid = dep_class.get_class_uuid() - if not dep_uuid: - return None - - # Lookup in context by UUID (string key) - if ctx.other_characteristics is None: - return None - return ctx.other_characteristics.get(str(dep_uuid)) - - @staticmethod - @lru_cache(maxsize=32) - def _get_characteristic_uuid_by_name( - characteristic_name: CharacteristicName | str, - ) -> BluetoothUUID | None: - """Get characteristic UUID by name using cached registry lookup.""" - # Convert enum to string value for registry lookup - name_str = ( - characteristic_name.value if isinstance(characteristic_name, CharacteristicName) else characteristic_name - ) - char_info = uuid_registry.get_characteristic_info(name_str) - return char_info.uuid if char_info else None - - def get_context_characteristic( - self, - ctx: CharacteristicContext | None, - characteristic_name: CharacteristicName | str | type[BaseCharacteristic[Any]], - ) -> Any: # noqa: ANN401 # Type determined by characteristic_name at runtime - """Find a characteristic in a context by name or class. - - Note: - Returns ``Any`` because the characteristic type is determined at - runtime by ``characteristic_name``. For type-safe access, use direct - characteristic class instantiation instead of this lookup method. - - Args: - ctx: Context containing other characteristics. - characteristic_name: Enum, string name, or characteristic class. - - Returns: - Parsed characteristic value if found, None otherwise. - - """ - if not ctx or not ctx.other_characteristics: - return None - - # Extract UUID from class if provided - if isinstance(characteristic_name, type): - # Class reference provided - try to get class-level UUID - configured_info: CharacteristicInfo | None = getattr(characteristic_name, "_configured_info", None) - if configured_info is not None: - # Custom characteristic with explicit _configured_info - char_uuid = configured_info.uuid - else: - # SIG characteristic: convert class name to SIG name and resolve via registry - class_name: str = characteristic_name.__name__ - # Remove 'Characteristic' suffix - name_without_suffix: str = class_name.replace("Characteristic", "") - # Insert spaces before capital letters to get SIG name - sig_name: str = re.sub(r"(? int | SpecialValueResult: - """Check if raw value is a special sentinel value and return appropriate result. - - Args: - raw_value: The raw integer value to check - - Returns: - SpecialValueResult if raw_value is special, otherwise raw_value unchanged - """ - res = self._special_resolver.resolve(raw_value) - if res is not None: - return res - return raw_value - - def _is_parse_trace_enabled(self) -> bool: - """Check if parse trace is enabled via environment variable or instance attribute. - - Returns: - True if parse tracing is enabled, False otherwise - - Environment Variables: - BLUETOOTH_SIG_ENABLE_PARSE_TRACE: Set to "0", "false", or "no" to disable - - Instance Attributes: - _enable_parse_trace: Set to False to disable tracing for this instance - """ - # Check environment variable first - env_value = os.getenv("BLUETOOTH_SIG_ENABLE_PARSE_TRACE", "").lower() - if env_value in ("0", "false", "no"): - return False - - # Return True unless explicitly disabled - return self._enable_parse_trace is not False - - def _perform_parse_validation( # pylint: disable=too-many-arguments,too-many-positional-arguments - self, - data_bytes: bytearray, - enable_trace: bool, - parse_trace: list[str], - validation: ValidationAccumulator, - validate: bool, - ) -> None: - """Perform initial validation on parse data.""" - if not validate: - return - if enable_trace: - parse_trace.append(f"Validating data length (got {len(data_bytes)} bytes)") - length_validation = self._validate_length(data_bytes) - validation.errors.extend(length_validation.errors) - validation.warnings.extend(length_validation.warnings) - if not length_validation.valid: - raise ValueError("; ".join(length_validation.errors)) - - def _extract_and_check_special_value( # pylint: disable=unused-argument # ctx used in get_valid_range_from_context by callers - self, data_bytes: bytearray, enable_trace: bool, parse_trace: list[str], ctx: CharacteristicContext | None - ) -> tuple[int | None, int | SpecialValueResult | None]: - """Extract raw int and check for special values.""" - # Extract raw integer using the pipeline - raw_int = self._extract_raw_int(data_bytes, enable_trace, parse_trace) - - # Check for special values if raw_int was extracted - parsed_value = None - if raw_int is not None: - if enable_trace: - parse_trace.append("Checking for special values") - parsed_value = self._check_special_value(raw_int) - if enable_trace: - if isinstance(parsed_value, SpecialValueResult): - parse_trace.append(f"Found special value: {parsed_value}") - else: - parse_trace.append("Not a special value, proceeding with decode") - - return raw_int, parsed_value - - def _decode_and_validate_value( # pylint: disable=too-many-arguments,too-many-positional-arguments # All parameters necessary for decode/validate pipeline - self, - data_bytes: bytearray, - enable_trace: bool, - parse_trace: list[str], - ctx: CharacteristicContext | None, - validation: ValidationAccumulator, - validate: bool, - ) -> T: - """Decode value and perform validation. - - At this point, special values have already been handled by the caller. - """ - if enable_trace: - parse_trace.append("Decoding value") - # Pass validate flag directly to template decode_value method - decoded_value: T = self._decode_value(data_bytes, ctx, validate=validate) - - if validate: - if enable_trace: - parse_trace.append("Validating range") - range_validation = self._validate_range(decoded_value, ctx) - validation.errors.extend(range_validation.errors) - validation.warnings.extend(range_validation.warnings) - if not range_validation.valid: - raise ValueError("; ".join(range_validation.errors)) - if enable_trace: - parse_trace.append("Validating type") - type_validation = self._validate_type(decoded_value) - validation.errors.extend(type_validation.errors) - validation.warnings.extend(type_validation.warnings) - if not type_validation.valid: - raise ValueError("; ".join(type_validation.errors)) - return decoded_value - def parse_value( self, data: bytes | bytearray, ctx: CharacteristicContext | None = None, validate: bool = True ) -> T: """Parse characteristic data. - Returns: Parsed value of type T + Delegates to :class:`ParsePipeline` for the multi-stage pipeline + (length validation → raw int extraction → special value detection → + decode → range/type validation). + + Returns: + Parsed value of type T. + Raises: SpecialValueDetectedError: Special sentinel (0x8000="unknown", 0x7FFFFFFF="NaN") CharacteristicParseError: Parse/validation failure - """ - data_bytes = bytearray(data) - enable_trace = self._is_parse_trace_enabled() - parse_trace: list[str] = ["Starting parse"] if enable_trace else [] - field_errors: list[FieldError] = [] - validation = ValidationAccumulator() - raw_int: int | None = None - - try: - self._perform_parse_validation(data_bytes, enable_trace, parse_trace, validation, validate) - raw_int, parsed_value = self._extract_and_check_special_value(data_bytes, enable_trace, parse_trace, ctx) - except Exception as e: - if enable_trace: - parse_trace.append(f"Parse failed: {type(e).__name__}: {e}") - raise CharacteristicParseError( - message=str(e), - name=self.name, - uuid=self.uuid, - raw_data=bytes(data), - raw_int=raw_int, - field_errors=field_errors, - parse_trace=parse_trace, - validation=validation, - ) from e - - if isinstance(parsed_value, SpecialValueResult): - if enable_trace: - parse_trace.append(f"Detected special value: {parsed_value.meaning}") - raise SpecialValueDetectedError( - special_value=parsed_value, name=self.name, uuid=self.uuid, raw_data=bytes(data), raw_int=raw_int - ) - try: - decoded_value = self._decode_and_validate_value( - data_bytes, enable_trace, parse_trace, ctx, validation, validate - ) - except Exception as e: - if enable_trace: - parse_trace.append(f"Parse failed: {type(e).__name__}: {e}") - if isinstance(e, ParseFieldError): - field_errors.append( - FieldError( - field=e.field, - reason=e.field_reason, - offset=e.offset, - raw_slice=bytes(e.data) if hasattr(e, "data") else None, - ) - ) - raise CharacteristicParseError( - message=str(e), - name=self.name, - uuid=self.uuid, - raw_data=bytes(data), - raw_int=raw_int, - field_errors=field_errors, - parse_trace=parse_trace, - validation=validation, - ) from e - - if enable_trace: - parse_trace.append("Parse completed successfully") - - self.last_parsed = decoded_value - return decoded_value + """ + decoded: T = self._parse_pipeline.run(data, ctx, validate) + self.last_parsed = decoded + return decoded def _encode_value(self, data: Any) -> bytearray: # noqa: ANN401 - """Internal encode the characteristic's value to raw bytes with no validation. - - This is expected to called from build_value() after validation. - - If _template is set, uses the template's encode_value method. - Otherwise, subclasses must override this method. - - This is a low-level method that performs no validation. For encoding - with validation, use encode() instead. - - Args: - data: Dataclass instance or value to encode - - Returns: - Encoded bytes for characteristic write - - Raises: - ValueError: If data is invalid for encoding - NotImplementedError: If no template is set and subclass doesn't override + """Encode a typed value into raw bytes (no validation). + Called internally by :meth:`build_value` after pipeline validation. + Uses *_template* when set; subclasses override for custom logic. """ if self._template is not None: return self._template.encode_value(data) # pylint: disable=protected-access raise NotImplementedError(f"{self.__class__.__name__} must either set _template or override encode_value()") - def build_value( # pylint: disable=too-many-branches - self, data: T | SpecialValueResult, validate: bool = True - ) -> bytearray: + def build_value(self, data: T | SpecialValueResult, validate: bool = True) -> bytearray: """Encode value or special value to characteristic bytes. + Delegates to :class:`EncodePipeline` for the multi-stage pipeline + (type validation → range validation → encode → length validation). + Args: - data: Value to encode (type T) or special value to encode - validate: Enable validation (type, range, length checks) - Note: Special values bypass validation + data: Value to encode (type T) or :class:`SpecialValueResult`. + validate: Enable validation (type, range, length checks). Returns: - Encoded bytes ready for BLE write + Encoded bytes ready for BLE write. Raises: - CharacteristicEncodeError: If encoding or validation fails - - Examples: - # Normal value - data = char.build_value(37.5) # Returns: bytearray([0xAA, 0x0E]) - - # Special value (for testing/simulation) - from bluetooth_sig.types import SpecialValueResult, SpecialValueType - special = SpecialValueResult( - raw_value=0x8000, - meaning="value is not known", - value_type=SpecialValueType.NOT_KNOWN - ) - data = char.build_value(special) # Returns: bytearray([0x00, 0x80]) - - # With validation disabled (for debugging) - data = char.build_value(200.0, validate=False) # Allows out-of-range - - # Error handling - try: - data = char.build_value(value) - except CharacteristicEncodeError as e: - print(f"Encode failed: {e}") + CharacteristicEncodeError: If encoding or validation fails. """ - enable_trace = self._is_parse_trace_enabled() - build_trace: list[str] = ["Starting build"] if enable_trace else [] - validation = ValidationAccumulator() - - # Special value encoding - bypass validation - if isinstance(data, SpecialValueResult): - if enable_trace: - build_trace.append(f"Encoding special value: {data.meaning}") - try: - return self._pack_raw_int(data.raw_value) - except Exception as e: - raise CharacteristicEncodeError( - message=f"Failed to encode special value: {e}", - name=self.name, - uuid=self.uuid, - value=data, - validation=None, - ) from e - - try: - # Type validation - if validate: - if enable_trace: - build_trace.append("Validating type") - type_validation = self._validate_type(data) - validation.errors.extend(type_validation.errors) - validation.warnings.extend(type_validation.warnings) - if not type_validation.valid: - raise TypeError("; ".join(type_validation.errors)) # noqa: TRY301 - - # Range validation for numeric types - if validate and isinstance(data, (int, float)): - if enable_trace: - build_trace.append("Validating range") - range_validation = self._validate_range(data, ctx=None) - validation.errors.extend(range_validation.errors) - validation.warnings.extend(range_validation.warnings) - if not range_validation.valid: - raise ValueError("; ".join(range_validation.errors)) # noqa: TRY301 - - # Encode - if enable_trace: - build_trace.append("Encoding value") - encoded = self._encode_value(data) - - # Length validation - if validate: - if enable_trace: - build_trace.append("Validating encoded length") - length_validation = self._validate_length(encoded) - validation.errors.extend(length_validation.errors) - validation.warnings.extend(length_validation.warnings) - if not length_validation.valid: - raise ValueError("; ".join(length_validation.errors)) # noqa: TRY301 - - if enable_trace: - build_trace.append("Build completed successfully") - - except Exception as e: - if enable_trace: - build_trace.append(f"Build failed: {type(e).__name__}: {e}") - - raise CharacteristicEncodeError( - message=str(e), - name=self.name, - uuid=self.uuid, - value=data, - validation=validation, - ) from e - else: - return encoded + return self._encode_pipeline.run(data, validate) # -------------------- Encoding helpers for special values -------------------- def encode_special(self, value_type: SpecialValueType) -> bytearray: @@ -1441,20 +621,14 @@ def encode_special(self, value_type: SpecialValueType) -> bytearray: Raises ValueError if no raw value of that type is defined for this characteristic. """ - raw = self._special_resolver.get_raw_for_type(value_type) - if raw is None: - raise ValueError(f"No special value of type {value_type.name} defined for this characteristic") - return self._pack_raw_int(raw) + return self._encode_pipeline.encode_special(value_type) def encode_special_by_meaning(self, meaning: str) -> bytearray: """Encode a special value by a partial meaning string match. Raises ValueError if no matching special value is found. """ - raw = self._special_resolver.get_raw_for_meaning(meaning) - if raw is None: - raise ValueError(f"No special value matching '{meaning}' defined for this characteristic") - return self._pack_raw_int(raw) + return self._encode_pipeline.encode_special_by_meaning(meaning) @property def unit(self) -> str: @@ -1519,137 +693,6 @@ def is_signed_from_yaml(self) -> bool: # Check for signed types: signed integers, medical floats, and standard floats return data_type.startswith("sint") or data_type in ("medfloat16", "medfloat32", "float32", "float64") - # Descriptor support methods - - def add_descriptor(self, descriptor: BaseDescriptor) -> None: - """Add a descriptor to this characteristic. - - Args: - descriptor: The descriptor instance to add - """ - self._descriptors[str(descriptor.uuid)] = descriptor - - def get_descriptor(self, uuid: str | BluetoothUUID) -> BaseDescriptor | None: - """Get a descriptor by UUID. - - Args: - uuid: Descriptor UUID (string or BluetoothUUID) - - Returns: - Descriptor instance if found, None otherwise - """ - # Convert to BluetoothUUID for consistent handling - if isinstance(uuid, str): - try: - uuid_obj = BluetoothUUID(uuid) - except ValueError: - return None - else: - uuid_obj = uuid - - return self._descriptors.get(uuid_obj.dashed_form) - - def get_descriptors(self) -> dict[str, BaseDescriptor]: - """Get all descriptors for this characteristic. - - Returns: - Dict mapping descriptor UUID strings to descriptor instances - """ - return self._descriptors.copy() - - def get_cccd(self) -> BaseDescriptor | None: - """Get the Client Characteristic Configuration Descriptor (CCCD). - - Returns: - CCCD descriptor instance if present, None otherwise - """ - return self.get_descriptor(CCCDDescriptor().uuid) - - def can_notify(self) -> bool: - """Check if this characteristic supports notifications. - - Returns: - True if the characteristic has a CCCD descriptor, False otherwise - """ - return self.get_cccd() is not None - - def get_descriptor_from_context( - self, ctx: CharacteristicContext | None, descriptor_class: type[BaseDescriptor] - ) -> DescriptorData | None: - """Get a descriptor of the specified type from the context. - - Args: - ctx: Characteristic context containing descriptors - descriptor_class: The descriptor class to look for (e.g., ValidRangeDescriptor) - - Returns: - DescriptorData if found, None otherwise - """ - return _get_descriptor(ctx, descriptor_class) - - def get_valid_range_from_context( - self, ctx: CharacteristicContext | None = None - ) -> tuple[int | float, int | float] | None: - """Get valid range from descriptor context if available. - - Args: - ctx: Characteristic context containing descriptors - - Returns: - Tuple of (min, max) values if Valid Range descriptor present, None otherwise - """ - return _get_valid_range(ctx) - - def get_presentation_format_from_context( - self, ctx: CharacteristicContext | None = None - ) -> CharacteristicPresentationFormatData | None: - """Get presentation format from descriptor context if available. - - Args: - ctx: Characteristic context containing descriptors - - Returns: - CharacteristicPresentationFormatData if present, None otherwise - """ - return _get_presentation_format(ctx) - - def get_user_description_from_context(self, ctx: CharacteristicContext | None = None) -> str | None: - """Get user description from descriptor context if available. - - Args: - ctx: Characteristic context containing descriptors - - Returns: - User description string if present, None otherwise - """ - return _get_user_description(ctx) - - def validate_value_against_descriptor_range(self, value: float, ctx: CharacteristicContext | None = None) -> bool: - """Validate a value against descriptor-defined valid range. - - Args: - value: Value to validate - ctx: Characteristic context containing descriptors - - Returns: - True if value is within valid range or no range defined, False otherwise - """ - return _validate_value_range(value, ctx) - - def enhance_error_message_with_descriptors( - self, base_message: str, ctx: CharacteristicContext | None = None - ) -> str: - """Enhance error message with descriptor information for better debugging. - - Args: - base_message: Original error message - ctx: Characteristic context containing descriptors - - Returns: - Enhanced error message with descriptor context - """ - return _enhance_error_message(base_message, ctx) - def get_byte_order_hint(self) -> str: """Get byte order hint (Bluetooth SIG uses little-endian by convention).""" return "little" diff --git a/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py b/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py new file mode 100644 index 00000000..63933db2 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/characteristic_meta.py @@ -0,0 +1,155 @@ +"""Helper classes for GATT characteristic infrastructure. + +Contains the SIG resolver, validation configuration, and metaclass used by +:class:`BaseCharacteristic`. Extracted to keep the base module focused on +the characteristic API itself. +""" + +from __future__ import annotations + +from abc import ABCMeta +from typing import Any + +import msgspec + +from ...registry.uuids.units import units_registry +from ...types import CharacteristicInfo +from ...types.gatt_enums import DataType +from ...types.registry import CharacteristicSpec +from ..exceptions import UUIDResolutionError +from ..resolver import CharacteristicRegistrySearch, NameNormalizer, NameVariantGenerator +from ..uuid_registry import uuid_registry + +# --------------------------------------------------------------------------- +# Validation configuration +# --------------------------------------------------------------------------- + + +class ValidationConfig(msgspec.Struct, kw_only=True): + """Configuration for characteristic validation constraints. + + Groups validation parameters into a single, optional configuration object + to simplify BaseCharacteristic constructor signatures. + """ + + min_value: int | float | None = None + max_value: int | float | None = None + expected_length: int | None = None + min_length: int | None = None + max_length: int | None = None + allow_variable_length: bool = False + expected_type: type | None = None + + +# --------------------------------------------------------------------------- +# SIG characteristic resolver +# --------------------------------------------------------------------------- + + +class SIGCharacteristicResolver: + """Resolves SIG characteristic information from YAML and registry. + + This class handles all SIG characteristic resolution logic, separating + concerns from the BaseCharacteristic constructor. Uses shared utilities + from the resolver module to avoid code duplication. + """ + + camel_case_to_display_name = staticmethod(NameNormalizer.camel_case_to_display_name) + + @staticmethod + def resolve_for_class(char_class: type) -> CharacteristicInfo: + """Resolve CharacteristicInfo for a SIG characteristic class. + + Args: + char_class: The characteristic class to resolve info for. + + Returns: + CharacteristicInfo with resolved UUID, name, value_type, unit. + + Raises: + UUIDResolutionError: If no UUID can be resolved for the class. + + """ + yaml_spec = SIGCharacteristicResolver.resolve_yaml_spec_for_class(char_class) + if yaml_spec: + return SIGCharacteristicResolver._create_info_from_yaml(yaml_spec, char_class) + + registry_info = SIGCharacteristicResolver.resolve_from_registry(char_class) + if registry_info: + return registry_info + + raise UUIDResolutionError(char_class.__name__, [char_class.__name__]) + + @staticmethod + def resolve_yaml_spec_for_class(char_class: type) -> CharacteristicSpec | None: + """Resolve YAML spec for a characteristic class using shared name variant logic.""" + characteristic_name = getattr(char_class, "_characteristic_name", None) + names_to_try = NameVariantGenerator.generate_characteristic_variants(char_class.__name__, characteristic_name) + + for try_name in names_to_try: + spec = uuid_registry.resolve_characteristic_spec(try_name) + if spec: + return spec + + return None + + @staticmethod + def _create_info_from_yaml(yaml_spec: CharacteristicSpec, char_class: type) -> CharacteristicInfo: + """Create CharacteristicInfo from YAML spec, resolving metadata via registry classes.""" + value_type = DataType.from_string(yaml_spec.data_type).to_value_type() + + unit_info = None + unit_name = getattr(yaml_spec, "unit_symbol", None) or getattr(yaml_spec, "unit", None) + if unit_name: + unit_info = units_registry.get_unit_info_by_name(unit_name) + if unit_info: + unit_symbol = str(getattr(unit_info, "symbol", getattr(unit_info, "name", unit_name))) + else: + unit_symbol = str(unit_name or "") + + return CharacteristicInfo( + uuid=yaml_spec.uuid, + name=yaml_spec.name or char_class.__name__, + unit=unit_symbol, + value_type=value_type, + ) + + @staticmethod + def resolve_from_registry(char_class: type) -> CharacteristicInfo | None: + """Fallback to registry resolution using shared search strategy.""" + search_strategy = CharacteristicRegistrySearch() + characteristic_name = getattr(char_class, "_characteristic_name", None) + return search_strategy.search(char_class, characteristic_name) + + +# --------------------------------------------------------------------------- +# Metaclass +# --------------------------------------------------------------------------- + + +class CharacteristicMeta(ABCMeta): + """Metaclass to automatically handle template flags for characteristics.""" + + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kwargs: Any, # noqa: ANN401 # Metaclass receives arbitrary keyword arguments + ) -> type: + """Create the characteristic class and handle template markers. + + This metaclass hook ensures template classes and concrete + implementations are correctly annotated with the ``_is_template`` + attribute before the class object is created. + """ + if bases: + module_name = namespace.get("__module__", "") + is_in_templates = "templates" in module_name + + if not is_in_templates and not namespace.get("_is_template_override", False): + has_template_parent = any(getattr(base, "_is_template", False) for base in bases) + if has_template_parent and "_is_template" not in namespace: + namespace["_is_template"] = False + + return super().__new__(mcs, name, bases, namespace, **kwargs) diff --git a/src/bluetooth_sig/gatt/characteristics/context_lookup.py b/src/bluetooth_sig/gatt/characteristics/context_lookup.py new file mode 100644 index 00000000..1674e8a9 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/context_lookup.py @@ -0,0 +1,82 @@ +"""Context lookup mixin for GATT characteristics. + +Provides methods to retrieve dependency and sibling characteristics from +a :class:`CharacteristicContext`, extracted from :mod:`.base` to keep it +focused on core parsing/encoding. +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from typing import Any + +from ...types import CharacteristicInfo +from ...types.gatt_enums import CharacteristicName +from ...types.uuid import BluetoothUUID +from ..context import CharacteristicContext +from ..uuid_registry import uuid_registry + + +class ContextLookupMixin: + """Mixin providing context-based characteristic lookup helpers. + + These methods allow a characteristic to resolve its dependencies and + siblings from a shared :class:`CharacteristicContext` at parse/encode + time. + """ + + @staticmethod + @lru_cache(maxsize=32) + def _get_characteristic_uuid_by_name( + characteristic_name: CharacteristicName | str, + ) -> BluetoothUUID | None: + """Get characteristic UUID by name using cached registry lookup.""" + name_str = ( + characteristic_name.value if isinstance(characteristic_name, CharacteristicName) else characteristic_name + ) + char_info = uuid_registry.get_characteristic_info(name_str) + return char_info.uuid if char_info else None + + def get_context_characteristic( + self, + ctx: CharacteristicContext | None, + characteristic_name: CharacteristicName | str | type, + ) -> Any: # noqa: ANN401 # Type determined by characteristic_name at runtime + """Find a characteristic in a context by name or class. + + Note: + Returns ``Any`` because the characteristic type is determined at + runtime by *characteristic_name*. For type-safe access, use direct + characteristic class instantiation instead of this lookup method. + + Args: + ctx: Context containing other characteristics. + characteristic_name: Enum, string name, or characteristic class. + + Returns: + Parsed characteristic value if found, ``None`` otherwise. + + """ + if not ctx or not ctx.other_characteristics: + return None + + if isinstance(characteristic_name, type): + configured_info: CharacteristicInfo | None = getattr(characteristic_name, "_configured_info", None) + if configured_info is not None: + char_uuid = configured_info.uuid + else: + class_name: str = characteristic_name.__name__ + name_without_suffix: str = class_name.replace("Characteristic", "") + sig_name: str = re.sub(r"(? None: + """Add a descriptor to this characteristic. + + Args: + descriptor: The descriptor instance to add. + """ + self._descriptors[str(descriptor.uuid)] = descriptor + + def get_descriptor(self, uuid: str | BluetoothUUID) -> BaseDescriptor | None: + """Get a descriptor by UUID. + + Args: + uuid: Descriptor UUID (string or BluetoothUUID). + + Returns: + Descriptor instance if found, ``None`` otherwise. + """ + if isinstance(uuid, str): + try: + uuid_obj = BluetoothUUID(uuid) + except ValueError: + return None + else: + uuid_obj = uuid + + return self._descriptors.get(uuid_obj.dashed_form) + + def get_descriptors(self) -> dict[str, BaseDescriptor]: + """Get all descriptors for this characteristic. + + Returns: + Dict mapping descriptor UUID strings to descriptor instances. + """ + return self._descriptors.copy() + + def get_cccd(self) -> BaseDescriptor | None: + """Get the Client Characteristic Configuration Descriptor (CCCD). + + Returns: + CCCD descriptor instance if present, ``None`` otherwise. + """ + return self.get_descriptor(CCCDDescriptor().uuid) + + def can_notify(self) -> bool: + """Check if this characteristic supports notifications. + + Returns: + ``True`` if the characteristic has a CCCD descriptor. + """ + return self.get_cccd() is not None + + # ------------------------------------------------------------------ + # Context-based descriptor lookups + # ------------------------------------------------------------------ + + def get_descriptor_from_context( + self, ctx: CharacteristicContext | None, descriptor_class: type[BaseDescriptor] + ) -> DescriptorData | None: + """Get a descriptor of the specified type from the context. + + Args: + ctx: Characteristic context containing descriptors. + descriptor_class: The descriptor class to look for. + + Returns: + DescriptorData if found, ``None`` otherwise. + """ + return _get_descriptor(ctx, descriptor_class) + + def get_valid_range_from_context( + self, + ctx: CharacteristicContext | None = None, + ) -> tuple[int | float, int | float] | None: + """Get valid range from descriptor context if available. + + Args: + ctx: Characteristic context containing descriptors. + + Returns: + Tuple of (min, max) values if Valid Range descriptor present, ``None`` otherwise. + """ + return _get_valid_range(ctx) + + def get_presentation_format_from_context( + self, + ctx: CharacteristicContext | None = None, + ) -> CharacteristicPresentationFormatData | None: + """Get presentation format from descriptor context if available. + + Args: + ctx: Characteristic context containing descriptors. + + Returns: + CharacteristicPresentationFormatData if present, ``None`` otherwise. + """ + return _get_presentation_format(ctx) + + def get_user_description_from_context(self, ctx: CharacteristicContext | None = None) -> str | None: + """Get user description from descriptor context if available. + + Args: + ctx: Characteristic context containing descriptors. + + Returns: + User description string if present, ``None`` otherwise. + """ + return _get_user_description(ctx) + + def validate_value_against_descriptor_range(self, value: float, ctx: CharacteristicContext | None = None) -> bool: + """Validate a value against descriptor-defined valid range. + + Args: + value: Value to validate. + ctx: Characteristic context containing descriptors. + + Returns: + ``True`` if value is within valid range or no range defined. + """ + return _validate_value_range(value, ctx) + + def enhance_error_message_with_descriptors( + self, + base_message: str, + ctx: CharacteristicContext | None = None, + ) -> str: + """Enhance error message with descriptor information for better debugging. + + Args: + base_message: Original error message. + ctx: Characteristic context containing descriptors. + + Returns: + Enhanced error message with descriptor context. + """ + return _enhance_error_message(base_message, ctx) diff --git a/src/bluetooth_sig/gatt/characteristics/pipeline/__init__.py b/src/bluetooth_sig/gatt/characteristics/pipeline/__init__.py new file mode 100644 index 00000000..cb4ccd27 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/pipeline/__init__.py @@ -0,0 +1,18 @@ +"""Pipeline components for characteristic parsing and encoding. + +This package provides the multi-stage pipeline implementation for GATT +characteristic value parsing and encoding, extracted from ``BaseCharacteristic`` +for separation of concerns. +""" + +from __future__ import annotations + +from .encode_pipeline import EncodePipeline +from .parse_pipeline import ParsePipeline +from .validation import CharacteristicValidator + +__all__ = [ + "CharacteristicValidator", + "EncodePipeline", + "ParsePipeline", +] diff --git a/src/bluetooth_sig/gatt/characteristics/pipeline/encode_pipeline.py b/src/bluetooth_sig/gatt/characteristics/pipeline/encode_pipeline.py new file mode 100644 index 00000000..4eec8bf9 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/pipeline/encode_pipeline.py @@ -0,0 +1,208 @@ +"""Encode pipeline for GATT characteristic values. + +Orchestrates the multi-stage encoding process: type validation → range +validation → value encoding → length validation. +""" + +from __future__ import annotations + +import os +from typing import Any + +from ....types import SpecialValueResult +from ....types.data_types import ValidationAccumulator +from ...exceptions import CharacteristicEncodeError +from ..utils.extractors import get_extractor +from .validation import CharacteristicValidator + + +class EncodePipeline: + """Multi-stage encode pipeline for characteristic values. + + Stages: + 1. Type validation + 2. Range validation (numeric types only) + 3. Value encoding (via template or subclass ``_encode_value``) + 4. Length validation (post-encode) + + Uses a back-reference to the owning characteristic for: + - ``_encode_value()`` dispatch (Template Method pattern) + - Metadata access (``name``, ``uuid``, ``_template``, ``_spec``) + - Special value resolver + """ + + def __init__(self, char: Any, validator: CharacteristicValidator) -> None: # noqa: ANN401 + """Initialise with back-reference to the owning characteristic. + + Args: + char: BaseCharacteristic instance. + validator: Shared validator instance. + + """ + self._char = char + self._validator = validator + + # ------------------------------------------------------------------ + # Main entry point + # ------------------------------------------------------------------ + + def run( # pylint: disable=too-many-branches + self, + data: Any, # noqa: ANN401 # T | SpecialValueResult + validate: bool = True, + ) -> bytearray: + """Execute the full encode pipeline. + + Args: + data: Value to encode (type T) or ``SpecialValueResult``. + validate: Enable validation (type, range, length checks). + Special values bypass validation. + + Returns: + Encoded bytes ready for BLE write. + + Raises: + CharacteristicEncodeError: If encoding or validation fails. + + """ + char = self._char + enable_trace = self._is_trace_enabled() + build_trace: list[str] = ["Starting build"] if enable_trace else [] + validation = ValidationAccumulator() + + # Special value encoding — bypass validation + if isinstance(data, SpecialValueResult): + if enable_trace: + build_trace.append(f"Encoding special value: {data.meaning}") + try: + return self._pack_raw_int(data.raw_value) + except Exception as e: + raise CharacteristicEncodeError( + message=f"Failed to encode special value: {e}", + name=char.name, + uuid=char.uuid, + value=data, + validation=None, + ) from e + + try: + # Type validation + if validate: + if enable_trace: + build_trace.append("Validating type") + type_validation = self._validator.validate_type(data) + validation.errors.extend(type_validation.errors) + validation.warnings.extend(type_validation.warnings) + if not type_validation.valid: + raise TypeError("; ".join(type_validation.errors)) # noqa: TRY301 + + # Range validation for numeric types + if validate and isinstance(data, (int, float)): + if enable_trace: + build_trace.append("Validating range") + range_validation = self._validator.validate_range(data, ctx=None) + validation.errors.extend(range_validation.errors) + validation.warnings.extend(range_validation.warnings) + if not range_validation.valid: + raise ValueError("; ".join(range_validation.errors)) # noqa: TRY301 + + # Encode + if enable_trace: + build_trace.append("Encoding value") + encoded: bytearray = char._encode_value(data) + + # Length validation + if validate: + if enable_trace: + build_trace.append("Validating encoded length") + length_validation = self._validator.validate_length(encoded) + validation.errors.extend(length_validation.errors) + validation.warnings.extend(length_validation.warnings) + if not length_validation.valid: + raise ValueError("; ".join(length_validation.errors)) # noqa: TRY301 + + if enable_trace: + build_trace.append("Build completed successfully") + + except Exception as e: + if enable_trace: + build_trace.append(f"Build failed: {type(e).__name__}: {e}") + + raise CharacteristicEncodeError( + message=str(e), + name=char.name, + uuid=char.uuid, + value=data, + validation=validation, + ) from e + else: + return encoded + + # ------------------------------------------------------------------ + # Special value encoding helpers + # ------------------------------------------------------------------ + + def encode_special(self, value_type: Any) -> bytearray: # noqa: ANN401 # SpecialValueType + """Encode a special value type to bytes (reverse lookup). + + Args: + value_type: ``SpecialValueType`` enum member. + + Returns: + Encoded bytes for the special value. + + Raises: + ValueError: If no raw value of that type is defined. + + """ + raw = self._char._special_resolver.get_raw_for_type(value_type) + if raw is None: + raise ValueError(f"No special value of type {value_type.name} defined for this characteristic") + return self._pack_raw_int(raw) + + def encode_special_by_meaning(self, meaning: str) -> bytearray: + """Encode a special value by a partial meaning string match. + + Args: + meaning: Partial meaning string to match. + + Returns: + Encoded bytes for the matching special value. + + Raises: + ValueError: If no matching special value is found. + + """ + raw = self._char._special_resolver.get_raw_for_meaning(meaning) + if raw is None: + raise ValueError(f"No special value matching '{meaning}' defined for this characteristic") + return self._pack_raw_int(raw) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _pack_raw_int(self, raw: int) -> bytearray: + """Pack a raw integer to bytes using template extractor or YAML extractor.""" + char = self._char + # Priority 1: template extractor + if char._template is not None: + extractor = getattr(char._template, "extractor", None) + if extractor is not None: + return bytearray(extractor.pack(raw)) + + # Priority 2: YAML-derived extractor + yaml_type = char.get_yaml_data_type() + if yaml_type is not None: + extractor = get_extractor(yaml_type) + if extractor is not None: + return bytearray(extractor.pack(raw)) + + raise ValueError("No extractor available to pack raw integer for this characteristic") + + def _is_trace_enabled(self) -> bool: + """Check if build trace is enabled.""" + env_value = os.getenv("BLUETOOTH_SIG_ENABLE_PARSE_TRACE", "").lower() + if env_value in ("0", "false", "no"): + return False + return self._char._enable_parse_trace is not False diff --git a/src/bluetooth_sig/gatt/characteristics/pipeline/parse_pipeline.py b/src/bluetooth_sig/gatt/characteristics/pipeline/parse_pipeline.py new file mode 100644 index 00000000..a97303a5 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/pipeline/parse_pipeline.py @@ -0,0 +1,273 @@ +"""Parse pipeline for GATT characteristic values. + +Orchestrates the multi-stage parsing process: length validation → raw integer +extraction → special value detection → value decoding → range/type validation. +""" + +from __future__ import annotations + +import os +from typing import Any, TypeVar + +from ....types import ParseFieldError as FieldError +from ....types import SpecialValueResult +from ....types.data_types import ValidationAccumulator +from ...exceptions import ( + CharacteristicParseError, + ParseFieldError, + SpecialValueDetectedError, +) +from ..utils.extractors import get_extractor +from .validation import CharacteristicValidator + +T = TypeVar("T") + + +class ParsePipeline: + """Multi-stage parse pipeline for characteristic values. + + Stages: + 1. Length validation (pre-decode) + 2. Raw integer extraction (little-endian per Bluetooth spec) + 3. Special value detection (sentinel values like 0x8000) + 4. Value decoding (via template or subclass ``_decode_value``) + 5. Range validation (post-decode) + 6. Type validation + + Uses a back-reference to the owning characteristic for: + - ``_decode_value()`` dispatch (Template Method pattern) + - Metadata access (``name``, ``uuid``, ``_template``, ``_spec``) + - Special value resolver + """ + + def __init__(self, char: Any, validator: CharacteristicValidator) -> None: # noqa: ANN401 + """Initialise with back-reference to the owning characteristic. + + Args: + char: BaseCharacteristic instance. + validator: Shared validator instance. + + """ + self._char = char + self._validator = validator + + # ------------------------------------------------------------------ + # Main entry point + # ------------------------------------------------------------------ + + def run( + self, + data: bytes | bytearray, + ctx: Any | None = None, # noqa: ANN401 # CharacteristicContext + validate: bool = True, + ) -> Any: # noqa: ANN401 # Returns T (generic of owning char) + """Execute the full parse pipeline. + + Args: + data: Raw bytes from BLE read. + ctx: Optional ``CharacteristicContext`` for dependency-aware parsing. + validate: Whether to run validation stages. + + Returns: + Parsed value of type T. + + Raises: + SpecialValueDetectedError: If a sentinel value is detected. + CharacteristicParseError: If parsing or validation fails. + + """ + char = self._char + data_bytes = bytearray(data) + enable_trace = self._is_trace_enabled() + parse_trace: list[str] = ["Starting parse"] if enable_trace else [] + field_errors: list[FieldError] = [] + validation = ValidationAccumulator() + raw_int: int | None = None + + try: + self._perform_length_validation(data_bytes, enable_trace, parse_trace, validation, validate) + raw_int, parsed_value = self._extract_and_check_special(data_bytes, enable_trace, parse_trace, ctx) + except Exception as e: + if enable_trace: + parse_trace.append(f"Parse failed: {type(e).__name__}: {e}") + raise CharacteristicParseError( + message=str(e), + name=char.name, + uuid=char.uuid, + raw_data=bytes(data), + raw_int=raw_int, + field_errors=field_errors, + parse_trace=parse_trace, + validation=validation, + ) from e + + if isinstance(parsed_value, SpecialValueResult): + if enable_trace: + parse_trace.append(f"Detected special value: {parsed_value.meaning}") + raise SpecialValueDetectedError( + special_value=parsed_value, + name=char.name, + uuid=char.uuid, + raw_data=bytes(data), + raw_int=raw_int, + ) + + try: + decoded_value = self._decode_and_validate(data_bytes, enable_trace, parse_trace, ctx, validation, validate) + except Exception as e: + if enable_trace: + parse_trace.append(f"Parse failed: {type(e).__name__}: {e}") + if isinstance(e, ParseFieldError): + field_errors.append( + FieldError( + field=e.field, + reason=e.field_reason, + offset=e.offset, + raw_slice=bytes(e.data) if hasattr(e, "data") else None, + ) + ) + raise CharacteristicParseError( + message=str(e), + name=char.name, + uuid=char.uuid, + raw_data=bytes(data), + raw_int=raw_int, + field_errors=field_errors, + parse_trace=parse_trace, + validation=validation, + ) from e + + if enable_trace: + parse_trace.append("Parse completed successfully") + + return decoded_value + + # ------------------------------------------------------------------ + # Pipeline stages + # ------------------------------------------------------------------ + + def _perform_length_validation( + self, + data_bytes: bytearray, + enable_trace: bool, + parse_trace: list[str], + validation: ValidationAccumulator, + validate: bool, + ) -> None: + """Stage 1: validate data length before parsing.""" + if not validate: + return + if enable_trace: + parse_trace.append(f"Validating data length (got {len(data_bytes)} bytes)") + length_validation = self._validator.validate_length(data_bytes) + validation.errors.extend(length_validation.errors) + validation.warnings.extend(length_validation.warnings) + if not length_validation.valid: + raise ValueError("; ".join(length_validation.errors)) + + def _extract_and_check_special( # pylint: disable=unused-argument + self, + data_bytes: bytearray, + enable_trace: bool, + parse_trace: list[str], + ctx: Any | None, # noqa: ANN401 # CharacteristicContext + ) -> tuple[int | None, int | SpecialValueResult | None]: + """Stage 2+3: extract raw int and check for special values.""" + raw_int = self._extract_raw_int(data_bytes, enable_trace, parse_trace) + + parsed_value = None + if raw_int is not None: + if enable_trace: + parse_trace.append("Checking for special values") + parsed_value = self._check_special_value(raw_int) + if enable_trace: + if isinstance(parsed_value, SpecialValueResult): + parse_trace.append(f"Found special value: {parsed_value}") + else: + parse_trace.append("Not a special value, proceeding with decode") + + return raw_int, parsed_value + + def _decode_and_validate( + self, + data_bytes: bytearray, + enable_trace: bool, + parse_trace: list[str], + ctx: Any | None, # noqa: ANN401 # CharacteristicContext + validation: ValidationAccumulator, + validate: bool, + ) -> Any: # noqa: ANN401 # Returns T + """Stage 4+5+6: decode value via template/subclass, then validate.""" + if enable_trace: + parse_trace.append("Decoding value") + decoded_value = self._char._decode_value(data_bytes, ctx, validate=validate) + + if validate: + if enable_trace: + parse_trace.append("Validating range") + range_validation = self._validator.validate_range(decoded_value, ctx) + validation.errors.extend(range_validation.errors) + validation.warnings.extend(range_validation.warnings) + if not range_validation.valid: + raise ValueError("; ".join(range_validation.errors)) + if enable_trace: + parse_trace.append("Validating type") + type_validation = self._validator.validate_type(decoded_value) + validation.errors.extend(type_validation.errors) + validation.warnings.extend(type_validation.warnings) + if not type_validation.valid: + raise ValueError("; ".join(type_validation.errors)) + return decoded_value + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _extract_raw_int( + self, + data: bytearray, + enable_trace: bool, + parse_trace: list[str], + ) -> int | None: + """Extract raw integer from bytes using template or YAML extractors.""" + char = self._char + + # Priority 1: Template extractor + if char._template is not None and char._template.extractor is not None: + if enable_trace: + parse_trace.append("Extracting raw integer via template extractor") + raw_int: int = char._template.extractor.extract(data, offset=0) + if enable_trace: + parse_trace.append(f"Extracted raw_int: {raw_int}") + return raw_int + + # Priority 2: YAML data type extractor + yaml_type = char.get_yaml_data_type() + if yaml_type is not None: + extractor = get_extractor(yaml_type) + if extractor is not None: + if enable_trace: + parse_trace.append(f"Extracting raw integer via YAML type '{yaml_type}'") + raw_int = extractor.extract(data, offset=0) + if enable_trace: + parse_trace.append(f"Extracted raw_int: {raw_int}") + return raw_int + + # No extractor available + if enable_trace: + parse_trace.append("No extractor available for raw_int extraction") + return None + + def _check_special_value(self, raw_value: int) -> int | SpecialValueResult: + """Check if raw value is a special sentinel value.""" + res: SpecialValueResult | None = self._char._special_resolver.resolve(raw_value) + if res is not None: + return res + return raw_value + + def _is_trace_enabled(self) -> bool: + """Check if parse trace is enabled via environment variable or instance attribute.""" + env_value = os.getenv("BLUETOOTH_SIG_ENABLE_PARSE_TRACE", "").lower() + if env_value in ("0", "false", "no"): + return False + return self._char._enable_parse_trace is not False diff --git a/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py b/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py new file mode 100644 index 00000000..d64d6b63 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/pipeline/validation.py @@ -0,0 +1,217 @@ +"""Validation logic for characteristic values. + +Provides range, type, and length validation with a three-level precedence +system: descriptor Valid Range > class-level attributes > YAML-derived ranges. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ....types import SpecialValueResult +from ....types.data_types import ValidationAccumulator +from ...descriptor_utils import get_valid_range_from_context as _get_valid_range + +if TYPE_CHECKING: + from ....types.registry import CharacteristicSpec + from ...context import CharacteristicContext + + +class CharacteristicValidator: + """Validates characteristic values against range, type, and length constraints. + + Uses a back-reference to the owning characteristic to access validation + attributes (``min_value``, ``max_value``, ``expected_length``, etc.) and + YAML-derived metadata. This class is an **internal** implementation detail + of ``BaseCharacteristic`` and should not be used directly. + """ + + def __init__(self, char: Any) -> None: # noqa: ANN401 # Avoids circular BaseCharacteristic import + """Initialise with a back-reference to the owning characteristic. + + Args: + char: BaseCharacteristic instance (typed as Any to avoid circular import) + + """ + self._char = char + + # ------------------------------------------------------------------ + # Range validation + # ------------------------------------------------------------------ + + def validate_range( # pylint: disable=too-many-branches + self, + value: Any, # noqa: ANN401 # Validates values of various numeric types + ctx: CharacteristicContext | None = None, + ) -> ValidationAccumulator: + """Validate value is within min/max range. + + Validation precedence: + 1. Descriptor Valid Range (if present in context) — most specific, device-reported + 2. Class-level validation attributes (min_value, max_value) — characteristic spec defaults + 3. YAML-derived value range from structure — Bluetooth SIG specification + + Args: + value: The value to validate. + ctx: Optional characteristic context containing descriptors. + + Returns: + ValidationAccumulator with errors if validation fails. + + """ + char = self._char + result = ValidationAccumulator() + + # Skip validation for SpecialValueResult + if isinstance(value, SpecialValueResult): + return result + + # Skip validation for non-numeric types + if not isinstance(value, (int, float)): + return result + + # Check descriptor Valid Range first (takes precedence over class attributes) + descriptor_range = _get_valid_range(ctx) if ctx else None + if descriptor_range is not None: + min_val, max_val = descriptor_range + if value < min_val or value > max_val: + error_msg = ( + f"Value {value} is outside valid range [{min_val}, {max_val}] " + f"(source: Valid Range descriptor for {char.name})" + ) + if char.unit: + error_msg += f" [unit: {char.unit}]" + result.add_error(error_msg) + # Descriptor validation checked — skip class-level checks + return result + + # Fall back to class-level validation attributes + if char.min_value is not None and value < char.min_value: + error_msg = ( + f"Value {value} is below minimum {char.min_value} " + f"(source: class-level constraint for {char.__class__.__name__})" + ) + if char.unit: + error_msg += f" [unit: {char.unit}]" + result.add_error(error_msg) + if char.max_value is not None and value > char.max_value: + error_msg = ( + f"Value {value} is above maximum {char.max_value} " + f"(source: class-level constraint for {char.__class__.__name__})" + ) + if char.unit: + error_msg += f" [unit: {char.unit}]" + result.add_error(error_msg) + + # Fall back to YAML-derived value range from structure + _validate_yaml_range(result, value, char) + + return result + + # ------------------------------------------------------------------ + # Type validation + # ------------------------------------------------------------------ + + def validate_type(self, value: Any) -> ValidationAccumulator: # noqa: ANN401 + """Validate value type matches expected_type if specified. + + Args: + value: The value to validate. + + Returns: + ValidationAccumulator with errors if validation fails. + + """ + result = ValidationAccumulator() + + expected_type: type | None = self._char.expected_type + if expected_type is not None and not isinstance(value, (expected_type, SpecialValueResult)): + error_msg = ( + f"Type validation failed for {self._char.name}: " + f"expected {expected_type.__name__}, got {type(value).__name__} " + f"(value: {value})" + ) + result.add_error(error_msg) + return result + + # ------------------------------------------------------------------ + # Length validation + # ------------------------------------------------------------------ + + def validate_length(self, data: bytes | bytearray) -> ValidationAccumulator: + """Validate data length meets requirements. + + Args: + data: The data to validate. + + Returns: + ValidationAccumulator with errors if validation fails. + + """ + char = self._char + result = ValidationAccumulator() + length = len(data) + + # Determine validation source for error context + yaml_size = char.get_yaml_field_size() + source_context = "" + if yaml_size is not None: + source_context = f" (YAML specification: {yaml_size} bytes)" + elif char.expected_length is not None or char.min_length is not None or char.max_length is not None: + source_context = f" (class-level constraint for {char.__class__.__name__})" + + if char.expected_length is not None and length != char.expected_length: + error_msg = ( + f"Length validation failed for {char.name}: " + f"expected exactly {char.expected_length} bytes, got {length}{source_context}" + ) + result.add_error(error_msg) + if char.min_length is not None and length < char.min_length: + error_msg = ( + f"Length validation failed for {char.name}: " + f"expected at least {char.min_length} bytes, got {length}{source_context}" + ) + result.add_error(error_msg) + if char.max_length is not None and length > char.max_length: + error_msg = ( + f"Length validation failed for {char.name}: " + f"expected at most {char.max_length} bytes, got {length}{source_context}" + ) + result.add_error(error_msg) + return result + + +# ------------------------------------------------------------------ +# Private helper +# ------------------------------------------------------------------ + + +def _validate_yaml_range( + result: ValidationAccumulator, + value: int | float, + char: Any, # noqa: ANN401 # BaseCharacteristic back-reference +) -> None: + """Add YAML-derived range validation errors to *result* (mutates in-place). + + Only applies when no class-level min/max constraints are set. + """ + spec: CharacteristicSpec | None = char._spec # Internal composition + if char.min_value is not None or char.max_value is not None or not spec or not spec.structure: + return + + for field in spec.structure: + yaml_range = field.value_range + if yaml_range is not None: + min_val, max_val = yaml_range + # Use tolerance for floating-point comparison (common in scaled characteristics) + tolerance = max(abs(max_val - min_val) * 1e-9, 1e-9) if isinstance(value, float) else 0 + if value < min_val - tolerance or value > max_val + tolerance: + yaml_source = f"{spec.name}" if spec.name else "YAML specification" + error_msg = ( + f"Value {value} is outside allowed range [{min_val}, {max_val}] " + f"(source: Bluetooth SIG {yaml_source})" + ) + if char.unit: + error_msg += f" [unit: {char.unit}]" + result.add_error(error_msg) + break # Use first field with range found diff --git a/src/bluetooth_sig/gatt/characteristics/registry.py b/src/bluetooth_sig/gatt/characteristics/registry.py index 025bc167..b58c13cd 100644 --- a/src/bluetooth_sig/gatt/characteristics/registry.py +++ b/src/bluetooth_sig/gatt/characteristics/registry.py @@ -8,9 +8,7 @@ class mappings. CharacteristicName enum is now centralized in from __future__ import annotations import re -from typing import Any, ClassVar - -from typing_extensions import TypeGuard +from typing import Any, ClassVar, TypeGuard from ...registry.base import BaseUUIDClassRegistry from ...types.gatt_enums import CharacteristicName diff --git a/src/bluetooth_sig/gatt/characteristics/role_classifier.py b/src/bluetooth_sig/gatt/characteristics/role_classifier.py new file mode 100644 index 00000000..e26622e1 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/role_classifier.py @@ -0,0 +1,92 @@ +"""Role classification for GATT characteristics. + +Classifies a characteristic's purpose (MEASUREMENT, FEATURE, CONTROL, etc.) +from SIG spec metadata using a heuristic based on name, value type, unit, and +specification structure. +""" + +from __future__ import annotations + +from ...types.gatt_enums import CharacteristicRole, ValueType +from ...types.registry import CharacteristicSpec + + +def classify_role( + char_name: str, + value_type: ValueType, + unit: str, + spec: CharacteristicSpec | None, +) -> CharacteristicRole: + """Classify a characteristic's purpose from SIG spec metadata. + + Classification priority (first match wins): + 1. Name contains *Control Point* → CONTROL + 2. Name ends with *Feature(s)* or + ``value_type`` is BITFIELD → FEATURE + 3. Name contains *Measurement* → MEASUREMENT + 4. Numeric type (INT / FLOAT) with a unit → MEASUREMENT + 5. Compound type (VARIOUS / DICT) with a + unit or field-level ``unit_id`` → MEASUREMENT + 6. Name ends with *Data* → MEASUREMENT + 7. Name contains *Status* → STATUS + 8. ``value_type`` is STRING → INFO + 9. Otherwise → UNKNOWN + + Args: + char_name: Display name of the characteristic. + value_type: Resolved value type enum. + unit: Unit string (empty string if not applicable). + spec: Resolved YAML spec (may be None). + + Returns: + The classified ``CharacteristicRole``. + + """ + # 1. Control points are write-only command interfaces + if "Control Point" in char_name: + return CharacteristicRole.CONTROL + + # 2. Feature / capability bitfields describe device capabilities + if char_name.endswith("Feature") or char_name.endswith("Features") or value_type == ValueType.BITFIELD: + return CharacteristicRole.FEATURE + + # 3. Explicit measurement by SIG naming convention + if "Measurement" in char_name: + return CharacteristicRole.MEASUREMENT + + # 4. Numeric scalar with a physical unit + if value_type in (ValueType.INT, ValueType.FLOAT) and unit: + return CharacteristicRole.MEASUREMENT + + # 5. Compound value with unit metadata (char-level or per-field) + if value_type in (ValueType.VARIOUS, ValueType.DICT) and (unit or _spec_has_unit_fields(spec)): + return CharacteristicRole.MEASUREMENT + + # 6. SIG *Data* characteristics (Treadmill Data, Indoor Bike Data, …) + if char_name.endswith(" Data"): + return CharacteristicRole.MEASUREMENT + + # 7. State / status reporting characteristics + if "Status" in char_name: + return CharacteristicRole.STATUS + + # 8. Pure string metadata (device name, revision strings, …) + if value_type == ValueType.STRING: + return CharacteristicRole.INFO + + return CharacteristicRole.UNKNOWN + + +def _spec_has_unit_fields(spec: CharacteristicSpec | None) -> bool: + """Check whether any field in the GSS spec carries a ``unit_id``. + + Returns ``True`` if the characteristic's resolved GSS specification + contains at least one field with a non-empty ``unit_id``, indicating + that the field represents a physical quantity with a unit. + """ + if not spec: + return False + structure = getattr(spec, "structure", None) + if not structure: + return False + return any(getattr(f, "unit_id", None) for f in structure) diff --git a/src/bluetooth_sig/gatt/characteristics/templates.py b/src/bluetooth_sig/gatt/characteristics/templates.py deleted file mode 100644 index 86ff3a8c..00000000 --- a/src/bluetooth_sig/gatt/characteristics/templates.py +++ /dev/null @@ -1,1487 +0,0 @@ -# mypy: warn_unused_ignores=False -"""Coding templates for characteristic composition patterns. - -This module provides reusable coding template classes that can be composed into -characteristics via dependency injection. Templates are pure coding strategies -that do NOT inherit from BaseCharacteristic. - -All templates follow the CodingTemplate protocol and can be used by both SIG -and custom characteristics through composition. - -Pipeline architecture: - bytes → [Extractor] → raw_int → [Translator] → typed_value - -Templates that handle single-field data expose `extractor` and `translator` -properties for pipeline access. Complex templates (multi-field, variable-length) -keep monolithic decode/encode since there's no single raw value to intercept. -""" -# pylint: disable=too-many-lines # Template classes are cohesive - splitting would break composition pattern - -from __future__ import annotations - -from abc import ABC, abstractmethod -from datetime import datetime -from enum import IntEnum -from typing import Any, Generic, TypeVar - -import msgspec - -from ...types.gatt_enums import AdjustReason, DayOfWeek -from ..constants import ( - PERCENTAGE_MAX, - SINT8_MAX, - SINT8_MIN, - SINT16_MAX, - SINT16_MIN, - SINT24_MAX, - SINT24_MIN, - UINT8_MAX, - UINT16_MAX, - UINT24_MAX, - UINT32_MAX, -) -from ..context import CharacteristicContext -from ..exceptions import InsufficientDataError, ValueRangeError -from .utils import DataParser, IEEE11073Parser -from .utils.extractors import ( - FLOAT32, - SINT8, - SINT16, - SINT24, - SINT32, - UINT8, - UINT16, - UINT24, - UINT32, - RawExtractor, -) -from .utils.translators import ( - IDENTITY, - SFLOAT, - IdentityTranslator, - LinearTranslator, - SfloatTranslator, - ValueTranslator, -) - -# ============================================================================= -# TYPE VARIABLES -# ============================================================================= - -# Type variable for CodingTemplate generic - represents the decoded value type -T_co = TypeVar("T_co", covariant=True) - -# Type variable for EnumTemplate - bound to IntEnum -T = TypeVar("T", bound=IntEnum) - -# Resolution constants for common measurement scales -_RESOLUTION_INTEGER = 1.0 # Integer resolution (10^0) -_RESOLUTION_TENTH = 0.1 # 0.1 resolution (10^-1) -_RESOLUTION_HUNDREDTH = 0.01 # 0.01 resolution (10^-2) - - -# ============================================================================= -# LEVEL 4 BASE CLASS -# ============================================================================= - - -class CodingTemplate(ABC, Generic[T_co]): - """Abstract base class for coding templates. - - Templates are pure coding utilities that don't inherit from BaseCharacteristic. - They provide coding strategies that can be injected into characteristics. - All templates MUST inherit from this base class and implement the required methods. - - Generic over T_co, the type of value produced by _decode_value. - Concrete templates specify their return type, e.g., CodingTemplate[int]. - - Pipeline Integration: - Simple templates (single-field) expose `extractor` and `translator` properties - for the decode/encode pipeline. Complex templates return None for these properties. - """ - - @abstractmethod - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> T_co: - """Decode raw bytes to typed value. - - Args: - data: Raw bytes to parse - offset: Byte offset to start parsing from - ctx: Optional context for parsing - validate: Whether to validate ranges (default True) - - Returns: - Parsed value of type T_co - - """ - - @abstractmethod - def encode_value(self, value: T_co, *, validate: bool = True) -> bytearray: # type: ignore[misc] # Covariant type in parameter is intentional for encode/decode symmetry - """Encode typed value to raw bytes. - - Args: - value: Typed value to encode - validate: Whether to validate ranges (default True) - - Returns: - Raw bytes representing the value - - """ - - @property - @abstractmethod - def data_size(self) -> int: - """Size of data in bytes that this template handles.""" - - @property - def extractor(self) -> RawExtractor | None: - """Get the raw byte extractor for pipeline access. - - Returns None for complex templates where extraction isn't separable. - """ - return None - - @property - def translator(self) -> ValueTranslator[Any] | None: - """Get the value translator for pipeline access. - - Returns None for complex templates where translation isn't separable. - """ - return None - - -# ============================================================================= -# DATA STRUCTURES -# ============================================================================= - - -class VectorData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods - """3D vector measurement data.""" - - x_axis: float - y_axis: float - z_axis: float - - -class Vector2DData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods - """2D vector measurement data.""" - - x_axis: float - y_axis: float - - -class TimeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods - """Time characteristic data structure.""" - - date_time: datetime | None - day_of_week: DayOfWeek - fractions256: int - adjust_reason: AdjustReason - - -# ============================================================================= -# BASIC INTEGER TEMPLATES -# ============================================================================= - - -class Uint8Template(CodingTemplate[int]): - """Template for 8-bit unsigned integer parsing (0-255).""" - - @property - def data_size(self) -> int: - """Size: 1 byte.""" - return 1 - - @property - def extractor(self) -> RawExtractor: - """Get uint8 extractor.""" - return UINT8 - - @property - def translator(self) -> ValueTranslator[int]: - """Return identity translator for no scaling.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse 8-bit unsigned integer.""" - if validate and len(data) < offset + 1: - raise InsufficientDataError("uint8", data[offset:], 1) - return self.extractor.extract(data, offset) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode uint8 value to bytes.""" - if validate and not 0 <= value <= UINT8_MAX: - raise ValueError(f"Value {value} out of range for uint8 (0-{UINT8_MAX})") - return self.extractor.pack(value) - - -class Sint8Template(CodingTemplate[int]): - """Template for 8-bit signed integer parsing (-128 to 127).""" - - @property - def data_size(self) -> int: - """Size: 1 byte.""" - return 1 - - @property - def extractor(self) -> RawExtractor: - """Get sint8 extractor.""" - return SINT8 - - @property - def translator(self) -> ValueTranslator[int]: - """Return identity translator for no scaling.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse 8-bit signed integer.""" - if validate and len(data) < offset + 1: - raise InsufficientDataError("sint8", data[offset:], 1) - return self.extractor.extract(data, offset) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode sint8 value to bytes.""" - if validate and not SINT8_MIN <= value <= SINT8_MAX: - raise ValueError(f"Value {value} out of range for sint8 ({SINT8_MIN} to {SINT8_MAX})") - return self.extractor.pack(value) - - -class Uint16Template(CodingTemplate[int]): - """Template for 16-bit unsigned integer parsing (0-65535).""" - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - @property - def extractor(self) -> RawExtractor: - """Get uint16 extractor.""" - return UINT16 - - @property - def translator(self) -> ValueTranslator[int]: - """Return identity translator for no scaling.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse 16-bit unsigned integer.""" - if validate and len(data) < offset + 2: - raise InsufficientDataError("uint16", data[offset:], 2) - return self.extractor.extract(data, offset) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode uint16 value to bytes.""" - if validate and not 0 <= value <= UINT16_MAX: - raise ValueError(f"Value {value} out of range for uint16 (0-{UINT16_MAX})") - return self.extractor.pack(value) - - -class Sint16Template(CodingTemplate[int]): - """Template for 16-bit signed integer parsing (-32768 to 32767).""" - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - @property - def extractor(self) -> RawExtractor: - """Get sint16 extractor.""" - return SINT16 - - @property - def translator(self) -> ValueTranslator[int]: - """Return identity translator for no scaling.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse 16-bit signed integer.""" - if validate and len(data) < offset + 2: - raise InsufficientDataError("sint16", data[offset:], 2) - return self.extractor.extract(data, offset) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode sint16 value to bytes.""" - if validate and not SINT16_MIN <= value <= SINT16_MAX: - raise ValueError(f"Value {value} out of range for sint16 ({SINT16_MIN} to {SINT16_MAX})") - return self.extractor.pack(value) - - -class Uint24Template(CodingTemplate[int]): - """Template for 24-bit unsigned integer parsing (0-16777215).""" - - @property - def data_size(self) -> int: - """Size: 3 bytes.""" - return 3 - - @property - def extractor(self) -> RawExtractor: - """Get uint24 extractor.""" - return UINT24 - - @property - def translator(self) -> ValueTranslator[int]: - """Return identity translator for no scaling.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse 24-bit unsigned integer.""" - if validate and len(data) < offset + 3: - raise InsufficientDataError("uint24", data[offset:], 3) - return self.extractor.extract(data, offset) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode uint24 value to bytes.""" - if validate and not 0 <= value <= UINT24_MAX: - raise ValueError(f"Value {value} out of range for uint24 (0-{UINT24_MAX})") - return self.extractor.pack(value) - - -class Uint32Template(CodingTemplate[int]): - """Template for 32-bit unsigned integer parsing.""" - - @property - def data_size(self) -> int: - """Size: 4 bytes.""" - return 4 - - @property - def extractor(self) -> RawExtractor: - """Get uint32 extractor.""" - return UINT32 - - @property - def translator(self) -> ValueTranslator[int]: - """Return identity translator for no scaling.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse 32-bit unsigned integer.""" - if validate and len(data) < offset + 4: - raise InsufficientDataError("uint32", data[offset:], 4) - return self.extractor.extract(data, offset) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode uint32 value to bytes.""" - if validate and not 0 <= value <= UINT32_MAX: - raise ValueError(f"Value {value} out of range for uint32 (0-{UINT32_MAX})") - return self.extractor.pack(value) - - -class EnumTemplate(CodingTemplate[T]): - """Template for IntEnum encoding/decoding with configurable byte size. - - Maps raw integer bytes to Python IntEnum instances through extraction and validation. - Supports any integer-based enum with any extractor (UINT8, UINT16, SINT8, etc.). - - This template validates enum membership explicitly, supporting non-contiguous - enum ranges (e.g., values 0, 2, 5, 10). - - Pipeline Integration: - bytes → [extractor] → raw_int → [IDENTITY translator] → int → enum constructor - - Examples: - >>> class Status(IntEnum): - ... IDLE = 0 - ... ACTIVE = 1 - ... ERROR = 2 - >>> - >>> # Create template with factory method - >>> template = EnumTemplate.uint8(Status) - >>> - >>> # Or with explicit extractor - >>> template = EnumTemplate(Status, UINT8) - >>> - >>> # Decode from bytes - >>> status = template.decode_value(bytearray([0x01])) # Status.ACTIVE - >>> - >>> # Encode enum to bytes - >>> data = template.encode_value(Status.ERROR) # bytearray([0x02]) - >>> - >>> # Encode int to bytes (also supported) - >>> data = template.encode_value(2) # bytearray([0x02]) - """ - - def __init__(self, enum_class: type[T], extractor: RawExtractor) -> None: - """Initialize with enum class and extractor. - - Args: - enum_class: IntEnum subclass to encode/decode - extractor: Raw extractor defining byte size and signedness - (e.g., UINT8, UINT16, SINT8, etc.) - """ - self._enum_class = enum_class - self._extractor = extractor - - @property - def data_size(self) -> int: - """Return byte size required for encoding.""" - return self._extractor.byte_size - - @property - def extractor(self) -> RawExtractor: - """Return extractor for pipeline access.""" - return self._extractor - - @property - def translator(self) -> ValueTranslator[int]: - """Get IDENTITY translator for enums (no scaling needed).""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> T: - """Decode bytes to enum instance. - - Args: - data: Raw bytes from BLE characteristic - offset: Starting offset in data buffer - ctx: Optional context for parsing - validate: Whether to validate enum membership (default True) - - Returns: - Enum instance of type T - - Raises: - InsufficientDataError: If data too short for required byte size - ValueRangeError: If raw value not a valid enum member and validate=True - """ - # Check data length - if validate and len(data) < offset + self.data_size: - raise InsufficientDataError(self._enum_class.__name__, data[offset:], self.data_size) - - # Extract raw integer value - raw_value = self._extractor.extract(data, offset) - - # Validate enum membership and construct - try: - return self._enum_class(raw_value) - except ValueError as e: - # Get valid range from enum members - valid_values = [member.value for member in self._enum_class] - min_val = min(valid_values) - max_val = max(valid_values) - raise ValueRangeError(self._enum_class.__name__, raw_value, min_val, max_val) from e - - def encode_value(self, value: T | int, *, validate: bool = True) -> bytearray: - """Encode enum instance or int to bytes. - - Args: - value: Enum instance or integer value to encode - validate: Whether to validate enum membership (default True) - - Returns: - Encoded bytes - - Raises: - ValueError: If value not a valid enum member and validate=True - """ - # Convert to int if enum instance - int_value = value.value if isinstance(value, self._enum_class) else int(value) - - # Validate membership - if validate: - valid_values = [member.value for member in self._enum_class] - if int_value not in valid_values: - min_val = min(valid_values) - max_val = max(valid_values) - raise ValueError( - f"{self._enum_class.__name__} value {int_value} is invalid. " - f"Valid range: {min_val}-{max_val}, valid values: {sorted(valid_values)}" - ) - - # Pack to bytes - return self._extractor.pack(int_value) - - @classmethod - def uint8(cls, enum_class: type[T]) -> EnumTemplate[T]: - """Create EnumTemplate for 1-byte unsigned enum. - - Args: - enum_class: IntEnum subclass with values 0-255 - - Returns: - Configured EnumTemplate instance - - Example:: - >>> class Status(IntEnum): - ... IDLE = 0 - ... ACTIVE = 1 - >>> template = EnumTemplate.uint8(Status) - """ - return cls(enum_class, UINT8) - - @classmethod - def uint16(cls, enum_class: type[T]) -> EnumTemplate[T]: - """Create EnumTemplate for 2-byte unsigned enum. - - Args: - enum_class: IntEnum subclass with values 0-65535 - - Returns: - Configured EnumTemplate instance - - Example:: - >>> class ExtendedStatus(IntEnum): - ... STATE_1 = 0x0100 - ... STATE_2 = 0x0200 - >>> template = EnumTemplate.uint16(ExtendedStatus) - """ - return cls(enum_class, UINT16) - - @classmethod - def uint32(cls, enum_class: type[T]) -> EnumTemplate[T]: - """Create EnumTemplate for 4-byte unsigned enum. - - Args: - enum_class: IntEnum subclass with values 0-4294967295 - - Returns: - Configured EnumTemplate instance - """ - return cls(enum_class, UINT32) - - @classmethod - def sint8(cls, enum_class: type[T]) -> EnumTemplate[T]: - """Create EnumTemplate for 1-byte signed enum. - - Args: - enum_class: IntEnum subclass with values -128 to 127 - - Returns: - Configured EnumTemplate instance - - Example:: - >>> class Temperature(IntEnum): - ... FREEZING = -10 - ... NORMAL = 0 - ... HOT = 10 - >>> template = EnumTemplate.sint8(Temperature) - """ - return cls(enum_class, SINT8) - - @classmethod - def sint16(cls, enum_class: type[T]) -> EnumTemplate[T]: - """Create EnumTemplate for 2-byte signed enum. - - Args: - enum_class: IntEnum subclass with values -32768 to 32767 - - Returns: - Configured EnumTemplate instance - """ - return cls(enum_class, SINT16) - - @classmethod - def sint32(cls, enum_class: type[T]) -> EnumTemplate[T]: - """Create EnumTemplate for 4-byte signed enum. - - Args: - enum_class: IntEnum subclass with values -2147483648 to 2147483647 - - Returns: - Configured EnumTemplate instance - """ - return cls(enum_class, SINT32) - - -# ============================================================================= -# SCALED VALUE TEMPLATES -# ============================================================================= - - -class ScaledTemplate(CodingTemplate[float]): - """Base class for scaled integer templates. - - Handles common scaling logic: value = (raw + offset) * scale_factor - Subclasses implement raw parsing/encoding and range checking. - - Exposes `extractor` and `translator` for pipeline access. - """ - - _extractor: RawExtractor - _translator: LinearTranslator - - def __init__(self, scale_factor: float, offset: int) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - self._translator = LinearTranslator(scale_factor=scale_factor, offset=offset) - - @property - def scale_factor(self) -> float: - """Get the scale factor.""" - return self._translator.scale_factor - - @property - def offset(self) -> int: - """Get the offset.""" - return self._translator.offset - - @property - def extractor(self) -> RawExtractor: - """Get the byte extractor for pipeline access.""" - return self._extractor - - @property - def translator(self) -> LinearTranslator: - """Get the value translator for pipeline access.""" - return self._translator - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> float: - """Parse scaled integer value.""" - raw_value = self._extractor.extract(data, offset) - return self._translator.translate(raw_value) - - def encode_value(self, value: float, *, validate: bool = True) -> bytearray: - """Encode scaled value to bytes.""" - raw_value = self._translator.untranslate(value) - if validate: - self._check_range(raw_value) - return self._extractor.pack(raw_value) - - @abstractmethod - def _check_range(self, raw: int) -> None: - """Check if raw value is in valid range.""" - - @classmethod - def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate: - """Create instance using scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - Returns: - ScaledTemplate instance - - """ - return cls(scale_factor=scale_factor, offset=offset) - - @classmethod - def from_letter_method(cls, M: int, d: int, b: int) -> ScaledTemplate: # noqa: N803 - """Create instance using Bluetooth SIG M, d, b parameters. - - Args: - M: Multiplier factor - d: Decimal exponent (10^d) - b: Offset to add to raw value before scaling - - Returns: - ScaledTemplate instance - - """ - scale_factor = M * (10**d) - return cls(scale_factor=scale_factor, offset=b) - - -class ScaledUint16Template(ScaledTemplate): - """Template for scaled 16-bit unsigned integer. - - Used for values that need decimal precision encoded as integers. - Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. - Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) - Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 - """ - - _extractor = UINT16 - - def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - def _check_range(self, raw: int) -> None: - """Check range for uint16.""" - if not 0 <= raw <= UINT16_MAX: - raise ValueError(f"Scaled value {raw} out of range for uint16") - - -class ScaledSint16Template(ScaledTemplate): - """Template for scaled 16-bit signed integer. - - Used for signed values that need decimal precision encoded as integers. - Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. - Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) - Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 - """ - - _extractor = SINT16 - - def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - def _check_range(self, raw: int) -> None: - """Check range for sint16.""" - if not SINT16_MIN <= raw <= SINT16_MAX: - raise ValueError(f"Scaled value {raw} out of range for sint16") - - -class ScaledSint8Template(ScaledTemplate): - """Template for scaled 8-bit signed integer. - - Used for signed values that need decimal precision encoded as integers. - Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. - Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) - Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 - """ - - _extractor = SINT8 - - def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 1 byte.""" - return 1 - - def _check_range(self, raw: int) -> None: - """Check range for sint8.""" - if not SINT8_MIN <= raw <= SINT8_MAX: - raise ValueError(f"Scaled value {raw} out of range for sint8") - - -class ScaledUint8Template(ScaledTemplate): - """Template for scaled 8-bit unsigned integer. - - Used for unsigned values that need decimal precision encoded as integers. - Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. - Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) - Example: Uncertainty with scale_factor=0.1, offset=0 or M=1, d=-1, b=0 - """ - - _extractor = UINT8 - - def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 1 byte.""" - return 1 - - def _check_range(self, raw: int) -> None: - """Check range for uint8.""" - if not 0 <= raw <= UINT8_MAX: - raise ValueError(f"Scaled value {raw} out of range for uint8") - - -class ScaledUint32Template(ScaledTemplate): - """Template for scaled 32-bit unsigned integer with configurable resolution and offset.""" - - _extractor = UINT32 - - def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place) - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 4 bytes.""" - return 4 - - def _check_range(self, raw: int) -> None: - """Check range for uint32.""" - if not 0 <= raw <= UINT32_MAX: - raise ValueError(f"Scaled value {raw} out of range for uint32") - - -class ScaledUint24Template(ScaledTemplate): - """Template for scaled 24-bit unsigned integer with configurable resolution and offset. - - Used for values encoded in 3 bytes as unsigned integers. - Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0 - """ - - _extractor = UINT24 - - def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 3 bytes.""" - return 3 - - def _check_range(self, raw: int) -> None: - """Check range for uint24.""" - if not 0 <= raw <= UINT24_MAX: - raise ValueError(f"Scaled value {raw} out of range for uint24") - - -class ScaledSint24Template(ScaledTemplate): - """Template for scaled 24-bit signed integer with configurable resolution and offset. - - Used for signed values encoded in 3 bytes. - Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0 - """ - - _extractor = SINT24 - - def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 3 bytes.""" - return 3 - - def _check_range(self, raw: int) -> None: - """Check range for sint24.""" - if not SINT24_MIN <= raw <= SINT24_MAX: - raise ValueError(f"Scaled value {raw} out of range for sint24") - - -class ScaledSint32Template(ScaledTemplate): - """Template for scaled 32-bit signed integer with configurable resolution and offset. - - Used for signed values encoded in 4 bytes. - Example: Longitude -180.0 to 180.0 degrees stored with scale_factor=1e-7 - """ - - _extractor = SINT32 - - def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: - """Initialize with scale factor and offset. - - Args: - scale_factor: Factor to multiply raw value by - offset: Offset to add to raw value before scaling - - """ - super().__init__(scale_factor, offset) - - @property - def data_size(self) -> int: - """Size: 4 bytes.""" - return 4 - - def _check_range(self, raw: int) -> None: - """Check range for sint32.""" - sint32_min = -(2**31) - sint32_max = (2**31) - 1 - if not sint32_min <= raw <= sint32_max: - raise ValueError(f"Scaled value {raw} out of range for sint32") - - -# ============================================================================= -# DOMAIN-SPECIFIC TEMPLATES -# ============================================================================= - - -class PercentageTemplate(CodingTemplate[int]): - """Template for percentage values (0-100%) using uint8.""" - - @property - def data_size(self) -> int: - """Size: 1 byte.""" - return 1 - - @property - def extractor(self) -> RawExtractor: - """Get uint8 extractor.""" - return UINT8 - - @property - def translator(self) -> IdentityTranslator: - """Return identity translator since validation is separate from translation.""" - return IDENTITY - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> int: - """Parse percentage value.""" - if validate and len(data) < offset + 1: - raise InsufficientDataError("percentage", data[offset:], 1) - value = self.extractor.extract(data, offset) - # Only validate range if validation is enabled - if validate and not 0 <= value <= PERCENTAGE_MAX: - raise ValueRangeError("percentage", value, 0, PERCENTAGE_MAX) - return self.translator.translate(value) - - def encode_value(self, value: int, *, validate: bool = True) -> bytearray: - """Encode percentage value to bytes.""" - if validate and not 0 <= value <= PERCENTAGE_MAX: - raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})") - raw = self.translator.untranslate(value) - return self.extractor.pack(raw) - - -class TemperatureTemplate(CodingTemplate[float]): - """Template for standard Bluetooth SIG temperature format (sint16, 0.01°C resolution).""" - - def __init__(self) -> None: - """Initialize with standard temperature resolution.""" - self._scaled_template = ScaledSint16Template.from_letter_method(1, -2, 0) - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - @property - def extractor(self) -> RawExtractor: - """Get extractor from underlying scaled template.""" - return self._scaled_template.extractor - - @property - def translator(self) -> ValueTranslator[float]: - """Return the linear translator from the underlying scaled template.""" - return self._scaled_template.translator - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> float: - """Parse temperature in 0.01°C resolution.""" - return self._scaled_template.decode_value(data, offset, ctx, validate=validate) # pylint: disable=protected-access - - def encode_value(self, value: float, *, validate: bool = True) -> bytearray: - """Encode temperature to bytes.""" - return self._scaled_template.encode_value(value, validate=validate) # pylint: disable=protected-access - - -class ConcentrationTemplate(CodingTemplate[float]): - """Template for concentration measurements with configurable resolution. - - Used for environmental sensors like CO2, VOC, particulate matter, etc. - """ - - def __init__(self, resolution: float = 1.0) -> None: - """Initialize with resolution. - - Args: - resolution: Measurement resolution (e.g., 1.0 for integer ppm, 0.1 for 0.1 ppm) - - """ - # Convert resolution to M, d, b parameters when it fits the pattern - # resolution = M * 10^d, so we find M and d such that M * 10^d = resolution - if resolution == _RESOLUTION_INTEGER: - # resolution = 1 * 10^0 # noqa: ERA001 - self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=0, b=0) - elif resolution == _RESOLUTION_TENTH: - # resolution = 1 * 10^-1 # noqa: ERA001 - self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-1, b=0) - elif resolution == _RESOLUTION_HUNDREDTH: - # resolution = 1 * 10^-2 # noqa: ERA001 - self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-2, b=0) - else: - # Fallback to scale_factor for resolutions that don't fit M * 10^d pattern - self._scaled_template = ScaledUint16Template(scale_factor=resolution) - - @classmethod - def from_letter_method(cls, M: int, d: int, b: int = 0) -> ConcentrationTemplate: # noqa: N803 - """Create instance using Bluetooth SIG M, d, b parameters. - - Args: - M: Multiplier factor - d: Decimal exponent (10^d) - b: Offset to add to raw value before scaling - - Returns: - ConcentrationTemplate instance - - """ - instance = cls.__new__(cls) - instance._scaled_template = ScaledUint16Template.from_letter_method(M=M, d=d, b=b) - return instance - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - @property - def extractor(self) -> RawExtractor: - """Get extractor from underlying scaled template.""" - return self._scaled_template.extractor - - @property - def translator(self) -> ValueTranslator[float]: - """Return the linear translator from the underlying scaled template.""" - return self._scaled_template.translator - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> float: - """Parse concentration with resolution.""" - return self._scaled_template.decode_value(data, offset, ctx, validate=validate) # pylint: disable=protected-access - - def encode_value(self, value: float, *, validate: bool = True) -> bytearray: - """Encode concentration value to bytes.""" - return self._scaled_template.encode_value(value, validate=validate) # pylint: disable=protected-access - - -class PressureTemplate(CodingTemplate[float]): - """Template for pressure measurements (uint32, 0.1 Pa resolution).""" - - def __init__(self) -> None: - """Initialize with standard pressure resolution (0.1 Pa).""" - self._scaled_template = ScaledUint32Template(scale_factor=0.1) - - @property - def data_size(self) -> int: - """Size: 4 bytes.""" - return 4 - - @property - def extractor(self) -> RawExtractor: - """Get extractor from underlying scaled template.""" - return self._scaled_template.extractor - - @property - def translator(self) -> ValueTranslator[float]: - """Return the linear translator from the underlying scaled template.""" - return self._scaled_template.translator - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> float: - """Parse pressure in 0.1 Pa resolution (returns Pa).""" - return self._scaled_template.decode_value(data, offset, ctx, validate=validate) # pylint: disable=protected-access - - def encode_value(self, value: float, *, validate: bool = True) -> bytearray: - """Encode pressure to bytes.""" - return self._scaled_template.encode_value(value, validate=validate) # pylint: disable=protected-access - - -class TimeDataTemplate(CodingTemplate[TimeData]): - """Template for Bluetooth SIG time data parsing (10 bytes). - - Used for Current Time and Time with DST characteristics. - Structure: Date Time (7 bytes) + Day of Week (1) + Fractions256 (1) + Adjust Reason (1) - """ - - LENGTH = 10 - DAY_OF_WEEK_MAX = 7 - FRACTIONS256_MAX = 255 - ADJUST_REASON_MAX = 255 - - @property - def data_size(self) -> int: - """Size: 10 bytes.""" - return self.LENGTH - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> TimeData: - """Parse time data from bytes.""" - if validate and len(data) < offset + self.LENGTH: - raise InsufficientDataError("time data", data[offset:], self.LENGTH) - - # Parse Date Time (7 bytes) - year = DataParser.parse_int16(data, offset, signed=False) - month = data[offset + 2] - day = data[offset + 3] - - date_time = None if year == 0 or month == 0 or day == 0 else IEEE11073Parser.parse_timestamp(data, offset) - - # Parse Day of Week (1 byte) - day_of_week_raw = data[offset + 7] - if validate and day_of_week_raw > self.DAY_OF_WEEK_MAX: - raise ValueRangeError("day_of_week", day_of_week_raw, 0, self.DAY_OF_WEEK_MAX) - day_of_week = DayOfWeek(day_of_week_raw) - - # Parse Fractions256 (1 byte) - fractions256 = data[offset + 8] - - # Parse Adjust Reason (1 byte) - adjust_reason = AdjustReason.from_raw(data[offset + 9]) - - return TimeData( - date_time=date_time, day_of_week=day_of_week, fractions256=fractions256, adjust_reason=adjust_reason - ) - - def encode_value(self, value: TimeData, *, validate: bool = True) -> bytearray: - """Encode time data to bytes.""" - result = bytearray() - - # Encode Date Time (7 bytes) - if value.date_time is None: - result.extend(bytearray(IEEE11073Parser.TIMESTAMP_LENGTH)) - else: - result.extend(IEEE11073Parser.encode_timestamp(value.date_time)) - - # Encode Day of Week (1 byte) - day_of_week_value = int(value.day_of_week) - if validate and day_of_week_value > self.DAY_OF_WEEK_MAX: - raise ValueRangeError("day_of_week", day_of_week_value, 0, self.DAY_OF_WEEK_MAX) - result.append(day_of_week_value) - - # Encode Fractions256 (1 byte) - if validate and value.fractions256 > self.FRACTIONS256_MAX: - raise ValueRangeError("fractions256", value.fractions256, 0, self.FRACTIONS256_MAX) - result.append(value.fractions256) - - # Encode Adjust Reason (1 byte) - if validate and int(value.adjust_reason) > self.ADJUST_REASON_MAX: - raise ValueRangeError("adjust_reason", int(value.adjust_reason), 0, self.ADJUST_REASON_MAX) - result.append(int(value.adjust_reason)) - - return result - - -class IEEE11073FloatTemplate(CodingTemplate[float]): - """Template for IEEE 11073 SFLOAT format (16-bit medical device float).""" - - @property - def data_size(self) -> int: - """Size: 2 bytes.""" - return 2 - - @property - def extractor(self) -> RawExtractor: - """Get uint16 extractor for raw bits.""" - return UINT16 - - @property - def translator(self) -> SfloatTranslator: - """Get SFLOAT translator.""" - return SFLOAT - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> float: - """Parse IEEE 11073 SFLOAT format.""" - if validate and len(data) < offset + 2: - raise InsufficientDataError("IEEE11073 SFLOAT", data[offset:], 2) - raw = self.extractor.extract(data, offset) - return self.translator.translate(raw) - - def encode_value(self, value: float, *, validate: bool = True) -> bytearray: - """Encode value to IEEE 11073 SFLOAT format.""" - raw = self.translator.untranslate(value) - return self.extractor.pack(raw) - - -class Float32Template(CodingTemplate[float]): - """Template for IEEE-754 32-bit float parsing.""" - - @property - def data_size(self) -> int: - """Size: 4 bytes.""" - return 4 - - @property - def extractor(self) -> RawExtractor: - """Get float32 extractor.""" - return FLOAT32 - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> float: - """Parse IEEE-754 32-bit float.""" - if validate and len(data) < offset + 4: - raise InsufficientDataError("float32", data[offset:], 4) - return DataParser.parse_float32(data, offset) - - def encode_value(self, value: float, *, validate: bool = True) -> bytearray: - """Encode float32 value to bytes.""" - return DataParser.encode_float32(float(value)) - - -# ============================================================================= -# STRING TEMPLATES -# ============================================================================= - - -class Utf8StringTemplate(CodingTemplate[str]): - """Template for UTF-8 string parsing with variable length.""" - - def __init__(self, max_length: int = 256) -> None: - """Initialize with maximum string length. - - Args: - max_length: Maximum string length in bytes - - """ - self.max_length = max_length - - @property - def data_size(self) -> int: - """Size: Variable (0 to max_length).""" - return self.max_length - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> str: - """Parse UTF-8 string from remaining data.""" - if offset >= len(data): - return "" - - # Take remaining data from offset - string_data = data[offset:] - - # Remove null terminator if present - if b"\x00" in string_data: - null_index = string_data.index(b"\x00") - string_data = string_data[:null_index] - - try: - return string_data.decode("utf-8") - except UnicodeDecodeError as e: - if validate: - raise ValueError(f"Invalid UTF-8 string data: {e}") from e - return "" - - def encode_value(self, value: str, *, validate: bool = True) -> bytearray: - """Encode string to UTF-8 bytes.""" - encoded = value.encode("utf-8") - if validate and len(encoded) > self.max_length: - raise ValueError(f"String too long: {len(encoded)} > {self.max_length}") - return bytearray(encoded) - - -class Utf16StringTemplate(CodingTemplate[str]): - """Template for UTF-16LE string parsing with variable length.""" - - # Unicode constants for UTF-16 validation - UNICODE_SURROGATE_START = 0xD800 - UNICODE_SURROGATE_END = 0xDFFF - UNICODE_BOM = "\ufeff" - - def __init__(self, max_length: int = 256) -> None: - """Initialize with maximum string length. - - Args: - max_length: Maximum string length in bytes (must be even) - - """ - if max_length % 2 != 0: - raise ValueError("max_length must be even for UTF-16 strings") - self.max_length = max_length - - @property - def data_size(self) -> int: - """Size: Variable (0 to max_length, even bytes only).""" - return self.max_length - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> str: - """Parse UTF-16LE string from remaining data.""" - if offset >= len(data): - return "" - - # Take remaining data from offset - string_data = data[offset:] - - # Find null terminator at even positions (UTF-16 alignment) - null_index = len(string_data) - for i in range(0, len(string_data) - 1, 2): - if string_data[i : i + 2] == bytearray(b"\x00\x00"): - null_index = i - break - string_data = string_data[:null_index] - - # UTF-16 requires even number of bytes - if validate and len(string_data) % 2 != 0: - raise ValueError(f"UTF-16 data must have even byte count, got {len(string_data)}") - - try: - decoded = string_data.decode("utf-16-le") - # Strip BOM if present (robustness) - if decoded.startswith(self.UNICODE_BOM): - decoded = decoded[1:] - # Check for invalid surrogate pairs - if validate and any(self.UNICODE_SURROGATE_START <= ord(c) <= self.UNICODE_SURROGATE_END for c in decoded): - raise ValueError("Invalid UTF-16LE string data: contains unpaired surrogates") - except UnicodeDecodeError as e: - if validate: - raise ValueError(f"Invalid UTF-16LE string data: {e}") from e - return "" - else: - return decoded - - def encode_value(self, value: str, *, validate: bool = True) -> bytearray: - """Encode string to UTF-16LE bytes.""" - encoded = value.encode("utf-16-le") - if validate and len(encoded) > self.max_length: - raise ValueError(f"String too long: {len(encoded)} > {self.max_length}") - return bytearray(encoded) - - -# ============================================================================= -# VECTOR TEMPLATES -# ============================================================================= - - -class VectorTemplate(CodingTemplate[VectorData]): - """Template for 3D vector measurements (x, y, z float32 components).""" - - @property - def data_size(self) -> int: - """Size: 12 bytes (3 x float32).""" - return 12 - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> VectorData: - """Parse 3D vector data.""" - if validate and len(data) < offset + 12: - raise InsufficientDataError("3D vector", data[offset:], 12) - - x_axis = DataParser.parse_float32(data, offset) - y_axis = DataParser.parse_float32(data, offset + 4) - z_axis = DataParser.parse_float32(data, offset + 8) - - return VectorData(x_axis=x_axis, y_axis=y_axis, z_axis=z_axis) - - def encode_value(self, value: VectorData, *, validate: bool = True) -> bytearray: - """Encode 3D vector data to bytes.""" - result = bytearray() - result.extend(DataParser.encode_float32(value.x_axis)) - result.extend(DataParser.encode_float32(value.y_axis)) - result.extend(DataParser.encode_float32(value.z_axis)) - return result - - -class Vector2DTemplate(CodingTemplate[Vector2DData]): - """Template for 2D vector measurements (x, y float32 components).""" - - @property - def data_size(self) -> int: - """Size: 8 bytes (2 x float32).""" - return 8 - - def decode_value( - self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True - ) -> Vector2DData: - """Parse 2D vector data.""" - if validate and len(data) < offset + 8: - raise InsufficientDataError("2D vector", data[offset:], 8) - - x_axis = DataParser.parse_float32(data, offset) - y_axis = DataParser.parse_float32(data, offset + 4) - - return Vector2DData(x_axis=x_axis, y_axis=y_axis) - - def encode_value(self, value: Vector2DData, *, validate: bool = True) -> bytearray: - """Encode 2D vector data to bytes.""" - result = bytearray() - result.extend(DataParser.encode_float32(value.x_axis)) - result.extend(DataParser.encode_float32(value.y_axis)) - return result - - -# ============================================================================= -# EXPORTS -# ============================================================================= - -__all__ = [ - # Protocol - "CodingTemplate", - "ConcentrationTemplate", - # Enum template - "EnumTemplate", - "Float32Template", - "IEEE11073FloatTemplate", - # Domain-specific templates - "PercentageTemplate", - "PressureTemplate", - "ScaledSint8Template", - "ScaledSint16Template", - "ScaledSint24Template", - "ScaledUint8Template", - # Scaled templates - "ScaledUint16Template", - "ScaledUint24Template", - "ScaledUint32Template", - "Sint8Template", - "Sint16Template", - "TemperatureTemplate", - "TimeData", - "TimeDataTemplate", - # Basic integer templates - "Uint8Template", - "Uint16Template", - "Uint24Template", - "Uint32Template", - # String templates - "Utf8StringTemplate", - "Utf16StringTemplate", - "Vector2DData", - "Vector2DTemplate", - # Data structures - "VectorData", - # Vector templates - "VectorTemplate", -] diff --git a/src/bluetooth_sig/gatt/characteristics/templates/__init__.py b/src/bluetooth_sig/gatt/characteristics/templates/__init__.py new file mode 100644 index 00000000..bcb8dd61 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/__init__.py @@ -0,0 +1,86 @@ +"""Coding templates for characteristic composition patterns. + +This package provides reusable coding template classes that can be composed into +characteristics via dependency injection. Templates are pure coding strategies +that do NOT inherit from BaseCharacteristic. + +All templates follow the CodingTemplate protocol and can be used by both SIG +and custom characteristics through composition. + +Pipeline architecture: + bytes → [Extractor] → raw_int → [Translator] → typed_value + +Templates that handle single-field data expose `extractor` and `translator` +properties for pipeline access. Complex templates (multi-field, variable-length) +keep monolithic decode/encode since there's no single raw value to intercept. +""" + +from .base import CodingTemplate +from .composite import TimeDataTemplate, Vector2DTemplate, VectorTemplate +from .data_structures import TimeData, Vector2DData, VectorData +from .domain import ConcentrationTemplate, PressureTemplate, TemperatureTemplate +from .enum import EnumTemplate +from .ieee_float import Float32Template, IEEE11073FloatTemplate +from .numeric import ( + Sint8Template, + Sint16Template, + Uint8Template, + Uint16Template, + Uint24Template, + Uint32Template, +) +from .scaled import ( + PercentageTemplate, + ScaledSint8Template, + ScaledSint16Template, + ScaledSint24Template, + ScaledSint32Template, + ScaledTemplate, + ScaledUint8Template, + ScaledUint16Template, + ScaledUint24Template, + ScaledUint32Template, +) +from .string import Utf8StringTemplate, Utf16StringTemplate + +__all__ = [ + # Protocol + "CodingTemplate", + "ConcentrationTemplate", + # Enum template + "EnumTemplate", + "Float32Template", + "IEEE11073FloatTemplate", + # Domain-specific templates + "PercentageTemplate", + "PressureTemplate", + "ScaledSint8Template", + "ScaledSint16Template", + "ScaledSint24Template", + "ScaledSint32Template", + # Scaled templates (base + concrete) + "ScaledTemplate", + "ScaledUint8Template", + "ScaledUint16Template", + "ScaledUint24Template", + "ScaledUint32Template", + "Sint8Template", + "Sint16Template", + "TemperatureTemplate", + "TimeData", + "TimeDataTemplate", + # Basic integer templates + "Uint8Template", + "Uint16Template", + "Uint24Template", + "Uint32Template", + # String templates + "Utf8StringTemplate", + "Utf16StringTemplate", + "Vector2DData", + "Vector2DTemplate", + # Data structures + "VectorData", + # Vector templates + "VectorTemplate", +] diff --git a/src/bluetooth_sig/gatt/characteristics/templates/base.py b/src/bluetooth_sig/gatt/characteristics/templates/base.py new file mode 100644 index 00000000..97bd7938 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/base.py @@ -0,0 +1,99 @@ +# mypy: warn_unused_ignores=False +"""Base coding template abstract class and shared constants. + +All coding templates MUST inherit from CodingTemplate defined here. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Generic, TypeVar + +from ....types.gatt_enums import AdjustReason, DayOfWeek # noqa: F401 # Re-export for sub-modules +from ...context import CharacteristicContext +from ..utils.extractors import RawExtractor +from ..utils.translators import ValueTranslator + +# ============================================================================= +# TYPE VARIABLES +# ============================================================================= + +# Type variable for CodingTemplate generic - represents the decoded value type +T_co = TypeVar("T_co", covariant=True) + +# Resolution constants for common measurement scales +_RESOLUTION_INTEGER = 1.0 # Integer resolution (10^0) +_RESOLUTION_TENTH = 0.1 # 0.1 resolution (10^-1) +_RESOLUTION_HUNDREDTH = 0.01 # 0.01 resolution (10^-2) + + +# ============================================================================= +# LEVEL 4 BASE CLASS +# ============================================================================= + + +class CodingTemplate(ABC, Generic[T_co]): + """Abstract base class for coding templates. + + Templates are pure coding utilities that don't inherit from BaseCharacteristic. + They provide coding strategies that can be injected into characteristics. + All templates MUST inherit from this base class and implement the required methods. + + Generic over T_co, the type of value produced by _decode_value. + Concrete templates specify their return type, e.g., CodingTemplate[int]. + + Pipeline Integration: + Simple templates (single-field) expose `extractor` and `translator` properties + for the decode/encode pipeline. Complex templates return None for these properties. + """ + + @abstractmethod + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> T_co: + """Decode raw bytes to typed value. + + Args: + data: Raw bytes to parse + offset: Byte offset to start parsing from + ctx: Optional context for parsing + validate: Whether to validate ranges (default True) + + Returns: + Parsed value of type T_co + + """ + + @abstractmethod + def encode_value(self, value: T_co, *, validate: bool = True) -> bytearray: # type: ignore[misc] # Covariant T_co in parameter is intentional for encode/decode symmetry + """Encode typed value to raw bytes. + + Args: + value: Typed value to encode + validate: Whether to validate ranges (default True) + + Returns: + Raw bytes representing the value + + """ + + @property + @abstractmethod + def data_size(self) -> int: + """Size of data in bytes that this template handles.""" + + @property + def extractor(self) -> RawExtractor | None: + """Get the raw byte extractor for pipeline access. + + Returns None for complex templates where extraction isn't separable. + """ + return None + + @property + def translator(self) -> ValueTranslator[Any] | None: + """Get the value translator for pipeline access. + + Returns None for complex templates where translation isn't separable. + """ + return None diff --git a/src/bluetooth_sig/gatt/characteristics/templates/composite.py b/src/bluetooth_sig/gatt/characteristics/templates/composite.py new file mode 100644 index 00000000..e6ffe410 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/composite.py @@ -0,0 +1,149 @@ +"""Composite multi-field templates for time and vector data. + +Covers TimeDataTemplate, VectorTemplate, and Vector2DTemplate. +These templates handle multi-field structures and do NOT expose extractor/translator +since there is no single raw value to intercept. +""" + +from __future__ import annotations + +from ....types.gatt_enums import AdjustReason, DayOfWeek +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError, ValueRangeError +from ..utils import DataParser, IEEE11073Parser +from .base import CodingTemplate +from .data_structures import TimeData, Vector2DData, VectorData + + +class TimeDataTemplate(CodingTemplate[TimeData]): + """Template for Bluetooth SIG time data parsing (10 bytes). + + Used for Current Time and Time with DST characteristics. + Structure: Date Time (7 bytes) + Day of Week (1) + Fractions256 (1) + Adjust Reason (1) + """ + + LENGTH = 10 + DAY_OF_WEEK_MAX = 7 + FRACTIONS256_MAX = 255 + ADJUST_REASON_MAX = 255 + + @property + def data_size(self) -> int: + """Size: 10 bytes.""" + return self.LENGTH + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> TimeData: + """Parse time data from bytes.""" + if validate and len(data) < offset + self.LENGTH: + raise InsufficientDataError("time data", data[offset:], self.LENGTH) + + # Parse Date Time (7 bytes) + year = DataParser.parse_int16(data, offset, signed=False) + month = data[offset + 2] + day = data[offset + 3] + + date_time = None if year == 0 or month == 0 or day == 0 else IEEE11073Parser.parse_timestamp(data, offset) + + # Parse Day of Week (1 byte) + day_of_week_raw = data[offset + 7] + if validate and day_of_week_raw > self.DAY_OF_WEEK_MAX: + raise ValueRangeError("day_of_week", day_of_week_raw, 0, self.DAY_OF_WEEK_MAX) + day_of_week = DayOfWeek(day_of_week_raw) + + # Parse Fractions256 (1 byte) + fractions256 = data[offset + 8] + + # Parse Adjust Reason (1 byte) + adjust_reason = AdjustReason.from_raw(data[offset + 9]) + + return TimeData( + date_time=date_time, day_of_week=day_of_week, fractions256=fractions256, adjust_reason=adjust_reason + ) + + def encode_value(self, value: TimeData, *, validate: bool = True) -> bytearray: + """Encode time data to bytes.""" + result = bytearray() + + # Encode Date Time (7 bytes) + if value.date_time is None: + result.extend(bytearray(IEEE11073Parser.TIMESTAMP_LENGTH)) + else: + result.extend(IEEE11073Parser.encode_timestamp(value.date_time)) + + # Encode Day of Week (1 byte) + day_of_week_value = int(value.day_of_week) + if validate and day_of_week_value > self.DAY_OF_WEEK_MAX: + raise ValueRangeError("day_of_week", day_of_week_value, 0, self.DAY_OF_WEEK_MAX) + result.append(day_of_week_value) + + # Encode Fractions256 (1 byte) + if validate and value.fractions256 > self.FRACTIONS256_MAX: + raise ValueRangeError("fractions256", value.fractions256, 0, self.FRACTIONS256_MAX) + result.append(value.fractions256) + + # Encode Adjust Reason (1 byte) + if validate and int(value.adjust_reason) > self.ADJUST_REASON_MAX: + raise ValueRangeError("adjust_reason", int(value.adjust_reason), 0, self.ADJUST_REASON_MAX) + result.append(int(value.adjust_reason)) + + return result + + +class VectorTemplate(CodingTemplate[VectorData]): + """Template for 3D vector measurements (x, y, z float32 components).""" + + @property + def data_size(self) -> int: + """Size: 12 bytes (3 x float32).""" + return 12 + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> VectorData: + """Parse 3D vector data.""" + if validate and len(data) < offset + 12: + raise InsufficientDataError("3D vector", data[offset:], 12) + + x_axis = DataParser.parse_float32(data, offset) + y_axis = DataParser.parse_float32(data, offset + 4) + z_axis = DataParser.parse_float32(data, offset + 8) + + return VectorData(x_axis=x_axis, y_axis=y_axis, z_axis=z_axis) + + def encode_value(self, value: VectorData, *, validate: bool = True) -> bytearray: + """Encode 3D vector data to bytes.""" + result = bytearray() + result.extend(DataParser.encode_float32(value.x_axis)) + result.extend(DataParser.encode_float32(value.y_axis)) + result.extend(DataParser.encode_float32(value.z_axis)) + return result + + +class Vector2DTemplate(CodingTemplate[Vector2DData]): + """Template for 2D vector measurements (x, y float32 components).""" + + @property + def data_size(self) -> int: + """Size: 8 bytes (2 x float32).""" + return 8 + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> Vector2DData: + """Parse 2D vector data.""" + if validate and len(data) < offset + 8: + raise InsufficientDataError("2D vector", data[offset:], 8) + + x_axis = DataParser.parse_float32(data, offset) + y_axis = DataParser.parse_float32(data, offset + 4) + + return Vector2DData(x_axis=x_axis, y_axis=y_axis) + + def encode_value(self, value: Vector2DData, *, validate: bool = True) -> bytearray: + """Encode 2D vector data to bytes.""" + result = bytearray() + result.extend(DataParser.encode_float32(value.x_axis)) + result.extend(DataParser.encode_float32(value.y_axis)) + return result diff --git a/src/bluetooth_sig/gatt/characteristics/templates/data_structures.py b/src/bluetooth_sig/gatt/characteristics/templates/data_structures.py new file mode 100644 index 00000000..7f34b589 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/data_structures.py @@ -0,0 +1,36 @@ +"""Data structures used by templates. + +Frozen msgspec.Struct types for structured template return values. +""" + +from __future__ import annotations + +from datetime import datetime + +import msgspec + +from ....types.gatt_enums import AdjustReason, DayOfWeek + + +class VectorData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """3D vector measurement data.""" + + x_axis: float + y_axis: float + z_axis: float + + +class Vector2DData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """2D vector measurement data.""" + + x_axis: float + y_axis: float + + +class TimeData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods + """Time characteristic data structure.""" + + date_time: datetime | None + day_of_week: DayOfWeek + fractions256: int + adjust_reason: AdjustReason diff --git a/src/bluetooth_sig/gatt/characteristics/templates/domain.py b/src/bluetooth_sig/gatt/characteristics/templates/domain.py new file mode 100644 index 00000000..aec96f37 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/domain.py @@ -0,0 +1,149 @@ +"""Domain-specific templates that compose scaled templates. + +Covers TemperatureTemplate, ConcentrationTemplate, PressureTemplate. +""" + +from __future__ import annotations + +from ...context import CharacteristicContext +from ..utils.extractors import RawExtractor +from ..utils.translators import ValueTranslator +from .base import _RESOLUTION_HUNDREDTH, _RESOLUTION_INTEGER, _RESOLUTION_TENTH, CodingTemplate +from .scaled import ScaledSint16Template, ScaledUint16Template, ScaledUint32Template + + +class TemperatureTemplate(CodingTemplate[float]): + """Template for standard Bluetooth SIG temperature format (sint16, 0.01°C resolution).""" + + def __init__(self) -> None: + """Initialize with standard temperature resolution.""" + self._scaled_template = ScaledSint16Template.from_letter_method(1, -2, 0) + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + @property + def extractor(self) -> RawExtractor: + """Get extractor from underlying scaled template.""" + return self._scaled_template.extractor + + @property + def translator(self) -> ValueTranslator[float]: + """Return the linear translator from the underlying scaled template.""" + return self._scaled_template.translator + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + """Parse temperature in 0.01°C resolution.""" + return self._scaled_template.decode_value(data, offset, ctx, validate=validate) # pylint: disable=protected-access + + def encode_value(self, value: float, *, validate: bool = True) -> bytearray: + """Encode temperature to bytes.""" + return self._scaled_template.encode_value(value, validate=validate) # pylint: disable=protected-access + + +class ConcentrationTemplate(CodingTemplate[float]): + """Template for concentration measurements with configurable resolution. + + Used for environmental sensors like CO2, VOC, particulate matter, etc. + """ + + def __init__(self, resolution: float = 1.0) -> None: + """Initialize with resolution. + + Args: + resolution: Measurement resolution (e.g., 1.0 for integer ppm, 0.1 for 0.1 ppm) + + """ + # Convert resolution to M, d, b parameters when it fits the pattern + # resolution = M * 10^d, so we find M and d such that M * 10^d = resolution + if resolution == _RESOLUTION_INTEGER: + # resolution = 1 * 10^0 # noqa: ERA001 + self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=0, b=0) + elif resolution == _RESOLUTION_TENTH: + # resolution = 1 * 10^-1 # noqa: ERA001 + self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-1, b=0) + elif resolution == _RESOLUTION_HUNDREDTH: + # resolution = 1 * 10^-2 # noqa: ERA001 + self._scaled_template = ScaledUint16Template.from_letter_method(M=1, d=-2, b=0) + else: + # Fallback to scale_factor for resolutions that don't fit M * 10^d pattern + self._scaled_template = ScaledUint16Template(scale_factor=resolution) + + @classmethod + def from_letter_method(cls, M: int, d: int, b: int = 0) -> ConcentrationTemplate: # noqa: N803 + """Create instance using Bluetooth SIG M, d, b parameters. + + Args: + M: Multiplier factor + d: Decimal exponent (10^d) + b: Offset to add to raw value before scaling + + Returns: + ConcentrationTemplate instance + + """ + instance = cls.__new__(cls) + instance._scaled_template = ScaledUint16Template.from_letter_method(M=M, d=d, b=b) + return instance + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + @property + def extractor(self) -> RawExtractor: + """Get extractor from underlying scaled template.""" + return self._scaled_template.extractor + + @property + def translator(self) -> ValueTranslator[float]: + """Return the linear translator from the underlying scaled template.""" + return self._scaled_template.translator + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + """Parse concentration with resolution.""" + return self._scaled_template.decode_value(data, offset, ctx, validate=validate) # pylint: disable=protected-access + + def encode_value(self, value: float, *, validate: bool = True) -> bytearray: + """Encode concentration value to bytes.""" + return self._scaled_template.encode_value(value, validate=validate) # pylint: disable=protected-access + + +class PressureTemplate(CodingTemplate[float]): + """Template for pressure measurements (uint32, 0.1 Pa resolution).""" + + def __init__(self) -> None: + """Initialize with standard pressure resolution (0.1 Pa).""" + self._scaled_template = ScaledUint32Template(scale_factor=0.1) + + @property + def data_size(self) -> int: + """Size: 4 bytes.""" + return 4 + + @property + def extractor(self) -> RawExtractor: + """Get extractor from underlying scaled template.""" + return self._scaled_template.extractor + + @property + def translator(self) -> ValueTranslator[float]: + """Return the linear translator from the underlying scaled template.""" + return self._scaled_template.translator + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + """Parse pressure in 0.1 Pa resolution (returns Pa).""" + return self._scaled_template.decode_value(data, offset, ctx, validate=validate) # pylint: disable=protected-access + + def encode_value(self, value: float, *, validate: bool = True) -> bytearray: + """Encode pressure to bytes.""" + return self._scaled_template.encode_value(value, validate=validate) # pylint: disable=protected-access diff --git a/src/bluetooth_sig/gatt/characteristics/templates/enum.py b/src/bluetooth_sig/gatt/characteristics/templates/enum.py new file mode 100644 index 00000000..375a3ceb --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/enum.py @@ -0,0 +1,243 @@ +"""Enum template for IntEnum encoding/decoding with configurable byte size.""" + +from __future__ import annotations + +from enum import IntEnum +from typing import TypeVar + +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError, ValueRangeError +from ..utils.extractors import ( + SINT8, + SINT16, + SINT32, + UINT8, + UINT16, + UINT32, + RawExtractor, +) +from ..utils.translators import ( + IDENTITY, + ValueTranslator, +) +from .base import CodingTemplate + +# Type variable for EnumTemplate - bound to IntEnum +T = TypeVar("T", bound=IntEnum) + + +class EnumTemplate(CodingTemplate[T]): + """Template for IntEnum encoding/decoding with configurable byte size. + + Maps raw integer bytes to Python IntEnum instances through extraction and validation. + Supports any integer-based enum with any extractor (UINT8, UINT16, SINT8, etc.). + + This template validates enum membership explicitly, supporting non-contiguous + enum ranges (e.g., values 0, 2, 5, 10). + + Pipeline Integration: + bytes → [extractor] → raw_int → [IDENTITY translator] → int → enum constructor + + Examples: + >>> class Status(IntEnum): + ... IDLE = 0 + ... ACTIVE = 1 + ... ERROR = 2 + >>> + >>> # Create template with factory method + >>> template = EnumTemplate.uint8(Status) + >>> + >>> # Or with explicit extractor + >>> template = EnumTemplate(Status, UINT8) + >>> + >>> # Decode from bytes + >>> status = template.decode_value(bytearray([0x01])) # Status.ACTIVE + >>> + >>> # Encode enum to bytes + >>> data = template.encode_value(Status.ERROR) # bytearray([0x02]) + >>> + >>> # Encode int to bytes (also supported) + >>> data = template.encode_value(2) # bytearray([0x02]) + """ + + def __init__(self, enum_class: type[T], extractor: RawExtractor) -> None: + """Initialize with enum class and extractor. + + Args: + enum_class: IntEnum subclass to encode/decode + extractor: Raw extractor defining byte size and signedness + (e.g., UINT8, UINT16, SINT8, etc.) + """ + self._enum_class = enum_class + self._extractor = extractor + + @property + def data_size(self) -> int: + """Return byte size required for encoding.""" + return self._extractor.byte_size + + @property + def extractor(self) -> RawExtractor: + """Return extractor for pipeline access.""" + return self._extractor + + @property + def translator(self) -> ValueTranslator[int]: + """Get IDENTITY translator for enums (no scaling needed).""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> T: + """Decode bytes to enum instance. + + Args: + data: Raw bytes from BLE characteristic + offset: Starting offset in data buffer + ctx: Optional context for parsing + validate: Whether to validate enum membership (default True) + + Returns: + Enum instance of type T + + Raises: + InsufficientDataError: If data too short for required byte size + ValueRangeError: If raw value not a valid enum member and validate=True + """ + # Check data length + if validate and len(data) < offset + self.data_size: + raise InsufficientDataError(self._enum_class.__name__, data[offset:], self.data_size) + + # Extract raw integer value + raw_value = self._extractor.extract(data, offset) + + # Validate enum membership and construct + try: + return self._enum_class(raw_value) + except ValueError as e: + # Get valid range from enum members + valid_values = [member.value for member in self._enum_class] + min_val = min(valid_values) + max_val = max(valid_values) + raise ValueRangeError(self._enum_class.__name__, raw_value, min_val, max_val) from e + + def encode_value(self, value: T | int, *, validate: bool = True) -> bytearray: + """Encode enum instance or int to bytes. + + Args: + value: Enum instance or integer value to encode + validate: Whether to validate enum membership (default True) + + Returns: + Encoded bytes + + Raises: + ValueError: If value not a valid enum member and validate=True + """ + # Convert to int if enum instance + int_value = value.value if isinstance(value, self._enum_class) else int(value) + + # Validate membership + if validate: + valid_values = [member.value for member in self._enum_class] + if int_value not in valid_values: + min_val = min(valid_values) + max_val = max(valid_values) + raise ValueError( + f"{self._enum_class.__name__} value {int_value} is invalid. " + f"Valid range: {min_val}-{max_val}, valid values: {sorted(valid_values)}" + ) + + # Pack to bytes + return self._extractor.pack(int_value) + + @classmethod + def uint8(cls, enum_class: type[T]) -> EnumTemplate[T]: + """Create EnumTemplate for 1-byte unsigned enum. + + Args: + enum_class: IntEnum subclass with values 0-255 + + Returns: + Configured EnumTemplate instance + + Example:: + >>> class Status(IntEnum): + ... IDLE = 0 + ... ACTIVE = 1 + >>> template = EnumTemplate.uint8(Status) + """ + return cls(enum_class, UINT8) + + @classmethod + def uint16(cls, enum_class: type[T]) -> EnumTemplate[T]: + """Create EnumTemplate for 2-byte unsigned enum. + + Args: + enum_class: IntEnum subclass with values 0-65535 + + Returns: + Configured EnumTemplate instance + + Example:: + >>> class ExtendedStatus(IntEnum): + ... STATE_1 = 0x0100 + ... STATE_2 = 0x0200 + >>> template = EnumTemplate.uint16(ExtendedStatus) + """ + return cls(enum_class, UINT16) + + @classmethod + def uint32(cls, enum_class: type[T]) -> EnumTemplate[T]: + """Create EnumTemplate for 4-byte unsigned enum. + + Args: + enum_class: IntEnum subclass with values 0-4294967295 + + Returns: + Configured EnumTemplate instance + """ + return cls(enum_class, UINT32) + + @classmethod + def sint8(cls, enum_class: type[T]) -> EnumTemplate[T]: + """Create EnumTemplate for 1-byte signed enum. + + Args: + enum_class: IntEnum subclass with values -128 to 127 + + Returns: + Configured EnumTemplate instance + + Example:: + >>> class Temperature(IntEnum): + ... FREEZING = -10 + ... NORMAL = 0 + ... HOT = 10 + >>> template = EnumTemplate.sint8(Temperature) + """ + return cls(enum_class, SINT8) + + @classmethod + def sint16(cls, enum_class: type[T]) -> EnumTemplate[T]: + """Create EnumTemplate for 2-byte signed enum. + + Args: + enum_class: IntEnum subclass with values -32768 to 32767 + + Returns: + Configured EnumTemplate instance + """ + return cls(enum_class, SINT16) + + @classmethod + def sint32(cls, enum_class: type[T]) -> EnumTemplate[T]: + """Create EnumTemplate for 4-byte signed enum. + + Args: + enum_class: IntEnum subclass with values -2147483648 to 2147483647 + + Returns: + Configured EnumTemplate instance + """ + return cls(enum_class, SINT32) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/ieee_float.py b/src/bluetooth_sig/gatt/characteristics/templates/ieee_float.py new file mode 100644 index 00000000..7d166231 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/ieee_float.py @@ -0,0 +1,79 @@ +"""IEEE floating-point templates for medical and standard float formats. + +Covers IEEE11073FloatTemplate (SFLOAT 16-bit) and Float32Template (IEEE-754 32-bit). +""" + +from __future__ import annotations + +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError +from ..utils import DataParser +from ..utils.extractors import ( + FLOAT32, + UINT16, + RawExtractor, +) +from ..utils.translators import ( + SFLOAT, + SfloatTranslator, +) +from .base import CodingTemplate + + +class IEEE11073FloatTemplate(CodingTemplate[float]): + """Template for IEEE 11073 SFLOAT format (16-bit medical device float).""" + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + @property + def extractor(self) -> RawExtractor: + """Get uint16 extractor for raw bits.""" + return UINT16 + + @property + def translator(self) -> SfloatTranslator: + """Get SFLOAT translator.""" + return SFLOAT + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + """Parse IEEE 11073 SFLOAT format.""" + if validate and len(data) < offset + 2: + raise InsufficientDataError("IEEE11073 SFLOAT", data[offset:], 2) + raw = self.extractor.extract(data, offset) + return self.translator.translate(raw) + + def encode_value(self, value: float, *, validate: bool = True) -> bytearray: + """Encode value to IEEE 11073 SFLOAT format.""" + raw = self.translator.untranslate(value) + return self.extractor.pack(raw) + + +class Float32Template(CodingTemplate[float]): + """Template for IEEE-754 32-bit float parsing.""" + + @property + def data_size(self) -> int: + """Size: 4 bytes.""" + return 4 + + @property + def extractor(self) -> RawExtractor: + """Get float32 extractor.""" + return FLOAT32 + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + """Parse IEEE-754 32-bit float.""" + if validate and len(data) < offset + 4: + raise InsufficientDataError("float32", data[offset:], 4) + return DataParser.parse_float32(data, offset) + + def encode_value(self, value: float, *, validate: bool = True) -> bytearray: + """Encode float32 value to bytes.""" + return DataParser.encode_float32(float(value)) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/numeric.py b/src/bluetooth_sig/gatt/characteristics/templates/numeric.py new file mode 100644 index 00000000..278afb04 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/numeric.py @@ -0,0 +1,231 @@ +"""Basic integer templates for unsigned and signed integer parsing. + +Covers Uint8, Sint8, Uint16, Sint16, Uint24, Uint32 templates. +""" + +from __future__ import annotations + +from ...constants import ( + SINT8_MAX, + SINT8_MIN, + SINT16_MAX, + SINT16_MIN, + UINT8_MAX, + UINT16_MAX, + UINT24_MAX, + UINT32_MAX, +) +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError +from ..utils.extractors import ( + SINT8, + SINT16, + UINT8, + UINT16, + UINT24, + UINT32, + RawExtractor, +) +from ..utils.translators import ( + IDENTITY, + ValueTranslator, +) +from .base import CodingTemplate + + +class Uint8Template(CodingTemplate[int]): + """Template for 8-bit unsigned integer parsing (0-255).""" + + @property + def data_size(self) -> int: + """Size: 1 byte.""" + return 1 + + @property + def extractor(self) -> RawExtractor: + """Get uint8 extractor.""" + return UINT8 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 8-bit unsigned integer.""" + if validate and len(data) < offset + 1: + raise InsufficientDataError("uint8", data[offset:], 1) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode uint8 value to bytes.""" + if validate and not 0 <= value <= UINT8_MAX: + raise ValueError(f"Value {value} out of range for uint8 (0-{UINT8_MAX})") + return self.extractor.pack(value) + + +class Sint8Template(CodingTemplate[int]): + """Template for 8-bit signed integer parsing (-128 to 127).""" + + @property + def data_size(self) -> int: + """Size: 1 byte.""" + return 1 + + @property + def extractor(self) -> RawExtractor: + """Get sint8 extractor.""" + return SINT8 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 8-bit signed integer.""" + if validate and len(data) < offset + 1: + raise InsufficientDataError("sint8", data[offset:], 1) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode sint8 value to bytes.""" + if validate and not SINT8_MIN <= value <= SINT8_MAX: + raise ValueError(f"Value {value} out of range for sint8 ({SINT8_MIN} to {SINT8_MAX})") + return self.extractor.pack(value) + + +class Uint16Template(CodingTemplate[int]): + """Template for 16-bit unsigned integer parsing (0-65535).""" + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + @property + def extractor(self) -> RawExtractor: + """Get uint16 extractor.""" + return UINT16 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 16-bit unsigned integer.""" + if validate and len(data) < offset + 2: + raise InsufficientDataError("uint16", data[offset:], 2) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode uint16 value to bytes.""" + if validate and not 0 <= value <= UINT16_MAX: + raise ValueError(f"Value {value} out of range for uint16 (0-{UINT16_MAX})") + return self.extractor.pack(value) + + +class Sint16Template(CodingTemplate[int]): + """Template for 16-bit signed integer parsing (-32768 to 32767).""" + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + @property + def extractor(self) -> RawExtractor: + """Get sint16 extractor.""" + return SINT16 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 16-bit signed integer.""" + if validate and len(data) < offset + 2: + raise InsufficientDataError("sint16", data[offset:], 2) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode sint16 value to bytes.""" + if validate and not SINT16_MIN <= value <= SINT16_MAX: + raise ValueError(f"Value {value} out of range for sint16 ({SINT16_MIN} to {SINT16_MAX})") + return self.extractor.pack(value) + + +class Uint24Template(CodingTemplate[int]): + """Template for 24-bit unsigned integer parsing (0-16777215).""" + + @property + def data_size(self) -> int: + """Size: 3 bytes.""" + return 3 + + @property + def extractor(self) -> RawExtractor: + """Get uint24 extractor.""" + return UINT24 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 24-bit unsigned integer.""" + if validate and len(data) < offset + 3: + raise InsufficientDataError("uint24", data[offset:], 3) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode uint24 value to bytes.""" + if validate and not 0 <= value <= UINT24_MAX: + raise ValueError(f"Value {value} out of range for uint24 (0-{UINT24_MAX})") + return self.extractor.pack(value) + + +class Uint32Template(CodingTemplate[int]): + """Template for 32-bit unsigned integer parsing.""" + + @property + def data_size(self) -> int: + """Size: 4 bytes.""" + return 4 + + @property + def extractor(self) -> RawExtractor: + """Get uint32 extractor.""" + return UINT32 + + @property + def translator(self) -> ValueTranslator[int]: + """Return identity translator for no scaling.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse 32-bit unsigned integer.""" + if validate and len(data) < offset + 4: + raise InsufficientDataError("uint32", data[offset:], 4) + return self.extractor.extract(data, offset) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode uint32 value to bytes.""" + if validate and not 0 <= value <= UINT32_MAX: + raise ValueError(f"Value {value} out of range for uint32 (0-{UINT32_MAX})") + return self.extractor.pack(value) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/scaled.py b/src/bluetooth_sig/gatt/characteristics/templates/scaled.py new file mode 100644 index 00000000..732042ad --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/scaled.py @@ -0,0 +1,417 @@ +"""Scaled value templates with configurable resolution and offset. + +Covers ScaledTemplate (abstract), ScaledUint8/16/24/32, ScaledSint8/16/24/32, +and PercentageTemplate. +""" + +from __future__ import annotations + +from abc import abstractmethod + +from ...constants import ( + PERCENTAGE_MAX, + SINT8_MAX, + SINT8_MIN, + SINT16_MAX, + SINT16_MIN, + SINT24_MAX, + SINT24_MIN, + UINT8_MAX, + UINT16_MAX, + UINT24_MAX, + UINT32_MAX, +) +from ...context import CharacteristicContext +from ...exceptions import InsufficientDataError, ValueRangeError +from ..utils.extractors import ( + SINT8, + SINT16, + SINT24, + SINT32, + UINT8, + UINT16, + UINT24, + UINT32, + RawExtractor, +) +from ..utils.translators import ( + IDENTITY, + IdentityTranslator, + LinearTranslator, +) +from .base import CodingTemplate + + +class ScaledTemplate(CodingTemplate[float]): + """Base class for scaled integer templates. + + Handles common scaling logic: value = (raw + offset) * scale_factor + Subclasses implement raw parsing/encoding and range checking. + + Exposes `extractor` and `translator` for pipeline access. + """ + + _extractor: RawExtractor + _translator: LinearTranslator + + def __init__(self, scale_factor: float, offset: int) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + self._translator = LinearTranslator(scale_factor=scale_factor, offset=offset) + + @property + def scale_factor(self) -> float: + """Get the scale factor.""" + return self._translator.scale_factor + + @property + def offset(self) -> int: + """Get the offset.""" + return self._translator.offset + + @property + def extractor(self) -> RawExtractor: + """Get the byte extractor for pipeline access.""" + return self._extractor + + @property + def translator(self) -> LinearTranslator: + """Get the value translator for pipeline access.""" + return self._translator + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> float: + """Parse scaled integer value.""" + raw_value = self._extractor.extract(data, offset) + return self._translator.translate(raw_value) + + def encode_value(self, value: float, *, validate: bool = True) -> bytearray: + """Encode scaled value to bytes.""" + raw_value = self._translator.untranslate(value) + if validate: + self._check_range(raw_value) + return self._extractor.pack(raw_value) + + @abstractmethod + def _check_range(self, raw: int) -> None: + """Check if raw value is in valid range.""" + + @classmethod + def from_scale_offset(cls, scale_factor: float, offset: int) -> ScaledTemplate: + """Create instance using scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + Returns: + ScaledTemplate instance + + """ + return cls(scale_factor=scale_factor, offset=offset) + + @classmethod + def from_letter_method(cls, M: int, d: int, b: int) -> ScaledTemplate: # noqa: N803 + """Create instance using Bluetooth SIG M, d, b parameters. + + Args: + M: Multiplier factor + d: Decimal exponent (10^d) + b: Offset to add to raw value before scaling + + Returns: + ScaledTemplate instance + + """ + scale_factor = M * (10**d) + return cls(scale_factor=scale_factor, offset=b) + + +class ScaledUint16Template(ScaledTemplate): + """Template for scaled 16-bit unsigned integer. + + Used for values that need decimal precision encoded as integers. + Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. + Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) + Example: Temperature 25.5°C stored as 2550 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 + """ + + _extractor = UINT16 + + def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + def _check_range(self, raw: int) -> None: + """Check range for uint16.""" + if not 0 <= raw <= UINT16_MAX: + raise ValueError(f"Scaled value {raw} out of range for uint16") + + +class ScaledSint16Template(ScaledTemplate): + """Template for scaled 16-bit signed integer. + + Used for signed values that need decimal precision encoded as integers. + Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. + Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) + Example: Temperature -10.5°C stored as -1050 with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 + """ + + _extractor = SINT16 + + def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 2 bytes.""" + return 2 + + def _check_range(self, raw: int) -> None: + """Check range for sint16.""" + if not SINT16_MIN <= raw <= SINT16_MAX: + raise ValueError(f"Scaled value {raw} out of range for sint16") + + +class ScaledSint8Template(ScaledTemplate): + """Template for scaled 8-bit signed integer. + + Used for signed values that need decimal precision encoded as integers. + Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. + Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) + Example: Temperature with scale_factor=0.01, offset=0 or M=1, d=-2, b=0 + """ + + _extractor = SINT8 + + def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 1 byte.""" + return 1 + + def _check_range(self, raw: int) -> None: + """Check range for sint8.""" + if not SINT8_MIN <= raw <= SINT8_MAX: + raise ValueError(f"Scaled value {raw} out of range for sint8") + + +class ScaledUint8Template(ScaledTemplate): + """Template for scaled 8-bit unsigned integer. + + Used for unsigned values that need decimal precision encoded as integers. + Can be initialized with scale_factor/offset or Bluetooth SIG M, d, b parameters. + Formula: value = scale_factor * (raw + offset) or value = M * 10^d * (raw + b) + Example: Uncertainty with scale_factor=0.1, offset=0 or M=1, d=-1, b=0 + """ + + _extractor = UINT8 + + def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 1 byte.""" + return 1 + + def _check_range(self, raw: int) -> None: + """Check range for uint8.""" + if not 0 <= raw <= UINT8_MAX: + raise ValueError(f"Scaled value {raw} out of range for uint8") + + +class ScaledUint32Template(ScaledTemplate): + """Template for scaled 32-bit unsigned integer with configurable resolution and offset.""" + + _extractor = UINT32 + + def __init__(self, scale_factor: float = 0.1, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by (e.g., 0.1 for 1 decimal place) + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 4 bytes.""" + return 4 + + def _check_range(self, raw: int) -> None: + """Check range for uint32.""" + if not 0 <= raw <= UINT32_MAX: + raise ValueError(f"Scaled value {raw} out of range for uint32") + + +class ScaledUint24Template(ScaledTemplate): + """Template for scaled 24-bit unsigned integer with configurable resolution and offset. + + Used for values encoded in 3 bytes as unsigned integers. + Example: Illuminance 1000 lux stored as bytes with scale_factor=1.0, offset=0 + """ + + _extractor = UINT24 + + def __init__(self, scale_factor: float = 1.0, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 3 bytes.""" + return 3 + + def _check_range(self, raw: int) -> None: + """Check range for uint24.""" + if not 0 <= raw <= UINT24_MAX: + raise ValueError(f"Scaled value {raw} out of range for uint24") + + +class ScaledSint24Template(ScaledTemplate): + """Template for scaled 24-bit signed integer with configurable resolution and offset. + + Used for signed values encoded in 3 bytes. + Example: Elevation 500.00m stored as bytes with scale_factor=0.01, offset=0 + """ + + _extractor = SINT24 + + def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 3 bytes.""" + return 3 + + def _check_range(self, raw: int) -> None: + """Check range for sint24.""" + if not SINT24_MIN <= raw <= SINT24_MAX: + raise ValueError(f"Scaled value {raw} out of range for sint24") + + +class ScaledSint32Template(ScaledTemplate): + """Template for scaled 32-bit signed integer with configurable resolution and offset. + + Used for signed values encoded in 4 bytes. + Example: Longitude -180.0 to 180.0 degrees stored with scale_factor=1e-7 + """ + + _extractor = SINT32 + + def __init__(self, scale_factor: float = 0.01, offset: int = 0) -> None: + """Initialize with scale factor and offset. + + Args: + scale_factor: Factor to multiply raw value by + offset: Offset to add to raw value before scaling + + """ + super().__init__(scale_factor, offset) + + @property + def data_size(self) -> int: + """Size: 4 bytes.""" + return 4 + + def _check_range(self, raw: int) -> None: + """Check range for sint32.""" + sint32_min = -(2**31) + sint32_max = (2**31) - 1 + if not sint32_min <= raw <= sint32_max: + raise ValueError(f"Scaled value {raw} out of range for sint32") + + +class PercentageTemplate(CodingTemplate[int]): + """Template for percentage values (0-100%) using uint8.""" + + @property + def data_size(self) -> int: + """Size: 1 byte.""" + return 1 + + @property + def extractor(self) -> RawExtractor: + """Get uint8 extractor.""" + return UINT8 + + @property + def translator(self) -> IdentityTranslator: + """Return identity translator since validation is separate from translation.""" + return IDENTITY + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> int: + """Parse percentage value.""" + if validate and len(data) < offset + 1: + raise InsufficientDataError("percentage", data[offset:], 1) + value = self.extractor.extract(data, offset) + # Only validate range if validation is enabled + if validate and not 0 <= value <= PERCENTAGE_MAX: + raise ValueRangeError("percentage", value, 0, PERCENTAGE_MAX) + return self.translator.translate(value) + + def encode_value(self, value: int, *, validate: bool = True) -> bytearray: + """Encode percentage value to bytes.""" + if validate and not 0 <= value <= PERCENTAGE_MAX: + raise ValueError(f"Percentage value {value} out of range (0-{PERCENTAGE_MAX})") + raw = self.translator.untranslate(value) + return self.extractor.pack(raw) diff --git a/src/bluetooth_sig/gatt/characteristics/templates/string.py b/src/bluetooth_sig/gatt/characteristics/templates/string.py new file mode 100644 index 00000000..a161e8b6 --- /dev/null +++ b/src/bluetooth_sig/gatt/characteristics/templates/string.py @@ -0,0 +1,125 @@ +"""String templates for UTF-8 and UTF-16LE variable-length parsing. + +Covers Utf8StringTemplate and Utf16StringTemplate. +""" + +from __future__ import annotations + +from ...context import CharacteristicContext +from .base import CodingTemplate + + +class Utf8StringTemplate(CodingTemplate[str]): + """Template for UTF-8 string parsing with variable length.""" + + def __init__(self, max_length: int = 256) -> None: + """Initialize with maximum string length. + + Args: + max_length: Maximum string length in bytes + + """ + self.max_length = max_length + + @property + def data_size(self) -> int: + """Size: Variable (0 to max_length).""" + return self.max_length + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> str: + """Parse UTF-8 string from remaining data.""" + if offset >= len(data): + return "" + + # Take remaining data from offset + string_data = data[offset:] + + # Remove null terminator if present + if b"\x00" in string_data: + null_index = string_data.index(b"\x00") + string_data = string_data[:null_index] + + try: + return string_data.decode("utf-8") + except UnicodeDecodeError as e: + if validate: + raise ValueError(f"Invalid UTF-8 string data: {e}") from e + return "" + + def encode_value(self, value: str, *, validate: bool = True) -> bytearray: + """Encode string to UTF-8 bytes.""" + encoded = value.encode("utf-8") + if validate and len(encoded) > self.max_length: + raise ValueError(f"String too long: {len(encoded)} > {self.max_length}") + return bytearray(encoded) + + +class Utf16StringTemplate(CodingTemplate[str]): + """Template for UTF-16LE string parsing with variable length.""" + + # Unicode constants for UTF-16 validation + UNICODE_SURROGATE_START = 0xD800 + UNICODE_SURROGATE_END = 0xDFFF + UNICODE_BOM = "\ufeff" + + def __init__(self, max_length: int = 256) -> None: + """Initialize with maximum string length. + + Args: + max_length: Maximum string length in bytes (must be even) + + """ + if max_length % 2 != 0: + raise ValueError("max_length must be even for UTF-16 strings") + self.max_length = max_length + + @property + def data_size(self) -> int: + """Size: Variable (0 to max_length, even bytes only).""" + return self.max_length + + def decode_value( + self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True + ) -> str: + """Parse UTF-16LE string from remaining data.""" + if offset >= len(data): + return "" + + # Take remaining data from offset + string_data = data[offset:] + + # Find null terminator at even positions (UTF-16 alignment) + null_index = len(string_data) + for i in range(0, len(string_data) - 1, 2): + if string_data[i : i + 2] == bytearray(b"\x00\x00"): + null_index = i + break + string_data = string_data[:null_index] + + # UTF-16 requires even number of bytes + if validate and len(string_data) % 2 != 0: + raise ValueError(f"UTF-16 data must have even byte count, got {len(string_data)}") + + try: + decoded = string_data.decode("utf-16-le") + # Strip BOM if present (robustness) + if decoded.startswith(self.UNICODE_BOM): + decoded = decoded[1:] + # Check for invalid surrogate pairs + if validate and any(self.UNICODE_SURROGATE_START <= ord(c) <= self.UNICODE_SURROGATE_END for c in decoded): + raise ValueError("Invalid UTF-16LE string data: contains unpaired surrogates") + except UnicodeDecodeError as e: + if validate: + raise ValueError(f"Invalid UTF-16LE string data: {e}") from e + return "" + else: + return decoded + + def encode_value(self, value: str, *, validate: bool = True) -> bytearray: + """Encode string to UTF-16LE bytes.""" + encoded = value.encode("utf-16-le") + if validate and len(encoded) > self.max_length: + raise ValueError(f"String too long: {len(encoded)} > {self.max_length}") + return bytearray(encoded) diff --git a/src/bluetooth_sig/gatt/descriptor_utils.py b/src/bluetooth_sig/gatt/descriptor_utils.py index b17b9c8b..eceb49b1 100644 --- a/src/bluetooth_sig/gatt/descriptor_utils.py +++ b/src/bluetooth_sig/gatt/descriptor_utils.py @@ -86,7 +86,7 @@ def get_presentation_format_from_context( """ descriptor_data = get_descriptor_from_context(ctx, CharacteristicPresentationFormatDescriptor) if descriptor_data and descriptor_data.value: - return descriptor_data.value # type: ignore[no-any-return] + return descriptor_data.value # type: ignore[no-any-return] # Generic BaseDescriptor.value is Any; caller knows concrete type return None diff --git a/src/bluetooth_sig/gatt/descriptors/base.py b/src/bluetooth_sig/gatt/descriptors/base.py index 360414c2..d0a3837e 100644 --- a/src/bluetooth_sig/gatt/descriptors/base.py +++ b/src/bluetooth_sig/gatt/descriptors/base.py @@ -150,8 +150,8 @@ def get_min_value(self, data: bytes) -> int | float: Returns: Minimum valid value for the characteristic """ - parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] - return parsed.min_value # type: ignore[no-any-return] + parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] # Mixin: concrete subclass provides _parse_descriptor_value + return parsed.min_value # type: ignore[no-any-return] # Parsed struct has typed fields def get_max_value(self, data: bytes) -> int | float: """Get the maximum valid value. @@ -162,8 +162,8 @@ def get_max_value(self, data: bytes) -> int | float: Returns: Maximum valid value for the characteristic """ - parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] - return parsed.max_value # type: ignore[no-any-return] + parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] # Mixin: concrete subclass provides _parse_descriptor_value + return parsed.max_value # type: ignore[no-any-return] # Parsed struct has typed fields def is_value_in_range(self, data: bytes, value: float) -> bool: """Check if a value is within the valid range. diff --git a/src/bluetooth_sig/gatt/descriptors/characteristic_presentation_format.py b/src/bluetooth_sig/gatt/descriptors/characteristic_presentation_format.py index 67758f20..d27f6496 100644 --- a/src/bluetooth_sig/gatt/descriptors/characteristic_presentation_format.py +++ b/src/bluetooth_sig/gatt/descriptors/characteristic_presentation_format.py @@ -25,7 +25,7 @@ class FormatNamespace(IntEnum): def _missing_(cls, value: object) -> FormatNamespace: """Return UNKNOWN for unrecognised namespace values.""" if not isinstance(value, int): - return None # type: ignore[return-value] + return None # type: ignore[return-value] # IntEnum._missing_ returns None to signal invalid input obj = int.__new__(cls, value) obj._name_ = f"UNKNOWN_{value}" obj._value_ = value @@ -71,7 +71,7 @@ class FormatType(IntEnum): def _missing_(cls, value: object) -> FormatType: """Return dynamically created member for unrecognised format values.""" if not isinstance(value, int): - return None # type: ignore[return-value] + return None # type: ignore[return-value] # IntEnum._missing_ returns None to signal invalid input obj = int.__new__(cls, value) obj._name_ = f"UNKNOWN_{value}" obj._value_ = value diff --git a/src/bluetooth_sig/gatt/descriptors/registry.py b/src/bluetooth_sig/gatt/descriptors/registry.py index 6d53ea35..642ab81e 100644 --- a/src/bluetooth_sig/gatt/descriptors/registry.py +++ b/src/bluetooth_sig/gatt/descriptors/registry.py @@ -2,11 +2,14 @@ from __future__ import annotations +import logging from typing import ClassVar from ...types.uuid import BluetoothUUID from .base import BaseDescriptor +logger = logging.getLogger(__name__) + class DescriptorRegistry: """Registry for descriptor classes.""" @@ -23,7 +26,7 @@ def register(cls, descriptor_class: type[BaseDescriptor]) -> None: cls._registry[uuid_str] = descriptor_class except (ValueError, TypeError, AttributeError): # If we can't create an instance or resolve UUID, skip registration - pass + logger.warning("Failed to register descriptor class %s", descriptor_class.__name__) @classmethod def get_descriptor_class(cls, uuid: str | BluetoothUUID | int) -> type[BaseDescriptor] | None: diff --git a/src/bluetooth_sig/gatt/registry_utils.py b/src/bluetooth_sig/gatt/registry_utils.py index c8529081..d57d97b9 100644 --- a/src/bluetooth_sig/gatt/registry_utils.py +++ b/src/bluetooth_sig/gatt/registry_utils.py @@ -8,10 +8,9 @@ import inspect import pkgutil +from collections.abc import Callable from importlib import import_module -from typing import Any, Callable, TypeVar - -from typing_extensions import TypeGuard +from typing import Any, TypeGuard, TypeVar class TypeValidator: # pylint: disable=too-few-public-methods diff --git a/src/bluetooth_sig/gatt/services/registry.py b/src/bluetooth_sig/gatt/services/registry.py index 4e48dfab..53af3deb 100644 --- a/src/bluetooth_sig/gatt/services/registry.py +++ b/src/bluetooth_sig/gatt/services/registry.py @@ -8,9 +8,7 @@ from __future__ import annotations -from typing import ClassVar - -from typing_extensions import TypeGuard +from typing import ClassVar, TypeGuard from ...registry.base import BaseUUIDClassRegistry from ...types.gatt_enums import ServiceName diff --git a/src/bluetooth_sig/gatt/uuid_registry.py b/src/bluetooth_sig/gatt/uuid_registry.py index cfd24481..0b5b39cb 100644 --- a/src/bluetooth_sig/gatt/uuid_registry.py +++ b/src/bluetooth_sig/gatt/uuid_registry.py @@ -3,19 +3,13 @@ from __future__ import annotations import contextlib - -from bluetooth_sig.types.base_types import SIGInfo - -__all__ = [ - "UuidRegistry", - "uuid_registry", -] - +import logging import threading from bluetooth_sig.registry.gss import GssRegistry from bluetooth_sig.registry.uuids.units import UnitsRegistry from bluetooth_sig.types import CharacteristicInfo, ServiceInfo +from bluetooth_sig.types.base_types import SIGInfo from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.registry.descriptor_types import DescriptorInfo from bluetooth_sig.types.registry.gss_characteristic import GssCharacteristicSpec @@ -24,6 +18,13 @@ from ..registry.utils import find_bluetooth_sig_path, load_yaml_uuids, normalize_uuid_string from ..types.registry import CharacteristicSpec, FieldInfo, UnitMetadata +__all__ = [ + "UuidRegistry", + "uuid_registry", +] + +logger = logging.getLogger(__name__) + class UuidRegistry: # pylint: disable=too-many-instance-attributes """Registry for Bluetooth SIG UUIDs with canonical storage + alias indices. @@ -389,7 +390,7 @@ def get_service_info(self, key: str | BluetoothUUID) -> ServiceInfo | None: if canonical_key in self._services: return self._services[canonical_key] except ValueError: - pass + logger.warning("UUID normalization failed for service lookup: %s", search_key) # Check alias index (normalized to lowercase) alias_key = self._service_aliases.get(search_key.lower()) @@ -417,7 +418,7 @@ def get_characteristic_info(self, identifier: str | BluetoothUUID) -> Characteri if canonical_key in self._characteristics: return self._characteristics[canonical_key] except ValueError: - pass + logger.warning("UUID normalization failed for characteristic lookup: %s", search_key) # Check alias index (normalized to lowercase) alias_key = self._characteristic_aliases.get(search_key.lower()) @@ -445,7 +446,7 @@ def get_descriptor_info(self, identifier: str | BluetoothUUID) -> DescriptorInfo if canonical_key in self._descriptors: return self._descriptors[canonical_key] except ValueError: - pass + logger.warning("UUID normalization failed for descriptor lookup: %s", search_key) # Check alias index (normalized to lowercase) alias_key = self._descriptor_aliases.get(search_key.lower()) diff --git a/src/bluetooth_sig/gatt/validation.py b/src/bluetooth_sig/gatt/validation.py index f0e6f44c..7ea3ffab 100644 --- a/src/bluetooth_sig/gatt/validation.py +++ b/src/bluetooth_sig/gatt/validation.py @@ -7,7 +7,8 @@ from __future__ import annotations -from typing import Any, Callable, TypeVar +from collections.abc import Callable +from typing import Any, TypeVar import msgspec diff --git a/src/bluetooth_sig/registry/base.py b/src/bluetooth_sig/registry/base.py index 16361455..907833fe 100644 --- a/src/bluetooth_sig/registry/base.py +++ b/src/bluetooth_sig/registry/base.py @@ -2,11 +2,13 @@ from __future__ import annotations +import logging import threading from abc import ABC, abstractmethod +from collections.abc import Callable from enum import Enum from pathlib import Path -from typing import Any, Callable, Generic, TypeVar +from typing import Any, Generic, TypeVar from bluetooth_sig.registry.utils import ( find_bluetooth_sig_path, @@ -16,6 +18,8 @@ from bluetooth_sig.types.registry import BaseUuidInfo, generate_basic_aliases from bluetooth_sig.types.uuid import BluetoothUUID +logger = logging.getLogger(__name__) + T = TypeVar("T") E = TypeVar("E", bound=Enum) # For enum-keyed registries C = TypeVar("C") # For class types @@ -209,7 +213,7 @@ def get_info(self, identifier: str | BluetoothUUID) -> U | None: if canonical_key in self._canonical_store: return self._canonical_store[canonical_key] except ValueError: - pass + logger.warning("UUID normalization failed for registry lookup: %s", search_key) # Check alias index (normalized to lowercase) alias_key = self._alias_index.get(search_key.lower()) @@ -376,7 +380,7 @@ def _get_sig_classes_map(self) -> dict[BluetoothUUID, type[C]]: self._sig_class_cache = {} for cls in self._discover_sig_classes(): try: - uuid_obj = cls.get_class_uuid() # type: ignore[attr-defined] + uuid_obj = cls.get_class_uuid() # type: ignore[attr-defined] # Generic C bound lacks this method; runtime dispatch is correct if uuid_obj: self._sig_class_cache[uuid_obj] = cls except (AttributeError, ValueError): diff --git a/src/bluetooth_sig/registry/gss.py b/src/bluetooth_sig/registry/gss.py index f18e737c..b20b7897 100644 --- a/src/bluetooth_sig/registry/gss.py +++ b/src/bluetooth_sig/registry/gss.py @@ -62,9 +62,9 @@ def _get_units_registry(self) -> UnitsRegistry: The UnitsRegistry singleton instance """ if self._units_registry is None: - # Cast needed because get_instance returns base class type - self._units_registry = UnitsRegistry.get_instance() # type: ignore[assignment] - return self._units_registry # type: ignore[return-value] + # get_instance() returns BaseUUIDRegistry; narrow to concrete subclass + self._units_registry = cast("UnitsRegistry", UnitsRegistry.get_instance()) + return self._units_registry def _load(self) -> None: """Load GSS specifications from YAML files.""" diff --git a/src/bluetooth_sig/stream/pairing.py b/src/bluetooth_sig/stream/pairing.py index 162b66a8..c5cb598f 100644 --- a/src/bluetooth_sig/stream/pairing.py +++ b/src/bluetooth_sig/stream/pairing.py @@ -9,8 +9,8 @@ from __future__ import annotations -from collections.abc import Hashable -from typing import Any, Callable +from collections.abc import Callable, Hashable +from typing import Any from ..core.translator import BluetoothSIGTranslator diff --git a/src/bluetooth_sig/types/__init__.py b/src/bluetooth_sig/types/__init__.py index 289192ec..e3063ee2 100644 --- a/src/bluetooth_sig/types/__init__.py +++ b/src/bluetooth_sig/types/__init__.py @@ -50,6 +50,7 @@ EADKeyMaterial, EncryptedAdvertisingData, ) +from .gatt_enums import CharacteristicRole from .location import PositionStatus from .mesh import ( DEVICE_UUID_LENGTH, @@ -125,6 +126,7 @@ "CharacteristicContext", "CharacteristicInfo", "CharacteristicProtocol", + "CharacteristicRole", "ClassOfDeviceInfo", "CompanyIdentifier", "ConcentrationUnit", diff --git a/src/bluetooth_sig/types/device_types.py b/src/bluetooth_sig/types/device_types.py index b32d0015..7705b75d 100644 --- a/src/bluetooth_sig/types/device_types.py +++ b/src/bluetooth_sig/types/device_types.py @@ -2,34 +2,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from collections.abc import Awaitable, Callable +from typing import Any, Literal, TypeAlias import msgspec +from ..gatt.characteristics.base import BaseCharacteristic +from ..gatt.services.base import BaseGattService from .advertising.result import AdvertisementData from .uuid import BluetoothUUID # Type alias for scanning mode ScanningMode = Literal["active", "passive"] -# Circular dependency: gatt.characteristics.base imports from types, -# and this module needs to reference those classes for type hints. -# Type aliases using Callable with union syntax (|) must be in TYPE_CHECKING -# because GenericAlias doesn't support | operator at runtime in Python 3.9. -# PEP 604 (X | Y syntax) is Python 3.10+ only. -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable +# Type alias for scan detection callback: receives ScannedDevice as it's discovered +ScanDetectionCallback: TypeAlias = Callable[["ScannedDevice"], Awaitable[None] | None] - from typing_extensions import TypeAlias - - from ..gatt.characteristics.base import BaseCharacteristic - from ..gatt.services.base import BaseGattService - - # Type alias for scan detection callback: receives ScannedDevice as it's discovered - ScanDetectionCallback: TypeAlias = Callable[["ScannedDevice"], Awaitable[None] | None] - - # Type alias for scan filter function: returns True if device matches - ScanFilterFunc: TypeAlias = Callable[["ScannedDevice"], bool] +# Type alias for scan filter function: returns True if device matches +ScanFilterFunc: TypeAlias = Callable[["ScannedDevice"], bool] class ScannedDevice(msgspec.Struct, kw_only=True): diff --git a/src/bluetooth_sig/types/gatt_enums.py b/src/bluetooth_sig/types/gatt_enums.py index 3b8f250a..7b75ad7b 100644 --- a/src/bluetooth_sig/types/gatt_enums.py +++ b/src/bluetooth_sig/types/gatt_enums.py @@ -95,6 +95,37 @@ class ValueType(Enum): UNKNOWN = "unknown" +class CharacteristicRole(Enum): + """Inferred purpose of a GATT characteristic. + + Derived algorithmically from SIG spec metadata (name patterns, + value_type, unit presence, field structure). No per-characteristic + maintenance is required — the classification is computed at + instantiation time from data already parsed from the SIG YAML specs. + + Members: + MEASUREMENT — carries numeric or structured sensor data with + physical units (temperature, heart rate, SpO₂, …). + STATUS — reports a device state or enum value + (training status, alert status, …). + FEATURE — describes device capabilities as a bitfield + (blood pressure feature, cycling power feature, …). + CONTROL — write-only control point used to command the device + (heart rate control point, fitness machine CP, …). + INFO — static metadata string + (device name, firmware revision, serial number, …). + UNKNOWN — cannot be classified from spec metadata alone; + consumers should apply their own heuristic. + """ + + MEASUREMENT = "measurement" + STATUS = "status" + FEATURE = "feature" + CONTROL = "control" + INFO = "info" + UNKNOWN = "unknown" + + class DataType(Enum): """Bluetooth SIG data types from GATT specifications.""" diff --git a/src/bluetooth_sig/types/registry/gss_characteristic.py b/src/bluetooth_sig/types/registry/gss_characteristic.py index 2fdcde8f..777b1e34 100644 --- a/src/bluetooth_sig/types/registry/gss_characteristic.py +++ b/src/bluetooth_sig/types/registry/gss_characteristic.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging import re import msgspec +logger = logging.getLogger(__name__) + class SpecialValue(msgspec.Struct, frozen=True): """A special sentinel value with its meaning. @@ -158,7 +161,7 @@ def value_range(self) -> tuple[float, float] | None: try: return float(min_val), float(max_val) except ValueError: - pass + logger.warning("Failed to parse min/max range values: min=%s, max=%s", min_val, max_val) return None diff --git a/src/bluetooth_sig/utils/profiling.py b/src/bluetooth_sig/utils/profiling.py index ba955ed6..6d66f3a4 100644 --- a/src/bluetooth_sig/utils/profiling.py +++ b/src/bluetooth_sig/utils/profiling.py @@ -3,9 +3,9 @@ from __future__ import annotations import time -from collections.abc import Generator +from collections.abc import Callable, Generator from contextlib import contextmanager -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar import msgspec diff --git a/tests/device/test_device.py b/tests/device/test_device.py index 0ec28823..c3292f8a 100644 --- a/tests/device/test_device.py +++ b/tests/device/test_device.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Callable, cast +from collections.abc import Callable +from typing import cast import pytest diff --git a/tests/device/test_device_async_methods.py b/tests/device/test_device_async_methods.py index 47c05bc3..ec60aa9f 100644 --- a/tests/device/test_device_async_methods.py +++ b/tests/device/test_device_async_methods.py @@ -12,7 +12,8 @@ from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import pytest diff --git a/tests/device/test_device_batch_ops.py b/tests/device/test_device_batch_ops.py index 37065531..ef6b674c 100644 --- a/tests/device/test_device_batch_ops.py +++ b/tests/device/test_device_batch_ops.py @@ -10,7 +10,7 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable from unittest.mock import MagicMock import pytest diff --git a/tests/gatt/characteristics/test_characteristic_role.py b/tests/gatt/characteristics/test_characteristic_role.py new file mode 100644 index 00000000..72889cf4 --- /dev/null +++ b/tests/gatt/characteristics/test_characteristic_role.py @@ -0,0 +1,257 @@ +"""Tests for characteristic role classification. + +Verifies that :attr:`BaseCharacteristic.role` assigns the correct +:class:`CharacteristicRole` based on SIG spec metadata (name patterns, +value_type, unit presence, and GSS field structure). +""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic +from bluetooth_sig.gatt.characteristics.registry import CharacteristicRegistry +from bluetooth_sig.types.gatt_enums import CharacteristicRole, ValueType + +# --------------------------------------------------------------------------- +# Helper — instantiate a registered characteristic by its SIG name +# --------------------------------------------------------------------------- + + +def _get_char(sig_name: str) -> BaseCharacteristic: # type: ignore[type-arg] + """Return an instance of the characteristic registered under *sig_name*. + + Raises ``KeyError`` if the name is not in the registry. + """ + chars = CharacteristicRegistry.get_all_characteristics() + for name_enum, cls in chars.items(): + display = name_enum.value if hasattr(name_enum, "value") else str(name_enum) + if display == sig_name: + return cls() + raise KeyError(f"{sig_name!r} not found in CharacteristicRegistry") + + +# --------------------------------------------------------------------------- +# MEASUREMENT — numeric / structured data with physical units +# --------------------------------------------------------------------------- + + +class TestMeasurementRole: + """Characteristics that carry sensor data should be classified MEASUREMENT.""" + + @pytest.mark.parametrize( + "sig_name", + [ + # Rule 3: name contains "Measurement" + "Temperature Measurement", + "Heart Rate Measurement", + "Blood Pressure Measurement", + "Weight Measurement", + "CSC Measurement", + # Rule 4: numeric scalar with a unit + "Temperature", + "Humidity", + "Pressure", + "Acceleration", + "Elevation", + # Rule 5: compound value with unit metadata + "Acceleration 3D", + "Activity Goal", + "Location and Speed", + "Navigation", + ], + ) + def test_measurement_characteristics(self, sig_name: str) -> None: + char = _get_char(sig_name) + assert char.role == CharacteristicRole.MEASUREMENT, ( + f"{sig_name} expected MEASUREMENT, got {char.role.value} (vtype={char.value_type.name}, unit={char.unit!r})" + ) + + def test_measurement_interval_by_name(self) -> None: + """'Measurement Interval' matches the name rule even though it could + also match numeric-with-unit. + """ + char = _get_char("Measurement Interval") + assert char.role == CharacteristicRole.MEASUREMENT + + +# --------------------------------------------------------------------------- +# CONTROL — write-only control points +# --------------------------------------------------------------------------- + + +class TestControlRole: + """Control Point characteristics should be classified CONTROL.""" + + @pytest.mark.parametrize( + "sig_name", + [ + "Heart Rate Control Point", + "Cycling Power Control Point", + "Bond Management Control Point", + "Alert Notification Control Point", + "LN Control Point", + "Time Update Control Point", + "HID Control Point", + ], + ) + def test_control_point_characteristics(self, sig_name: str) -> None: + char = _get_char(sig_name) + assert char.role == CharacteristicRole.CONTROL + + +# --------------------------------------------------------------------------- +# FEATURE — capability bitfields +# --------------------------------------------------------------------------- + + +class TestFeatureRole: + """Feature / capability bitfield characteristics should be FEATURE.""" + + @pytest.mark.parametrize( + "sig_name", + [ + "Blood Pressure Feature", + "Body Composition Feature", + "Bond Management Feature", + "CSC Feature", + "Cycling Power Feature", + "Glucose Feature", + "LN Feature", + "RSC Feature", + "Weight Scale Feature", + ], + ) + def test_feature_characteristics(self, sig_name: str) -> None: + char = _get_char(sig_name) + assert char.role == CharacteristicRole.FEATURE + + +# --------------------------------------------------------------------------- +# STATUS — state / enum reporting +# --------------------------------------------------------------------------- + + +class TestStatusRole: + """Status reporting characteristics should be classified STATUS.""" + + @pytest.mark.parametrize( + "sig_name", + [ + "Alert Status", + "Unread Alert Status", + "Battery Level Status", + "Battery Critical Status", + "Acceleration Detection Status", + ], + ) + def test_status_characteristics(self, sig_name: str) -> None: + char = _get_char(sig_name) + assert char.role == CharacteristicRole.STATUS + + +# --------------------------------------------------------------------------- +# INFO — static metadata strings +# --------------------------------------------------------------------------- + + +class TestInfoRole: + """String metadata characteristics should be classified INFO.""" + + @pytest.mark.parametrize( + "sig_name", + [ + "Device Name", + "Firmware Revision String", + "Hardware Revision String", + "Software Revision String", + "Manufacturer Name String", + "Model Number String", + "Serial Number String", + "Email Address", + "First Name", + "Last Name", + ], + ) + def test_info_characteristics(self, sig_name: str) -> None: + char = _get_char(sig_name) + assert char.role == CharacteristicRole.INFO + + +# --------------------------------------------------------------------------- +# Classification priority — earlier rules win over later ones +# --------------------------------------------------------------------------- + + +class TestRolePriority: + """Verify that the priority ordering resolves ambiguity correctly.""" + + def test_control_point_beats_measurement_name(self) -> None: + """A hypothetical 'Measurement Control Point' would be CONTROL, + not MEASUREMENT, because rule 1 fires before rule 3. + Verified via Heart Rate Control Point (INT, no unit). + """ + char = _get_char("Heart Rate Control Point") + assert char.role == CharacteristicRole.CONTROL + + def test_feature_bitfield_type_beats_unknown(self) -> None: + """A BITFIELD value_type triggers FEATURE even without 'Feature' + in the name. + """ + char = _get_char("Body Composition Feature") + assert char.value_type == ValueType.BITFIELD + assert char.role == CharacteristicRole.FEATURE + + +# --------------------------------------------------------------------------- +# UNKNOWN — characteristics that cannot be classified from metadata alone +# --------------------------------------------------------------------------- + + +class TestUnknownRole: + """Characteristics with insufficient metadata remain UNKNOWN.""" + + @pytest.mark.parametrize( + "sig_name", + [ + "Alert Level", # INT, no unit, no matching name pattern + "Appearance", # VARIOUS, no unit + "Boolean", # BOOL, no unit + "PnP ID", # VARIOUS, no unit + "System ID", # VARIOUS, no unit + ], + ) + def test_unknown_characteristics(self, sig_name: str) -> None: + char = _get_char(sig_name) + assert char.role == CharacteristicRole.UNKNOWN + + +# --------------------------------------------------------------------------- +# Property semantics — caching, type +# --------------------------------------------------------------------------- + + +class TestRolePropertySemantics: + """Verify that the role property behaves correctly as a cached_property.""" + + def test_role_returns_enum_member(self) -> None: + char = _get_char("Temperature") + assert isinstance(char.role, CharacteristicRole) + + def test_role_is_cached(self) -> None: + """Accessing role twice should return the same object (cached).""" + char = _get_char("Temperature") + first = char.role + second = char.role + assert first is second + + def test_all_roles_are_valid_enum_members(self) -> None: + """Every registered characteristic should return a valid + CharacteristicRole member. + """ + chars = CharacteristicRegistry.get_all_characteristics() + for _name_enum, cls in chars.items(): + inst = cls() + assert isinstance(inst.role, CharacteristicRole), ( + f"{inst.name} returned {type(inst.role)} instead of CharacteristicRole" + ) diff --git a/tests/gatt/characteristics/test_characteristic_test_coverage.py b/tests/gatt/characteristics/test_characteristic_test_coverage.py index acf6c9a8..b02b16f5 100644 --- a/tests/gatt/characteristics/test_characteristic_test_coverage.py +++ b/tests/gatt/characteristics/test_characteristic_test_coverage.py @@ -32,6 +32,7 @@ def test_characteristic_test_coverage(self) -> None: excluded_test_files = { "test_base_characteristic.py", # Tests base class "test_characteristic_common.py", # Common test utilities + "test_characteristic_role.py", # Tests role classification, not a single characteristic "test_characteristic_test_coverage.py", # This coverage test "test_custom_characteristics.py", # Tests custom characteristic functionality "test_templates.py", # Tests template classes, not characteristics