diff --git a/examples/thingy52/thingy52_characteristics.py b/examples/thingy52/thingy52_characteristics.py new file mode 100644 index 00000000..89e1a962 --- /dev/null +++ b/examples/thingy52/thingy52_characteristics.py @@ -0,0 +1,402 @@ +"""Nordic Thingy:52 vendor characteristic implementations. + +This module provides custom characteristic classes for Nordic Thingy:52 +environmental sensors, UI elements, and motion sensors. These use vendor-specific +UUIDs and data formats that differ from Bluetooth SIG standards. +""" + +from __future__ import annotations + +import msgspec + +from bluetooth_sig.gatt.characteristics.custom import CustomBaseCharacteristic +from bluetooth_sig.gatt.characteristics.utils.data_parser import DataParser +from bluetooth_sig.gatt.exceptions import InsufficientDataError, ValueRangeError +from bluetooth_sig.types import CharacteristicInfo +from bluetooth_sig.types.context import CharacteristicContext +from bluetooth_sig.types.gatt_enums import ValueType +from bluetooth_sig.types.uuid import BluetoothUUID + +# Nordic Thingy:52 UUID base +NORDIC_UUID_BASE = "EF680000-9B35-4933-9B10-52FFA9740042" + + +# Data structures for characteristic values +class ThingyTemperatureData(msgspec.Struct, frozen=True, kw_only=True): + """Temperature sensor data from Nordic Thingy:52. + + Attributes: + temperature_celsius: Temperature in degrees Celsius (integer.decimal format) + """ + + temperature_celsius: float + + +class ThingyPressureData(msgspec.Struct, frozen=True, kw_only=True): + """Pressure sensor data from Nordic Thingy:52. + + Attributes: + pressure_hpa: Pressure in hectopascals (integer.decimal format) + """ + + pressure_hpa: float + + +class ThingyHumidityData(msgspec.Struct, frozen=True, kw_only=True): + """Humidity sensor data from Nordic Thingy:52. + + Attributes: + humidity_percent: Humidity percentage (0-100) + """ + + humidity_percent: int + + +class ThingyGasData(msgspec.Struct, frozen=True, kw_only=True): + """Gas sensor data from Nordic Thingy:52. + + Attributes: + eco2_ppm: Equivalent CO2 concentration in ppm + tvoc_ppb: Total Volatile Organic Compounds in ppb + """ + + eco2_ppm: int + tvoc_ppb: int + + +class ThingyColorData(msgspec.Struct, frozen=True, kw_only=True): + """Color sensor data from Nordic Thingy:52. + + Attributes: + red: Red channel value (0-255) + green: Green channel value (0-255) + blue: Blue channel value (0-255) + clear: Clear channel value (0-255) + """ + + red: int + green: int + blue: int + clear: int + + +class ThingyButtonData(msgspec.Struct, frozen=True, kw_only=True): + """Button state data from Nordic Thingy:52. + + Attributes: + pressed: True if button is pressed, False if released + """ + + pressed: bool + + +class ThingyOrientationData(msgspec.Struct, frozen=True, kw_only=True): + """Orientation sensor data from Nordic Thingy:52. + + Attributes: + orientation: Orientation value (0-2, meaning depends on device) + """ + + orientation: int + + +class ThingyHeadingData(msgspec.Struct, frozen=True, kw_only=True): + """Heading sensor data from Nordic Thingy:52. + + Attributes: + heading_degrees: Compass heading in degrees (0-360) + """ + + heading_degrees: float + + +# Characteristic implementations +class ThingyTemperatureCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Temperature characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0201")), + name="Thingy Temperature", + value_type=ValueType.DICT, + unit="°C", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyTemperatureData: + """Decode temperature data. + + Format: 2 bytes - signed int8 (integer part) + uint8 (decimal part / 256) + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed temperature data + + Raises: + InsufficientDataError: If data length is invalid + """ + if len(data) != 2: + raise InsufficientDataError("Thingy Temperature", data, 2) + + # Signed int8 for integer part + integer_part = DataParser.parse_int8(data, offset=0, signed=True) + # Uint8 for decimal part (0-255, divide by 256 for 0.0-0.996) + decimal_part = data[1] / 256.0 + + temperature = integer_part + decimal_part + + return ThingyTemperatureData(temperature_celsius=temperature) + + +class ThingyPressureCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Pressure characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0202")), + name="Thingy Pressure", + value_type=ValueType.DICT, + unit="hPa", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyPressureData: + """Decode pressure data. + + Format: 5 bytes - int32 LE (integer part) + uint8 (decimal part / 256) + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed pressure data + + Raises: + InsufficientDataError: If data length is invalid + """ + if len(data) != 5: + raise InsufficientDataError("Thingy Pressure", data, 5) + + # Signed int32 for integer part + integer_part = DataParser.parse_int32(data, offset=0, signed=True) + # Uint8 for decimal part + decimal_part = data[4] / 256.0 + + pressure = integer_part + decimal_part + + return ThingyPressureData(pressure_hpa=pressure) + + +class ThingyHumidityCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Humidity characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0203")), + name="Thingy Humidity", + value_type=ValueType.DICT, + unit="%", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyHumidityData: + """Decode humidity data. + + Format: 1 byte - signed int8 percentage + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed humidity data + + Raises: + InsufficientDataError: If data length is invalid + ValueRangeError: If humidity value is out of range + """ + if len(data) != 1: + raise InsufficientDataError("Thingy Humidity", data, 1) + + humidity = DataParser.parse_int8(data, offset=0, signed=True) + + if not 0 <= humidity <= 100: + raise ValueRangeError("humidity", humidity, 0, 100) + + return ThingyHumidityData(humidity_percent=humidity) + + +class ThingyGasCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Gas characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0204")), + name="Thingy Gas", + value_type=ValueType.DICT, + unit="ppm/ppb", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyGasData: + """Decode gas sensor data. + + Format: 4 bytes - 2x uint16 LE (eCO2 ppm, TVOC ppb) + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed gas data + + Raises: + InsufficientDataError: If data length is invalid + """ + if len(data) != 4: + raise InsufficientDataError("Thingy Gas", data, 4) + + eco2 = DataParser.parse_int16(data, offset=0, signed=False) + tvoc = DataParser.parse_int16(data, offset=2, signed=False) + + return ThingyGasData(eco2_ppm=eco2, tvoc_ppb=tvoc) + + +class ThingyColorCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Color characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0205")), + name="Thingy Color", + value_type=ValueType.DICT, + unit="", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyColorData: + """Decode color sensor data. + + Format: 8 bytes - 4x uint16 LE (red, green, blue, clear) + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed color data + + Raises: + InsufficientDataError: If data length is invalid + """ + if len(data) != 8: + raise InsufficientDataError("Thingy Color", data, 8) + + red = DataParser.parse_int16(data, offset=0, signed=False) + green = DataParser.parse_int16(data, offset=2, signed=False) + blue = DataParser.parse_int16(data, offset=4, signed=False) + clear = DataParser.parse_int16(data, offset=6, signed=False) + + return ThingyColorData(red=red, green=green, blue=blue, clear=clear) + + +class ThingyButtonCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Button characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0302")), + name="Thingy Button", + value_type=ValueType.DICT, + unit="", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyButtonData: + """Decode button state data. + + Format: 1 byte - uint8 (1 = released, 0 = pressed) + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed button data + + Raises: + InsufficientDataError: If data length is invalid + ValueRangeError: If button state is invalid + """ + if len(data) != 1: + raise InsufficientDataError("Thingy Button", data, 1) + + state = DataParser.parse_int8(data, offset=0, signed=False) + if state not in (0, 1): + raise ValueRangeError("button_state", state, 0, 1) + + pressed = state == 0 # 0 = pressed, 1 = released + + return ThingyButtonData(pressed=pressed) + + +class ThingyOrientationCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Orientation characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0403")), + name="Thingy Orientation", + value_type=ValueType.DICT, + unit="", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyOrientationData: + """Decode orientation data. + + Format: 1 byte - uint8 orientation value + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed orientation data + + Raises: + InsufficientDataError: If data length is invalid + ValueRangeError: If orientation value is out of range + """ + if len(data) != 1: + raise InsufficientDataError("Thingy Orientation", data, 1) + + orientation = DataParser.parse_int8(data, offset=0, signed=False) + if not 0 <= orientation <= 2: + raise ValueRangeError("orientation", orientation, 0, 2) + + return ThingyOrientationData(orientation=orientation) + + +class ThingyHeadingCharacteristic(CustomBaseCharacteristic): + """Nordic Thingy:52 Heading characteristic.""" + + _info = CharacteristicInfo( + uuid=BluetoothUUID(NORDIC_UUID_BASE.replace("0000", "0409")), + name="Thingy Heading", + value_type=ValueType.DICT, + unit="°", + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> ThingyHeadingData: + """Decode heading data. + + Format: 4 bytes - float32 LE compass heading in degrees + + Args: + data: Raw characteristic data + ctx: Optional context providing surrounding characteristic metadata when available. + + Returns: + Parsed heading data + + Raises: + InsufficientDataError: If data length is invalid + """ + if len(data) != 4: + raise InsufficientDataError("Thingy Heading", data, 4) + + # Convert 4 bytes to float32 (little-endian) + heading = DataParser.parse_float32(data, offset=0) + + return ThingyHeadingData(heading_degrees=heading) diff --git a/examples/thingy52/thingy52_example.py b/examples/thingy52/thingy52_example.py new file mode 100644 index 00000000..436c9926 --- /dev/null +++ b/examples/thingy52/thingy52_example.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Nordic Thingy:52 BLE sensor reading example. + +This example demonstrates reading environmental sensors, UI elements, +and motion sensors from a Nordic Thingy:52 device using the +bluetooth-sig-python library. + +The Thingy:52 uses vendor-specific characteristics that differ from +Bluetooth SIG standards, so custom characteristic classes are used. + +Requirements: + pip install bleak # or bluepy, or simplepyble + +Usage: + python thingy52_example.py --address 12:34:56:78:9A:BC + python thingy52_example.py --address 12:34:56:78:9A:BC --all + python thingy52_example.py --address 12:34:56:78:9A:BC --temperature --pressure + python thingy52_example.py --address 12:34:56:78:9A:BC --connection-manager bleak +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +from typing import TYPE_CHECKING + +from bluetooth_sig import BluetoothSIGTranslator +from bluetooth_sig.device import Device +from examples.thingy52.thingy52_characteristics import ( + ThingyButtonCharacteristic, + ThingyColorCharacteristic, + ThingyGasCharacteristic, + ThingyHeadingCharacteristic, + ThingyHumidityCharacteristic, + ThingyOrientationCharacteristic, + ThingyPressureCharacteristic, + ThingyTemperatureCharacteristic, +) +from examples.utils.argparse_utils import create_connection_manager + +if TYPE_CHECKING: + pass + + +async def read_thingy_characteristic(device: Device, char_uuid: str, name: str) -> None: + """Read and display a Thingy:52 characteristic. + + Args: + device: Connected Device instance + char_uuid: Characteristic UUID string + name: Human-readable name for display + """ + try: + result = await device.read(char_uuid) + print(f"✅ {name}: {result}") + except Exception as e: + print(f"❌ {name}: Failed to read - {e}") + + +async def demonstrate_thingy52_reading(address: str, sensors: list[str], connection_manager_name: str) -> None: + """Demonstrate reading characteristics from a Nordic Thingy:52 device. + + Args: + address: BLE device address + sensors: List of sensor names to read + connection_manager_name: Name of connection manager to use + """ + print(f"🔍 Connecting to Thingy:52 at {address} using {connection_manager_name}...") + + # Initialize the translator + translator = BluetoothSIGTranslator() + + # Register custom Thingy:52 characteristics + translator.register_custom_characteristic_class(ThingyTemperatureCharacteristic) + translator.register_custom_characteristic_class(ThingyPressureCharacteristic) + translator.register_custom_characteristic_class(ThingyHumidityCharacteristic) + translator.register_custom_characteristic_class(ThingyGasCharacteristic) + translator.register_custom_characteristic_class(ThingyColorCharacteristic) + translator.register_custom_characteristic_class(ThingyButtonCharacteristic) + translator.register_custom_characteristic_class(ThingyOrientationCharacteristic) + translator.register_custom_characteristic_class(ThingyHeadingCharacteristic) + + # Create device + device = Device(address, translator) + + # Create and attach connection manager + connection_manager = create_connection_manager(connection_manager_name, address) + device.attach_connection_manager(connection_manager) + + try: + # Connect to device + await device.connect() + print("✅ Connected to Thingy:52") + + # Read selected sensors + if "temperature" in sensors or "all" in sensors: + uuid = str(ThingyTemperatureCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Temperature") + + if "pressure" in sensors or "all" in sensors: + uuid = str(ThingyPressureCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Pressure") + + if "humidity" in sensors or "all" in sensors: + uuid = str(ThingyHumidityCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Humidity") + + if "gas" in sensors or "all" in sensors: + uuid = str(ThingyGasCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Gas (eCO2/TVOC)") + + if "color" in sensors or "all" in sensors: + uuid = str(ThingyColorCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Color") + + if "button" in sensors or "all" in sensors: + uuid = str(ThingyButtonCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Button") + + if "orientation" in sensors or "all" in sensors: + uuid = str(ThingyOrientationCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Orientation") + + if "heading" in sensors or "all" in sensors: + uuid = str(ThingyHeadingCharacteristic.get_class_uuid() or "") + await read_thingy_characteristic(device, uuid, "Heading") + + except Exception as e: + print(f"❌ Operation failed: {e}") + finally: + # Always disconnect + try: + await device.disconnect() + print("✅ Disconnected from Thingy:52") + except Exception as e: + print(f"⚠️ Disconnect failed: {e}") + + +def main() -> None: + """Main function for Thingy:52 demonstration.""" + parser = argparse.ArgumentParser(description="Nordic Thingy:52 BLE sensor reading example") + + parser.add_argument("--address", "-a", required=True, help="Thingy:52 BLE device address (e.g., AA:BB:CC:DD:EE:FF)") + parser.add_argument( + "--connection-manager", + "-c", + choices=["bleak-retry", "bluepy", "simplepyble"], + default=os.getenv("BLE_CONNECTION_MANAGER", "bleak-retry"), + help="BLE connection manager to use (default: bleak-retry, or BLE_CONNECTION_MANAGER env var)", + ) + + # Sensor selection flags + parser.add_argument("--temperature", "-t", action="store_true", help="Read temperature sensor") + parser.add_argument("--pressure", "-p", action="store_true", help="Read pressure sensor") + parser.add_argument("--humidity", action="store_true", help="Read humidity sensor") + parser.add_argument("--gas", "-g", action="store_true", help="Read gas sensor (eCO2/TVOC)") + parser.add_argument("--color", action="store_true", help="Read color sensor") + parser.add_argument("--button", "-b", action="store_true", help="Read button state") + parser.add_argument("--orientation", "-o", action="store_true", help="Read orientation sensor") + parser.add_argument("--heading", action="store_true", help="Read heading sensor") + parser.add_argument("--all", action="store_true", help="Read all sensors") + + args = parser.parse_args() + + # Determine which sensors to read + sensors = [] + if args.all: + sensors = ["all"] + else: + sensor_flags = [ + ("temperature", args.temperature), + ("pressure", args.pressure), + ("humidity", args.humidity), + ("gas", args.gas), + ("color", args.color), + ("button", args.button), + ("orientation", args.orientation), + ("heading", args.heading), + ] + sensors = [name for name, flag in sensor_flags if flag] + + if not sensors: + print("❌ No sensors selected!") + print("Use --all or specify individual sensors (e.g., --temperature --pressure)") + return + + print("🔵 Nordic Thingy:52 BLE Sensor Reading Example") + print("=" * 50) + print(f"Device: {args.address}") + print(f"Connection Manager: {args.connection_manager}") + print(f"Sensors: {', '.join(sensors)}") + print() + + try: + asyncio.run(demonstrate_thingy52_reading(args.address, sensors, args.connection_manager)) + except KeyboardInterrupt: + print("\n⚠️ Interrupted by user") + except Exception as e: + print(f"❌ Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/bluetooth_sig/core/translator.py b/src/bluetooth_sig/core/translator.py index 8a53b6bc..ff0fd87c 100644 --- a/src/bluetooth_sig/core/translator.py +++ b/src/bluetooth_sig/core/translator.py @@ -8,20 +8,20 @@ from typing import Any, cast from ..gatt.characteristics.base import BaseCharacteristic, CharacteristicData +from ..gatt.characteristics.custom import CustomBaseCharacteristic from ..gatt.characteristics.registry import CharacteristicRegistry from ..gatt.characteristics.unknown import UnknownCharacteristic from ..gatt.exceptions import MissingDependencyError from ..gatt.services import ServiceName from ..gatt.services.base import BaseGattService +from ..gatt.services.custom import CustomBaseGattService from ..gatt.services.registry import GattServiceRegistry from ..gatt.uuid_registry import CustomUuidEntry, uuid_registry from ..types import ( CharacteristicContext, CharacteristicDataProtocol, CharacteristicInfo, - CharacteristicRegistration, ServiceInfo, - ServiceRegistration, SIGInfo, ValidationResult, ) @@ -789,73 +789,81 @@ def get_service_characteristics(self, service_uuid: str) -> list[str]: # pylint def register_custom_characteristic_class( self, - uuid_or_name: str, - cls: type[BaseCharacteristic], - metadata: CharacteristicRegistration | None = None, + characteristic_class: type[CustomBaseCharacteristic], override: bool = False, ) -> None: """Register a custom characteristic class at runtime. Args: - uuid_or_name: The characteristic UUID or name - cls: The characteristic class to register - metadata: Optional metadata dataclass with name, unit, value_type, summary + characteristic_class: The characteristic class to register override: Whether to override existing registrations Raises: TypeError: If cls does not inherit from BaseCharacteristic - ValueError: If UUID conflicts with existing registration and override=False + ValueError: If class has no valid _info with UUID """ - # Register the class - CharacteristicRegistry.register_characteristic_class(uuid_or_name, cls, override) - - # Register metadata if provided - if metadata: - # Convert ValueType enum to string for registry storage - vtype_str = metadata.value_type.value - entry = CustomUuidEntry( - uuid=metadata.uuid, - name=metadata.name or cls.__name__, - id=metadata.id, - summary=metadata.summary, - unit=metadata.unit, - value_type=vtype_str, + # Extract UUID from class._info + configured_info = characteristic_class.get_configured_info() + if not configured_info or not configured_info.uuid: + raise ValueError( + f"Cannot register {characteristic_class.__name__}: class has no valid _info with UUID. " + "Ensure the class has a _info class attribute with a valid CharacteristicInfo." ) - uuid_registry.register_characteristic(entry, override) + uuid_str = str(configured_info.uuid) + + # Register the class + CharacteristicRegistry.register_characteristic_class(uuid_str, characteristic_class, override) + + # Register metadata in UUID registry + entry = CustomUuidEntry( + uuid=configured_info.uuid, + name=configured_info.name, + summary=configured_info.description or "", + unit=configured_info.unit, + value_type=( + configured_info.value_type.value + if isinstance(configured_info.value_type, ValueType) + else str(configured_info.value_type) + ), + ) + uuid_registry.register_characteristic(entry, override) def register_custom_service_class( self, - uuid_or_name: str, - cls: type[BaseGattService], - metadata: ServiceRegistration | None = None, + service_class: type[CustomBaseGattService], override: bool = False, ) -> None: """Register a custom service class at runtime. Args: - uuid_or_name: The service UUID or name - cls: The service class to register - metadata: Optional metadata dataclass with name, summary + service_class: The service class to register override: Whether to override existing registrations Raises: - TypeError: If cls does not inherit from BaseGattService - ValueError: If UUID conflicts with existing registration and override=False + TypeError: If cls does not inherit from CustomGattService + ValueError: If class has no valid _info with UUID """ - # Register the class - GattServiceRegistry.register_service_class(uuid_or_name, cls, override) - - # Register metadata if provided - if metadata: - entry = CustomUuidEntry( - uuid=metadata.uuid, - name=metadata.name or cls.__name__, - id=metadata.id, - summary=metadata.summary, + # Extract UUID from class._info + configured_info = service_class.get_configured_info() + if not configured_info or not configured_info.uuid: + raise ValueError( + f"Cannot register {service_class.__name__}: class has no valid _info with UUID. " + "Ensure the class has a _info class attribute with a valid ServiceInfo." ) - uuid_registry.register_service(entry, override) + uuid_str = str(configured_info.uuid) + + # Register the class + GattServiceRegistry.register_service_class(uuid_str, service_class, override) + + # Register metadata in UUID registry + entry = CustomUuidEntry( + uuid=configured_info.uuid, + name=configured_info.name, + summary=configured_info.description or "", + ) + uuid_registry.register_service(entry, override) # Async methods for non-blocking operation in async contexts diff --git a/src/bluetooth_sig/gatt/characteristics/base.py b/src/bluetooth_sig/gatt/characteristics/base.py index 9bfd61a0..14ca5022 100644 --- a/src/bluetooth_sig/gatt/characteristics/base.py +++ b/src/bluetooth_sig/gatt/characteristics/base.py @@ -615,9 +615,37 @@ def get_class_uuid(cls) -> BluetoothUUID | None: """ return cls._resolve_class_uuid() + @classmethod + def get_name(cls) -> str: + """Get the characteristic name for this class without creating an instance. + + Returns: + The characteristic name as registered in the UUID registry. + + """ + # Try configured info first (for custom characteristics) + configured_info = cls.get_configured_info() + if configured_info: + return configured_info.name + + # For SIG characteristics, resolve from registry + uuid = cls.get_class_uuid() + if uuid: + char_info = uuid_registry.get_characteristic_info(uuid) + if char_info: + return char_info.name + + # Fallback to class name + return cls.__name__ + @classmethod def _resolve_class_uuid(cls) -> BluetoothUUID | None: """Resolve the characteristic UUID for this class without creating an instance.""" + # Try configured info first (for custom characteristics) + configured_info = cls.get_configured_info() + if configured_info: + return configured_info.uuid + # Try cross-file resolution first yaml_spec = cls._resolve_yaml_spec_class() if yaml_spec: diff --git a/src/bluetooth_sig/gatt/characteristics/custom.py b/src/bluetooth_sig/gatt/characteristics/custom.py index bcfaa5ca..f62e9e5b 100644 --- a/src/bluetooth_sig/gatt/characteristics/custom.py +++ b/src/bluetooth_sig/gatt/characteristics/custom.py @@ -15,22 +15,15 @@ class CustomBaseCharacteristic(BaseCharacteristic): defined in the Bluetooth SIG specification. It supports both manual info passing and automatic class-level _info binding via __init_subclass__. - Auto-Registration: + Registration: Custom characteristics automatically register themselves with the global - BluetoothSIGTranslator singleton when first instantiated. No manual - registration needed! + BluetoothSIGTranslator singleton when first instantiated. Examples: >>> from bluetooth_sig.types.data_types import CharacteristicInfo >>> from bluetooth_sig.types.uuid import BluetoothUUID >>> class MyCharacteristic(CustomBaseCharacteristic): ... _info = CharacteristicInfo(uuid=BluetoothUUID("AAAA"), name="My Char") - >>> # Auto-registers with singleton on first instantiation - >>> char = MyCharacteristic() # Auto-registered! - >>> # Now accessible via the global translator - >>> from bluetooth_sig import BluetoothSIGTranslator - >>> translator = BluetoothSIGTranslator.get_instance() - >>> result = translator.parse_characteristic("AAAA", b"\x42") """ _is_custom = True @@ -87,13 +80,11 @@ def __init_subclass__(cls, allow_sig_override: bool = False, **kwargs: Any) -> N def __init__( self, info: CharacteristicInfo | None = None, - auto_register: bool = True, ) -> None: """Initialize a custom characteristic with automatic _info resolution and registration. Args: info: Optional override for class-configured _info - auto_register: If True (default), automatically register with global translator singleton Raises: ValueError: If no valid info available from class or parameter @@ -114,29 +105,6 @@ def __init__( if not final_info.uuid or str(final_info.uuid) == "0000": raise ValueError("Valid UUID is required for custom characteristics") - # Auto-register if requested and not already registered - if auto_register: - # TODO - # NOTE: Import here to avoid circular import (translator imports characteristics) - from ...core.translator import BluetoothSIGTranslator # pylint: disable=import-outside-toplevel - - # Get the singleton translator instance - translator = BluetoothSIGTranslator.get_instance() - - # Track registration to avoid duplicate registrations - uuid_str = str(final_info.uuid) - registry_key = f"{id(translator)}:{uuid_str}" - - if registry_key not in CustomBaseCharacteristic._registry_tracker: - # Register this characteristic class with the translator - # Use override=True to allow re-registration (idempotent behavior) - translator.register_custom_characteristic_class( - uuid_str, - self.__class__, - override=True, # Allow override for idempotent registration - ) - CustomBaseCharacteristic._registry_tracker.add(registry_key) - # Call parent constructor with our info to maintain consistency super().__init__(info=final_info) diff --git a/src/bluetooth_sig/gatt/services/base.py b/src/bluetooth_sig/gatt/services/base.py index a409285b..5b8b64ea 100644 --- a/src/bluetooth_sig/gatt/services/base.py +++ b/src/bluetooth_sig/gatt/services/base.py @@ -69,6 +69,10 @@ def resolve_for_class(service_class: type[BaseGattService]) -> ServiceInfo: UUIDResolutionError: If no UUID can be resolved for the class """ + # Try configured info first (for custom services) + if hasattr(service_class, "_info") and service_class._info is not None: + return service_class._info + # Try registry resolution registry_info = SIGServiceResolver.resolve_from_registry(service_class) if registry_info: @@ -252,6 +256,27 @@ def get_class_uuid(cls) -> BluetoothUUID: info = SIGServiceResolver.resolve_for_class(cls) return info.uuid + @classmethod + def get_name(cls) -> str: + """Get the service name for this class without creating an instance. + + Returns: + The service name as registered in the UUID registry. + + """ + # Try configured info first (for custom services) + if hasattr(cls, "_info") and cls._info is not None: + return cls._info.name + + # For SIG services, resolve from registry + uuid = cls.get_class_uuid() + service_info = uuid_registry.get_service_info(uuid) + if service_info: + return service_info.name + + # Fallback to class name + return cls.__name__ + @classmethod def matches_uuid(cls, uuid: str | BluetoothUUID) -> bool: """Check if this service matches the given UUID.""" diff --git a/src/bluetooth_sig/gatt/services/custom.py b/src/bluetooth_sig/gatt/services/custom.py index b8f738c2..616f2eea 100644 --- a/src/bluetooth_sig/gatt/services/custom.py +++ b/src/bluetooth_sig/gatt/services/custom.py @@ -18,6 +18,16 @@ class CustomBaseGattService(BaseGattService): _configured_info: ServiceInfo | None = None _allows_sig_override = False + @classmethod + def get_configured_info(cls) -> ServiceInfo | None: + """Get the class-level configured ServiceInfo. + + Returns: + ServiceInfo if configured, None otherwise + + """ + return cls._configured_info + def __init_subclass__(cls, allow_sig_override: bool = False, **kwargs: object) -> None: """Set up _info if provided as class attribute. diff --git a/src/bluetooth_sig/types/__init__.py b/src/bluetooth_sig/types/__init__.py index d887318e..3cf4a323 100644 --- a/src/bluetooth_sig/types/__init__.py +++ b/src/bluetooth_sig/types/__init__.py @@ -43,10 +43,8 @@ from .context import CharacteristicContext, DeviceInfo from .data_types import ( CharacteristicInfo, - CharacteristicRegistration, ParseFieldError, ServiceInfo, - ServiceRegistration, ValidationResult, ) from .descriptor_types import DescriptorData, DescriptorInfo @@ -102,7 +100,6 @@ "CharacteristicContext", "CharacteristicDataProtocol", "CharacteristicInfo", - "CharacteristicRegistration", "ClassOfDeviceInfo", "ConcentrationUnit", "ConnectionData", @@ -130,7 +127,6 @@ "PressureUnit", "SecurityData", "ServiceInfo", - "ServiceRegistration", "SIGInfo", "SoundUnit", "TemperatureUnit", diff --git a/src/bluetooth_sig/types/data_types.py b/src/bluetooth_sig/types/data_types.py index 35818a2f..8a601bad 100644 --- a/src/bluetooth_sig/types/data_types.py +++ b/src/bluetooth_sig/types/data_types.py @@ -6,7 +6,6 @@ from .base_types import SIGInfo from .gatt_enums import ValueType -from .uuid import BluetoothUUID class ParseFieldError(msgspec.Struct, frozen=True, kw_only=True): @@ -54,23 +53,3 @@ class ValidationResult(SIGInfo): expected_length: int | None = None actual_length: int | None = None error_message: str = "" - - -class CharacteristicRegistration(msgspec.Struct, kw_only=True): - """Unified metadata for custom UUID registration.""" - - uuid: BluetoothUUID - name: str = "" - id: str | None = None - summary: str = "" - unit: str = "" - value_type: ValueType = ValueType.STRING - - -class ServiceRegistration(msgspec.Struct, kw_only=True): - """Unified metadata for custom UUID registration.""" - - uuid: BluetoothUUID - name: str = "" - id: str | None = None - summary: str = "" diff --git a/tests/gatt/characteristics/test_custom_characteristics.py b/tests/gatt/characteristics/test_custom_characteristics.py index 00a4806e..ceff038f 100644 --- a/tests/gatt/characteristics/test_custom_characteristics.py +++ b/tests/gatt/characteristics/test_custom_characteristics.py @@ -22,7 +22,7 @@ from bluetooth_sig.gatt.characteristics.templates import ScaledUint16Template, Uint8Template from bluetooth_sig.gatt.characteristics.utils import DataParser from bluetooth_sig.gatt.context import CharacteristicContext -from bluetooth_sig.types import CharacteristicInfo, CharacteristicRegistration +from bluetooth_sig.types import CharacteristicInfo from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -421,17 +421,8 @@ def test_register_simple_characteristic(self) -> None: """Test registering a simple custom characteristic.""" translator = BluetoothSIGTranslator() - # Register the characteristic - translator.register_custom_characteristic_class( - str(SimpleTemperatureSensor._info.uuid), - SimpleTemperatureSensor, - metadata=CharacteristicRegistration( - uuid=SimpleTemperatureSensor._info.uuid, - name="Simple Temperature Sensor", - unit="°C", - value_type=ValueType.INT, - ), - ) + # Register the characteristic using the new class-only API + translator.register_custom_characteristic_class(SimpleTemperatureSensor) # Parse data using the registered characteristic data = bytearray([0x14, 0x00]) # 20°C @@ -447,10 +438,7 @@ def test_register_multi_field_characteristic(self) -> None: """Test registering multi-field characteristic.""" translator = BluetoothSIGTranslator() - translator.register_custom_characteristic_class( - str(MultiSensorCharacteristic._info.uuid), - MultiSensorCharacteristic, - ) + translator.register_custom_characteristic_class(MultiSensorCharacteristic) # Create test data data = bytearray( diff --git a/tests/gatt/services/test_custom_services.py b/tests/gatt/services/test_custom_services.py index 265a9568..17c48ce8 100644 --- a/tests/gatt/services/test_custom_services.py +++ b/tests/gatt/services/test_custom_services.py @@ -25,7 +25,7 @@ from bluetooth_sig.gatt.services.custom import CustomBaseGattService from bluetooth_sig.gatt.services.registry import GattServiceRegistry from bluetooth_sig.gatt.services.unknown import UnknownService -from bluetooth_sig.types import CharacteristicInfo, ServiceInfo, ServiceRegistration +from bluetooth_sig.types import CharacteristicInfo, ServiceInfo from bluetooth_sig.types.gatt_enums import ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -454,15 +454,7 @@ class CustomService(CustomBaseGattService): translator = BluetoothSIGTranslator() service = CustomService() - translator.register_custom_service_class( - str(service.uuid), - CustomService, - metadata=ServiceRegistration( - uuid=service.uuid, - name="Custom", - summary="Custom service", - ), - ) + translator.register_custom_service_class(CustomService) retrieved_cls = GattServiceRegistry.get_service_class(str(service.uuid)) assert retrieved_cls == CustomService diff --git a/tests/integration/test_auto_registration.py b/tests/integration/test_auto_registration.py deleted file mode 100644 index 14c6ddbc..00000000 --- a/tests/integration/test_auto_registration.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Tests for auto-registration feature in CustomBaseCharacteristic. - -This test suite verifies that custom characteristics can automatically -register themselves with the global BluetoothSIGTranslator singleton when instantiated. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from bluetooth_sig import BluetoothSIGTranslator -from bluetooth_sig.gatt.characteristics.custom import CustomBaseCharacteristic -from bluetooth_sig.types.data_types import CharacteristicInfo -from bluetooth_sig.types.uuid import BluetoothUUID - - -# A small self-contained test characteristic used only for these tests. -class LocalTemperatureCharacteristic(CustomBaseCharacteristic): - """Simple custom characteristic used by auto-registration tests. - - This is intentionally minimal — it's only used to validate auto-registration - logic (manual registration, auto-registration idempotence and parse). The - decode/encode methods are trivial. - """ - - _info = CharacteristicInfo( - uuid=BluetoothUUID("12345678-1234-5678-1234-567812345671"), - name="Local Temperature Characteristic", - ) - - def decode_value(self, data: bytearray, ctx: Any = None) -> float: # noqa: ANN401 - # Expect 2-byte format: int8 (whole degrees) + uint8 decimal (00-99) - if len(data) != 2: - # For test, accept single byte too - return float(int(data[0])) if data else 0.0 - whole = int(data[0]) - dec = int(data[1]) - return float(whole + dec / 100.0) - - def encode_value(self, data: float) -> bytearray: - whole = int(data) - dec = int((data - whole) * 100) - return bytearray([whole & 0xFF, dec & 0xFF]) - - -@pytest.fixture(autouse=True) -def reset_singleton() -> None: - """Reset the singleton translator and registry tracker between tests.""" - # Reset the singleton instance - BluetoothSIGTranslator._instance = None - BluetoothSIGTranslator._instance_lock = False - # Reset the registry tracker - CustomBaseCharacteristic._registry_tracker.clear() - - -class TestAutoRegistration: - """Test auto-registration feature for custom characteristics.""" - - def test_manual_registration_still_works(self) -> None: - """Test that manual registration still works (backward compatibility).""" - # Get the singleton translator - translator = BluetoothSIGTranslator.get_instance() - - # Create characteristic without auto-registration - char = LocalTemperatureCharacteristic(auto_register=False) - - # Manually register with override=True since parse_characteristic will instantiate and try to auto-register - info = char.__class__.get_configured_info() - assert info is not None - translator.register_custom_characteristic_class(str(info.uuid), LocalTemperatureCharacteristic, override=True) - - # Verify it's registered - raw_data = bytes([0x18, 0x32]) # 24.50°C (24 whole + 50 decimal) - result = translator.parse_characteristic(str(info.uuid), raw_data) - assert result.value == 24.5 - - def test_auto_registration_on_init(self) -> None: - """Test that characteristic auto-registers when instantiated.""" - # Create characteristic with auto-registration (uses global singleton) - char = LocalTemperatureCharacteristic(auto_register=True) - - # Verify it's registered by parsing data using the singleton - translator = BluetoothSIGTranslator.get_instance() - info = char.__class__.get_configured_info() - assert info is not None - - raw_data = bytes([0x18, 0x32]) # 24.50°C (24 whole + 50 decimal) - result = translator.parse_characteristic(str(info.uuid), raw_data) - assert result.value == 24.5 - - def test_auto_registration_idempotent(self) -> None: - """Test that multiple instantiations don't cause duplicate registrations.""" - # Create multiple instances with auto-registration - char1 = LocalTemperatureCharacteristic(auto_register=True) - LocalTemperatureCharacteristic(auto_register=True) # char2 - LocalTemperatureCharacteristic(auto_register=True) # char3 - - # Verify parsing still works (no errors from duplicate registration) - translator = BluetoothSIGTranslator.get_instance() - info = char1.__class__.get_configured_info() - assert info is not None - - raw_data = bytes([0x18, 0x32]) # 24.50°C (24 whole + 50 decimal) - result = translator.parse_characteristic(str(info.uuid), raw_data) - assert result.value == 24.5 - - def test_default_auto_register_is_true(self) -> None: - """Test that auto_register defaults to True.""" - # Should auto-register when no auto_register parameter provided - char = LocalTemperatureCharacteristic() - - # Verify characteristic was created and registered - assert char is not None - info = char.__class__.get_configured_info() - assert info is not None - - # Verify it's accessible via singleton - translator = BluetoothSIGTranslator.get_instance() - raw_data = bytes([0x18, 0x32]) # 24.50°C - result = translator.parse_characteristic(str(info.uuid), raw_data) - assert result.value == 24.5 - - def test_dynamic_custom_characteristic_auto_registration(self) -> None: - """Test auto-registration with dynamically created custom characteristic.""" - - class DynamicCharacteristic(CustomBaseCharacteristic): - """Test characteristic created at runtime.""" - - _info = CharacteristicInfo( - name="Dynamic Test Characteristic", - uuid=BluetoothUUID("12345678-1234-5678-1234-567812345678"), - ) - - def decode_value(self, data: bytearray, ctx: Any = None) -> int: # noqa: ANN401 - """Decode single byte as integer.""" - return int(data[0]) if data else 0 - - # Auto-register the dynamic characteristic - DynamicCharacteristic(auto_register=True) # char - - # Verify it's registered with the singleton - translator = BluetoothSIGTranslator.get_instance() - result = translator.parse_characteristic( - "12345678-1234-5678-1234-567812345678", - bytes([42]), - ) - assert result.value == 42 diff --git a/tests/integration/test_custom_registration.py b/tests/integration/test_custom_registration.py index 1bc379ae..7708a2f6 100644 --- a/tests/integration/test_custom_registration.py +++ b/tests/integration/test_custom_registration.py @@ -15,7 +15,7 @@ from bluetooth_sig.gatt.services.custom import CustomBaseGattService from bluetooth_sig.gatt.services.registry import GattServiceRegistry from bluetooth_sig.gatt.uuid_registry import CustomUuidEntry, uuid_registry -from bluetooth_sig.types import CharacteristicInfo, CharacteristicRegistration, ServiceRegistration +from bluetooth_sig.types import CharacteristicInfo, ServiceInfo from bluetooth_sig.types.gatt_enums import GattProperty, ValueType from bluetooth_sig.types.uuid import BluetoothUUID @@ -60,7 +60,11 @@ def from_uuid( class CustomServiceImpl(CustomBaseGattService): """Test custom service implementation.""" - _service_name = "Test Service" + _info = ServiceInfo( + uuid=BluetoothUUID("12345678-1234-1234-1234-123456789abc"), + name="Test Service", + description="Test custom service", + ) class TestRuntimeRegistration: @@ -127,18 +131,26 @@ def test_translator_register_custom_characteristic(self) -> None: """Test translator convenience method for registering custom characteristic.""" translator = BluetoothSIGTranslator() - translator.register_custom_characteristic_class( - "abcd1234-0000-1000-8000-00805f9b34fb", - CustomCharacteristicImpl, - metadata=CharacteristicRegistration( + # Create a test class with _info + class TestChar(CustomBaseCharacteristic): + _info = CharacteristicInfo( uuid=BluetoothUUID("abcd1234-0000-1000-8000-00805f9b34fb"), name="Test Characteristic", unit="°C", value_type=ValueType.INT, - ), - ) # Verify class registration + ) + + def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> int: + return data[0] + + def encode_value(self, data: int) -> bytearray: + return bytearray([data]) + + translator.register_custom_characteristic_class(TestChar) + + # Verify class registration cls = CharacteristicRegistry.get_characteristic_class_by_uuid("abcd1234-0000-1000-8000-00805f9b34fb") - assert cls == CustomCharacteristicImpl + assert cls == TestChar # Verify metadata registration info = uuid_registry.get_characteristic_info("abcd1234-0000-1000-8000-00805f9b34fb") @@ -150,15 +162,7 @@ def test_translator_register_custom_service(self) -> None: """Test translator convenience method for registering custom service.""" translator = BluetoothSIGTranslator() - translator.register_custom_service_class( - "12345678-1234-1234-1234-123456789abc", - CustomServiceImpl, - metadata=ServiceRegistration( - uuid=BluetoothUUID("12345678-1234-1234-1234-123456789abc"), - name="Test Service", - summary="Test custom service", - ), - ) + translator.register_custom_service_class(CustomServiceImpl) # Verify class registration cls = GattServiceRegistry.get_service_class("12345678-1234-1234-1234-123456789abc") @@ -168,15 +172,14 @@ def test_translator_register_custom_service(self) -> None: info = uuid_registry.get_service_info("12345678-1234-1234-1234-123456789abc") assert info is not None assert info.name == "Test Service" + assert info.summary == "Test custom service" def test_parse_custom_characteristic(self) -> None: """Test parsing data with a custom characteristic.""" translator = BluetoothSIGTranslator() - # Register custom characteristic - translator.register_custom_characteristic_class( - "abcd1234-0000-1000-8000-00805f9b34fb", CustomCharacteristicImpl - ) + # Register custom characteristic using the new API + translator.register_custom_characteristic_class(CustomCharacteristicImpl) # Parse data test_data = bytes([0x34, 0x12]) # 0x1234 = 4660 diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index d26da046..b9146454 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -206,22 +206,18 @@ def translator(self) -> BluetoothSIGTranslator: # Register custom characteristics (with override=True for test re-runs) translator.register_custom_characteristic_class( - CALIBRATION_UUID, CalibrationCharacteristic, override=True, ) translator.register_custom_characteristic_class( - SENSOR_READING_UUID, SensorReadingCharacteristic, override=True, ) translator.register_custom_characteristic_class( - SEQUENCE_NUMBER_UUID, SequenceNumberCharacteristic, override=True, ) translator.register_custom_characteristic_class( - SEQUENCED_DATA_UUID, SequencedDataCharacteristic, override=True, ) @@ -491,8 +487,8 @@ def encode_value(self, data: int) -> bytearray: CharA._required_dependencies = [CharB] # Register characteristics - translator.register_custom_characteristic_class(str(CharA._info.uuid), CharA) - translator.register_custom_characteristic_class(str(CharB._info.uuid), CharB) + translator.register_custom_characteristic_class(CharA) + translator.register_custom_characteristic_class(CharB) # Try to parse with circular dependencies # Topological sort will fail, fallback to original order @@ -520,12 +516,10 @@ def test_readme_example(self) -> None: # Register custom characteristics translator.register_custom_characteristic_class( - str(CalibrationCharacteristic._info.uuid), CalibrationCharacteristic, override=True, ) translator.register_custom_characteristic_class( - str(SensorReadingCharacteristic._info.uuid), SensorReadingCharacteristic, override=True, ) @@ -534,15 +528,15 @@ def test_readme_example(self) -> None: # Prepare characteristic data char_data = { - str(CalibrationCharacteristic._info.uuid): struct.pack(" bytearray: ) # Register all characteristics - translator.register_custom_characteristic_class( - str(MeasurementCharacteristic().info.uuid), MeasurementCharacteristic, override=True - ) - translator.register_custom_characteristic_class( - str(ContextCharacteristic().info.uuid), ContextCharacteristic, override=True - ) - translator.register_custom_characteristic_class( - str(EnrichmentCharacteristic().info.uuid), EnrichmentCharacteristic, override=True - ) - translator.register_custom_characteristic_class( - str(DataCharacteristic().info.uuid), DataCharacteristic, override=True - ) - translator.register_custom_characteristic_class( - str(MultiDependencyCharacteristic().info.uuid), MultiDependencyCharacteristic, override=True - ) + translator.register_custom_characteristic_class(MeasurementCharacteristic, override=True) + translator.register_custom_characteristic_class(ContextCharacteristic, override=True) + translator.register_custom_characteristic_class(EnrichmentCharacteristic, override=True) + translator.register_custom_characteristic_class(DataCharacteristic, override=True) + translator.register_custom_characteristic_class(MultiDependencyCharacteristic, override=True) return translator diff --git a/tests/integration/test_thingy52_characteristics.py b/tests/integration/test_thingy52_characteristics.py new file mode 100644 index 00000000..167928d0 --- /dev/null +++ b/tests/integration/test_thingy52_characteristics.py @@ -0,0 +1,235 @@ +"""Tests for Nordic Thingy:52 vendor characteristics.""" + +from __future__ import annotations + +import pytest + +from bluetooth_sig.gatt.exceptions import InsufficientDataError, ValueRangeError +from examples.thingy52.thingy52_characteristics import ( + ThingyButtonCharacteristic, + ThingyColorCharacteristic, + ThingyGasCharacteristic, + ThingyHeadingCharacteristic, + ThingyHumidityCharacteristic, + ThingyOrientationCharacteristic, + ThingyPressureCharacteristic, + ThingyTemperatureCharacteristic, +) + + +@pytest.fixture(autouse=True, scope="session") +def auto_register_thingy_characteristics() -> None: + """Auto-register Thingy:52 characteristics via CustomBaseCharacteristic.""" + # CustomBaseCharacteristic handles auto-registration on first instantiation + # Just instantiate one of each to trigger registration + ThingyTemperatureCharacteristic() + ThingyPressureCharacteristic() + ThingyHumidityCharacteristic() + ThingyGasCharacteristic() + ThingyColorCharacteristic() + ThingyButtonCharacteristic() + ThingyOrientationCharacteristic() + ThingyHeadingCharacteristic() + + +class TestThingyTemperatureCharacteristic: + """Test Thingy:52 temperature characteristic.""" + + def test_decode_valid_temperature(self) -> None: + """Test decoding valid temperature data.""" + char = ThingyTemperatureCharacteristic() + # 25.5°C: integer=25, decimal=128 (128/256=0.5) + data = bytearray([25, 128]) + result = char.decode_value(data) + assert result.temperature_celsius == 25.5 + + def test_decode_negative_temperature(self) -> None: + """Test decoding negative temperature data.""" + char = ThingyTemperatureCharacteristic() + # -5.25°C: integer=-5, decimal=64 (64/256=0.25) + data = bytearray([251, 64]) # 251 = -5 as signed int8 + result = char.decode_value(data) + assert result.temperature_celsius == -4.75 + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyTemperatureCharacteristic() + data = bytearray([25]) # Only 1 byte + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyPressureCharacteristic: + """Test Thingy:52 pressure characteristic.""" + + def test_decode_valid_pressure(self) -> None: + """Test decoding valid pressure data.""" + char = ThingyPressureCharacteristic() + # 1013.25 hPa: integer=1013, decimal=64 (64/256=0.25) + data = bytearray([245, 3, 0, 0, 64]) # 1013 = 0x000003F5 LE + result = char.decode_value(data) + assert result.pressure_hpa == 1013.25 + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyPressureCharacteristic() + data = bytearray([245, 3, 0, 0]) # Only 4 bytes + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyHumidityCharacteristic: + """Test Thingy:52 humidity characteristic.""" + + def test_decode_valid_humidity(self) -> None: + """Test decoding valid humidity data.""" + char = ThingyHumidityCharacteristic() + data = bytearray([65]) # 65% + result = char.decode_value(data) + assert result.humidity_percent == 65 + + def test_decode_humidity_too_low(self) -> None: + """Test decoding humidity below minimum.""" + char = ThingyHumidityCharacteristic() + data = bytearray([255]) # -1% + with pytest.raises(ValueRangeError): + char.decode_value(data) + + def test_decode_humidity_too_high(self) -> None: + """Test decoding humidity above maximum.""" + char = ThingyHumidityCharacteristic() + data = bytearray([101]) # 101% + with pytest.raises(ValueRangeError): + char.decode_value(data) + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyHumidityCharacteristic() + data = bytearray([]) # Empty + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyGasCharacteristic: + """Test Thingy:52 gas characteristic.""" + + def test_decode_valid_gas(self) -> None: + """Test decoding valid gas data.""" + char = ThingyGasCharacteristic() + # eCO2: 400 ppm, TVOC: 0 ppb + data = bytearray([144, 1, 0, 0]) # 400 = 0x0190 LE, 0 = 0x0000 LE + result = char.decode_value(data) + assert result.eco2_ppm == 400 + assert result.tvoc_ppb == 0 + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyGasCharacteristic() + data = bytearray([144, 1, 0]) # Only 3 bytes + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyColorCharacteristic: + """Test Thingy:52 color characteristic.""" + + def test_decode_valid_color(self) -> None: + """Test decoding valid color data.""" + char = ThingyColorCharacteristic() + # Red: 255, Green: 128, Blue: 64, Clear: 512 + data = bytearray([255, 0, 128, 0, 64, 0, 0, 2]) # All LE uint16 + result = char.decode_value(data) + assert result.red == 255 + assert result.green == 128 + assert result.blue == 64 + assert result.clear == 512 + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyColorCharacteristic() + data = bytearray([255, 0, 128, 0, 64, 0, 0]) # Only 7 bytes + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyButtonCharacteristic: + """Test Thingy:52 button characteristic.""" + + def test_decode_button_pressed(self) -> None: + """Test decoding button pressed state.""" + char = ThingyButtonCharacteristic() + data = bytearray([0]) # 0 = pressed + result = char.decode_value(data) + assert result.pressed is True + + def test_decode_button_released(self) -> None: + """Test decoding button released state.""" + char = ThingyButtonCharacteristic() + data = bytearray([1]) # 1 = released + result = char.decode_value(data) + assert result.pressed is False + + def test_decode_invalid_button_state(self) -> None: + """Test decoding invalid button state.""" + char = ThingyButtonCharacteristic() + data = bytearray([2]) # Invalid state + with pytest.raises(ValueRangeError): + char.decode_value(data) + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyButtonCharacteristic() + data = bytearray([]) # Empty + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyOrientationCharacteristic: + """Test Thingy:52 orientation characteristic.""" + + def test_decode_valid_orientation(self) -> None: + """Test decoding valid orientation data.""" + char = ThingyOrientationCharacteristic() + data = bytearray([1]) # Orientation value 1 + result = char.decode_value(data) + assert result.orientation == 1 + + def test_decode_orientation_too_low(self) -> None: + """Test decoding orientation below minimum.""" + char = ThingyOrientationCharacteristic() + data = bytearray([255]) # -1 + with pytest.raises(ValueRangeError): + char.decode_value(data) + + def test_decode_orientation_too_high(self) -> None: + """Test decoding orientation above maximum.""" + char = ThingyOrientationCharacteristic() + data = bytearray([3]) # 3 > 2 + with pytest.raises(ValueRangeError): + char.decode_value(data) + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyOrientationCharacteristic() + data = bytearray([]) # Empty + with pytest.raises(InsufficientDataError): + char.decode_value(data) + + +class TestThingyHeadingCharacteristic: + """Test Thingy:52 heading characteristic.""" + + def test_decode_valid_heading(self) -> None: + """Test decoding valid heading data.""" + char = ThingyHeadingCharacteristic() + # 90.0 degrees as float32 LE + data = bytearray([0x00, 0x00, 0xB4, 0x42]) # 90.0f in IEEE 754 + result = char.decode_value(data) + assert abs(result.heading_degrees - 90.0) < 0.01 + + def test_decode_insufficient_data(self) -> None: + """Test decoding with insufficient data.""" + char = ThingyHeadingCharacteristic() + data = bytearray([0x00, 0x00, 0x5A]) # Only 3 bytes + with pytest.raises(InsufficientDataError): + char.decode_value(data) diff --git a/tests/stream/test_pairing.py b/tests/stream/test_dependency_pairing.py similarity index 100% rename from tests/stream/test_pairing.py rename to tests/stream/test_dependency_pairing.py