From 5ae77468d5d8c22ea73840677fc0ede527a2caaa Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Mon, 16 Mar 2026 10:30:13 +0000 Subject: [PATCH 1/2] Prewarm methods and returns char objects for service char methods --- src/bluetooth_sig/__init__.py | 8 + src/bluetooth_sig/core/query.py | 21 ++- src/bluetooth_sig/core/translator.py | 10 +- .../device/dependency_resolver.py | 2 +- .../gatt/characteristics/base.py | 55 +++++- .../gatt/characteristics/role_classifier.py | 38 +++- .../gatt/characteristics/unknown.py | 33 ++-- src/bluetooth_sig/gatt/services/base.py | 3 +- src/bluetooth_sig/gatt/uuid_registry.py | 19 +- src/bluetooth_sig/registry/base.py | 8 + src/bluetooth_sig/registry/gss.py | 9 +- src/bluetooth_sig/registry/uuids/units.py | 43 +++++ .../types/advertising/ad_structures.py | 51 ++++++ src/bluetooth_sig/types/registry/common.py | 14 ++ .../types/registry/gss_characteristic.py | 74 ++++++-- src/bluetooth_sig/types/registry/units.py | 16 ++ src/bluetooth_sig/utils/prewarm.py | 48 +++++ src/bluetooth_sig/utils/values.py | 65 +++++++ tests/__init__.py | 0 .../test_characteristic_role.py | 165 +++++++++++++++++- tests/gatt/test_uuid_registry.py | 2 - 21 files changed, 615 insertions(+), 69 deletions(-) create mode 100644 src/bluetooth_sig/utils/prewarm.py create mode 100644 src/bluetooth_sig/utils/values.py create mode 100644 tests/__init__.py diff --git a/src/bluetooth_sig/__init__.py b/src/bluetooth_sig/__init__.py index 469d20c2..3fca4f22 100644 --- a/src/bluetooth_sig/__init__.py +++ b/src/bluetooth_sig/__init__.py @@ -18,6 +18,10 @@ from .types.base_types import SIGInfo from .types.data_types import CharacteristicInfo, ServiceInfo, ValidationResult +# Consumer utilities +from .utils.prewarm import prewarm_registries +from .utils.values import is_struct_value, to_primitive + try: from ._version import __version__ except ImportError: @@ -40,6 +44,10 @@ "SIGInfo", "ServiceInfo", "ValidationResult", + # Consumer utilities + "is_struct_value", + "prewarm_registries", + "to_primitive", # Version "__version__", ] diff --git a/src/bluetooth_sig/core/query.py b/src/bluetooth_sig/core/query.py index a37de577..e6fa6ec8 100644 --- a/src/bluetooth_sig/core/query.py +++ b/src/bluetooth_sig/core/query.py @@ -7,7 +7,9 @@ from __future__ import annotations import logging +from typing import Any +from ..gatt.characteristics.base import BaseCharacteristic from ..gatt.characteristics.registry import CharacteristicRegistry from ..gatt.services import ServiceName from ..gatt.services.registry import GattServiceRegistry @@ -234,14 +236,18 @@ def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, Chara 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. + def get_service_characteristics(self, service_uuid: str) -> list[BaseCharacteristic[Any]]: + """Get the characteristic instances associated with a service. + + Instantiates each required characteristic class from the service + definition and returns the live objects. Args: service_uuid: The service UUID Returns: - List of characteristic UUIDs for this service + List of BaseCharacteristic instances for this service's + required characteristics. """ service_class = GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID(service_uuid)) @@ -251,9 +257,16 @@ def get_service_characteristics(self, service_uuid: str) -> list[str]: try: temp_service = service_class() required_chars = temp_service.get_required_characteristics() - return [str(k) for k in required_chars] + result: list[BaseCharacteristic[Any]] = [] + for spec in required_chars.values(): + try: + result.append(spec.char_class()) + except (TypeError, ValueError, AttributeError): + logger.debug("Could not instantiate %s", spec.char_class.__name__) except Exception: # pylint: disable=broad-exception-caught return [] + else: + return result def get_sig_info_by_name(self, name: str) -> SIGInfo | None: """Get Bluetooth SIG information for a characteristic or service by name. diff --git a/src/bluetooth_sig/core/translator.py b/src/bluetooth_sig/core/translator.py index 3e5c5871..62b126b8 100644 --- a/src/bluetooth_sig/core/translator.py +++ b/src/bluetooth_sig/core/translator.py @@ -427,14 +427,18 @@ def get_characteristics_info_by_uuids(self, uuids: list[str]) -> dict[str, Chara """ return self._query.get_characteristics_info_by_uuids(uuids) - def get_service_characteristics(self, service_uuid: str) -> list[str]: - """Get the characteristic UUIDs associated with a service. + def get_service_characteristics(self, service_uuid: str) -> list[BaseCharacteristic[Any]]: + """Get the characteristic instances associated with a service. + + Instantiates each required characteristic class from the service + definition and returns the live objects. Args: service_uuid: The service UUID Returns: - List of characteristic UUIDs for this service + List of BaseCharacteristic instances for this service's + required characteristics. """ return self._query.get_service_characteristics(service_uuid) diff --git a/src/bluetooth_sig/device/dependency_resolver.py b/src/bluetooth_sig/device/dependency_resolver.py index 983a8a6a..0bd3b609 100644 --- a/src/bluetooth_sig/device/dependency_resolver.py +++ b/src/bluetooth_sig/device/dependency_resolver.py @@ -139,7 +139,7 @@ async def _resolve_single( 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_info = CharacteristicInfo(uuid=dep_uuid, name=dep_uuid_str) char_instance = UnknownCharacteristic(info=char_info) self._connected.cache_characteristic(dep_uuid, char_instance) diff --git a/src/bluetooth_sig/gatt/characteristics/base.py b/src/bluetooth_sig/gatt/characteristics/base.py index 27f72728..95dac390 100644 --- a/src/bluetooth_sig/gatt/characteristics/base.py +++ b/src/bluetooth_sig/gatt/characteristics/base.py @@ -23,7 +23,7 @@ SpecialValueType, classify_special_value, ) -from ...types.gatt_enums import CharacteristicRole, GattProperty +from ...types.gatt_enums import CharacteristicRole from ...types.registry import CharacteristicSpec from ...types.uuid import BluetoothUUID from ..context import CharacteristicContext @@ -106,14 +106,12 @@ def __init__( self, info: CharacteristicInfo | None = None, validation: ValidationConfig | None = None, - properties: list[GattProperty] | None = None, ) -> None: """Initialize characteristic with structured configuration. Args: info: Complete characteristic information (optional for SIG characteristics) validation: Validation constraints configuration (optional) - properties: Runtime BLE properties discovered from device (optional) """ # Store provided info or None (will be resolved in __post_init__) @@ -123,9 +121,6 @@ def __init__( self._info: CharacteristicInfo self._spec: CharacteristicSpec | None = None - # Runtime properties (from actual device, not YAML) - self.properties: list[GattProperty] = properties if properties is not None else [] - # Manual overrides with proper types (using explicit class attributes) self._manual_unit: str | None = self.__class__._manual_unit @@ -627,6 +622,54 @@ def unit(self) -> str: """ return self._info.unit or "" + @cached_property + def unit_symbol(self) -> str: + """Get the canonical SIG unit symbol for this characteristic. + + Resolves via the ``UnitsRegistry`` using the YAML ``unit_id`` + (e.g. ``org.bluetooth.unit.thermodynamic_temperature.degree_celsius`` + → ``°C``). Falls back to :attr:`unit` when no symbol is available. + + Returns: + SI symbol string (e.g. ``'°C'``, ``'%'``, ``'bpm'``), + or empty string if the characteristic has no unit. + + """ + from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415 + + unit_id = self.get_yaml_unit_id() + if unit_id: + symbol = resolve_unit_symbol(unit_id) + if symbol: + return symbol + + return self._info.unit or "" + + def get_field_unit(self, field_name: str) -> str: + """Get the resolved unit symbol for a specific struct field. + + For struct-valued characteristics with per-field units (e.g. + Heart Rate Measurement: ``bpm`` for heart rate, ``J`` for + energy expended), this resolves the unit for a single field + via ``FieldSpec.unit_id`` → ``UnitsRegistry`` → ``.symbol``. + + Args: + field_name: The Python-style field name (e.g. ``'heart_rate'``) + or raw GSS field name (e.g. ``'Heart Rate Measurement Value'``). + + Returns: + Resolved unit symbol, or empty string if not found. + + """ + if not self._spec or not self._spec.structure: + return "" + + for field in self._spec.structure: + if field_name in (field.python_name, field.field): + return field.unit_symbol + + return "" + @property def size(self) -> int | None: """Get the size in bytes for this characteristic from YAML specifications. diff --git a/src/bluetooth_sig/gatt/characteristics/role_classifier.py b/src/bluetooth_sig/gatt/characteristics/role_classifier.py index f5dff322..b5f0db08 100644 --- a/src/bluetooth_sig/gatt/characteristics/role_classifier.py +++ b/src/bluetooth_sig/gatt/characteristics/role_classifier.py @@ -26,12 +26,12 @@ def classify_role( ``is_bitfield`` is True → FEATURE 3. Name contains *Measurement* → MEASUREMENT 4. Numeric type (int / float) with a unit → MEASUREMENT - 5. Compound type (struct, dict, etc.) with a - unit or field-level ``unit_id`` → MEASUREMENT + 5. Multi-field struct with per-field units → MEASUREMENT 6. Name ends with *Data* → MEASUREMENT 7. Name contains *Status* → STATUS 8. ``python_type`` is str → INFO - 9. Otherwise → UNKNOWN + 9. Compound type (struct, dict, etc.) → MEASUREMENT + 10. Otherwise → UNKNOWN Args: char_name: Display name of the characteristic. @@ -61,10 +61,11 @@ def classify_role( if python_type in (int, float) and unit: return CharacteristicRole.MEASUREMENT - # 5. Compound type with unit metadata (char-level or per-field) - scalar_types = (int, float, str, bool, bytes) - is_compound = isinstance(python_type, type) and python_type not in scalar_types - if is_compound and (unit or _spec_has_unit_fields(spec)): + # 5. Multi-field struct with per-field measurement units. + # The GSS structure is the authoritative source — python_type may be + # None for multi-field structs whose registry entry was not updated + # with a scalar wire type. + if _spec_is_multi_field_measurement(spec): return CharacteristicRole.MEASUREMENT # 6. SIG *Data* characteristics (Treadmill Data, Indoor Bike Data, …) @@ -79,9 +80,32 @@ def classify_role( if python_type is str: return CharacteristicRole.INFO + # 9. Compound type (struct, dict, etc.) — by this point we've excluded + # control points, features, status, info, and name-matched characteristics. + # A compound type at this stage is almost certainly a measurement + # struct (e.g. VectorData, datetime, range structs). + scalar_types = (int, float, str, bool, bytes) + is_compound = isinstance(python_type, type) and python_type not in scalar_types + if is_compound: + return CharacteristicRole.MEASUREMENT + return CharacteristicRole.UNKNOWN +def _spec_is_multi_field_measurement(spec: CharacteristicSpec | None) -> bool: + """Check whether the spec describes a multi-field struct with measurement units. + + Returns ``True`` when the GSS specification has more than one field + **and** at least one of those fields carries a ``unit_id``. + """ + if not spec: + return False + structure = getattr(spec, "structure", None) + if not structure or len(structure) < 2: + return False + return any(getattr(f, "unit_id", None) for f in structure) + + def _spec_has_unit_fields(spec: CharacteristicSpec | None) -> bool: """Check whether any field in the GSS spec carries a ``unit_id``. diff --git a/src/bluetooth_sig/gatt/characteristics/unknown.py b/src/bluetooth_sig/gatt/characteristics/unknown.py index e4f98693..0b03b544 100644 --- a/src/bluetooth_sig/gatt/characteristics/unknown.py +++ b/src/bluetooth_sig/gatt/characteristics/unknown.py @@ -5,7 +5,6 @@ from typing import Any from ...types import CharacteristicInfo -from ...types.gatt_enums import GattProperty from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -21,31 +20,39 @@ class UnknownCharacteristic(BaseCharacteristic[bytes]): # NOTE: Exempt from registry validation — UnknownCharacteristic has no fixed UUID _is_base_class = True + _UNKNOWN_PREFIX = "Unknown: " + def __init__( self, info: CharacteristicInfo, - properties: list[GattProperty] | None = None, ) -> None: """Initialize an unknown characteristic. + The name is normalised to ``"Unknown: "`` format. + If no name is provided, the UUID short form is used as the + description. + Args: info: CharacteristicInfo object with UUID, name, unit, python_type - properties: Runtime BLE properties discovered from device (optional) Raises: ValueError: If UUID is invalid """ - # If no name provided, generate one from UUID - if not info.name: - info = CharacteristicInfo( - uuid=info.uuid, - name=f"Unknown Characteristic ({info.uuid})", - unit=info.unit or "", - python_type=info.python_type, - ) - - super().__init__(info=info, properties=properties) + name = info.name.strip() if info.name else "" + if not name: + name = f"{self._UNKNOWN_PREFIX}{info.uuid.short_form}" + elif not name.startswith(self._UNKNOWN_PREFIX): + name = f"{self._UNKNOWN_PREFIX}{name}" + + info = CharacteristicInfo( + uuid=info.uuid, + name=name, + unit=info.unit or "", + python_type=info.python_type, + ) + + super().__init__(info=info) def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/services/base.py b/src/bluetooth_sig/gatt/services/base.py index 4ee84ae8..a4422517 100644 --- a/src/bluetooth_sig/gatt/services/base.py +++ b/src/bluetooth_sig/gatt/services/base.py @@ -388,11 +388,10 @@ def process_characteristics(self, characteristics: ServiceDiscoveryData) -> None char_instance = UnknownCharacteristic( info=CharacteristicInfo( uuid=uuid_obj, - name=char_info.name or f"Unknown Characteristic ({uuid_obj})", + name=char_info.name or "", unit=char_info.unit or "", python_type=char_info.python_type, ), - properties=[], ) self.characteristics[uuid_obj] = char_instance diff --git a/src/bluetooth_sig/gatt/uuid_registry.py b/src/bluetooth_sig/gatt/uuid_registry.py index 60816718..862ad4ae 100644 --- a/src/bluetooth_sig/gatt/uuid_registry.py +++ b/src/bluetooth_sig/gatt/uuid_registry.py @@ -212,6 +212,13 @@ def _load_gss_characteristic_info(self) -> None: } unit, value_type = self._gss_registry.extract_info_from_gss(char_data) + # Multi-field structs have per-field units; no single representative + # unit, and the first field's scalar wire type (e.g. int) is not + # representative of the struct-valued characteristic. + if len(spec.structure) > 1: + unit = None + value_type = None + if unit or value_type: self._update_characteristic_with_gss_info(spec.name, spec.identifier, unit, value_type) @@ -528,6 +535,7 @@ def resolve_characteristic_spec(self, characteristic_name: str) -> Characteristi field_size = None unit_id = None unit_symbol = None + unit_readable_name = None base_unit = None resolution_text = None description = None @@ -548,14 +556,20 @@ def resolve_characteristic_spec(self, characteristic_name: str) -> Characteristi if primary.unit_id: unit_id = f"org.bluetooth.unit.{primary.unit_id}" unit_symbol = self._convert_bluetooth_unit_to_readable(primary.unit_id) + # Preserve the human-readable long-form name + unit_info_obj = UnitsRegistry.get_instance().get_info(unit_id) + if unit_info_obj: + unit_readable_name = unit_info_obj.readable_name base_unit = unit_id # Get resolution from FieldSpec if primary.resolution is not None: resolution_text = f"Resolution: {primary.resolution}" - # 4. Use existing unit/value_type from CharacteristicInfo if GSS didn't provide them - if not unit_symbol and char_info.unit: + # 4. Use existing unit from CharacteristicInfo if GSS didn't provide one. + # Multi-field structs have per-field units; don't promote one to top-level. + is_multi_field = gss_spec is not None and len(gss_spec.structure) > 1 + if not unit_symbol and char_info.unit and not is_multi_field: unit_symbol = char_info.unit return CharacteristicSpec( @@ -565,6 +579,7 @@ def resolve_characteristic_spec(self, characteristic_name: str) -> Characteristi unit_info=UnitMetadata( unit_id=unit_id, unit_symbol=unit_symbol, + unit_name=unit_readable_name, base_unit=base_unit, resolution_text=resolution_text, ), diff --git a/src/bluetooth_sig/registry/base.py b/src/bluetooth_sig/registry/base.py index 5d2a05fd..64585253 100644 --- a/src/bluetooth_sig/registry/base.py +++ b/src/bluetooth_sig/registry/base.py @@ -61,6 +61,14 @@ def _ensure_loaded(self) -> None: """ self._lazy_load(lambda: self._loaded, self._load) + def ensure_loaded(self) -> None: + """Public API to eagerly load the registry. + + Equivalent to :meth:`_ensure_loaded` but intended for consumers + that need to pre-warm registries (e.g. during application startup). + """ + self._ensure_loaded() + @abstractmethod def _load(self) -> None: """Perform the actual loading of registry data.""" diff --git a/src/bluetooth_sig/registry/gss.py b/src/bluetooth_sig/registry/gss.py index 70034548..a4dbf706 100644 --- a/src/bluetooth_sig/registry/gss.py +++ b/src/bluetooth_sig/registry/gss.py @@ -128,10 +128,11 @@ def _process_gss_file(self, yaml_file: Path) -> None: ) # Store by both ID and name for lookup flexibility + # Normalise keys to lowercase for case-insensitive lookup if char_id: - self._specs[char_id] = gss_spec + self._specs[char_id.lower()] = gss_spec if char_name: - self._specs[char_name] = gss_spec + self._specs[char_name.lower()] = gss_spec except (msgspec.DecodeError, OSError, KeyError) as e: logging.warning("Failed to parse GSS YAML file %s: %s", yaml_file, e) @@ -140,14 +141,14 @@ def get_spec(self, identifier: str) -> GssCharacteristicSpec | None: """Get a GSS specification by name or ID. Args: - identifier: Characteristic name or ID + identifier: Characteristic name or ID (case-insensitive) Returns: GssCharacteristicSpec if found, None otherwise """ self._ensure_loaded() with self._lock: - return self._specs.get(identifier) + return self._specs.get(identifier.lower()) def get_all_specs(self) -> dict[str, GssCharacteristicSpec]: """Get all loaded GSS specifications. diff --git a/src/bluetooth_sig/registry/uuids/units.py b/src/bluetooth_sig/registry/uuids/units.py index 06c73067..e7edde47 100644 --- a/src/bluetooth_sig/registry/uuids/units.py +++ b/src/bluetooth_sig/registry/uuids/units.py @@ -56,6 +56,26 @@ "radian": "rad", "steradian": "sr", "mole": "mol", + "beats per minute": "bpm", + "kilometre per hour": "km/h", + "kilowatt hour": "kWh", + "watt per square metre": "W/m²", + "newton metre": "N·m", + "lumen per watt": "lm/W", + "lux hour": "lx·h", + "gram per second": "g/s", + "litre per second": "L/s", + "kilogram calorie": "kcal", + "minute": "min", + "hour": "h", + "day": "d", + "month": "mo", + "year": "yr", + "decibel": "dB", + "count per second": "cps", + "revolution per minute": "rpm", + "step per minute": "steps/min", + "stroke per minute": "strokes/min", } _SPECIAL_UNIT_NAMES: dict[str, str] = { @@ -180,3 +200,26 @@ def get_all_units(self) -> list[UnitInfo]: # Global instance units_registry = UnitsRegistry() + +_UNIT_ID_PREFIX = "org.bluetooth.unit." + + +def resolve_unit_symbol(unit_id: str) -> str: + """Resolve a SIG unit identifier to its canonical symbol. + + Accepts both short-form (``thermodynamic_temperature.degree_celsius``) + and full-form (``org.bluetooth.unit.thermodynamic_temperature.degree_celsius``) + identifiers, normalises to full-form, and looks up the symbol via + :data:`units_registry`. + + Args: + unit_id: Unit identifier (short or full form). + + Returns: + SI symbol string (e.g. ``'°C'``, ``'bpm'``), or empty string + if the identifier cannot be resolved. + + """ + full_id = unit_id if unit_id.startswith(_UNIT_ID_PREFIX) else f"{_UNIT_ID_PREFIX}{unit_id}" + info = units_registry.get_unit_info_by_id(full_id) + return info.symbol if info and info.symbol else "" diff --git a/src/bluetooth_sig/types/advertising/ad_structures.py b/src/bluetooth_sig/types/advertising/ad_structures.py index cc647c88..d7f57466 100644 --- a/src/bluetooth_sig/types/advertising/ad_structures.py +++ b/src/bluetooth_sig/types/advertising/ad_structures.py @@ -234,3 +234,54 @@ class AdvertisingDataStructures(msgspec.Struct, kw_only=True): location: LocationAndSensingData = msgspec.field(default_factory=LocationAndSensingData) mesh: MeshAndBroadcastData = msgspec.field(default_factory=MeshAndBroadcastData) security: SecurityData = msgspec.field(default_factory=SecurityData) + + @classmethod + def from_common_fields( + cls, + *, + manufacturer_data: dict[int, ManufacturerData] | None = None, + service_data: dict[BluetoothUUID, bytes] | None = None, + service_uuids: list[BluetoothUUID] | None = None, + local_name: str = "", + tx_power: int = 0, + address: str = "", + connectable: bool = False, + ) -> AdvertisingDataStructures: + """Create from common platform advertisement fields. + + Builds an ``AdvertisingDataStructures`` with sensible defaults for + fields that are typically unavailable from BLE platform APIs + (OOB security, mesh, location, etc.). + + Args: + manufacturer_data: Manufacturer-specific data keyed by company ID. + service_data: Service data keyed by service UUID. + service_uuids: Advertised service UUIDs. + local_name: Device local name. + tx_power: Transmission power level in dBm. + address: BLE device address (MAC). + connectable: Whether the device is connectable. + + Returns: + Populated ``AdvertisingDataStructures`` instance. + + """ + flags = BLEAdvertisingFlags.BR_EDR_NOT_SUPPORTED + if connectable: + flags |= BLEAdvertisingFlags.LE_GENERAL_DISCOVERABLE_MODE + + return cls( + core=CoreAdvertisingData( + manufacturer_data=manufacturer_data or {}, + service_data=service_data or {}, + service_uuids=service_uuids or [], + local_name=local_name, + ), + properties=DeviceProperties( + flags=flags, + tx_power=tx_power, + ), + directed=DirectedAdvertisingData( + le_bluetooth_device_address=address, + ), + ) diff --git a/src/bluetooth_sig/types/registry/common.py b/src/bluetooth_sig/types/registry/common.py index 1fd5d407..a464be7a 100644 --- a/src/bluetooth_sig/types/registry/common.py +++ b/src/bluetooth_sig/types/registry/common.py @@ -22,10 +22,19 @@ class UnitMetadata(msgspec.Struct, frozen=True, kw_only=True): This is embedded metadata within characteristic specs, distinct from the Units registry which uses UUID-based entries. + + Attributes: + unit_id: Full SIG unit identifier (e.g. ``org.bluetooth.unit.period.beats_per_minute``). + unit_symbol: Short SI symbol (e.g. ``'bpm'``, ``'°C'``). + unit_name: Human-readable long-form name (e.g. ``'beats per minute'``). + base_unit: Base unit identifier. + resolution_text: Resolution description from the GSS spec. + """ unit_id: str | None = None unit_symbol: str | None = None + unit_name: str | None = None base_unit: str | None = None resolution_text: str | None = None @@ -60,6 +69,11 @@ def unit_symbol(self) -> str | None: """Get unit symbol from unit info.""" return self.unit_info.unit_symbol if self.unit_info else None + @property + def unit_name(self) -> str | None: + """Get human-readable unit name from unit info.""" + return self.unit_info.unit_name if self.unit_info else None + @property def base_unit(self) -> str | None: """Get base unit from unit info.""" diff --git a/src/bluetooth_sig/types/registry/gss_characteristic.py b/src/bluetooth_sig/types/registry/gss_characteristic.py index 777b1e34..79f37e0a 100644 --- a/src/bluetooth_sig/types/registry/gss_characteristic.py +++ b/src/bluetooth_sig/types/registry/gss_characteristic.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import logging import re @@ -10,6 +11,44 @@ logger = logging.getLogger(__name__) +# Module-level caches for FieldSpec computed properties. +# msgspec frozen Structs cannot have per-instance cached_property, so we +# cache here keyed on the (field, description) pair which uniquely +# identifies a FieldSpec for these purposes. + + +@functools.lru_cache(maxsize=512) +def _compute_python_name(field: str) -> str: + """Convert raw field name to Python snake_case (cached).""" + name = field.lower() + name = re.sub(r"[\s\-]+", "_", name) + name = re.sub(r"\([^)]*\)", "", name) + name = re.sub(r"[^\w]", "", name) + name = re.sub(r"_+", "_", name) + return name.strip("_") + + +@functools.lru_cache(maxsize=512) +def _compute_unit_id(description: str) -> str | None: + """Extract org.bluetooth.unit.* identifier from description (cached).""" + normalized = re.sub(r"org\.bluetooth\.unit\.\s*", "org.bluetooth.unit.", description) + match = re.search(r"org\.bluetooth\.unit\.([a-z0-9_.]+)", normalized, re.IGNORECASE) + if match: + return match.group(1).rstrip(".") + return None + + +@functools.lru_cache(maxsize=512) +def _resolve_field_unit_symbol(description: str) -> str: + """Resolve unit symbol from a FieldSpec description string (cached).""" + uid = _compute_unit_id(description) + if not uid: + return "" + from ...registry.uuids.units import resolve_unit_symbol # noqa: PLC0415 + + return resolve_unit_symbol(uid) + + class SpecialValue(msgspec.Struct, frozen=True): """A special sentinel value with its meaning. @@ -42,22 +81,13 @@ class FieldSpec(msgspec.Struct, frozen=True, kw_only=True): @property def python_name(self) -> str: - """Convert field name to Python snake_case identifier. + """Convert field name to Python snake_case identifier (cached). Examples: "Instantaneous Speed" -> "instantaneous_speed" "Location - Latitude" -> "location_latitude" """ - name = self.field.lower() - # Replace separators with underscores - name = re.sub(r"[\s\-]+", "_", name) - # Remove parentheses and their contents - name = re.sub(r"\([^)]*\)", "", name) - # Remove non-alphanumeric characters except underscores - name = re.sub(r"[^\w]", "", name) - # Collapse multiple underscores - name = re.sub(r"_+", "_", name) - return name.strip("_") + return _compute_python_name(self.field) @property def is_optional(self) -> bool: @@ -81,7 +111,7 @@ def fixed_size(self) -> int | None: @property def unit_id(self) -> str | None: - """Extract org.bluetooth.unit.* identifier from description. + """Extract org.bluetooth.unit.* identifier from description (cached). Handles various formats: - "Base Unit:" or "Base unit:" (case-insensitive) @@ -92,15 +122,21 @@ def unit_id(self) -> str | None: Returns: Unit ID string (e.g., "thermodynamic_temperature.degree_celsius"), or None. """ - # Remove spaces around dots to handle "org.bluetooth.unit. foo" -> "org.bluetooth.unit.foo" - normalized = re.sub(r"org\.bluetooth\.unit\.\s*", "org.bluetooth.unit.", self.description) + return _compute_unit_id(self.description) - # Extract org.bluetooth.unit.* pattern - match = re.search(r"org\.bluetooth\.unit\.([a-z0-9_.]+)", normalized, re.IGNORECASE) - if match: - return match.group(1).rstrip(".") + @property + def unit_symbol(self) -> str: + """Get the resolved SIG unit symbol for this field. - return None + Resolves ``unit_id`` → ``UnitsRegistry`` → ``.symbol`` + (e.g. ``'thermodynamic_temperature.degree_celsius'`` → ``'°C'``). + Result is LRU-cached by description text. + + Returns: + SI symbol string, or empty string if no unit is available. + + """ + return _resolve_field_unit_symbol(self.description) @property def resolution(self) -> float | None: diff --git a/src/bluetooth_sig/types/registry/units.py b/src/bluetooth_sig/types/registry/units.py index 4fa1614b..4aa2973c 100644 --- a/src/bluetooth_sig/types/registry/units.py +++ b/src/bluetooth_sig/types/registry/units.py @@ -18,3 +18,19 @@ class UnitInfo(UuidIdInfo, frozen=True, kw_only=True): """ symbol: str = "" + + @property + def readable_name(self) -> str: + """Human-readable unit name extracted from the descriptive name. + + Extracts the parenthetical content from YAML-style names: + - ``"thermodynamic temperature (degree celsius)"`` → ``"degree celsius"`` + - ``"period (beats per minute)"`` → ``"beats per minute"`` + - ``"percentage"`` → ``"percentage"`` (no parenthetical → returns full name) + """ + if "(" in self.name and ")" in self.name: + start = self.name.find("(") + 1 + end = self.name.find(")", start) + if 0 < start < end: + return self.name[start:end].strip() + return self.name diff --git a/src/bluetooth_sig/utils/prewarm.py b/src/bluetooth_sig/utils/prewarm.py new file mode 100644 index 00000000..e9e7e481 --- /dev/null +++ b/src/bluetooth_sig/utils/prewarm.py @@ -0,0 +1,48 @@ +"""Registry pre-warming for eager YAML loading. + +Consumers that run inside an event loop (e.g. Home Assistant) should call +:func:`prewarm_registries` in an executor thread during setup to avoid +blocking I/O on first access. +""" + +from __future__ import annotations + +import logging + +from ..gatt.characteristics.registry import CharacteristicRegistry +from ..gatt.services.registry import GattServiceRegistry +from ..registry.company_identifiers import company_identifiers_registry +from ..registry.core.ad_types import ad_types_registry +from ..registry.uuids.units import units_registry +from ..types.uuid import BluetoothUUID + +logger = logging.getLogger(__name__) + + +def prewarm_registries() -> None: + """Eagerly load all bluetooth-sig YAML registries. + + Triggers the lazy-load path for every registry so that subsequent + lookups are lock-free and allocation-free. This function performs + synchronous file I/O and should be called from an executor thread + when used inside an async framework. + + Covers: + - Characteristic registry (all characteristic classes) + - Service registry (all service classes) + - Units registry (unit UUID → symbol mapping) + - Company identifiers registry (manufacturer ID → name) + - AD types registry (advertising data type codes) + """ + CharacteristicRegistry.get_all_characteristics() + GattServiceRegistry.get_all_services() + + # Trigger UUID-keyed lookups to populate reverse maps. + GattServiceRegistry.get_service_class_by_uuid(BluetoothUUID("0000")) + CharacteristicRegistry.get_characteristic_class_by_uuid(BluetoothUUID("0000")) + + units_registry.ensure_loaded() + company_identifiers_registry.ensure_loaded() + ad_types_registry.ensure_loaded() + + logger.debug("bluetooth-sig registries pre-warmed") diff --git a/src/bluetooth_sig/utils/values.py b/src/bluetooth_sig/utils/values.py new file mode 100644 index 00000000..7f2a03d0 --- /dev/null +++ b/src/bluetooth_sig/utils/values.py @@ -0,0 +1,65 @@ +"""Value utilities for consumers of parsed Bluetooth SIG data. + +Provides helpers that avoid leaking implementation details (msgspec, +enum ordering) into every consumer codebase. +""" + +from __future__ import annotations + +import enum +from typing import Any + +import msgspec + + +def is_struct_value(obj: object) -> bool: + """Check whether *obj* is a parsed struct produced by the library. + + Use this instead of ``hasattr(obj, '__struct_fields__')`` so consumer + code does not depend on the msgspec implementation detail. + + Args: + obj: Any parsed characteristic value. + + Returns: + ``True`` if *obj* is a ``msgspec.Struct`` instance. + + """ + return isinstance(obj, msgspec.Struct) + + +def to_primitive(value: Any) -> int | float | str | bool: # noqa: ANN401 + """Coerce a parsed characteristic value to a plain Python primitive. + + Handles the full range of types the library may return + (``bool``, ``IntFlag``, ``IntEnum``, ``Enum``, ``int``, ``float``, + ``str``, ``datetime``, ``timedelta``, msgspec Structs, …). + + **Order matters:** + + * ``bool`` before ``int`` — ``bool`` is a subclass of ``int``. + * ``IntFlag`` before the ``.name`` branch — bit-field values expose + a ``.name`` attribute but should be stored as a plain ``int``. + * ``IntEnum`` / ``Enum`` → ``.name`` string. + + Args: + value: Any value returned by ``BaseCharacteristic.parse_value()`` + or extracted from a struct field. + + Returns: + A plain ``int``, ``float``, ``str``, or ``bool``. + + """ + if isinstance(value, bool): + return value + if isinstance(value, enum.IntFlag): + return int(value) + if (name := getattr(value, "name", None)) is not None: + return str(name) + if isinstance(value, int): + return int(value) + if isinstance(value, float): + return value + if isinstance(value, str): + return value + return str(value) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/gatt/characteristics/test_characteristic_role.py b/tests/gatt/characteristics/test_characteristic_role.py index b0a5a650..832e528e 100644 --- a/tests/gatt/characteristics/test_characteristic_role.py +++ b/tests/gatt/characteristics/test_characteristic_role.py @@ -3,6 +3,9 @@ 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). + +Also tests :func:`classify_role` directly for edge-case coverage of the +multi-field struct detection path. """ from __future__ import annotations @@ -11,7 +14,15 @@ from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic from bluetooth_sig.gatt.characteristics.registry import CharacteristicRegistry +from bluetooth_sig.gatt.characteristics.role_classifier import ( + _spec_has_unit_fields, + _spec_is_multi_field_measurement, + classify_role, +) from bluetooth_sig.types.gatt_enums import CharacteristicRole +from bluetooth_sig.types.registry.common import CharacteristicSpec +from bluetooth_sig.types.registry.gss_characteristic import FieldSpec +from bluetooth_sig.types.uuid import BluetoothUUID # --------------------------------------------------------------------------- # Helper — instantiate a registered characteristic by its SIG name @@ -54,11 +65,15 @@ class TestMeasurementRole: "Pressure", "Acceleration", "Elevation", - # Rule 5: compound value with unit metadata - "Acceleration 3D", - "Activity Goal", + # Rule 5: multi-field struct with per-field units "Location and Speed", "Navigation", + "Activity Goal", + # Rule 6: compound type + "Acceleration 3D", + "Appearance", + "PnP ID", + "System ID", ], ) def test_measurement_characteristics(self, sig_name: str) -> None: @@ -216,10 +231,7 @@ class TestUnknownRole: "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: @@ -256,3 +268,144 @@ def test_all_roles_are_valid_enum_members(self) -> None: assert isinstance(inst.role, CharacteristicRole), ( f"{inst.name} returned {type(inst.role)} instead of CharacteristicRole" ) + + +# --------------------------------------------------------------------------- +# Helpers for building synthetic specs +# --------------------------------------------------------------------------- + +_DUMMY_UUID = BluetoothUUID("00002a37-0000-1000-8000-00805f9b34fb") + + +def _make_field( + name: str = "Value", + data_type: str = "uint16", + size: str = "2", + description: str = "", +) -> FieldSpec: + """Build a minimal FieldSpec for testing.""" + return FieldSpec(field=name, type=data_type, size=size, description=description) + + +def _make_spec(fields: list[FieldSpec]) -> CharacteristicSpec: + """Build a CharacteristicSpec with the given field list.""" + return CharacteristicSpec( + uuid=_DUMMY_UUID, + name="Test Characteristic", + structure=fields, + ) + + +# --------------------------------------------------------------------------- +# Direct classify_role() tests — multi-field struct detection +# --------------------------------------------------------------------------- + + +class TestClassifyRoleMultiField: + """Direct tests for classify_role() covering the multi-field struct path.""" + + def test_multi_field_with_unit_fields_is_measurement(self) -> None: + """A multi-field spec with per-field units → MEASUREMENT, + even with python_type=None and unit=''. + """ + spec = _make_spec([ + _make_field("Heart Rate", description="Unit: org.bluetooth.unit.period.beats_per_minute"), + _make_field("Energy Expended", description="Unit: org.bluetooth.unit.energy.joule"), + ]) + result = classify_role("Some Sensor", None, False, "", spec) + assert result == CharacteristicRole.MEASUREMENT + + def test_multi_field_without_units_is_not_measurement(self) -> None: + """A multi-field spec where NO field has a unit_id should not + trigger the multi-field measurement rule. + """ + spec = _make_spec([ + _make_field("Flags", description="Flags field"), + _make_field("Opcode", description="Control opcode"), + ]) + result = classify_role("Some Thing", None, False, "", spec) + assert result == CharacteristicRole.UNKNOWN + + def test_single_field_with_unit_does_not_trigger_multi_field_rule(self) -> None: + """A single-field spec should not be matched by the multi-field rule; + it falls through to other heuristics. + """ + spec = _make_spec([ + _make_field("Temperature", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), + ]) + # With python_type=None and unit='', rule 4 doesn't fire, + # rule 5 requires >1 field → falls to UNKNOWN + result = classify_role("Custom Temp", None, False, "", spec) + assert result == CharacteristicRole.UNKNOWN + + def test_multi_field_with_python_type_none(self) -> None: + """After the python_type pollution fix, multi-field chars arrive + with python_type=None. Should still be MEASUREMENT via rule 5. + """ + spec = _make_spec([ + _make_field("Speed", description="Unit: org.bluetooth.unit.velocity.metres_per_second"), + _make_field("Distance", description="Unit: org.bluetooth.unit.length.metre"), + _make_field("Position Status", description="Status flags"), + ]) + result = classify_role("Location and Speed", None, False, "", spec) + assert result == CharacteristicRole.MEASUREMENT + + def test_name_based_rule_takes_priority_over_multi_field(self) -> None: + """Rule 3 ('Measurement' in name) fires before rule 5.""" + spec = _make_spec([ + _make_field("Systolic", description="Unit: org.bluetooth.unit.pressure.pascal"), + _make_field("Diastolic", description="Unit: org.bluetooth.unit.pressure.pascal"), + ]) + result = classify_role("Blood Pressure Measurement", None, False, "", spec) + assert result == CharacteristicRole.MEASUREMENT + + +# --------------------------------------------------------------------------- +# Direct tests for spec helper functions +# --------------------------------------------------------------------------- + + +class TestSpecHelpers: + """Tests for _spec_is_multi_field_measurement and _spec_has_unit_fields.""" + + def test_spec_is_multi_field_none_spec(self) -> None: + assert _spec_is_multi_field_measurement(None) is False + + def test_spec_is_multi_field_empty_structure(self) -> None: + spec = _make_spec([]) + assert _spec_is_multi_field_measurement(spec) is False + + def test_spec_is_multi_field_single_field(self) -> None: + spec = _make_spec([ + _make_field("Temp", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), + ]) + assert _spec_is_multi_field_measurement(spec) is False + + def test_spec_is_multi_field_two_fields_with_units(self) -> None: + spec = _make_spec([ + _make_field("HR", description="Unit: org.bluetooth.unit.period.beats_per_minute"), + _make_field("Energy", description="Unit: org.bluetooth.unit.energy.joule"), + ]) + assert _spec_is_multi_field_measurement(spec) is True + + def test_spec_is_multi_field_two_fields_no_units(self) -> None: + spec = _make_spec([ + _make_field("Flags", description="Flags field"), + _make_field("Value", description="Some value"), + ]) + assert _spec_is_multi_field_measurement(spec) is False + + def test_spec_has_unit_fields_none_spec(self) -> None: + assert _spec_has_unit_fields(None) is False + + def test_spec_has_unit_fields_with_unit(self) -> None: + spec = _make_spec([ + _make_field("Temp", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), + ]) + assert _spec_has_unit_fields(spec) is True + + def test_spec_has_unit_fields_without_unit(self) -> None: + spec = _make_spec([ + _make_field("Flags", description="Control flags"), + ]) + assert _spec_has_unit_fields(spec) is False diff --git a/tests/gatt/test_uuid_registry.py b/tests/gatt/test_uuid_registry.py index 2ccbc8a4..12b50343 100644 --- a/tests/gatt/test_uuid_registry.py +++ b/tests/gatt/test_uuid_registry.py @@ -159,8 +159,6 @@ def test_characteristic_discovery() -> None: assert len(battery.characteristics) == 1, "Incorrect battery char count" char = next(iter(battery.characteristics.values())) assert char.name == "Battery Level" - # Properties come from YAML or class definition, not from discovery data - assert char.properties is not None # Test Environmental Service characteristic discovery env = EnvironmentalSensingService() env.process_characteristics(mock_env_data) From a70916604f536aeb43151c6eff68d854f5f31664 Mon Sep 17 00:00:00 2001 From: Ronan Byrne Date: Mon, 16 Mar 2026 11:52:22 +0000 Subject: [PATCH 2/2] fix: type correctness audit and lint cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove _python_type overrides from 40 characteristic classes (metadata-only, not used for decoding; contradicted BaseCharacteristic[T] generic param) - Remove _manual_role from 10 enum characteristics (classifier rule 12 handles enum→STATUS automatically) - Enforce list[CharacteristicTestData] return type in valid_test_data fixture (base class + 55 subclass test files); remove isinstance guards - Move Appearance, PnP ID, System ID from MEASUREMENT to INFO test group (they have _manual_role=INFO — device metadata, not sensor data) - Refactor classify_role() into 5 tier functions to fix PLR0911 (too many return statements) and remove ERA001 false-positive comment - Fix test_alert_level: pass AlertLevel(int) instead of raw int to build_value() - Fix examples: remove invalid properties kwarg from BaseCharacteristic and UnknownCharacteristic constructors in 3 connection manager examples - Fix test_readme_badges: add socket_enabled fixture and graceful skip when pytest-socket blocks network access - Fix test_magnetic_flux_density_2d/3d: correct python_type assertions --- examples/connection_managers/bleak_retry.py | 7 +- examples/connection_managers/bluepy.py | 7 +- examples/connection_managers/simpleble.py | 7 +- scripts/ble_device_debugger.py | 4 +- scripts/extract_validation_info.py | 25 +- scripts/test_real_device.py | 3 +- .../acceleration_detection_status.py | 2 +- .../gatt/characteristics/alert_level.py | 2 +- .../gatt/characteristics/appearance.py | 2 + .../characteristics/blood_pressure_common.py | 2 - .../characteristics/blood_pressure_feature.py | 2 - .../characteristics/body_sensor_location.py | 2 +- .../bond_management_control_point.py | 4 +- .../bond_management_feature.py | 2 - .../boot_keyboard_input_report.py | 2 + .../boot_mouse_input_report.py | 2 + .../chromaticity_in_cct_and_duv_values.py | 3 + .../cie_13_3_1995_color_rendering_index.py | 2 + .../gatt/characteristics/co2_concentration.py | 1 - .../characteristics/content_control_id.py | 2 + .../gatt/characteristics/count_16.py | 2 + .../gatt/characteristics/count_24.py | 2 + .../gatt/characteristics/country_code.py | 2 + .../gatt/characteristics/current_time.py | 3 - .../gatt/characteristics/day_of_week.py | 7 +- .../gatt/characteristics/device_name.py | 2 - .../device_wearing_position.py | 2 + .../gatt/characteristics/dst_offset.py | 2 +- .../characteristics/electric_current_range.py | 3 - .../electric_current_specification.py | 3 - .../electric_current_statistics.py | 3 - .../gatt/characteristics/elevation.py | 1 - .../gatt/characteristics/floor_number.py | 3 +- .../gatt/characteristics/gender.py | 2 +- .../gatt/characteristics/generic_level.py | 2 + .../global_trade_item_number.py | 2 + .../gatt/characteristics/handedness.py | 2 +- .../heart_rate_control_point.py | 2 +- .../gatt/characteristics/hid_control_point.py | 4 +- .../gatt/characteristics/hid_information.py | 2 - ...0601_regulatory_certification_data_list.py | 2 + .../indoor_positioning_configuration.py | 1 - .../gatt/characteristics/latitude.py | 2 - .../gatt/characteristics/ln_control_point.py | 2 - .../gatt/characteristics/ln_feature.py | 1 - .../characteristics/location_and_speed.py | 2 - .../gatt/characteristics/location_name.py | 2 - .../gatt/characteristics/longitude.py | 2 - .../characteristics/magnetic_declination.py | 2 - .../magnetic_flux_density_2d.py | 2 - .../magnetic_flux_density_3d.py | 1 - .../characteristics/methane_concentration.py | 1 - .../gatt/characteristics/navigation.py | 2 - .../gatt/characteristics/object_id.py | 2 + .../characteristics/ozone_concentration.py | 1 - .../characteristics/perceived_lightness.py | 2 + ...ipheral_preferred_connection_parameters.py | 2 + .../peripheral_privacy_flag.py | 2 - .../gatt/characteristics/plx_features.py | 1 - .../plx_spot_check_measurement.py | 2 - .../gatt/characteristics/pnp_id.py | 2 + .../characteristics/pollen_concentration.py | 1 - .../gatt/characteristics/position_quality.py | 2 - .../gatt/characteristics/preferred_units.py | 2 + .../gatt/characteristics/protocol_mode.py | 4 +- .../pulse_oximetry_measurement.py | 2 - .../characteristics/reconnection_address.py | 2 - .../gatt/characteristics/report.py | 2 - .../gatt/characteristics/report_map.py | 2 - .../gatt/characteristics/role_classifier.py | 248 +++++++++++++----- .../characteristics/scan_interval_window.py | 2 + .../gatt/characteristics/scan_refresh.py | 3 +- ...pe_for_aerobic_and_anaerobic_thresholds.py | 2 +- .../sulfur_dioxide_concentration.py | 1 - .../characteristics/supported_power_range.py | 2 - .../gatt/characteristics/system_id.py | 2 + .../gatt/characteristics/temperature_type.py | 2 +- .../gatt/characteristics/time_source.py | 2 +- .../gatt/characteristics/time_zone.py | 2 - .../gatt/characteristics/user_index.py | 2 + .../characteristics/voltage_specification.py | 2 - .../characteristics/voltage_statistics.py | 2 - src/bluetooth_sig/types/gatt_enums.py | 25 +- src/bluetooth_sig/utils/values.py | 4 +- .../gatt/characteristics/test_acceleration.py | 2 +- .../test_acceleration_detection_status.py | 17 +- .../gatt/characteristics/test_alert_level.py | 2 +- .../test_apparent_energy_32.py | 2 +- .../characteristics/test_apparent_power.py | 2 +- tests/gatt/characteristics/test_appearance.py | 2 +- .../characteristics/test_average_current.py | 2 +- .../characteristics/test_average_voltage.py | 2 +- .../test_battery_critical_status.py | 2 +- .../test_battery_energy_status.py | 2 +- .../test_battery_health_information.py | 2 +- .../test_battery_health_status.py | 2 +- .../test_battery_information.py | 2 +- .../test_battery_level_status.py | 2 +- .../test_battery_time_status.py | 2 +- .../test_blood_pressure_feature.py | 2 +- .../test_body_composition_feature.py | 2 +- .../test_body_sensor_location.py | 2 +- .../test_carbon_monoxide_concentration.py | 6 +- .../test_characteristic_common.py | 39 ++- .../test_characteristic_role.py | 173 ++++++------ .../test_chromaticity_coordinates.py | 2 +- ...test_chromaticity_in_cct_and_duv_values.py | 2 +- .../gatt/characteristics/test_day_of_week.py | 15 +- .../gatt/characteristics/test_device_name.py | 2 +- .../characteristics/test_electric_current.py | 2 +- .../test_electric_current_range.py | 2 +- .../test_electric_current_specification.py | 2 +- .../test_electric_current_statistics.py | 2 +- .../test_firmware_revision_string.py | 2 +- tests/gatt/characteristics/test_force.py | 2 +- .../test_hardware_revision_string.py | 2 +- .../test_heart_rate_control_point.py | 21 +- .../test_heart_rate_measurement.py | 2 +- .../gatt/characteristics/test_high_voltage.py | 2 +- .../gatt/characteristics/test_illuminance.py | 2 +- .../characteristics/test_linear_position.py | 2 +- .../characteristics/test_location_name.py | 4 +- .../test_luminous_flux_range.py | 2 +- .../test_magnetic_flux_density_2d.py | 4 +- .../test_magnetic_flux_density_3d.py | 2 +- .../test_manufacturer_name_string.py | 2 +- .../test_model_number_string.py | 2 +- tests/gatt/characteristics/test_noise.py | 2 +- .../gatt/characteristics/test_plx_features.py | 2 +- .../test_pollen_concentration.py | 2 +- .../test_power_specification.py | 2 +- tests/gatt/characteristics/test_pressure.py | 2 +- .../test_pulse_oximetry_measurement.py | 2 +- .../characteristics/test_rotational_speed.py | 2 +- .../gatt/characteristics/test_rsc_feature.py | 2 +- .../test_serial_number_string.py | 4 +- .../test_software_revision_string.py | 2 +- .../test_supported_heart_rate_range.py | 2 +- .../test_supported_inclination_range.py | 2 +- .../test_supported_power_range.py | 2 +- .../test_supported_resistance_level_range.py | 2 +- .../test_supported_speed_range.py | 2 +- .../gatt/characteristics/test_temperature.py | 2 +- .../characteristics/test_temperature_range.py | 2 +- .../characteristics/test_temperature_type.py | 43 ++- .../gatt/characteristics/test_time_source.py | 33 ++- .../characteristics/test_tx_power_level.py | 2 +- .../characteristics/test_voltage_frequency.py | 2 +- .../test_voltage_specification.py | 2 +- .../test_voltage_statistics.py | 2 +- .../test_weight_scale_feature.py | 2 +- tests/utils/test_prewarm.py | 33 +++ tests/utils/test_values.py | 151 +++++++++++ 153 files changed, 721 insertions(+), 422 deletions(-) create mode 100644 tests/utils/test_prewarm.py create mode 100644 tests/utils/test_values.py diff --git a/examples/connection_managers/bleak_retry.py b/examples/connection_managers/bleak_retry.py index d635ca12..957baa5f 100644 --- a/examples/connection_managers/bleak_retry.py +++ b/examples/connection_managers/bleak_retry.py @@ -192,16 +192,15 @@ async def get_services(self) -> list[DeviceService]: char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(char_uuid) if char_class: - # Create characteristic instance with runtime properties from device - char_instance = char_class(properties=properties) + char_instance = char_class() characteristics[str(char_uuid)] = char_instance else: # Fallback: Create UnknownCharacteristic for unrecognized UUIDs char_info = CharacteristicInfo( uuid=char_uuid, - name=char.description or f"Unknown Characteristic ({char_uuid.short_form}...)", + name=char.description or char_uuid.short_form, ) - char_instance = UnknownCharacteristic(info=char_info, properties=properties) + char_instance = UnknownCharacteristic(info=char_info) characteristics[str(char_uuid)] = char_instance # Type ignore needed due to dict invariance with union types diff --git a/examples/connection_managers/bluepy.py b/examples/connection_managers/bluepy.py index ca4431ec..69b57dfd 100644 --- a/examples/connection_managers/bluepy.py +++ b/examples/connection_managers/bluepy.py @@ -279,16 +279,15 @@ def _get_services() -> list[DeviceService]: char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(char_uuid) if char_class: - # Create characteristic instance with runtime properties from device - char_instance = char_class(properties=properties) + char_instance = char_class() characteristics[str(char_uuid)] = char_instance else: # Fallback: Create UnknownCharacteristic for unrecognized UUIDs char_info = CharacteristicInfo( uuid=char_uuid, - name=f"Unknown Characteristic ({char_uuid.short_form}...)", + name=char_uuid.short_form, ) - char_instance = UnknownCharacteristic(info=char_info, properties=properties) + char_instance = UnknownCharacteristic(info=char_info) characteristics[str(char_uuid)] = char_instance # Type ignore needed due to dict invariance with union types diff --git a/examples/connection_managers/simpleble.py b/examples/connection_managers/simpleble.py index 6419e981..ece7c972 100644 --- a/examples/connection_managers/simpleble.py +++ b/examples/connection_managers/simpleble.py @@ -285,16 +285,15 @@ def _get_services() -> list[DeviceService]: char_class = CharacteristicRegistry.get_characteristic_class_by_uuid(char_uuid) if char_class: - # Create characteristic instance with runtime properties from device - char_instance = char_class(properties=properties) + char_instance = char_class() characteristics[str(char_uuid)] = char_instance else: # Fallback: Create UnknownCharacteristic for unrecognized UUIDs char_info = CharacteristicInfo( uuid=char_uuid, - name=f"Unknown Characteristic ({char_uuid.short_form}...)", + name=char_uuid.short_form, ) - char_instance = UnknownCharacteristic(info=char_info, properties=properties) + char_instance = UnknownCharacteristic(info=char_info) characteristics[str(char_uuid)] = char_instance # Type ignore needed due to dict invariance with union types diff --git a/scripts/ble_device_debugger.py b/scripts/ble_device_debugger.py index 6ad33df3..b9f3f670 100644 --- a/scripts/ble_device_debugger.py +++ b/scripts/ble_device_debugger.py @@ -123,9 +123,7 @@ async def debug_ble_device(target_address: str) -> None: try: if len(value) == 1: int_val = value[0] - elif len(value) == 2: - int_val = int.from_bytes(value, byteorder="little") - elif len(value) == 4: + elif len(value) == 2 or len(value) == 4: int_val = int.from_bytes(value, byteorder="little") else: int_val = None diff --git a/scripts/extract_validation_info.py b/scripts/extract_validation_info.py index d6093aef..56f4336e 100755 --- a/scripts/extract_validation_info.py +++ b/scripts/extract_validation_info.py @@ -47,19 +47,18 @@ import importlib import inspect import json -import tempfile -from pathlib import Path -from typing import Any # Add src to path import sys +import tempfile +from pathlib import Path +from typing import Any src_path = Path(__file__).parent.parent / "src" if str(src_path) not in sys.path: sys.path.insert(0, str(src_path)) from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic -from bluetooth_sig.types.gatt_enums import ValueType def get_all_characteristic_classes() -> list[type[BaseCharacteristic]]: @@ -349,7 +348,7 @@ def main() -> None: # Generate report try: generate_validation_report(output_file) - print(f"\n📊 Report summary:") + print("\n📊 Report summary:") print(f" Output file: {output_file}") # Show stats @@ -362,14 +361,14 @@ def main() -> None: if with_errors: print(f" With errors: {with_errors}") - print(f"\n📋 Output format:") - print(f" Each value includes a 'source' field indicating origin:") - print(f" - resolved_sig_info: From Bluetooth SIG registry") - print(f" - yaml_spec: From Bluetooth SIG YAML specifications") - print(f" - class_level_or_yaml: From characteristic class or YAML defaults") - print(f" - manual_override: Explicit manual override") - print(f" - runtime_descriptor_check: Discovered at runtime") - print(f" - (see script header for full source legend)") + print("\n📋 Output format:") + print(" Each value includes a 'source' field indicating origin:") + print(" - resolved_sig_info: From Bluetooth SIG registry") + print(" - yaml_spec: From Bluetooth SIG YAML specifications") + print(" - class_level_or_yaml: From characteristic class or YAML defaults") + print(" - manual_override: Explicit manual override") + print(" - runtime_descriptor_check: Discovered at runtime") + print(" - (see script header for full source legend)") except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) diff --git a/scripts/test_real_device.py b/scripts/test_real_device.py index 04e2e959..b39aaa7b 100644 --- a/scripts/test_real_device.py +++ b/scripts/test_real_device.py @@ -9,7 +9,6 @@ import logging import sys from pathlib import Path -from typing import Optional # Configure path for imports script_dir = Path(__file__).parent @@ -32,7 +31,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") -async def test_device_connection(mac_address: str) -> Optional[bool]: +async def test_device_connection(mac_address: str) -> bool | None: """Test connection to a real device using Bleak.""" print(f"\n🔍 Testing connection to device: {mac_address}") print("=" * 60) diff --git a/src/bluetooth_sig/gatt/characteristics/acceleration_detection_status.py b/src/bluetooth_sig/gatt/characteristics/acceleration_detection_status.py index b3f9b6b4..568369aa 100644 --- a/src/bluetooth_sig/gatt/characteristics/acceleration_detection_status.py +++ b/src/bluetooth_sig/gatt/characteristics/acceleration_detection_status.py @@ -15,7 +15,7 @@ class AccelerationDetectionStatus(IntEnum): CHANGE_DETECTED = 1 -class AccelerationDetectionStatusCharacteristic(BaseCharacteristic[int]): +class AccelerationDetectionStatusCharacteristic(BaseCharacteristic[AccelerationDetectionStatus]): """Acceleration Detection Status characteristic (0x2C0B). org.bluetooth.characteristic.acceleration_detection_status diff --git a/src/bluetooth_sig/gatt/characteristics/alert_level.py b/src/bluetooth_sig/gatt/characteristics/alert_level.py index c677710d..773ee1de 100644 --- a/src/bluetooth_sig/gatt/characteristics/alert_level.py +++ b/src/bluetooth_sig/gatt/characteristics/alert_level.py @@ -22,7 +22,7 @@ class AlertLevel(IntEnum): HIGH_ALERT = 0x02 -class AlertLevelCharacteristic(BaseCharacteristic[int]): +class AlertLevelCharacteristic(BaseCharacteristic[AlertLevel]): """Alert Level characteristic (0x2A06). org.bluetooth.characteristic.alert_level diff --git a/src/bluetooth_sig/gatt/characteristics/appearance.py b/src/bluetooth_sig/gatt/characteristics/appearance.py index b3c0e6fe..f3b80d57 100644 --- a/src/bluetooth_sig/gatt/characteristics/appearance.py +++ b/src/bluetooth_sig/gatt/characteristics/appearance.py @@ -4,6 +4,7 @@ from ...registry.core.appearance_values import appearance_values_registry from ...types.appearance import AppearanceData +from ...types.gatt_enums import CharacteristicRole from ..context import CharacteristicContext from .base import BaseCharacteristic from .utils import DataParser @@ -17,6 +18,7 @@ class AppearanceCharacteristic(BaseCharacteristic[AppearanceData]): Appearance characteristic with human-readable device type information. """ + _manual_role = CharacteristicRole.INFO expected_length = 2 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py b/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py index b95d067c..9e1af0dc 100644 --- a/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py +++ b/src/bluetooth_sig/gatt/characteristics/blood_pressure_common.py @@ -51,8 +51,6 @@ class BaseBloodPressureCharacteristic(BaseCharacteristic[Any]): _is_base_class = True # Exclude from characteristic discovery - _python_type = str # Override since decode_value returns dataclass - # Declare optional dependency on Blood Pressure Feature for status interpretation _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [BloodPressureFeatureCharacteristic] diff --git a/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py b/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py index f585bdb6..5210d85a 100644 --- a/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py +++ b/src/bluetooth_sig/gatt/characteristics/blood_pressure_feature.py @@ -43,8 +43,6 @@ class BloodPressureFeatureCharacteristic(BaseCharacteristic[BloodPressureFeature available. """ - _python_type: type | str | None = dict # Override since decode_value returns dataclass - # YAML has no range constraint; enforce full uint16 bitmap range. min_value: int = 0 max_value: int = UINT16_MAX diff --git a/src/bluetooth_sig/gatt/characteristics/body_sensor_location.py b/src/bluetooth_sig/gatt/characteristics/body_sensor_location.py index 083d0606..61ad9556 100644 --- a/src/bluetooth_sig/gatt/characteristics/body_sensor_location.py +++ b/src/bluetooth_sig/gatt/characteristics/body_sensor_location.py @@ -20,7 +20,7 @@ class BodySensorLocation(IntEnum): FOOT = 6 -class BodySensorLocationCharacteristic(BaseCharacteristic[int]): +class BodySensorLocationCharacteristic(BaseCharacteristic[BodySensorLocation]): """Body Sensor Location characteristic (0x2A38). Represents the location of a sensor on the human body. diff --git a/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py b/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py index b5f22567..a1e9da67 100644 --- a/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/bond_management_control_point.py @@ -16,7 +16,7 @@ class BondManagementCommand(IntEnum): DELETE_ALL_BUT_ACTIVE_BOND_ON_SERVER = 0x03 -class BondManagementControlPointCharacteristic(BaseCharacteristic[int]): +class BondManagementControlPointCharacteristic(BaseCharacteristic[BondManagementCommand]): """Bond Management Control Point characteristic (0x2AA4). org.bluetooth.characteristic.bond_management_control_point @@ -25,8 +25,6 @@ class BondManagementControlPointCharacteristic(BaseCharacteristic[int]): Variable length, starting with command byte. """ - _python_type: type | str | None = int - min_length = 1 allow_variable_length = True _template = EnumTemplate.uint8(BondManagementCommand) diff --git a/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py b/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py index b4a4ea26..3d616e4f 100644 --- a/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py +++ b/src/bluetooth_sig/gatt/characteristics/bond_management_feature.py @@ -26,8 +26,6 @@ class BondManagementFeatureCharacteristic(BaseCharacteristic[BondManagementFeatu 3 bytes containing boolean flags for supported operations. """ - _python_type: type | str | None = "BondManagementFeatureData" - # SIG spec: three uint8 feature flags → fixed 3-byte payload; no GSS YAML expected_length = 3 min_length = 3 diff --git a/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py b/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py index 591c969b..a6fcf8e1 100644 --- a/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py +++ b/src/bluetooth_sig/gatt/characteristics/boot_keyboard_input_report.py @@ -6,6 +6,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..constants import SIZE_UINT16 from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -50,6 +51,7 @@ class BootKeyboardInputReportCharacteristic(BaseCharacteristic[BootKeyboardInput USB HID Specification v1.11, Appendix B - Boot Interface Descriptors """ + _manual_role = CharacteristicRole.INFO min_length = 1 max_length = 8 allow_variable_length = True diff --git a/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py b/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py index b985cf78..ab361ab7 100644 --- a/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py +++ b/src/bluetooth_sig/gatt/characteristics/boot_mouse_input_report.py @@ -6,6 +6,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..constants import SIZE_UINT32 from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -48,6 +49,7 @@ class BootMouseInputReportCharacteristic(BaseCharacteristic[BootMouseInputReport USB HID Specification v1.11, Appendix B - Boot Interface Descriptors """ + _manual_role = CharacteristicRole.INFO min_length = 3 max_length = 4 allow_variable_length = True diff --git a/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py b/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py index 248f6184..c6769611 100644 --- a/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py +++ b/src/bluetooth_sig/gatt/characteristics/chromaticity_in_cct_and_duv_values.py @@ -4,6 +4,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..constants import SINT16_MAX, SINT16_MIN, UINT16_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -47,6 +48,8 @@ class ChromaticityInCCTAndDuvValuesCharacteristic(BaseCharacteristic[Chromaticit Field 2: Duv — sint16, M=1 d=-5 b=0 (references Chromatic Distance From Planckian). """ + _manual_role = CharacteristicRole.MEASUREMENT + # Validation attributes expected_length: int = 4 # uint16 + sint16 min_length: int = 4 diff --git a/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py b/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py index 3af0bf65..a94ceae6 100644 --- a/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py +++ b/src/bluetooth_sig/gatt/characteristics/cie_13_3_1995_color_rendering_index.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Sint8Template @@ -20,5 +21,6 @@ class CIE133ColorRenderingIndexCharacteristic(BaseCharacteristic[int]): SpecialValueDetectedError: If raw value is a sentinel (e.g. 127). """ + _manual_role = CharacteristicRole.MEASUREMENT _characteristic_name = "CIE 13.3-1995 Color Rendering Index" _template = Sint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/co2_concentration.py b/src/bluetooth_sig/gatt/characteristics/co2_concentration.py index 6cee2422..44710140 100644 --- a/src/bluetooth_sig/gatt/characteristics/co2_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/co2_concentration.py @@ -22,7 +22,6 @@ class CO2ConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _python_type: type | str | None = int _manual_unit: str | None = "ppm" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/content_control_id.py b/src/bluetooth_sig/gatt/characteristics/content_control_id.py index 3719f3ae..9098d61b 100644 --- a/src/bluetooth_sig/gatt/characteristics/content_control_id.py +++ b/src/bluetooth_sig/gatt/characteristics/content_control_id.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint8Template @@ -14,4 +15,5 @@ class ContentControlIdCharacteristic(BaseCharacteristic[int]): The ID of the content control service instance. """ + _manual_role = CharacteristicRole.STATUS _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/count_16.py b/src/bluetooth_sig/gatt/characteristics/count_16.py index 40fd21f6..7e3ab98f 100644 --- a/src/bluetooth_sig/gatt/characteristics/count_16.py +++ b/src/bluetooth_sig/gatt/characteristics/count_16.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint16Template @@ -14,4 +15,5 @@ class Count16Characteristic(BaseCharacteristic[int]): Represents a count value using 16-bit unsigned integer. """ + _manual_role = CharacteristicRole.MEASUREMENT _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/count_24.py b/src/bluetooth_sig/gatt/characteristics/count_24.py index e6358081..eff2bfa3 100644 --- a/src/bluetooth_sig/gatt/characteristics/count_24.py +++ b/src/bluetooth_sig/gatt/characteristics/count_24.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint24Template @@ -14,4 +15,5 @@ class Count24Characteristic(BaseCharacteristic[int]): Represents a count value using 24-bit unsigned integer. """ + _manual_role = CharacteristicRole.MEASUREMENT _template = Uint24Template() diff --git a/src/bluetooth_sig/gatt/characteristics/country_code.py b/src/bluetooth_sig/gatt/characteristics/country_code.py index 51596025..f1596669 100644 --- a/src/bluetooth_sig/gatt/characteristics/country_code.py +++ b/src/bluetooth_sig/gatt/characteristics/country_code.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint16Template @@ -14,4 +15,5 @@ class CountryCodeCharacteristic(BaseCharacteristic[int]): ISO 3166-1 numeric country code. """ + _manual_role = CharacteristicRole.STATUS _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/current_time.py b/src/bluetooth_sig/gatt/characteristics/current_time.py index c56579a4..21316582 100644 --- a/src/bluetooth_sig/gatt/characteristics/current_time.py +++ b/src/bluetooth_sig/gatt/characteristics/current_time.py @@ -35,9 +35,6 @@ class CurrentTimeCharacteristic(BaseCharacteristic[TimeData]): - Adjust Reason: uint8 bitfield """ - # Validation attributes - _python_type: type | str | None = dict - def __init__(self) -> None: """Initialize the Current Time characteristic.""" super().__init__() diff --git a/src/bluetooth_sig/gatt/characteristics/day_of_week.py b/src/bluetooth_sig/gatt/characteristics/day_of_week.py index f571b275..4b833d97 100644 --- a/src/bluetooth_sig/gatt/characteristics/day_of_week.py +++ b/src/bluetooth_sig/gatt/characteristics/day_of_week.py @@ -2,11 +2,12 @@ from __future__ import annotations +from ...types.gatt_enums import DayOfWeek from .base import BaseCharacteristic -from .templates import Uint8Template +from .templates import EnumTemplate -class DayOfWeekCharacteristic(BaseCharacteristic[int]): +class DayOfWeekCharacteristic(BaseCharacteristic[DayOfWeek]): """Day of Week characteristic (0x2A09). org.bluetooth.characteristic.day_of_week @@ -14,4 +15,4 @@ class DayOfWeekCharacteristic(BaseCharacteristic[int]): Represents the day of the week as an 8-bit enumeration (1=Monday, 7=Sunday). """ - _template = Uint8Template() + _template = EnumTemplate.uint8(DayOfWeek) diff --git a/src/bluetooth_sig/gatt/characteristics/device_name.py b/src/bluetooth_sig/gatt/characteristics/device_name.py index 1f23aea2..c6f0a5d6 100644 --- a/src/bluetooth_sig/gatt/characteristics/device_name.py +++ b/src/bluetooth_sig/gatt/characteristics/device_name.py @@ -14,6 +14,4 @@ class DeviceNameCharacteristic(BaseCharacteristic[str]): Represents the device name as a UTF-8 string. """ - _python_type: type | str | None = str - _template = Utf8StringTemplate() diff --git a/src/bluetooth_sig/gatt/characteristics/device_wearing_position.py b/src/bluetooth_sig/gatt/characteristics/device_wearing_position.py index 05c9db61..7d625967 100644 --- a/src/bluetooth_sig/gatt/characteristics/device_wearing_position.py +++ b/src/bluetooth_sig/gatt/characteristics/device_wearing_position.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint8Template @@ -14,4 +15,5 @@ class DeviceWearingPositionCharacteristic(BaseCharacteristic[int]): Device Wearing Position characteristic. """ + _manual_role = CharacteristicRole.STATUS _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/dst_offset.py b/src/bluetooth_sig/gatt/characteristics/dst_offset.py index 8905c01a..1683aa5c 100644 --- a/src/bluetooth_sig/gatt/characteristics/dst_offset.py +++ b/src/bluetooth_sig/gatt/characteristics/dst_offset.py @@ -18,7 +18,7 @@ class DSTOffset(IntEnum): UNKNOWN = 255 -class DstOffsetCharacteristic(BaseCharacteristic[int]): +class DstOffsetCharacteristic(BaseCharacteristic[DSTOffset]): """DST Offset characteristic (0x2A0D). org.bluetooth.characteristic.dst_offset diff --git a/src/bluetooth_sig/gatt/characteristics/electric_current_range.py b/src/bluetooth_sig/gatt/characteristics/electric_current_range.py index 597b1f13..692f7902 100644 --- a/src/bluetooth_sig/gatt/characteristics/electric_current_range.py +++ b/src/bluetooth_sig/gatt/characteristics/electric_current_range.py @@ -43,9 +43,6 @@ class ElectricCurrentRangeCharacteristic(BaseCharacteristic[ElectricCurrentRange expected_length: int = 4 # 2x uint16 min_length: int = 4 - # Override since decode_value returns structured ElectricCurrentRangeData - _python_type: type | str | None = dict - def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True ) -> ElectricCurrentRangeData: diff --git a/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py b/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py index 0ea4c1c5..ebf536b2 100644 --- a/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py +++ b/src/bluetooth_sig/gatt/characteristics/electric_current_specification.py @@ -44,9 +44,6 @@ class ElectricCurrentSpecificationCharacteristic(BaseCharacteristic[ElectricCurr expected_length: int = 4 # 2x uint16 min_length: int = 4 - # Override since decode_value returns structured ElectricCurrentSpecificationData - _python_type: type | str | None = dict - def _decode_value( self, data: bytearray, _ctx: CharacteristicContext | None = None, *, validate: bool = True ) -> ElectricCurrentSpecificationData: diff --git a/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py b/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py index 281cc02c..5819c847 100644 --- a/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py +++ b/src/bluetooth_sig/gatt/characteristics/electric_current_statistics.py @@ -55,9 +55,6 @@ class ElectricCurrentStatisticsCharacteristic(BaseCharacteristic[ElectricCurrent expected_length: int = 6 # 3x uint16 min_length: int = 6 - # Override since decode_value returns structured ElectricCurrentStatisticsData - _python_type: type | str | None = dict - def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True ) -> ElectricCurrentStatisticsData: diff --git a/src/bluetooth_sig/gatt/characteristics/elevation.py b/src/bluetooth_sig/gatt/characteristics/elevation.py index d1302463..3b060f17 100644 --- a/src/bluetooth_sig/gatt/characteristics/elevation.py +++ b/src/bluetooth_sig/gatt/characteristics/elevation.py @@ -22,6 +22,5 @@ class ElevationCharacteristic(BaseCharacteristic[float]): _template = ScaledSint24Template(scale_factor=0.01) - _python_type: type | str | None = float # Override YAML int type since decode_value returns float _manual_unit: str | None = LengthUnit.METERS.value # Override template's "units" default resolution: float = 0.01 diff --git a/src/bluetooth_sig/gatt/characteristics/floor_number.py b/src/bluetooth_sig/gatt/characteristics/floor_number.py index d98ef4a0..48831881 100644 --- a/src/bluetooth_sig/gatt/characteristics/floor_number.py +++ b/src/bluetooth_sig/gatt/characteristics/floor_number.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Sint8Template @@ -14,7 +15,7 @@ class FloorNumberCharacteristic(BaseCharacteristic[int]): Floor Number characteristic. """ - _python_type: type | str | None = int + _manual_role = CharacteristicRole.INFO # SIG spec: sint8 floor index → fixed 1-byte payload; no GSS YAML expected_length = 1 diff --git a/src/bluetooth_sig/gatt/characteristics/gender.py b/src/bluetooth_sig/gatt/characteristics/gender.py index 54838f0d..6334dfa6 100644 --- a/src/bluetooth_sig/gatt/characteristics/gender.py +++ b/src/bluetooth_sig/gatt/characteristics/gender.py @@ -16,7 +16,7 @@ class Gender(IntEnum): UNSPECIFIED = 2 -class GenderCharacteristic(BaseCharacteristic[int]): +class GenderCharacteristic(BaseCharacteristic[Gender]): """Gender characteristic (0x2A8C). org.bluetooth.characteristic.gender diff --git a/src/bluetooth_sig/gatt/characteristics/generic_level.py b/src/bluetooth_sig/gatt/characteristics/generic_level.py index 54379e2b..a2f6b9c2 100644 --- a/src/bluetooth_sig/gatt/characteristics/generic_level.py +++ b/src/bluetooth_sig/gatt/characteristics/generic_level.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint16Template @@ -14,4 +15,5 @@ class GenericLevelCharacteristic(BaseCharacteristic[int]): Unitless 16-bit level value (0-65535). """ + _manual_role = CharacteristicRole.MEASUREMENT _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py b/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py index 8b5f0abd..f52bd41f 100644 --- a/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py +++ b/src/bluetooth_sig/gatt/characteristics/global_trade_item_number.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint48Template @@ -19,4 +20,5 @@ class GlobalTradeItemNumberCharacteristic(BaseCharacteristic[int]): SpecialValueDetectedError: If raw value is a sentinel (e.g. 0x000000000000). """ + _manual_role = CharacteristicRole.INFO _template = Uint48Template() diff --git a/src/bluetooth_sig/gatt/characteristics/handedness.py b/src/bluetooth_sig/gatt/characteristics/handedness.py index 813eee20..32c43daf 100644 --- a/src/bluetooth_sig/gatt/characteristics/handedness.py +++ b/src/bluetooth_sig/gatt/characteristics/handedness.py @@ -17,7 +17,7 @@ class Handedness(IntEnum): UNSPECIFIED = 0x03 -class HandednessCharacteristic(BaseCharacteristic[int]): +class HandednessCharacteristic(BaseCharacteristic[Handedness]): """Handedness characteristic (0x2B4A). org.bluetooth.characteristic.handedness diff --git a/src/bluetooth_sig/gatt/characteristics/heart_rate_control_point.py b/src/bluetooth_sig/gatt/characteristics/heart_rate_control_point.py index 0e047912..8d90accb 100644 --- a/src/bluetooth_sig/gatt/characteristics/heart_rate_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/heart_rate_control_point.py @@ -15,7 +15,7 @@ class HeartRateControlCommand(IntEnum): RESET_ENERGY_EXPENDED = 0x01 -class HeartRateControlPointCharacteristic(BaseCharacteristic[int]): +class HeartRateControlPointCharacteristic(BaseCharacteristic[HeartRateControlCommand]): """Heart Rate Control Point characteristic (0x2A39). org.bluetooth.characteristic.heart_rate_control_point diff --git a/src/bluetooth_sig/gatt/characteristics/hid_control_point.py b/src/bluetooth_sig/gatt/characteristics/hid_control_point.py index a4ea1b4e..1cc09120 100644 --- a/src/bluetooth_sig/gatt/characteristics/hid_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/hid_control_point.py @@ -18,7 +18,7 @@ class HidControlPointCommand(IntEnum): EXIT_SUSPEND = 1 -class HidControlPointCharacteristic(BaseCharacteristic[int]): +class HidControlPointCharacteristic(BaseCharacteristic[HidControlPointCommand]): """HID Control Point characteristic (0x2A4C). org.bluetooth.characteristic.hid_control_point @@ -26,7 +26,5 @@ class HidControlPointCharacteristic(BaseCharacteristic[int]): HID Control Point characteristic. """ - _python_type: type | str | None = int - _template = EnumTemplate.uint8(HidControlPointCommand) expected_length = HID_CONTROL_POINT_DATA_LENGTH diff --git a/src/bluetooth_sig/gatt/characteristics/hid_information.py b/src/bluetooth_sig/gatt/characteristics/hid_information.py index 40768138..1997d76d 100644 --- a/src/bluetooth_sig/gatt/characteristics/hid_information.py +++ b/src/bluetooth_sig/gatt/characteristics/hid_information.py @@ -53,8 +53,6 @@ class HidInformationCharacteristic(BaseCharacteristic[HidInformationData]): HID Information characteristic. """ - _python_type: type | str | None = "HidInformationData" - expected_length: int = 4 # bcdHID(2) + bCountryCode(1) + Flags(1) min_length: int = 4 max_length: int = 4 diff --git a/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py b/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py index 0be324bf..38d48096 100644 --- a/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py +++ b/src/bluetooth_sig/gatt/characteristics/ieee_11073_20601_regulatory_certification_data_list.py @@ -22,6 +22,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -48,6 +49,7 @@ class IEEE1107320601RegulatoryCharacteristic( bytes by this library. """ + _manual_role = CharacteristicRole.INFO _characteristic_name = "IEEE 11073-20601 Regulatory Certification Data List" expected_type = IEEE11073RegulatoryData min_length: int = 1 diff --git a/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py b/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py index 281a4f8c..65f4f4a1 100644 --- a/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py +++ b/src/bluetooth_sig/gatt/characteristics/indoor_positioning_configuration.py @@ -14,7 +14,6 @@ class IndoorPositioningConfigurationCharacteristic(BaseCharacteristic[int]): Indoor Positioning Configuration characteristic. """ - _python_type: type | str | None = int _is_bitfield = True _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/latitude.py b/src/bluetooth_sig/gatt/characteristics/latitude.py index b48640ee..dc9ecaf9 100644 --- a/src/bluetooth_sig/gatt/characteristics/latitude.py +++ b/src/bluetooth_sig/gatt/characteristics/latitude.py @@ -15,8 +15,6 @@ class LatitudeCharacteristic(BaseCharacteristic[float]): Encoded as sint32 with scale factor 1e-7 degrees per unit. """ - _python_type: type | str | None = float - # Geographic coordinate constants DEGREE_SCALING_FACTOR = 1e-7 # 10^-7 degrees per unit diff --git a/src/bluetooth_sig/gatt/characteristics/ln_control_point.py b/src/bluetooth_sig/gatt/characteristics/ln_control_point.py index 1d928290..b4e166fa 100644 --- a/src/bluetooth_sig/gatt/characteristics/ln_control_point.py +++ b/src/bluetooth_sig/gatt/characteristics/ln_control_point.py @@ -111,8 +111,6 @@ class LNControlPointCharacteristic(BaseCharacteristic[LNControlPointData]): Used to enable device-specific procedures related to the exchange of location and navigation information. """ - _python_type: type | str | None = dict # Override since decode_value returns dataclass - min_length = 1 # Op Code(1) minimum max_length = 18 # Op Code(1) + Parameter(max 17) maximum allow_variable_length: bool = True # Variable parameter length diff --git a/src/bluetooth_sig/gatt/characteristics/ln_feature.py b/src/bluetooth_sig/gatt/characteristics/ln_feature.py index c591cd69..0db5dbdc 100644 --- a/src/bluetooth_sig/gatt/characteristics/ln_feature.py +++ b/src/bluetooth_sig/gatt/characteristics/ln_feature.py @@ -71,7 +71,6 @@ class LNFeatureCharacteristic(BaseCharacteristic[LNFeatureData]): """ min_length = 4 - _python_type: type | str | None = dict # Override since decode_value returns dataclass def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/location_and_speed.py b/src/bluetooth_sig/gatt/characteristics/location_and_speed.py index 8450647e..0719c1a4 100644 --- a/src/bluetooth_sig/gatt/characteristics/location_and_speed.py +++ b/src/bluetooth_sig/gatt/characteristics/location_and_speed.py @@ -73,8 +73,6 @@ class LocationAndSpeedCharacteristic(BaseCharacteristic[LocationAndSpeedData]): Note that it is possible for this characteristic to exceed the default LE ATT_MTU size. """ - _python_type: type | str | None = dict # Override since decode_value returns dataclass - min_length = 2 # Flags(2) minimum max_length = 28 # Flags(2) + InstantaneousSpeed(2) + TotalDistance(3) + Location(8) + # Elevation(3) + Heading(2) + RollingTime(1) + UTCTime(7) maximum diff --git a/src/bluetooth_sig/gatt/characteristics/location_name.py b/src/bluetooth_sig/gatt/characteristics/location_name.py index 17343830..e8bae348 100644 --- a/src/bluetooth_sig/gatt/characteristics/location_name.py +++ b/src/bluetooth_sig/gatt/characteristics/location_name.py @@ -14,7 +14,5 @@ class LocationNameCharacteristic(BaseCharacteristic[str]): Location Name characteristic. """ - _python_type: type | str | None = str - _template = Utf8StringTemplate() min_length = 0 diff --git a/src/bluetooth_sig/gatt/characteristics/longitude.py b/src/bluetooth_sig/gatt/characteristics/longitude.py index 645e3cb7..f02ad3b0 100644 --- a/src/bluetooth_sig/gatt/characteristics/longitude.py +++ b/src/bluetooth_sig/gatt/characteristics/longitude.py @@ -15,8 +15,6 @@ class LongitudeCharacteristic(BaseCharacteristic[float]): Encoded as sint32 with scale factor 1e-7 degrees per unit. """ - _python_type: type | str | None = float - # Geographic coordinate constants DEGREE_SCALING_FACTOR = 1e-7 # 10^-7 degrees per unit diff --git a/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py b/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py index 937aff93..cad88f9a 100644 --- a/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py +++ b/src/bluetooth_sig/gatt/characteristics/magnetic_declination.py @@ -22,8 +22,6 @@ class MagneticDeclinationCharacteristic(BaseCharacteristic[float]): _template = ScaledUint16Template.from_letter_method(M=1, d=-2, b=0) _characteristic_name: str = "Magnetic Declination" - # Override YAML int type since decode_value returns float - _python_type: type | str | None = float _manual_unit: str = AngleUnit.DEGREES.value # Override template's "units" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py index e40bb8ae..b17609d2 100644 --- a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py +++ b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_2d.py @@ -25,8 +25,6 @@ class MagneticFluxDensity2DCharacteristic(BaseCharacteristic[Vector2DData]): """ _characteristic_name: str | None = "Magnetic Flux Density - 2D" - # Override YAML since decode_value returns structured dict - _python_type: type | str | None = str # Override since decode_value returns dict _manual_unit: str | None = PhysicalUnit.TESLA.value # Tesla _vector_components: ClassVar[list[str]] = ["x_axis", "y_axis"] diff --git a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py index 44ed834b..f4f83992 100644 --- a/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py +++ b/src/bluetooth_sig/gatt/characteristics/magnetic_flux_density_3d.py @@ -26,7 +26,6 @@ class MagneticFluxDensity3DCharacteristic(BaseCharacteristic[VectorData]): """ _characteristic_name: str | None = "Magnetic Flux Density - 3D" - _python_type: type | str | None = str # Override since decode_value returns dict _manual_unit: str | None = PhysicalUnit.TESLA.value # Override template's "units" default _vector_components: ClassVar[list[str]] = ["x_axis", "y_axis", "z_axis"] diff --git a/src/bluetooth_sig/gatt/characteristics/methane_concentration.py b/src/bluetooth_sig/gatt/characteristics/methane_concentration.py index f68ed5f9..7c07d6d8 100644 --- a/src/bluetooth_sig/gatt/characteristics/methane_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/methane_concentration.py @@ -15,7 +15,6 @@ class MethaneConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _python_type: type | str | None = int _manual_unit: str = "ppm" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/navigation.py b/src/bluetooth_sig/gatt/characteristics/navigation.py index 7429a142..9057f391 100644 --- a/src/bluetooth_sig/gatt/characteristics/navigation.py +++ b/src/bluetooth_sig/gatt/characteristics/navigation.py @@ -57,8 +57,6 @@ class NavigationCharacteristic(BaseCharacteristic[NavigationData]): Used to represent data related to a navigation sensor. """ - _python_type: type | str | None = dict # Override since decode_value returns dataclass - min_length = 6 # Flags(2) + Bearing(2) + Heading(2) minimum max_length = 19 # + RemainingDistance(3) + RemainingVerticalDistance(3) + EstimatedTimeOfArrival(7) maximum allow_variable_length: bool = True # Variable optional fields diff --git a/src/bluetooth_sig/gatt/characteristics/object_id.py b/src/bluetooth_sig/gatt/characteristics/object_id.py index 576fa868..9057cb72 100644 --- a/src/bluetooth_sig/gatt/characteristics/object_id.py +++ b/src/bluetooth_sig/gatt/characteristics/object_id.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from ..constants import UINT48_MAX from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -17,6 +18,7 @@ class ObjectIdCharacteristic(BaseCharacteristic[int]): Transfer Service (OTS). """ + _manual_role = CharacteristicRole.INFO expected_length: int = 6 # uint48 min_length: int = 6 min_value = 0 diff --git a/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py b/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py index 34e8caae..471adb15 100644 --- a/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/ozone_concentration.py @@ -16,7 +16,6 @@ class OzoneConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _python_type: type | str | None = int # Manual override needed as no YAML available _manual_unit: str = "ppb" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py b/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py index 1b870eba..f03e6974 100644 --- a/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py +++ b/src/bluetooth_sig/gatt/characteristics/perceived_lightness.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint16Template @@ -14,4 +15,5 @@ class PerceivedLightnessCharacteristic(BaseCharacteristic[int]): Unitless perceived lightness value (0-65535). """ + _manual_role = CharacteristicRole.MEASUREMENT _template = Uint16Template() diff --git a/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py b/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py index 38b2d07e..fcc76e15 100644 --- a/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py +++ b/src/bluetooth_sig/gatt/characteristics/peripheral_preferred_connection_parameters.py @@ -4,6 +4,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..context import CharacteristicContext from .base import BaseCharacteristic from .utils import DataParser @@ -26,6 +27,7 @@ class PeripheralPreferredConnectionParametersCharacteristic(BaseCharacteristic[C Contains the preferred connection parameters (8 bytes). """ + _manual_role = CharacteristicRole.INFO expected_length = 8 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py b/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py index cf849d91..b69c99f3 100644 --- a/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py +++ b/src/bluetooth_sig/gatt/characteristics/peripheral_privacy_flag.py @@ -15,8 +15,6 @@ class PeripheralPrivacyFlagCharacteristic(BaseCharacteristic[bool]): Indicates whether privacy is enabled (True) or disabled (False). """ - _python_type: type | str | None = bool - def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True ) -> bool: diff --git a/src/bluetooth_sig/gatt/characteristics/plx_features.py b/src/bluetooth_sig/gatt/characteristics/plx_features.py index 20b865a4..9523df7a 100644 --- a/src/bluetooth_sig/gatt/characteristics/plx_features.py +++ b/src/bluetooth_sig/gatt/characteristics/plx_features.py @@ -36,7 +36,6 @@ class PLXFeaturesCharacteristic(BaseCharacteristic[PLXFeatureFlags]): Spec: Bluetooth SIG Assigned Numbers, PLX Features characteristic """ - _python_type: type | str | None = int _is_bitfield = True expected_length: int | None = 2 diff --git a/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py b/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py index 7bc948b4..01cf7889 100644 --- a/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py +++ b/src/bluetooth_sig/gatt/characteristics/plx_spot_check_measurement.py @@ -80,8 +80,6 @@ class PLXSpotCheckMeasurementCharacteristic(BaseCharacteristic[PLXSpotCheckData] measurements from spot-check readings. """ - _python_type: type | str | None = dict - _characteristic_name: str = "PLX Spot-Check Measurement" _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [PLXFeaturesCharacteristic] diff --git a/src/bluetooth_sig/gatt/characteristics/pnp_id.py b/src/bluetooth_sig/gatt/characteristics/pnp_id.py index 8c480cae..17b11e4b 100644 --- a/src/bluetooth_sig/gatt/characteristics/pnp_id.py +++ b/src/bluetooth_sig/gatt/characteristics/pnp_id.py @@ -6,6 +6,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..context import CharacteristicContext from .base import BaseCharacteristic from .utils import DataParser @@ -46,6 +47,7 @@ class PnpIdCharacteristic(BaseCharacteristic[PnpIdData]): Contains PnP ID information (7 bytes). """ + _manual_role = CharacteristicRole.INFO expected_length = 7 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py b/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py index aa671d5e..36f78c13 100644 --- a/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/pollen_concentration.py @@ -15,7 +15,6 @@ class PollenConcentrationCharacteristic(BaseCharacteristic[float]): _template = ScaledUint24Template(scale_factor=1.0) - _python_type: type | str | None = float # Override YAML spec since decode_value returns float _manual_unit: str = "grains/m³" # Override template's "units" default # SIG specification configuration diff --git a/src/bluetooth_sig/gatt/characteristics/position_quality.py b/src/bluetooth_sig/gatt/characteristics/position_quality.py index 0b9178af..8fc9eb6f 100644 --- a/src/bluetooth_sig/gatt/characteristics/position_quality.py +++ b/src/bluetooth_sig/gatt/characteristics/position_quality.py @@ -44,8 +44,6 @@ class PositionQualityCharacteristic(BaseCharacteristic[PositionQualityData]): Used to represent data related to the quality of a position measurement. """ - _python_type: type | str | None = dict # Override since decode_value returns dataclass - min_length = 2 # Flags(2) minimum max_length = 16 # Flags(2) + NumberOfBeaconsInSolution(1) + NumberOfBeaconsInView(1) + # TimeToFirstFix(2) + EHPE(4) + EVPE(4) + HDOP(1) + VDOP(1) maximum diff --git a/src/bluetooth_sig/gatt/characteristics/preferred_units.py b/src/bluetooth_sig/gatt/characteristics/preferred_units.py index e191c6bc..d7c4ab8f 100644 --- a/src/bluetooth_sig/gatt/characteristics/preferred_units.py +++ b/src/bluetooth_sig/gatt/characteristics/preferred_units.py @@ -5,6 +5,7 @@ import msgspec from ...registry.uuids.units import units_registry +from ...types.gatt_enums import CharacteristicRole from ...types.registry.units import UnitInfo from ...types.uuid import BluetoothUUID from ..context import CharacteristicContext @@ -28,6 +29,7 @@ class PreferredUnitsCharacteristic(BaseCharacteristic[PreferredUnitsData]): Each unit is represented by a 16-bit Bluetooth UUID from the Bluetooth SIG units registry. """ + _manual_role = CharacteristicRole.INFO # Variable length: minimum 0 bytes (empty list), multiples of 2 bytes (16-bit UUIDs) min_length = 0 allow_variable_length = True diff --git a/src/bluetooth_sig/gatt/characteristics/protocol_mode.py b/src/bluetooth_sig/gatt/characteristics/protocol_mode.py index d5ff46ee..d776df2f 100644 --- a/src/bluetooth_sig/gatt/characteristics/protocol_mode.py +++ b/src/bluetooth_sig/gatt/characteristics/protocol_mode.py @@ -15,7 +15,7 @@ class ProtocolMode(IntEnum): REPORT_PROTOCOL = 1 -class ProtocolModeCharacteristic(BaseCharacteristic[int]): +class ProtocolModeCharacteristic(BaseCharacteristic[ProtocolMode]): """Protocol Mode characteristic (0x2A4E). org.bluetooth.characteristic.protocol_mode @@ -23,8 +23,6 @@ class ProtocolModeCharacteristic(BaseCharacteristic[int]): Protocol Mode characteristic. """ - _python_type: type | str | None = int - _template = EnumTemplate.uint8(ProtocolMode) # SIG spec: uint8 enumerated mode value → fixed 1-byte payload; no GSS YAML diff --git a/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py b/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py index de1dcc35..680038d6 100644 --- a/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py +++ b/src/bluetooth_sig/gatt/characteristics/pulse_oximetry_measurement.py @@ -56,8 +56,6 @@ class PulseOximetryMeasurementCharacteristic(BaseCharacteristic[PulseOximetryDat measurements. """ - _python_type: type | str | None = dict - _characteristic_name: str = "PLX Continuous Measurement" _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [PLXFeaturesCharacteristic] diff --git a/src/bluetooth_sig/gatt/characteristics/reconnection_address.py b/src/bluetooth_sig/gatt/characteristics/reconnection_address.py index 6d423c58..f562c9ba 100644 --- a/src/bluetooth_sig/gatt/characteristics/reconnection_address.py +++ b/src/bluetooth_sig/gatt/characteristics/reconnection_address.py @@ -15,8 +15,6 @@ class ReconnectionAddressCharacteristic(BaseCharacteristic[str]): Contains a 48-bit Bluetooth device address for reconnection. """ - _python_type: type | str | None = str - expected_length = 6 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: diff --git a/src/bluetooth_sig/gatt/characteristics/report.py b/src/bluetooth_sig/gatt/characteristics/report.py index 309ce158..17680f66 100644 --- a/src/bluetooth_sig/gatt/characteristics/report.py +++ b/src/bluetooth_sig/gatt/characteristics/report.py @@ -26,8 +26,6 @@ class ReportCharacteristic(BaseCharacteristic[ReportData]): Report characteristic. """ - _python_type: type | str | None = "ReportData" - min_length = 1 expected_type = bytes diff --git a/src/bluetooth_sig/gatt/characteristics/report_map.py b/src/bluetooth_sig/gatt/characteristics/report_map.py index ae9dffef..bbb51616 100644 --- a/src/bluetooth_sig/gatt/characteristics/report_map.py +++ b/src/bluetooth_sig/gatt/characteristics/report_map.py @@ -26,8 +26,6 @@ class ReportMapCharacteristic(BaseCharacteristic[ReportMapData]): Report Map characteristic. """ - _python_type: type | str | None = "ReportMapData" - min_length = 1 expected_type = bytes diff --git a/src/bluetooth_sig/gatt/characteristics/role_classifier.py b/src/bluetooth_sig/gatt/characteristics/role_classifier.py index b5f0db08..ba0b4195 100644 --- a/src/bluetooth_sig/gatt/characteristics/role_classifier.py +++ b/src/bluetooth_sig/gatt/characteristics/role_classifier.py @@ -1,15 +1,28 @@ """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 SIG spec metadata using a tiered heuristic based on GSS YAML signals, +name conventions, and type inference. + +Validated against a hand-verified ground truth map of all 294 registered +characteristics — see ``scripts/test_classifier.py``. Characteristics +that cannot be classified from the available YAML metadata alone should +use ``_manual_role`` on their class. """ from __future__ import annotations +import enum + from ...types.gatt_enums import CharacteristicRole from ...types.registry import CharacteristicSpec +# Struct name words that indicate compound measurement data +_MEASUREMENT_STRUCT_WORDS = frozenset({"Range", "Statistics", "Specification", "Record", "Relative", "Coordinates"}) + +# Struct name words that indicate temporal or state-snapshot data +_STATUS_STRUCT_WORDS = frozenset({"Time", "Information", "Created", "Modified", "Changed", "Alert", "Setting"}) + def classify_role( char_name: str, @@ -20,18 +33,51 @@ def classify_role( ) -> 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 - ``is_bitfield`` is True → FEATURE - 3. Name contains *Measurement* → MEASUREMENT - 4. Numeric type (int / float) with a unit → MEASUREMENT - 5. Multi-field struct with per-field units → MEASUREMENT - 6. Name ends with *Data* → MEASUREMENT - 7. Name contains *Status* → STATUS - 8. ``python_type`` is str → INFO - 9. Compound type (struct, dict, etc.) → MEASUREMENT - 10. Otherwise → UNKNOWN + Role definitions: + - ``MEASUREMENT``: value represents something measured or observed from + the device or environment (physical quantity, sampled reading, derived + sensor metric). + - ``STATUS``: discrete operational/device state (mode, flag, trend, + categorical state snapshot). + - ``FEATURE``: capability declaration (supported options/bitmasks), not a + live measured value. + - ``CONTROL``: command/control endpoint (typically control point writes). + - ``INFO``: contextual metadata, identifiers, or descriptive/static data + that does not represent a measurement. + + Uses a tiered priority system — strongest YAML signals first, + then name conventions, type inference, and struct name patterns. + + Tier 1 — YAML-data-driven (highest confidence): + 1. Name contains *Control Point* → CONTROL + 2. Physical (non-unitless) ``unit_id`` → MEASUREMENT + 3. Field-level physical units in structure → MEASUREMENT + + Tier 2 — Name + type signals: + 4. Name contains *Status* → STATUS + 5. ``is_bitfield`` is True → FEATURE + 6. ``python_type`` is IntFlag subclass → FEATURE + + Tier 3 — SIG naming conventions: + 7. Name contains *Measurement* or ends + with *Data* → MEASUREMENT + 8. Name ends with *Feature(s)* → FEATURE + + Tier 4 — Type-driven inference: + 9. Non-empty unit string → MEASUREMENT + 10. ``python_type is str`` → INFO + 11. ``python_type`` is a string subtype name → INFO + 12. ``python_type`` is an Enum subclass → STATUS + 13. Unitless ``unit_id`` + numeric type → MEASUREMENT + 14. ``python_type is float`` → MEASUREMENT + 15. ``python_type is bool`` → STATUS + + Tier 5 — Struct name patterns (for structs with no YAML signal): + 16. Measurement struct keyword → MEASUREMENT + 17. Status struct keyword → STATUS + + Tier 6 — Fallback: + 18. Otherwise → UNKNOWN Args: char_name: Display name of the characteristic. @@ -42,80 +88,150 @@ def classify_role( Returns: The classified ``CharacteristicRole``. - """ - # 1. Control points are write-only command interfaces + # Derive YAML signals from spec + has_unit_id = bool(spec and spec.unit_id) + is_unitless = "unitless" in (spec.unit_id or "") if spec else False + has_field_units = _spec_has_physical_field_units(spec) + is_intflag = isinstance(python_type, type) and issubclass(python_type, enum.IntFlag) + is_enum = isinstance(python_type, type) and issubclass(python_type, enum.Enum) + is_struct = isinstance(python_type, type) and python_type not in ( + int, + float, + str, + bool, + bytes, + ) + + # Walk tiers in priority order; first match wins + return ( + _classify_yaml_signals(char_name, has_unit_id, is_unitless, has_field_units) + or _classify_name_and_type(char_name, is_bitfield, is_intflag) + or _classify_naming_conventions(char_name) + or _classify_type_inference(python_type, unit, is_enum, is_unitless) + or _classify_struct_patterns(char_name, is_struct) + or CharacteristicRole.UNKNOWN + ) + + +def _classify_yaml_signals( + char_name: str, + has_unit_id: bool, + is_unitless: bool, + has_field_units: bool, +) -> CharacteristicRole | None: + """Tier 1: YAML-data-driven signals (highest confidence).""" + # 1. Control points — write-only command interfaces if "Control Point" in char_name: return CharacteristicRole.CONTROL - # 2. Feature / capability bitfields describe device capabilities - is_feature_name = char_name.endswith("Feature") or char_name.endswith("Features") - if is_feature_name or (is_bitfield and "Status" not in char_name): - return CharacteristicRole.FEATURE - - # 3. Explicit measurement by SIG naming convention - if "Measurement" in char_name: + # 2. Physical (non-unitless) unit_id from the GSS YAML + if has_unit_id and not is_unitless: return CharacteristicRole.MEASUREMENT - # 4. Numeric scalar with a physical unit - if python_type in (int, float) and unit: + # 3. Structure fields carry physical units + if has_field_units: return CharacteristicRole.MEASUREMENT - # 5. Multi-field struct with per-field measurement units. - # The GSS structure is the authoritative source — python_type may be - # None for multi-field structs whose registry entry was not updated - # with a scalar wire type. - if _spec_is_multi_field_measurement(spec): - return CharacteristicRole.MEASUREMENT + return None - # 6. SIG *Data* characteristics (Treadmill Data, Indoor Bike Data, …) - if char_name.endswith(" Data"): - return CharacteristicRole.MEASUREMENT - # 7. State / status reporting characteristics +def _classify_name_and_type( + char_name: str, + is_bitfield: bool, + is_intflag: bool, +) -> CharacteristicRole | None: + """Tier 2: Name + type signals.""" + # 4. "Status" in name — checked BEFORE bitfield to catch status + # bitfields (e.g. Alert Status, Battery Critical Status) if "Status" in char_name: return CharacteristicRole.STATUS - # 8. Pure string metadata (device name, revision strings, …) + # 5. Bitfield characteristics describe device capabilities + if is_bitfield: + return CharacteristicRole.FEATURE + + # 6. IntFlag types are capability/category flag sets + if is_intflag: + return CharacteristicRole.FEATURE + + return None + + +def _classify_naming_conventions(char_name: str) -> CharacteristicRole | None: + """Tier 3: SIG naming conventions.""" + # 7. Explicit measurement/data by SIG naming convention + if "Measurement" in char_name or char_name.endswith(" Data"): + return CharacteristicRole.MEASUREMENT + + # 8. Feature by name (catches non-bitfield feature characteristics) + if char_name.endswith("Feature") or char_name.endswith("Features"): + return CharacteristicRole.FEATURE + + return None + + +def _classify_type_inference( + python_type: type | str | None, + unit: str, + is_enum: bool, + is_unitless: bool, +) -> CharacteristicRole | None: + """Tier 4: Type-driven inference.""" + # 9. Has unit string from CharacteristicInfo + if unit: + return CharacteristicRole.MEASUREMENT + + # 10. Pure string types are info/metadata if python_type is str: return CharacteristicRole.INFO - # 9. Compound type (struct, dict, etc.) — by this point we've excluded - # control points, features, status, info, and name-matched characteristics. - # A compound type at this stage is almost certainly a measurement - # struct (e.g. VectorData, datetime, range structs). - scalar_types = (int, float, str, bool, bytes) - is_compound = isinstance(python_type, type) and python_type not in scalar_types - if is_compound: + # 11. String subtypes (e.g. "ReportData", "HidInformationData") + if isinstance(python_type, str): + return CharacteristicRole.INFO + + # 12. Enum types are categorical state values + if is_enum: + return CharacteristicRole.STATUS + + # 13. Unitless unit_id with numeric type → dimensionless measurement + if is_unitless and python_type in (int, float): + return CharacteristicRole.MEASUREMENT + + # 14. Float type without any other signal → physical quantity + if python_type is float: return CharacteristicRole.MEASUREMENT - return CharacteristicRole.UNKNOWN + # 15. Boolean type → state flag + if python_type is bool: + return CharacteristicRole.STATUS + return None -def _spec_is_multi_field_measurement(spec: CharacteristicSpec | None) -> bool: - """Check whether the spec describes a multi-field struct with measurement units. - Returns ``True`` when the GSS specification has more than one field - **and** at least one of those fields carries a ``unit_id``. - """ - if not spec: - return False - structure = getattr(spec, "structure", None) - if not structure or len(structure) < 2: - return False - return any(getattr(f, "unit_id", None) for f in structure) +def _classify_struct_patterns( + char_name: str, + is_struct: bool, +) -> CharacteristicRole | None: + """Tier 5: Struct name patterns (for structs with no YAML signal).""" + if not is_struct: + return None + + # 16. Measurement struct patterns + if any(w in char_name for w in _MEASUREMENT_STRUCT_WORDS): + return CharacteristicRole.MEASUREMENT + if " in a " in char_name: + return CharacteristicRole.MEASUREMENT + # 17. Status struct patterns + if any(w in char_name for w in _STATUS_STRUCT_WORDS): + return CharacteristicRole.STATUS -def _spec_has_unit_fields(spec: CharacteristicSpec | None) -> bool: - """Check whether any field in the GSS spec carries a ``unit_id``. + return None - 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: + +def _spec_has_physical_field_units(spec: CharacteristicSpec | None) -> bool: + """Check whether any field in the spec carries a physical (non-unitless) unit_id.""" + if not spec or not spec.structure: return False - return any(getattr(f, "unit_id", None) for f in structure) + return any(f.unit_id and "unitless" not in f.unit_id for f in spec.structure) diff --git a/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py b/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py index a1d84915..0c710c75 100644 --- a/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py +++ b/src/bluetooth_sig/gatt/characteristics/scan_interval_window.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from ...types.scan_interval_window import ScanIntervalWindowData from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -23,6 +24,7 @@ class ScanIntervalWindowCharacteristic(BaseCharacteristic[ScanIntervalWindowData The scan window must be less than or equal to the scan interval. """ + _manual_role = CharacteristicRole.INFO _characteristic_name = "Scan Interval Window" min_length = 4 # Scan Interval(2) + Scan Window(2) diff --git a/src/bluetooth_sig/gatt/characteristics/scan_refresh.py b/src/bluetooth_sig/gatt/characteristics/scan_refresh.py index b6fe68c2..2bf30f98 100644 --- a/src/bluetooth_sig/gatt/characteristics/scan_refresh.py +++ b/src/bluetooth_sig/gatt/characteristics/scan_refresh.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint8Template @@ -14,6 +15,6 @@ class ScanRefreshCharacteristic(BaseCharacteristic[int]): Requests the server to refresh the scan. """ - _python_type: type | str | None = int + _manual_role = CharacteristicRole.STATUS _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/sport_type_for_aerobic_and_anaerobic_thresholds.py b/src/bluetooth_sig/gatt/characteristics/sport_type_for_aerobic_and_anaerobic_thresholds.py index 5f31cffe..dd5e7bd1 100644 --- a/src/bluetooth_sig/gatt/characteristics/sport_type_for_aerobic_and_anaerobic_thresholds.py +++ b/src/bluetooth_sig/gatt/characteristics/sport_type_for_aerobic_and_anaerobic_thresholds.py @@ -25,7 +25,7 @@ class SportType(IntEnum): WHOLE_BODY_EXERCISING = 11 -class SportTypeForAerobicAndAnaerobicThresholdsCharacteristic(BaseCharacteristic[int]): +class SportTypeForAerobicAndAnaerobicThresholdsCharacteristic(BaseCharacteristic[SportType]): """Sport Type for Aerobic and Anaerobic Thresholds characteristic (0x2A93). org.bluetooth.characteristic.sport_type_for_aerobic_and_anaerobic_thresholds diff --git a/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py b/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py index a51eb5c9..56ee37c6 100644 --- a/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py +++ b/src/bluetooth_sig/gatt/characteristics/sulfur_dioxide_concentration.py @@ -19,7 +19,6 @@ class SulfurDioxideConcentrationCharacteristic(BaseCharacteristic[float]): _template = ConcentrationTemplate() - _python_type: type | str | None = int _manual_unit: str = "ppb" # Override template's "ppm" default # Template configuration diff --git a/src/bluetooth_sig/gatt/characteristics/supported_power_range.py b/src/bluetooth_sig/gatt/characteristics/supported_power_range.py index 6c7a12dc..d6d931d2 100644 --- a/src/bluetooth_sig/gatt/characteristics/supported_power_range.py +++ b/src/bluetooth_sig/gatt/characteristics/supported_power_range.py @@ -41,8 +41,6 @@ class SupportedPowerRangeCharacteristic(BaseCharacteristic[SupportedPowerRangeDa min_length = 4 _characteristic_name: str = "Supported Power Range" - # Override since decode_value returns structured SupportedPowerRangeData - _python_type: type | str | None = dict def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/system_id.py b/src/bluetooth_sig/gatt/characteristics/system_id.py index 898ba9e4..b8d4228b 100644 --- a/src/bluetooth_sig/gatt/characteristics/system_id.py +++ b/src/bluetooth_sig/gatt/characteristics/system_id.py @@ -4,6 +4,7 @@ import msgspec +from ...types.gatt_enums import CharacteristicRole from ..context import CharacteristicContext from .base import BaseCharacteristic @@ -28,6 +29,7 @@ class SystemIdCharacteristic(BaseCharacteristic[SystemIdData]): Represents a 64-bit system identifier: 40-bit manufacturer ID + 24-bit organizationally unique ID. """ + _manual_role = CharacteristicRole.INFO expected_length = 8 def _decode_value( diff --git a/src/bluetooth_sig/gatt/characteristics/temperature_type.py b/src/bluetooth_sig/gatt/characteristics/temperature_type.py index dae753b6..aeebaffb 100644 --- a/src/bluetooth_sig/gatt/characteristics/temperature_type.py +++ b/src/bluetooth_sig/gatt/characteristics/temperature_type.py @@ -26,7 +26,7 @@ class TemperatureType(IntEnum): TYMPANUM = 9 # Ear drum -class TemperatureTypeCharacteristic(BaseCharacteristic[int]): +class TemperatureTypeCharacteristic(BaseCharacteristic[TemperatureType]): """Temperature Type characteristic (0x2A1D). org.bluetooth.characteristic.temperature_type diff --git a/src/bluetooth_sig/gatt/characteristics/time_source.py b/src/bluetooth_sig/gatt/characteristics/time_source.py index fd5b1cfa..02509883 100644 --- a/src/bluetooth_sig/gatt/characteristics/time_source.py +++ b/src/bluetooth_sig/gatt/characteristics/time_source.py @@ -24,7 +24,7 @@ class TimeSource(IntEnum): NOT_SYNCHRONIZED = 7 -class TimeSourceCharacteristic(BaseCharacteristic[int]): +class TimeSourceCharacteristic(BaseCharacteristic[TimeSource]): """Time Source characteristic (0x2A13). org.bluetooth.characteristic.time_source diff --git a/src/bluetooth_sig/gatt/characteristics/time_zone.py b/src/bluetooth_sig/gatt/characteristics/time_zone.py index ceadf208..4cb01101 100644 --- a/src/bluetooth_sig/gatt/characteristics/time_zone.py +++ b/src/bluetooth_sig/gatt/characteristics/time_zone.py @@ -23,8 +23,6 @@ class TimeZoneCharacteristic(BaseCharacteristic[str]): standard time and UTC. """ - # Manual override: YAML indicates sint8->int but we return descriptive strings - _python_type: type | str | None = str min_length: int = 1 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True) -> str: diff --git a/src/bluetooth_sig/gatt/characteristics/user_index.py b/src/bluetooth_sig/gatt/characteristics/user_index.py index c70fe256..8e1e3ed0 100644 --- a/src/bluetooth_sig/gatt/characteristics/user_index.py +++ b/src/bluetooth_sig/gatt/characteristics/user_index.py @@ -2,6 +2,7 @@ from __future__ import annotations +from ...types.gatt_enums import CharacteristicRole from .base import BaseCharacteristic from .templates import Uint8Template @@ -14,4 +15,5 @@ class UserIndexCharacteristic(BaseCharacteristic[int]): Identifies a user by index as an unsigned 8-bit integer. """ + _manual_role = CharacteristicRole.INFO _template = Uint8Template() diff --git a/src/bluetooth_sig/gatt/characteristics/voltage_specification.py b/src/bluetooth_sig/gatt/characteristics/voltage_specification.py index 0a0a8c5a..8d7eb9d8 100644 --- a/src/bluetooth_sig/gatt/characteristics/voltage_specification.py +++ b/src/bluetooth_sig/gatt/characteristics/voltage_specification.py @@ -45,8 +45,6 @@ class VoltageSpecificationCharacteristic(BaseCharacteristic[VoltageSpecification """ min_length = 4 - # Override since decode_value returns structured VoltageSpecificationData - _python_type: type | str | None = dict def _decode_value( self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True diff --git a/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py b/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py index efb28191..c288b254 100644 --- a/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py +++ b/src/bluetooth_sig/gatt/characteristics/voltage_statistics.py @@ -51,8 +51,6 @@ class VoltageStatisticsCharacteristic(BaseCharacteristic[VoltageStatisticsData]) Provides statistical voltage data over time. """ - # Override since decode_value returns structured VoltageStatisticsData - _python_type: type | str | None = dict expected_length: int = 6 # Minimum(2) + Maximum(2) + Average(2) min_length: int = 6 diff --git a/src/bluetooth_sig/types/gatt_enums.py b/src/bluetooth_sig/types/gatt_enums.py index 4757a0dc..acf89c24 100644 --- a/src/bluetooth_sig/types/gatt_enums.py +++ b/src/bluetooth_sig/types/gatt_enums.py @@ -88,18 +88,19 @@ class CharacteristicRole(Enum): 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 — value represents something measured or observed from + a device or environment (temperature, heart rate, SpO2, + acceleration, concentration, range statistics, etc.). + STATUS — discrete operational state (mode, trend, boolean flag, + categorical state snapshot). + FEATURE — capability declaration or supported option bitmask, + not a live measured value. + CONTROL — command/control endpoint used to change behaviour. + INFO — contextual metadata or identifiers that are not + measured values (device identity, names, indices, + topology/location context such as floor number). + UNKNOWN — cannot be classified from available spec metadata alone; + use per-characteristic manual overrides where required. """ MEASUREMENT = "measurement" diff --git a/src/bluetooth_sig/utils/values.py b/src/bluetooth_sig/utils/values.py index 7f2a03d0..e15ff5a6 100644 --- a/src/bluetooth_sig/utils/values.py +++ b/src/bluetooth_sig/utils/values.py @@ -54,8 +54,8 @@ def to_primitive(value: Any) -> int | float | str | bool: # noqa: ANN401 return value if isinstance(value, enum.IntFlag): return int(value) - if (name := getattr(value, "name", None)) is not None: - return str(name) + if isinstance(value, enum.Enum): + return str(value.name) if isinstance(value, int): return int(value) if isinstance(value, float): diff --git a/tests/gatt/characteristics/test_acceleration.py b/tests/gatt/characteristics/test_acceleration.py index 3405a34b..cf0d01ab 100644 --- a/tests/gatt/characteristics/test_acceleration.py +++ b/tests/gatt/characteristics/test_acceleration.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2C06" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid acceleration test data covering various accelerations and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_acceleration_detection_status.py b/tests/gatt/characteristics/test_acceleration_detection_status.py index e6a6539f..ff707777 100644 --- a/tests/gatt/characteristics/test_acceleration_detection_status.py +++ b/tests/gatt/characteristics/test_acceleration_detection_status.py @@ -5,6 +5,7 @@ import pytest from bluetooth_sig.gatt.characteristics import AccelerationDetectionStatusCharacteristic +from bluetooth_sig.gatt.characteristics.acceleration_detection_status import AccelerationDetectionStatus from tests.gatt.characteristics.test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests @@ -25,18 +26,26 @@ def expected_uuid(self) -> str: def valid_test_data(self) -> list[CharacteristicTestData]: """Return valid test data for acceleration detection status.""" return [ - CharacteristicTestData(input_data=bytearray([0]), expected_value=0, description="No acceleration detected"), - CharacteristicTestData(input_data=bytearray([1]), expected_value=1, description="Acceleration detected"), + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=AccelerationDetectionStatus.NO_CHANGE, + description="No acceleration detected", + ), + CharacteristicTestData( + input_data=bytearray([1]), + expected_value=AccelerationDetectionStatus.CHANGE_DETECTED, + description="Acceleration detected", + ), ] def test_no_acceleration(self) -> None: """Test no acceleration detected.""" char = AccelerationDetectionStatusCharacteristic() result = char.parse_value(bytearray([0])) - assert result == 0 + assert result == AccelerationDetectionStatus.NO_CHANGE def test_acceleration_detected(self) -> None: """Test acceleration detected.""" char = AccelerationDetectionStatusCharacteristic() result = char.parse_value(bytearray([1])) - assert result == 1 + assert result == AccelerationDetectionStatus.CHANGE_DETECTED diff --git a/tests/gatt/characteristics/test_alert_level.py b/tests/gatt/characteristics/test_alert_level.py index 9cf9628a..c24f2802 100644 --- a/tests/gatt/characteristics/test_alert_level.py +++ b/tests/gatt/characteristics/test_alert_level.py @@ -70,7 +70,7 @@ def test_alert_level_encoding_enum(self, characteristic: AlertLevelCharacteristi ) def test_alert_level_encoding_int(self, characteristic: AlertLevelCharacteristic, alert_int: int) -> None: """Test encoding alert levels from integer values.""" - encoded = characteristic.build_value(alert_int) + encoded = characteristic.build_value(AlertLevel(alert_int)) assert len(encoded) == 1 assert encoded[0] == alert_int diff --git a/tests/gatt/characteristics/test_apparent_energy_32.py b/tests/gatt/characteristics/test_apparent_energy_32.py index 95e74e7c..3a69d8c8 100644 --- a/tests/gatt/characteristics/test_apparent_energy_32.py +++ b/tests/gatt/characteristics/test_apparent_energy_32.py @@ -25,7 +25,7 @@ def expected_uuid(self) -> str: return "2B89" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid apparent energy 32 test data covering various energies and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_apparent_power.py b/tests/gatt/characteristics/test_apparent_power.py index ed997dec..a3495466 100644 --- a/tests/gatt/characteristics/test_apparent_power.py +++ b/tests/gatt/characteristics/test_apparent_power.py @@ -25,7 +25,7 @@ def expected_uuid(self) -> str: return "2B8A" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid apparent power test data covering various powers and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_appearance.py b/tests/gatt/characteristics/test_appearance.py index a4ee8c6b..b91baa53 100644 --- a/tests/gatt/characteristics/test_appearance.py +++ b/tests/gatt/characteristics/test_appearance.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2A01" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: # Create AppearanceData objects directly with raw values to avoid registry dependency # This makes tests deterministic and independent of registry state from bluetooth_sig.types.registry.appearance_info import AppearanceInfo, AppearanceSubcategoryInfo diff --git a/tests/gatt/characteristics/test_average_current.py b/tests/gatt/characteristics/test_average_current.py index 8b3a025e..6fd04302 100644 --- a/tests/gatt/characteristics/test_average_current.py +++ b/tests/gatt/characteristics/test_average_current.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2AE0" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: # 0x2710 = 10000 * 0.01A = 100A return [ CharacteristicTestData(input_data=bytearray([0x00, 0x00]), expected_value=0.0, description="0A (min)"), diff --git a/tests/gatt/characteristics/test_average_voltage.py b/tests/gatt/characteristics/test_average_voltage.py index a1157ef1..17464249 100644 --- a/tests/gatt/characteristics/test_average_voltage.py +++ b/tests/gatt/characteristics/test_average_voltage.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2AE1" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: # Resolution: 1/64 V per unit return [ CharacteristicTestData(input_data=bytearray([0x00, 0x00]), expected_value=0.0, description="0V (min)"), diff --git a/tests/gatt/characteristics/test_battery_critical_status.py b/tests/gatt/characteristics/test_battery_critical_status.py index e0b925a1..2dcedd07 100644 --- a/tests/gatt/characteristics/test_battery_critical_status.py +++ b/tests/gatt/characteristics/test_battery_critical_status.py @@ -28,7 +28,7 @@ def expected_uuid(self) -> str: return "2BE9" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery critical status test data covering various bit combinations.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_battery_energy_status.py b/tests/gatt/characteristics/test_battery_energy_status.py index d871ac06..0330c15a 100644 --- a/tests/gatt/characteristics/test_battery_energy_status.py +++ b/tests/gatt/characteristics/test_battery_energy_status.py @@ -40,7 +40,7 @@ def expected_uuid(self) -> str: return "2BF0" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery energy status test data.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_battery_health_information.py b/tests/gatt/characteristics/test_battery_health_information.py index fef6f7c3..e8af4e7d 100644 --- a/tests/gatt/characteristics/test_battery_health_information.py +++ b/tests/gatt/characteristics/test_battery_health_information.py @@ -33,7 +33,7 @@ def expected_uuid(self) -> str: return "2BEB" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery health information test data.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_battery_health_status.py b/tests/gatt/characteristics/test_battery_health_status.py index 17929abb..5214ead5 100644 --- a/tests/gatt/characteristics/test_battery_health_status.py +++ b/tests/gatt/characteristics/test_battery_health_status.py @@ -33,7 +33,7 @@ def expected_uuid(self) -> str: return "2BEA" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery health status test data.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_battery_information.py b/tests/gatt/characteristics/test_battery_information.py index 83b29759..49003d6b 100644 --- a/tests/gatt/characteristics/test_battery_information.py +++ b/tests/gatt/characteristics/test_battery_information.py @@ -46,7 +46,7 @@ def expected_uuid(self) -> str: return "2BEC" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery information test data.""" # 19724 days since epoch = 2024-01-01 (approx) # 21550 days since epoch = 2029-01-01 (approx) diff --git a/tests/gatt/characteristics/test_battery_level_status.py b/tests/gatt/characteristics/test_battery_level_status.py index b5828309..389c8580 100644 --- a/tests/gatt/characteristics/test_battery_level_status.py +++ b/tests/gatt/characteristics/test_battery_level_status.py @@ -37,7 +37,7 @@ def expected_uuid(self) -> str: return "2BED" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery level status test data.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_battery_time_status.py b/tests/gatt/characteristics/test_battery_time_status.py index 247fd8ae..0460dff8 100644 --- a/tests/gatt/characteristics/test_battery_time_status.py +++ b/tests/gatt/characteristics/test_battery_time_status.py @@ -33,7 +33,7 @@ def expected_uuid(self) -> str: return "2BEE" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid battery time status test data.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_blood_pressure_feature.py b/tests/gatt/characteristics/test_blood_pressure_feature.py index e0781b39..fe5c42aa 100644 --- a/tests/gatt/characteristics/test_blood_pressure_feature.py +++ b/tests/gatt/characteristics/test_blood_pressure_feature.py @@ -19,7 +19,7 @@ def expected_uuid(self) -> str: return "2A49" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: # 0x0000: all features off; 0x003F: all features on; 0x0001: only body movement detection return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_body_composition_feature.py b/tests/gatt/characteristics/test_body_composition_feature.py index 96d16bd5..a67b976d 100644 --- a/tests/gatt/characteristics/test_body_composition_feature.py +++ b/tests/gatt/characteristics/test_body_composition_feature.py @@ -28,7 +28,7 @@ def expected_uuid(self) -> str: return "2A9B" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), # No features supported diff --git a/tests/gatt/characteristics/test_body_sensor_location.py b/tests/gatt/characteristics/test_body_sensor_location.py index 77ac6720..88b0a4b4 100644 --- a/tests/gatt/characteristics/test_body_sensor_location.py +++ b/tests/gatt/characteristics/test_body_sensor_location.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2A38" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00]), diff --git a/tests/gatt/characteristics/test_carbon_monoxide_concentration.py b/tests/gatt/characteristics/test_carbon_monoxide_concentration.py index 94b827f7..dd131b72 100644 --- a/tests/gatt/characteristics/test_carbon_monoxide_concentration.py +++ b/tests/gatt/characteristics/test_carbon_monoxide_concentration.py @@ -54,12 +54,10 @@ def test_typical_concentration(self) -> None: def test_round_trip( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test round trip with value comparison (SFLOAT has precision limits).""" - test_cases = valid_test_data if isinstance(valid_test_data, list) else [valid_test_data] - - for i, test_case in enumerate(test_cases): + for i, test_case in enumerate(valid_test_data): case_desc = f"Test case {i + 1} ({test_case.description})" # Decode the input parsed = characteristic.parse_value(test_case.input_data) diff --git a/tests/gatt/characteristics/test_characteristic_common.py b/tests/gatt/characteristics/test_characteristic_common.py index 6a51775c..377c8f09 100644 --- a/tests/gatt/characteristics/test_characteristic_common.py +++ b/tests/gatt/characteristics/test_characteristic_common.py @@ -58,8 +58,11 @@ def expected_uuid(self) -> str: return "2A19" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: - return bytearray([75]) # 75% battery + def valid_test_data(self) -> list[CharacteristicTestData]: + return [ + CharacteristicTestData(bytearray([75]), 75, "75% battery"), + CharacteristicTestData(bytearray([100]), 100, "100% battery"), + ] """ @pytest.fixture @@ -109,7 +112,6 @@ def test_parse_valid_data_succeeds( self, characteristic: BaseCharacteristic[Any], valid_test_data: list[CharacteristicTestData] ) -> None: """Test parsing valid data succeeds and returns meaningful result.""" - # Handle both single and multiple test cases if len(valid_test_data) < 2: raise ValueError("valid_test_data should be a list with at least two test cases for this test to work") for i, test_case in enumerate(valid_test_data): @@ -132,13 +134,10 @@ def test_parse_valid_data_succeeds( def test_decode_valid_data_returns_expected_value( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test raw decoding returns the exact expected value.""" - # Handle both single and multiple test cases - test_cases = valid_test_data if isinstance(valid_test_data, list) else [valid_test_data] - - for i, test_case in enumerate(test_cases): + for i, test_case in enumerate(valid_test_data): input_data = test_case.input_data result = characteristic.parse_value(input_data) @@ -185,11 +184,10 @@ def test_empty_data_handling(self, characteristic: BaseCharacteristic[Any]) -> N def test_oversized_data_validation( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test that excessively large data is properly validated.""" - # Use first test case if list, otherwise use single test case - test_case = valid_test_data[0] if isinstance(valid_test_data, list) else valid_test_data + test_case = valid_test_data[0] input_data = test_case.input_data # Create data that's much larger than reasonable @@ -222,11 +220,10 @@ def test_length_validation_behaviour(self, characteristic: BaseCharacteristic[An def test_range_validation_behaviour( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test that characteristics handle malformed data appropriately.""" - # Use first test case if list, otherwise use single test case - test_case = valid_test_data[0] if isinstance(valid_test_data, list) else valid_test_data + test_case = valid_test_data[0] # Test with clearly invalid data patterns that should fail parsing invalid_patterns = [ @@ -280,11 +277,10 @@ def test_decode_exception_handling(self, characteristic: BaseCharacteristic[Any] def test_undersized_data_handling( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test handling of data that's too short.""" - # Use first test case if list, otherwise use single test case - test_case = valid_test_data[0] if isinstance(valid_test_data, list) else valid_test_data + test_case = valid_test_data[0] if len(test_case.input_data) > 1: short_data = test_case.input_data[:-1] # Remove last byte @@ -297,11 +293,10 @@ def test_undersized_data_handling( def test_parse_decode_consistency( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test that parse_value and decode_value return consistent results.""" - # Use first test case if list, otherwise use single test case - test_case = valid_test_data[0] if isinstance(valid_test_data, list) else valid_test_data + test_case = valid_test_data[0] input_data = test_case.input_data parse_result = characteristic.parse_value(input_data) @@ -313,10 +308,10 @@ def test_parse_decode_consistency( def test_round_trip( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Test that encoding and decoding preserve data (round trip).""" - test_cases = valid_test_data if isinstance(valid_test_data, list) else [valid_test_data] + test_cases = valid_test_data for i, test_case in enumerate(test_cases): case_desc = f"Test case {i + 1} ({test_case.description})" diff --git a/tests/gatt/characteristics/test_characteristic_role.py b/tests/gatt/characteristics/test_characteristic_role.py index 832e528e..b29a202e 100644 --- a/tests/gatt/characteristics/test_characteristic_role.py +++ b/tests/gatt/characteristics/test_characteristic_role.py @@ -15,8 +15,7 @@ from bluetooth_sig.gatt.characteristics.base import BaseCharacteristic from bluetooth_sig.gatt.characteristics.registry import CharacteristicRegistry from bluetooth_sig.gatt.characteristics.role_classifier import ( - _spec_has_unit_fields, - _spec_is_multi_field_measurement, + _spec_has_physical_field_units, classify_role, ) from bluetooth_sig.types.gatt_enums import CharacteristicRole @@ -71,9 +70,6 @@ class TestMeasurementRole: "Activity Goal", # Rule 6: compound type "Acceleration 3D", - "Appearance", - "PnP ID", - "System ID", ], ) def test_measurement_characteristics(self, sig_name: str) -> None: @@ -154,11 +150,13 @@ class TestStatusRole: @pytest.mark.parametrize( "sig_name", [ + "Alert Level", "Alert Status", "Unread Alert Status", "Battery Level Status", "Battery Critical Status", "Acceleration Detection Status", + "Boolean", ], ) def test_status_characteristics(self, sig_name: str) -> None: @@ -187,6 +185,10 @@ class TestInfoRole: "Email Address", "First Name", "Last Name", + # Compound info: struct type with _manual_role=INFO + "Appearance", + "PnP ID", + "System ID", ], ) def test_info_characteristics(self, sig_name: str) -> None: @@ -219,26 +221,6 @@ def test_feature_bitfield_type_beats_unknown(self) -> None: 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 - "Boolean", # BOOL, 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 # --------------------------------------------------------------------------- @@ -308,10 +290,12 @@ def test_multi_field_with_unit_fields_is_measurement(self) -> None: """A multi-field spec with per-field units → MEASUREMENT, even with python_type=None and unit=''. """ - spec = _make_spec([ - _make_field("Heart Rate", description="Unit: org.bluetooth.unit.period.beats_per_minute"), - _make_field("Energy Expended", description="Unit: org.bluetooth.unit.energy.joule"), - ]) + spec = _make_spec( + [ + _make_field("Heart Rate", description="Unit: org.bluetooth.unit.period.beats_per_minute"), + _make_field("Energy Expended", description="Unit: org.bluetooth.unit.energy.joule"), + ] + ) result = classify_role("Some Sensor", None, False, "", spec) assert result == CharacteristicRole.MEASUREMENT @@ -319,43 +303,51 @@ def test_multi_field_without_units_is_not_measurement(self) -> None: """A multi-field spec where NO field has a unit_id should not trigger the multi-field measurement rule. """ - spec = _make_spec([ - _make_field("Flags", description="Flags field"), - _make_field("Opcode", description="Control opcode"), - ]) + spec = _make_spec( + [ + _make_field("Flags", description="Flags field"), + _make_field("Opcode", description="Control opcode"), + ] + ) result = classify_role("Some Thing", None, False, "", spec) assert result == CharacteristicRole.UNKNOWN - def test_single_field_with_unit_does_not_trigger_multi_field_rule(self) -> None: - """A single-field spec should not be matched by the multi-field rule; - it falls through to other heuristics. + def test_single_field_with_unit_is_measurement(self) -> None: + """A single-field spec with a physical unit triggers rule 3 + (_spec_has_physical_field_units) → MEASUREMENT. """ - spec = _make_spec([ - _make_field("Temperature", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), - ]) - # With python_type=None and unit='', rule 4 doesn't fire, - # rule 5 requires >1 field → falls to UNKNOWN + spec = _make_spec( + [ + _make_field( + "Temperature", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius" + ), + ] + ) result = classify_role("Custom Temp", None, False, "", spec) - assert result == CharacteristicRole.UNKNOWN + assert result == CharacteristicRole.MEASUREMENT def test_multi_field_with_python_type_none(self) -> None: """After the python_type pollution fix, multi-field chars arrive with python_type=None. Should still be MEASUREMENT via rule 5. """ - spec = _make_spec([ - _make_field("Speed", description="Unit: org.bluetooth.unit.velocity.metres_per_second"), - _make_field("Distance", description="Unit: org.bluetooth.unit.length.metre"), - _make_field("Position Status", description="Status flags"), - ]) + spec = _make_spec( + [ + _make_field("Speed", description="Unit: org.bluetooth.unit.velocity.metres_per_second"), + _make_field("Distance", description="Unit: org.bluetooth.unit.length.metre"), + _make_field("Position Status", description="Status flags"), + ] + ) result = classify_role("Location and Speed", None, False, "", spec) assert result == CharacteristicRole.MEASUREMENT def test_name_based_rule_takes_priority_over_multi_field(self) -> None: """Rule 3 ('Measurement' in name) fires before rule 5.""" - spec = _make_spec([ - _make_field("Systolic", description="Unit: org.bluetooth.unit.pressure.pascal"), - _make_field("Diastolic", description="Unit: org.bluetooth.unit.pressure.pascal"), - ]) + spec = _make_spec( + [ + _make_field("Systolic", description="Unit: org.bluetooth.unit.pressure.pascal"), + _make_field("Diastolic", description="Unit: org.bluetooth.unit.pressure.pascal"), + ] + ) result = classify_role("Blood Pressure Measurement", None, False, "", spec) assert result == CharacteristicRole.MEASUREMENT @@ -366,46 +358,45 @@ def test_name_based_rule_takes_priority_over_multi_field(self) -> None: class TestSpecHelpers: - """Tests for _spec_is_multi_field_measurement and _spec_has_unit_fields.""" + """Tests for _spec_has_physical_field_units.""" - def test_spec_is_multi_field_none_spec(self) -> None: - assert _spec_is_multi_field_measurement(None) is False + def test_spec_has_physical_field_units_none_spec(self) -> None: + assert _spec_has_physical_field_units(None) is False - def test_spec_is_multi_field_empty_structure(self) -> None: + def test_spec_has_physical_field_units_empty_structure(self) -> None: spec = _make_spec([]) - assert _spec_is_multi_field_measurement(spec) is False - - def test_spec_is_multi_field_single_field(self) -> None: - spec = _make_spec([ - _make_field("Temp", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), - ]) - assert _spec_is_multi_field_measurement(spec) is False - - def test_spec_is_multi_field_two_fields_with_units(self) -> None: - spec = _make_spec([ - _make_field("HR", description="Unit: org.bluetooth.unit.period.beats_per_minute"), - _make_field("Energy", description="Unit: org.bluetooth.unit.energy.joule"), - ]) - assert _spec_is_multi_field_measurement(spec) is True - - def test_spec_is_multi_field_two_fields_no_units(self) -> None: - spec = _make_spec([ - _make_field("Flags", description="Flags field"), - _make_field("Value", description="Some value"), - ]) - assert _spec_is_multi_field_measurement(spec) is False - - def test_spec_has_unit_fields_none_spec(self) -> None: - assert _spec_has_unit_fields(None) is False - - def test_spec_has_unit_fields_with_unit(self) -> None: - spec = _make_spec([ - _make_field("Temp", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), - ]) - assert _spec_has_unit_fields(spec) is True - - def test_spec_has_unit_fields_without_unit(self) -> None: - spec = _make_spec([ - _make_field("Flags", description="Control flags"), - ]) - assert _spec_has_unit_fields(spec) is False + assert _spec_has_physical_field_units(spec) is False + + def test_spec_has_physical_field_units_with_unit(self) -> None: + spec = _make_spec( + [ + _make_field("Temp", description="Unit: org.bluetooth.unit.thermodynamic_temperature.degree_celsius"), + ] + ) + assert _spec_has_physical_field_units(spec) is True + + def test_spec_has_physical_field_units_without_unit(self) -> None: + spec = _make_spec( + [ + _make_field("Flags", description="Control flags"), + ] + ) + assert _spec_has_physical_field_units(spec) is False + + def test_spec_has_physical_field_units_two_fields_with_units(self) -> None: + spec = _make_spec( + [ + _make_field("HR", description="Unit: org.bluetooth.unit.period.beats_per_minute"), + _make_field("Energy", description="Unit: org.bluetooth.unit.energy.joule"), + ] + ) + assert _spec_has_physical_field_units(spec) is True + + def test_spec_has_physical_field_units_two_fields_no_units(self) -> None: + spec = _make_spec( + [ + _make_field("Flags", description="Flags field"), + _make_field("Value", description="Some value"), + ] + ) + assert _spec_has_physical_field_units(spec) is False diff --git a/tests/gatt/characteristics/test_chromaticity_coordinates.py b/tests/gatt/characteristics/test_chromaticity_coordinates.py index 8f88b0dc..87a900be 100644 --- a/tests/gatt/characteristics/test_chromaticity_coordinates.py +++ b/tests/gatt/characteristics/test_chromaticity_coordinates.py @@ -23,7 +23,7 @@ def expected_uuid(self) -> str: return "2AE4" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py b/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py index 1c370cc6..01477498 100644 --- a/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py +++ b/tests/gatt/characteristics/test_chromaticity_in_cct_and_duv_values.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AE5" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_day_of_week.py b/tests/gatt/characteristics/test_day_of_week.py index 697dc811..178233e9 100644 --- a/tests/gatt/characteristics/test_day_of_week.py +++ b/tests/gatt/characteristics/test_day_of_week.py @@ -5,6 +5,7 @@ import pytest from bluetooth_sig.gatt.characteristics import DayOfWeekCharacteristic +from bluetooth_sig.types.gatt_enums import DayOfWeek from tests.gatt.characteristics.test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests @@ -25,27 +26,29 @@ def expected_uuid(self) -> str: def valid_test_data(self) -> list[CharacteristicTestData]: """Return valid test data for day of week.""" return [ - CharacteristicTestData(input_data=bytearray([1]), expected_value=1, description="Monday"), - CharacteristicTestData(input_data=bytearray([5]), expected_value=5, description="Friday"), - CharacteristicTestData(input_data=bytearray([7]), expected_value=7, description="Sunday"), + CharacteristicTestData(input_data=bytearray([1]), expected_value=DayOfWeek.MONDAY, description="Monday"), + CharacteristicTestData(input_data=bytearray([5]), expected_value=DayOfWeek.FRIDAY, description="Friday"), + CharacteristicTestData(input_data=bytearray([7]), expected_value=DayOfWeek.SUNDAY, description="Sunday"), ] def test_monday(self) -> None: """Test Monday (day 1).""" char = DayOfWeekCharacteristic() result = char.parse_value(bytearray([1])) - assert result == 1 + assert result == DayOfWeek.MONDAY def test_sunday(self) -> None: """Test Sunday (day 7).""" char = DayOfWeekCharacteristic() result = char.parse_value(bytearray([7])) - assert result == 7 + assert result == DayOfWeek.SUNDAY def test_custom_round_trip(self) -> None: """Test encoding and decoding preserve values.""" char = DayOfWeekCharacteristic() - for day in range(1, 8): # Days 1-7 + for day in DayOfWeek: + if day is DayOfWeek.UNKNOWN: + continue encoded = char.build_value(day) decoded = char.parse_value(encoded) assert decoded == day diff --git a/tests/gatt/characteristics/test_device_name.py b/tests/gatt/characteristics/test_device_name.py index d92258ee..4056e95e 100644 --- a/tests/gatt/characteristics/test_device_name.py +++ b/tests/gatt/characteristics/test_device_name.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A00" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"My Device"), expected_value="My Device", description="Simple ASCII device name" diff --git a/tests/gatt/characteristics/test_electric_current.py b/tests/gatt/characteristics/test_electric_current.py index 39ea4681..8be18a67 100644 --- a/tests/gatt/characteristics/test_electric_current.py +++ b/tests/gatt/characteristics/test_electric_current.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AEE" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00]), # 0 A diff --git a/tests/gatt/characteristics/test_electric_current_range.py b/tests/gatt/characteristics/test_electric_current_range.py index 9782b738..4df966ef 100644 --- a/tests/gatt/characteristics/test_electric_current_range.py +++ b/tests/gatt/characteristics/test_electric_current_range.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2AEF" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), # 0.00 A, 0.00 A diff --git a/tests/gatt/characteristics/test_electric_current_specification.py b/tests/gatt/characteristics/test_electric_current_specification.py index 35d168ee..73c8b98a 100644 --- a/tests/gatt/characteristics/test_electric_current_specification.py +++ b/tests/gatt/characteristics/test_electric_current_specification.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2AF0" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), # 0.00 A, 0.00 A diff --git a/tests/gatt/characteristics/test_electric_current_statistics.py b/tests/gatt/characteristics/test_electric_current_statistics.py index 43821b53..069814e6 100644 --- a/tests/gatt/characteristics/test_electric_current_statistics.py +++ b/tests/gatt/characteristics/test_electric_current_statistics.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2AF1" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), # 0.00 A, 0.00 A, 0.00 A diff --git a/tests/gatt/characteristics/test_firmware_revision_string.py b/tests/gatt/characteristics/test_firmware_revision_string.py index d05ecfb0..fcb8ed4c 100644 --- a/tests/gatt/characteristics/test_firmware_revision_string.py +++ b/tests/gatt/characteristics/test_firmware_revision_string.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A26" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"1.2.3"), expected_value="1.2.3", description="Standard version format" diff --git a/tests/gatt/characteristics/test_force.py b/tests/gatt/characteristics/test_force.py index 3962c994..17c1c95b 100644 --- a/tests/gatt/characteristics/test_force.py +++ b/tests/gatt/characteristics/test_force.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2C07" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid force test data covering various forces and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_hardware_revision_string.py b/tests/gatt/characteristics/test_hardware_revision_string.py index ce34db8c..e3e1acfb 100644 --- a/tests/gatt/characteristics/test_hardware_revision_string.py +++ b/tests/gatt/characteristics/test_hardware_revision_string.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A27" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"Rev 1.0"), expected_value="Rev 1.0", description="Standard hardware revision" diff --git a/tests/gatt/characteristics/test_heart_rate_control_point.py b/tests/gatt/characteristics/test_heart_rate_control_point.py index 3c4189fb..d7662829 100644 --- a/tests/gatt/characteristics/test_heart_rate_control_point.py +++ b/tests/gatt/characteristics/test_heart_rate_control_point.py @@ -5,6 +5,7 @@ import pytest from bluetooth_sig.gatt.characteristics import HeartRateControlPointCharacteristic +from bluetooth_sig.gatt.characteristics.heart_rate_control_point import HeartRateControlCommand from tests.gatt.characteristics.test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests @@ -25,25 +26,33 @@ def expected_uuid(self) -> str: def valid_test_data(self) -> list[CharacteristicTestData]: """Return valid test data for heart rate control point.""" return [ - CharacteristicTestData(input_data=bytearray([1]), expected_value=1, description="Reset Energy Expended"), - CharacteristicTestData(input_data=bytearray([0]), expected_value=0, description="Reserved value"), + CharacteristicTestData( + input_data=bytearray([1]), + expected_value=HeartRateControlCommand.RESET_ENERGY_EXPENDED, + description="Reset Energy Expended", + ), + CharacteristicTestData( + input_data=bytearray([0]), + expected_value=HeartRateControlCommand.RESERVED, + description="Reserved value", + ), ] def test_reset_energy_expended(self) -> None: """Test reset energy expended command.""" char = HeartRateControlPointCharacteristic() result = char.parse_value(bytearray([1])) - assert result == 1 + assert result == HeartRateControlCommand.RESET_ENERGY_EXPENDED def test_encode_reset_command(self) -> None: """Test encoding reset command.""" char = HeartRateControlPointCharacteristic() - encoded = char.build_value(1) + encoded = char.build_value(HeartRateControlCommand.RESET_ENERGY_EXPENDED) assert encoded == bytearray([1]) def test_custom_round_trip(self) -> None: """Test encoding and decoding preserve values.""" char = HeartRateControlPointCharacteristic() - encoded = char.build_value(1) + encoded = char.build_value(HeartRateControlCommand.RESET_ENERGY_EXPENDED) decoded = char.parse_value(encoded) - assert decoded == 1 + assert decoded == HeartRateControlCommand.RESET_ENERGY_EXPENDED diff --git a/tests/gatt/characteristics/test_heart_rate_measurement.py b/tests/gatt/characteristics/test_heart_rate_measurement.py index d5d9a3cf..eca9e99c 100644 --- a/tests/gatt/characteristics/test_heart_rate_measurement.py +++ b/tests/gatt/characteristics/test_heart_rate_measurement.py @@ -27,7 +27,7 @@ def expected_uuid(self) -> str: return "2A37" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x50]), # Flags=0, HR=80 BPM (8-bit) diff --git a/tests/gatt/characteristics/test_high_voltage.py b/tests/gatt/characteristics/test_high_voltage.py index aa70eb7a..562bcd01 100644 --- a/tests/gatt/characteristics/test_high_voltage.py +++ b/tests/gatt/characteristics/test_high_voltage.py @@ -21,7 +21,7 @@ def expected_uuid(self) -> str: return "2BE0" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00]), # 0 V diff --git a/tests/gatt/characteristics/test_illuminance.py b/tests/gatt/characteristics/test_illuminance.py index bb229174..6ef269bb 100644 --- a/tests/gatt/characteristics/test_illuminance.py +++ b/tests/gatt/characteristics/test_illuminance.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AFB" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00]), # 0 lux diff --git a/tests/gatt/characteristics/test_linear_position.py b/tests/gatt/characteristics/test_linear_position.py index 8f3adf05..30adddc2 100644 --- a/tests/gatt/characteristics/test_linear_position.py +++ b/tests/gatt/characteristics/test_linear_position.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2C08" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid linear position test data covering various positions and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_location_name.py b/tests/gatt/characteristics/test_location_name.py index eb58f9d8..62bfdc81 100644 --- a/tests/gatt/characteristics/test_location_name.py +++ b/tests/gatt/characteristics/test_location_name.py @@ -106,11 +106,11 @@ def test_location_name_round_trip(self, characteristic: LocationNameCharacterist def test_round_trip( self, characteristic: BaseCharacteristic[Any], - valid_test_data: CharacteristicTestData | list[CharacteristicTestData], + valid_test_data: list[CharacteristicTestData], ) -> None: """Override round trip test to exclude null-terminated strings with extra data.""" # Use only the first 3 test cases that can round trip exactly - round_trip_data = valid_test_data[:3] if isinstance(valid_test_data, list) else [valid_test_data] + round_trip_data = valid_test_data[:3] for i, test_case in enumerate(round_trip_data): case_desc = f"Test case {i + 1} ({test_case.description})" diff --git a/tests/gatt/characteristics/test_luminous_flux_range.py b/tests/gatt/characteristics/test_luminous_flux_range.py index 98030c90..9cdb4227 100644 --- a/tests/gatt/characteristics/test_luminous_flux_range.py +++ b/tests/gatt/characteristics/test_luminous_flux_range.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2B00" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_magnetic_flux_density_2d.py b/tests/gatt/characteristics/test_magnetic_flux_density_2d.py index 3352ad2a..b3b465f9 100644 --- a/tests/gatt/characteristics/test_magnetic_flux_density_2d.py +++ b/tests/gatt/characteristics/test_magnetic_flux_density_2d.py @@ -28,7 +28,7 @@ def expected_uuid(self) -> str: return "2AA0" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid magnetic flux density 2D test data.""" return [ CharacteristicTestData( @@ -57,7 +57,7 @@ def test_magnetic_flux_density_2d_parsing(self, characteristic: BaseCharacterist """Test Magnetic Flux Density 2D characteristic parsing.""" # Test metadata assert characteristic.unit == "T" - assert characteristic.python_type is str + assert characteristic.python_type is Vector2DData # Test normal parsing: X=1000, Y=-500 (in 10^-7 Tesla units) test_data = bytearray(struct.pack(" str: return "2A29" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"Apple Inc."), expected_value="Apple Inc.", description="Apple manufacturer name" diff --git a/tests/gatt/characteristics/test_model_number_string.py b/tests/gatt/characteristics/test_model_number_string.py index 9117a3ad..f5db82f7 100644 --- a/tests/gatt/characteristics/test_model_number_string.py +++ b/tests/gatt/characteristics/test_model_number_string.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A24" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"iPhone 15 Pro"), diff --git a/tests/gatt/characteristics/test_noise.py b/tests/gatt/characteristics/test_noise.py index 1100e62f..d2f76a63 100644 --- a/tests/gatt/characteristics/test_noise.py +++ b/tests/gatt/characteristics/test_noise.py @@ -21,7 +21,7 @@ def expected_uuid(self) -> str: return "2BE4" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00]), # 0 dB diff --git a/tests/gatt/characteristics/test_plx_features.py b/tests/gatt/characteristics/test_plx_features.py index 7cb144ca..815b4197 100644 --- a/tests/gatt/characteristics/test_plx_features.py +++ b/tests/gatt/characteristics/test_plx_features.py @@ -27,7 +27,7 @@ def expected_uuid(self) -> str: return "2A60" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_pollen_concentration.py b/tests/gatt/characteristics/test_pollen_concentration.py index e7293668..5d5634d3 100644 --- a/tests/gatt/characteristics/test_pollen_concentration.py +++ b/tests/gatt/characteristics/test_pollen_concentration.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2A75" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid pollen concentration test data.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_power_specification.py b/tests/gatt/characteristics/test_power_specification.py index 388f5b96..ea05bc22 100644 --- a/tests/gatt/characteristics/test_power_specification.py +++ b/tests/gatt/characteristics/test_power_specification.py @@ -24,7 +24,7 @@ def expected_uuid(self) -> str: return "2B06" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid power specification test data covering various powers and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_pressure.py b/tests/gatt/characteristics/test_pressure.py index 1b3e39b5..5297ccc9 100644 --- a/tests/gatt/characteristics/test_pressure.py +++ b/tests/gatt/characteristics/test_pressure.py @@ -20,7 +20,7 @@ def expected_uuid(self) -> str: return "2A6D" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_pulse_oximetry_measurement.py b/tests/gatt/characteristics/test_pulse_oximetry_measurement.py index d60c8f32..df90e940 100644 --- a/tests/gatt/characteristics/test_pulse_oximetry_measurement.py +++ b/tests/gatt/characteristics/test_pulse_oximetry_measurement.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A5F" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: from datetime import datetime return [ diff --git a/tests/gatt/characteristics/test_rotational_speed.py b/tests/gatt/characteristics/test_rotational_speed.py index ea864153..49c8de00 100644 --- a/tests/gatt/characteristics/test_rotational_speed.py +++ b/tests/gatt/characteristics/test_rotational_speed.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2C09" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid rotational speed test data covering various speeds and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_rsc_feature.py b/tests/gatt/characteristics/test_rsc_feature.py index 276a5b1f..e62a1c77 100644 --- a/tests/gatt/characteristics/test_rsc_feature.py +++ b/tests/gatt/characteristics/test_rsc_feature.py @@ -21,7 +21,7 @@ def expected_uuid(self) -> str: return "2A54" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00]), # No features supported diff --git a/tests/gatt/characteristics/test_serial_number_string.py b/tests/gatt/characteristics/test_serial_number_string.py index 72b1c04e..59898cec 100644 --- a/tests/gatt/characteristics/test_serial_number_string.py +++ b/tests/gatt/characteristics/test_serial_number_string.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A25" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"F8L123456789"), @@ -43,7 +43,7 @@ def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestDat ] @pytest.fixture - def invalid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def invalid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"\xff\xfe\xfd"), # Invalid UTF-8 sequence diff --git a/tests/gatt/characteristics/test_software_revision_string.py b/tests/gatt/characteristics/test_software_revision_string.py index 9e4f788d..57deb9f3 100644 --- a/tests/gatt/characteristics/test_software_revision_string.py +++ b/tests/gatt/characteristics/test_software_revision_string.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A28" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray(b"3.1.4"), expected_value="3.1.4", description="Standard software version" diff --git a/tests/gatt/characteristics/test_supported_heart_rate_range.py b/tests/gatt/characteristics/test_supported_heart_rate_range.py index 74c219f1..5513cb59 100644 --- a/tests/gatt/characteristics/test_supported_heart_rate_range.py +++ b/tests/gatt/characteristics/test_supported_heart_rate_range.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AD7" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_supported_inclination_range.py b/tests/gatt/characteristics/test_supported_inclination_range.py index 57635e43..ebb27720 100644 --- a/tests/gatt/characteristics/test_supported_inclination_range.py +++ b/tests/gatt/characteristics/test_supported_inclination_range.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AD5" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_supported_power_range.py b/tests/gatt/characteristics/test_supported_power_range.py index 80b8e72a..42b9b411 100644 --- a/tests/gatt/characteristics/test_supported_power_range.py +++ b/tests/gatt/characteristics/test_supported_power_range.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AD8" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), # Min=0W, Max=0W diff --git a/tests/gatt/characteristics/test_supported_resistance_level_range.py b/tests/gatt/characteristics/test_supported_resistance_level_range.py index 5fecf5ea..4960848d 100644 --- a/tests/gatt/characteristics/test_supported_resistance_level_range.py +++ b/tests/gatt/characteristics/test_supported_resistance_level_range.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2AD6" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_supported_speed_range.py b/tests/gatt/characteristics/test_supported_speed_range.py index b72644c7..5afb56fb 100644 --- a/tests/gatt/characteristics/test_supported_speed_range.py +++ b/tests/gatt/characteristics/test_supported_speed_range.py @@ -20,7 +20,7 @@ def expected_uuid(self) -> str: return "2AD4" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_temperature.py b/tests/gatt/characteristics/test_temperature.py index 58024765..f179a42a 100644 --- a/tests/gatt/characteristics/test_temperature.py +++ b/tests/gatt/characteristics/test_temperature.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2A6E" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: """Valid temperature test data covering various temperatures and edge cases.""" return [ CharacteristicTestData( diff --git a/tests/gatt/characteristics/test_temperature_range.py b/tests/gatt/characteristics/test_temperature_range.py index c87cfa9b..0f8ac47c 100644 --- a/tests/gatt/characteristics/test_temperature_range.py +++ b/tests/gatt/characteristics/test_temperature_range.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2B10" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), diff --git a/tests/gatt/characteristics/test_temperature_type.py b/tests/gatt/characteristics/test_temperature_type.py index b173f701..77649eee 100644 --- a/tests/gatt/characteristics/test_temperature_type.py +++ b/tests/gatt/characteristics/test_temperature_type.py @@ -5,6 +5,7 @@ import pytest from bluetooth_sig.gatt.characteristics import TemperatureTypeCharacteristic +from bluetooth_sig.gatt.characteristics.temperature_type import TemperatureType from tests.gatt.characteristics.test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests @@ -25,33 +26,51 @@ def expected_uuid(self) -> str: def valid_test_data(self) -> list[CharacteristicTestData]: """Return valid test data for temperature type.""" return [ - CharacteristicTestData(input_data=bytearray([1]), expected_value=1, description="Armpit"), - CharacteristicTestData(input_data=bytearray([2]), expected_value=2, description="Body (general)"), - CharacteristicTestData(input_data=bytearray([3]), expected_value=3, description="Ear"), - CharacteristicTestData(input_data=bytearray([4]), expected_value=4, description="Finger"), - CharacteristicTestData(input_data=bytearray([5]), expected_value=5, description="Gastro-intestinal Tract"), - CharacteristicTestData(input_data=bytearray([6]), expected_value=6, description="Mouth"), - CharacteristicTestData(input_data=bytearray([7]), expected_value=7, description="Rectum"), - CharacteristicTestData(input_data=bytearray([8]), expected_value=8, description="Toe"), - CharacteristicTestData(input_data=bytearray([9]), expected_value=9, description="Tympanum (ear drum)"), + CharacteristicTestData( + input_data=bytearray([1]), expected_value=TemperatureType.ARMPIT, description="Armpit" + ), + CharacteristicTestData( + input_data=bytearray([2]), expected_value=TemperatureType.BODY_GENERAL, description="Body (general)" + ), + CharacteristicTestData(input_data=bytearray([3]), expected_value=TemperatureType.EAR, description="Ear"), + CharacteristicTestData( + input_data=bytearray([4]), expected_value=TemperatureType.FINGER, description="Finger" + ), + CharacteristicTestData( + input_data=bytearray([5]), + expected_value=TemperatureType.GASTROINTESTINAL_TRACT, + description="Gastro-intestinal Tract", + ), + CharacteristicTestData( + input_data=bytearray([6]), expected_value=TemperatureType.MOUTH, description="Mouth" + ), + CharacteristicTestData( + input_data=bytearray([7]), expected_value=TemperatureType.RECTUM, description="Rectum" + ), + CharacteristicTestData(input_data=bytearray([8]), expected_value=TemperatureType.TOE, description="Toe"), + CharacteristicTestData( + input_data=bytearray([9]), expected_value=TemperatureType.TYMPANUM, description="Tympanum (ear drum)" + ), ] def test_armpit_type(self) -> None: """Test armpit temperature type.""" char = TemperatureTypeCharacteristic() result = char.parse_value(bytearray([1])) - assert result == 1 + assert result == TemperatureType.ARMPIT def test_mouth_type(self) -> None: """Test mouth temperature type.""" char = TemperatureTypeCharacteristic() result = char.parse_value(bytearray([6])) - assert result == 6 + assert result == TemperatureType.MOUTH def test_custom_round_trip(self) -> None: """Test encoding and decoding preserve values.""" char = TemperatureTypeCharacteristic() - for temp_type in range(1, 10): + for temp_type in TemperatureType: + if temp_type is TemperatureType.RESERVED_0: + continue encoded = char.build_value(temp_type) decoded = char.parse_value(encoded) assert decoded == temp_type diff --git a/tests/gatt/characteristics/test_time_source.py b/tests/gatt/characteristics/test_time_source.py index 3de7deed..6710cad3 100644 --- a/tests/gatt/characteristics/test_time_source.py +++ b/tests/gatt/characteristics/test_time_source.py @@ -5,6 +5,7 @@ import pytest from bluetooth_sig.gatt.characteristics import TimeSourceCharacteristic +from bluetooth_sig.gatt.characteristics.time_source import TimeSource from tests.gatt.characteristics.test_characteristic_common import CharacteristicTestData, CommonCharacteristicTests @@ -25,37 +26,47 @@ def expected_uuid(self) -> str: def valid_test_data(self) -> list[CharacteristicTestData]: """Return valid test data for time source.""" return [ - CharacteristicTestData(input_data=bytearray([0]), expected_value=0, description="Unknown"), - CharacteristicTestData(input_data=bytearray([1]), expected_value=1, description="Network Time Protocol"), - CharacteristicTestData(input_data=bytearray([2]), expected_value=2, description="GPS"), - CharacteristicTestData(input_data=bytearray([3]), expected_value=3, description="Radio Time Signal"), - CharacteristicTestData(input_data=bytearray([4]), expected_value=4, description="Manual"), - CharacteristicTestData(input_data=bytearray([5]), expected_value=5, description="Atomic Clock"), - CharacteristicTestData(input_data=bytearray([6]), expected_value=6, description="Cellular Network"), + CharacteristicTestData(input_data=bytearray([0]), expected_value=TimeSource.UNKNOWN, description="Unknown"), + CharacteristicTestData( + input_data=bytearray([1]), + expected_value=TimeSource.NETWORK_TIME_PROTOCOL, + description="Network Time Protocol", + ), + CharacteristicTestData(input_data=bytearray([2]), expected_value=TimeSource.GPS, description="GPS"), + CharacteristicTestData( + input_data=bytearray([3]), expected_value=TimeSource.RADIO_TIME_SIGNAL, description="Radio Time Signal" + ), + CharacteristicTestData(input_data=bytearray([4]), expected_value=TimeSource.MANUAL, description="Manual"), + CharacteristicTestData( + input_data=bytearray([5]), expected_value=TimeSource.ATOMIC_CLOCK, description="Atomic Clock" + ), + CharacteristicTestData( + input_data=bytearray([6]), expected_value=TimeSource.CELLULAR_NETWORK, description="Cellular Network" + ), ] def test_unknown_source(self) -> None: """Test unknown time source.""" char = TimeSourceCharacteristic() result = char.parse_value(bytearray([0])) - assert result == 0 + assert result == TimeSource.UNKNOWN def test_gps_source(self) -> None: """Test GPS time source.""" char = TimeSourceCharacteristic() result = char.parse_value(bytearray([2])) - assert result == 2 + assert result == TimeSource.GPS def test_atomic_clock_source(self) -> None: """Test atomic clock time source.""" char = TimeSourceCharacteristic() result = char.parse_value(bytearray([5])) - assert result == 5 + assert result == TimeSource.ATOMIC_CLOCK def test_custom_round_trip(self) -> None: """Test encoding and decoding preserve values.""" char = TimeSourceCharacteristic() - for source in range(7): + for source in TimeSource: encoded = char.build_value(source) decoded = char.parse_value(encoded) assert decoded == source diff --git a/tests/gatt/characteristics/test_tx_power_level.py b/tests/gatt/characteristics/test_tx_power_level.py index 8f13ebc7..9313834f 100644 --- a/tests/gatt/characteristics/test_tx_power_level.py +++ b/tests/gatt/characteristics/test_tx_power_level.py @@ -22,7 +22,7 @@ def expected_uuid(self) -> str: return "2A07" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00]), # 0 dBm diff --git a/tests/gatt/characteristics/test_voltage_frequency.py b/tests/gatt/characteristics/test_voltage_frequency.py index 0db6c5b5..d26bcec9 100644 --- a/tests/gatt/characteristics/test_voltage_frequency.py +++ b/tests/gatt/characteristics/test_voltage_frequency.py @@ -21,7 +21,7 @@ def expected_uuid(self) -> str: return "2BE8" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00]), # 0 Hz diff --git a/tests/gatt/characteristics/test_voltage_specification.py b/tests/gatt/characteristics/test_voltage_specification.py index a8e8c644..6b9644fc 100644 --- a/tests/gatt/characteristics/test_voltage_specification.py +++ b/tests/gatt/characteristics/test_voltage_specification.py @@ -18,7 +18,7 @@ def expected_uuid(self) -> str: return "2B19" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), # 0.00 V, 0.00 V diff --git a/tests/gatt/characteristics/test_voltage_statistics.py b/tests/gatt/characteristics/test_voltage_statistics.py index 6fa248c3..dce28f0f 100644 --- a/tests/gatt/characteristics/test_voltage_statistics.py +++ b/tests/gatt/characteristics/test_voltage_statistics.py @@ -21,7 +21,7 @@ def expected_uuid(self) -> str: return "2B1A" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), # All zeros diff --git a/tests/gatt/characteristics/test_weight_scale_feature.py b/tests/gatt/characteristics/test_weight_scale_feature.py index 6fdd65cf..64693a59 100644 --- a/tests/gatt/characteristics/test_weight_scale_feature.py +++ b/tests/gatt/characteristics/test_weight_scale_feature.py @@ -26,7 +26,7 @@ def expected_uuid(self) -> str: return "2A9E" @pytest.fixture - def valid_test_data(self) -> CharacteristicTestData | list[CharacteristicTestData]: + def valid_test_data(self) -> list[CharacteristicTestData]: return [ CharacteristicTestData( input_data=bytearray([0x00, 0x00, 0x00, 0x00]), # No features diff --git a/tests/utils/test_prewarm.py b/tests/utils/test_prewarm.py new file mode 100644 index 00000000..fe82c35a --- /dev/null +++ b/tests/utils/test_prewarm.py @@ -0,0 +1,33 @@ +"""Tests for bluetooth_sig.utils.prewarm — prewarm_registries.""" + +from __future__ import annotations + +from bluetooth_sig.utils.prewarm import prewarm_registries + + +class TestPrewarmRegistries: + """Tests for prewarm_registries().""" + + def test_prewarm_runs_without_error(self) -> None: + """Calling prewarm_registries should not raise.""" + prewarm_registries() + + def test_prewarm_is_idempotent(self) -> None: + """Calling prewarm_registries twice should not raise.""" + prewarm_registries() + prewarm_registries() + + def test_registries_populated_after_prewarm(self) -> None: + """After pre-warming, characteristic and service registries are populated.""" + from bluetooth_sig.gatt.characteristics.registry import ( + CharacteristicRegistry, + ) + from bluetooth_sig.gatt.services.registry import GattServiceRegistry + + prewarm_registries() + + chars = CharacteristicRegistry.get_all_characteristics() + assert len(chars) > 0 + + services = GattServiceRegistry.get_all_services() + assert len(services) > 0 diff --git a/tests/utils/test_values.py b/tests/utils/test_values.py new file mode 100644 index 00000000..bf87c390 --- /dev/null +++ b/tests/utils/test_values.py @@ -0,0 +1,151 @@ +"""Tests for bluetooth_sig.utils.values — to_primitive and is_struct_value.""" + +from __future__ import annotations + +import enum +from datetime import datetime, timedelta + +import msgspec +import pytest + +from bluetooth_sig.utils.values import is_struct_value, to_primitive + +# ── Test fixtures ──────────────────────────────────────────────────────────── + + +class _Colour(enum.Enum): + RED = 1 + GREEN = 2 + + +class _Sensor(enum.IntEnum): + TEMPERATURE = 0 + HUMIDITY = 1 + + +class _Flags(enum.IntFlag): + READ = 1 + WRITE = 2 + NOTIFY = 4 + + +class _SampleStruct(msgspec.Struct): + name: str + value: int + + +class _ObjectWithName: + """Non-enum object that happens to have a .name attribute.""" + + name: str = "should_not_match" + + +# ── is_struct_value ────────────────────────────────────────────────────────── + + +class TestIsStructValue: + """Tests for is_struct_value().""" + + def test_returns_true_for_struct(self) -> None: + assert is_struct_value(_SampleStruct(name="x", value=1)) is True + + def test_returns_false_for_dict(self) -> None: + assert is_struct_value({"name": "x", "value": 1}) is False + + def test_returns_false_for_none(self) -> None: + assert is_struct_value(None) is False + + def test_returns_false_for_primitive(self) -> None: + assert is_struct_value(42) is False + assert is_struct_value("hello") is False + + def test_returns_false_for_dataclass_like(self) -> None: + assert is_struct_value(_ObjectWithName()) is False + + +# ── to_primitive ───────────────────────────────────────────────────────────── + + +class TestToPrimitive: + """Tests for to_primitive().""" + + # ── bool (must come before int) ────────────────────────────────────── + + def test_bool_true(self) -> None: + assert to_primitive(True) is True + + def test_bool_false(self) -> None: + assert to_primitive(False) is False + + # ── IntFlag → int ──────────────────────────────────────────────────── + + def test_intflag_single(self) -> None: + result = to_primitive(_Flags.READ) + assert result == 1 + assert isinstance(result, int) + + def test_intflag_combined(self) -> None: + result = to_primitive(_Flags.READ | _Flags.WRITE) + assert result == 3 + assert isinstance(result, int) + + # ── Enum / IntEnum → name string ───────────────────────────────────── + + def test_enum_returns_name(self) -> None: + assert to_primitive(_Colour.RED) == "RED" + + def test_intenum_returns_name(self) -> None: + assert to_primitive(_Sensor.HUMIDITY) == "HUMIDITY" + + # ── plain int ──────────────────────────────────────────────────────── + + def test_plain_int(self) -> None: + result = to_primitive(42) + assert result == 42 + assert type(result) is int + + def test_int_subclass_unwrapped(self) -> None: + """An int-subclass that is NOT an enum should still return plain int.""" + + class _CustomInt(int): + pass + + result = to_primitive(_CustomInt(7)) + assert result == 7 + assert type(result) is int + + # ── float ──────────────────────────────────────────────────────────── + + def test_float(self) -> None: + assert to_primitive(3.14) == pytest.approx(3.14) + + # ── str passthrough ────────────────────────────────────────────────── + + def test_str(self) -> None: + assert to_primitive("hello") == "hello" + + def test_empty_str(self) -> None: + assert to_primitive("") == "" + + # ── fallback to str() ──────────────────────────────────────────────── + + def test_datetime_falls_through(self) -> None: + dt = datetime(2026, 1, 1, 12, 0, 0) + assert to_primitive(dt) == str(dt) + + def test_timedelta_falls_through(self) -> None: + td = timedelta(seconds=90) + assert to_primitive(td) == str(td) + + def test_struct_falls_through(self) -> None: + s = _SampleStruct(name="x", value=1) + assert to_primitive(s) == str(s) + + # ── regression: object with .name must NOT be treated as enum ──────── + + def test_object_with_name_attr_not_treated_as_enum(self) -> None: + """Ensure non-enum objects with a .name attribute fall through to str().""" + obj = _ObjectWithName() + result = to_primitive(obj) + assert result == str(obj) + assert result != "should_not_match"