Migration Guide: Adopt the Bluetooth SIG Standards Library

This guide shows how to migrate existing BLE code to use the library’s parsing. Examples are based on real-world applications like heart rate monitors, environmental sensors, and smart home devices.

The library stays backend-agnostic: you keep your connection code (Bleak, SimplePyBLE, etc.) and drop the parser into your reads/notifications.

Who is this for?

  • You already read GATT characteristics via a BLE client (Bleak, SimplePyBLE, Home Assistant, etc.)

  • You manually parse bytes today and want robust, spec-aligned parsing with validation and types

  • You’re building health monitors, environmental sensors, or smart home devices

Real-World Example 1: Heart Rate Monitor

Based on: Polar H10, Fitbit, Apple Watch integrations

Before (manual parsing - typical fitness app pattern):

# SKIP: Migration "before" example (anti-pattern)
# Common pattern from real fitness app integrations
async with BleakClient(device_address) as client:

    def heart_rate_handler(sender, data: bytearray):
        # Manual bit manipulation from BLE Heart Rate spec
        flags = data[0]
        hr_format = flags & 0x01
        if hr_format == 0:
            heart_rate = data[1]
            offset = 2
        else:
            heart_rate = int.from_bytes(data[1:3], "little")
            offset = 3

        # Energy expended (if present)
        energy = None
        if flags & 0x08:
            energy = int.from_bytes(data[offset : offset + 2], "little")
            offset += 2

        # RR intervals (if present)
        rr_intervals = []
        if flags & 0x10:
            while offset < len(data):
                rr = int.from_bytes(data[offset : offset + 2], "little")
                rr_intervals.append(rr / 1024.0)  # Convert to seconds
                offset += 2

        print(f"HR: {heart_rate} bpm, Energy: {energy} kJ, RR: {rr_intervals}")

    await client.start_notify("2A37", heart_rate_handler)

After (with bluetooth-sig-python):

import asyncio

from bluetooth_sig import BluetoothSIGTranslator


async def main():
    translator = BluetoothSIGTranslator()

    # Note: This is a simplified example - in production, use actual BleakClient
    def heart_rate_handler(sender, data: bytearray):
        # Returns HeartRateData directly - type-safe dataclass
        hr_data = translator.parse_characteristic("2A37", data)
        print(f"HR: {hr_data.heart_rate} bpm")
        if hr_data.energy_expended:
            print(f"Energy: {hr_data.energy_expended} kJ")
        if hr_data.rr_intervals:
            print(f"RR intervals: {hr_data.rr_intervals} seconds")

    # Test with sample data
    heart_rate_handler(None, bytearray([0x00, 0x55]))


asyncio.run(main())

What improved:

  • ✅ No manual bit manipulation or offset tracking

  • ✅ Automatic unit conversions (RR intervals to seconds)

  • ✅ Type-safe access to all fields with autocomplete

  • ✅ Validates against spec (catches malformed data)

Real-World Example 2: Environmental Sensor Dashboard

Based on: Xiaomi LYWSD03MMC, SwitchBot Meter, Govee sensors

Before (manual parsing - typical home automation pattern):

# SKIP: Migration "before" example (anti-pattern)
# Common pattern from home automation projects (Home Assistant, openHAB)
async with BleakClient(device_address) as client:
    # Read battery level
    battery_bytes = await client.read_gatt_char("2A19")
    battery = battery_bytes[0]

    # Read temperature
    temp_bytes = await client.read_gatt_char("2A6E")
    temp_raw = int.from_bytes(temp_bytes[0:2], "little", signed=True)
    temperature = temp_raw / 100.0  # Convert to °C

    # Read humidity
    humidity_bytes = await client.read_gatt_char("2A6F")
    humidity = int.from_bytes(humidity_bytes[0:2], "little") / 100.0

    print(f"Battery: {battery}%, Temp: {temperature}°C, Humidity: {humidity}%")

After (with bluetooth-sig-python):

import asyncio

from bluetooth_sig import BluetoothSIGTranslator


async def main():
    translator = BluetoothSIGTranslator()

    # ============================================
    # SIMULATED DATA - Replace with actual BLE reads
    # ============================================
    SIMULATED_BATTERY_DATA = bytearray([85])  # Simulates 85% battery
    SIMULATED_TEMP_DATA = bytearray([0x64, 0x09])  # Simulates 24.04°C
    SIMULATED_HUMIDITY_DATA = bytearray([0x3A, 0x13])  # Simulates 49.22%

    # Parse each characteristic - returns values directly
    battery = translator.parse_characteristic(
        "2A19", SIMULATED_BATTERY_DATA
    )  # int
    temp = translator.parse_characteristic(
        "2A6E", SIMULATED_TEMP_DATA
    )  # float
    humidity = translator.parse_characteristic(
        "2A6F", SIMULATED_HUMIDITY_DATA
    )  # float

    print(f"Battery: {battery}%")
    print(f"Temp: {temp}°C")
    print(f"Humidity: {humidity}%")


asyncio.run(main())

What improved:

  • ✅ Single parse call for multiple characteristics

  • ✅ Automatic unit handling (no hardcoded divisors)

  • ✅ Cleaner, more maintainable code

  • ✅ Consistent error handling across all values

Real-World Example 3: Glucose Meter with Context

Based on: OneTouch Verio, AccuChek Guide, Abbott FreeStyle

Before (manual pairing logic):

# SKIP: Migration "before" example (anti-pattern)
# Typical medical device integration pattern
async with BleakClient(device_address) as client:
    measurements = {}
    contexts = {}

    def measurement_handler(sender, data: bytearray):
        seq = int.from_bytes(data[1:3], "little")
        # Complex flag parsing...
        glucose = int.from_bytes(data[3:5], "little")
        measurements[seq] = glucose / 100000.0  # mg/dL conversion

    def context_handler(sender, data: bytearray):
        seq = int.from_bytes(data[1:3], "little")
        # More flag parsing...
        carbs = data[3]
        contexts[seq] = carbs

    await client.start_notify(
        "2A18", measurement_handler
    )  # Glucose Measurement
    await client.start_notify("2A34", context_handler)  # Glucose Context

    # Manual pairing logic
    await asyncio.sleep(5)
    for seq in measurements:
        if seq in contexts:
            print(
                f"Seq {seq}: {measurements[seq]} mg/dL, Carbs: {contexts[seq]}g"
            )

After (with automatic dependency resolution):

# SKIP: Requires BLE hardware and bleak client connection
import asyncio

from bluetooth_sig import BluetoothSIGTranslator


async def main():
    translator = BluetoothSIGTranslator()
    gm_uuid = "2A18"
    gmc_uuid = "2A34"

    measurements_cache = {}
    contexts_cache = {}

    # Example of handling paired characteristics
    def combined_handler(char_uuid: str, data: bytearray):
        # Parse immediately to get the sequence number
        # Returns the parsed value directly (GlucoseMeasurement or GlucoseMeasurementContext)
        parsed = translator.parse_characteristic(char_uuid, data)

        # Cache using the parsed sequence number
        if char_uuid == gm_uuid:
            seq = parsed.sequence_number
            measurements_cache[seq] = data
        elif char_uuid == gmc_uuid:
            seq = parsed.sequence_number
            contexts_cache[seq] = data

        # Parse pairs when we have matching sequence numbers
        matching_seqs = set(measurements_cache.keys()) & set(
            contexts_cache.keys()
        )
        for seq in matching_seqs:
            results = translator.parse_characteristics(
                {
                    gm_uuid: measurements_cache[seq],
                    gmc_uuid: contexts_cache[seq],
                }
            )

            gm = results[gm_uuid]  # GlucoseMeasurementData directly
            gmc = results[gmc_uuid]  # GlucoseMeasurementContextData directly
            print(f"Seq {seq}: Glucose {gm.glucose_concentration} {gm.unit}")
            print(f"Seq {seq}: Carbs {gmc.carbohydrate} g")

            # Remove processed entries
            del measurements_cache[seq]
            del contexts_cache[seq]

    await client.start_notify(
        gm_uuid, lambda s, d: combined_handler(gm_uuid, d)
    )
    await client.start_notify(
        gmc_uuid, lambda s, d: combined_handler(gmc_uuid, d)
    )

What improved:

  • ✅ Automatic validation of sequence number pairing

  • ✅ Type-safe access to complex nested structures

  • ✅ Proper unit handling (mol/L vs mg/dL)

  • ✅ No manual offset calculations

Real-World Example 4: Device Service Discovery

Based on: Bleak’s service_explorer.py pattern

Before (reading and manually parsing all characteristics):

# SKIP: Migration "before" example (anti-pattern)
# Common pattern from BLE exploration tools
async with BleakClient(device_address) as client:
    for service in client.services:
        print(f"[Service] {service.uuid}")
        for char in service.characteristics:
            if "read" in char.properties:
                try:
                    value = await client.read_gatt_char(char)
                    # Manual parsing depends on UUID
                    if char.uuid == "2A19":
                        print(f"  Battery: {value[0]}%")
                    elif char.uuid == "2A37":
                        hr = value[1]
                        print(f"  Heart Rate: {hr} bpm")
                    else:
                        print(f"  Raw: {value.hex()}")
                except Exception as e:
                    print(f"  Error: {e}")

After (with automatic parsing):

import asyncio

from bluetooth_sig import BluetoothSIGTranslator
from bluetooth_sig.gatt.exceptions import CharacteristicParseError

# ============================================
# SIMULATED DATA - Replace with actual BLE reads
# ============================================
SIMULATED_BATTERY_DATA = bytearray([85])  # Simulates 85% battery
SIMULATED_HR_DATA = bytearray([0x00, 0x55])  # Simulates heart rate measurement


async def main():
    translator = BluetoothSIGTranslator()

    # Example: parse known characteristics using UUIDs
    characteristics = {
        "2A19": SIMULATED_BATTERY_DATA,  # Battery
        "2A37": SIMULATED_HR_DATA,  # Heart Rate
    }

    for uuid, raw_bytes in characteristics.items():
        try:
            result = translator.parse_characteristic(uuid, raw_bytes)
            info = translator.get_characteristic_info_by_uuid(uuid)
            # For simple types (int/float), result is the value directly
            # For complex types, result is a dataclass with fields
            if hasattr(result, "heart_rate"):
                print(f"  {info.name}: {result.heart_rate} bpm")
            else:
                print(f"  {info.name}: {result}")
        except CharacteristicParseError:
            print(f"  {uuid}: {raw_bytes.hex()} (parse failed)")


asyncio.run(main())

What improved:

  • ✅ Automatic characteristic identification by UUID

  • ✅ No UUID-specific parsing logic needed

  • ✅ Graceful fallback for unknown characteristics

  • ✅ Human-readable characteristic names

Batch Parsing (Dependencies)

If characteristics depend on each other (e.g., Glucose Measurement + Context), parse them together.

Minimal path (no adapters)

from bluetooth_sig import BluetoothSIGTranslator

# Example data (replace with actual BLE reads)
bpm_bytes = bytearray(
    [0x00, 0x78, 0x00, 0x50, 0x00, 0x46, 0x00]
)  # Blood Pressure
battery_bytes = bytearray([85])  # Battery Level

translator = BluetoothSIGTranslator()

bpm_uuid = "2A35"
battery_uuid = "2A19"

values = {
    bpm_uuid: bpm_bytes,
    battery_uuid: battery_bytes,
}
results = translator.parse_characteristics(values)

print(f"Blood Pressure: {results[bpm_uuid]}")
print(f"Battery: {results[battery_uuid]}%")

With Connection Managers

Example adapters are provided in examples/connection_managers/ as references.

from bluetooth_sig import BluetoothSIGTranslator

# Example data (replace with actual BLE reads)
heart_rate_bytes = bytearray([0x00, 75])  # Heart Rate 75 bpm
battery_bytes = bytearray([92])  # Battery 92%

translator = BluetoothSIGTranslator()

# Simple batch parsing
char_data = {
    "2A37": heart_rate_bytes,
    "2A19": battery_bytes,
}

results = translator.parse_characteristics(char_data)
print(f"Heart Rate: {results['2A37']}")
print(f"Battery: {results['2A19']}%")

With descriptors/services (adapters in examples)

  • Bleak: examples/connection_managers/bleak_utils.py

  • SimplePyBLE: examples/connection_managers/simpleble.py

# SKIP: Requires BLE connection (client.services from bleak)
from bluetooth_sig import CharacteristicName
from bluetooth_sig.types.io import to_parse_inputs
from examples.connection_managers.bleak_utils import bleak_services_to_batch

gm_uuid = "2A18"
gmc_uuid = "2A34"

services = client.services
values = {gm_uuid: gm_bytes, gmc_uuid: gmc_bytes}

batch = bleak_services_to_batch(services, values_by_uuid=values)
char_data, desc_data = to_parse_inputs(batch)
results = translator.parse_characteristics(
    char_data, descriptor_data=desc_data
)

Note: Example adapters are provided as references; update them as needed for your project/backend versions.

Device + Connection Manager (larger apps)

For applications managing multiple devices or complex workflows, use the Device pattern:

# SKIP: Needs real BLE device
from bluetooth_sig import BluetoothSIGTranslator
from bluetooth_sig.device import Device
from bluetooth_sig.types.gatt_enums import CharacteristicName

translator = BluetoothSIGTranslator()
device = Device(address, translator)

# Your manager implements ConnectionManagerProtocol (connect/read/write/notify/etc.)
from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic
from examples.connection_managers.bleak_retry import (
    BleakRetryClientManager,
)

battery = BatteryLevelCharacteristic()
manager = BleakRetryClientManager(address)
device.attach_connection_manager(manager)
await device.connect()

# Type-safe read with characteristic class
parsed = await device.read(BatteryLevelCharacteristic)
await device.disconnect()

Migration Checklist

  • ✅ Keep your BLE client code (Bleak/SimplePyBLE) unchanged

  • ✅ After each read or in notification callbacks, call parse_characteristic(uuid, bytes)

  • ✅ For multiple related characteristics, call parse_characteristics({uuid: bytes})

  • ✅ Include descriptors when needed (via adapters or descriptor_data mapping)

  • ✅ For larger apps, adopt a ConnectionManagerProtocol + Device pattern

Where are the example adapters?

  • examples/connection_managers/bleak_utils.pybleak_services_to_batch(...)

  • examples/connection_managers/simpleble.pysimpleble_services_to_batch(...)

These adapters are intentionally kept in examples to avoid hard dependencies. Copy or adapt them for your project; they may require updates to match your specific library versions and object shapes.

Adoption in Real Projects

Home Assistant Integration Pattern

# SKIP: Requires Home Assistant
from homeassistant.components import bluetooth

from bluetooth_sig.gatt.characteristics import TemperatureCharacteristic


class MySensorEntity(SensorEntity):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._temp = TemperatureCharacteristic()

    def _async_handle_bluetooth_event(
        self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange
    ) -> None:
        """Handle bluetooth callback."""
        # Your existing connection/read logic
        char_data = self._read_characteristic(str(self._temp.uuid))

        # Type-safe parsing - IDE knows value is float
        value = self._temp.parse_value(bytearray(char_data))
        self._attr_native_value = value
        self._attr_native_unit_of_measurement = self._temp.unit

SimplePyBLE Pattern

# SKIP: Requires SimplePyBLE
import simplepyble

from bluetooth_sig.gatt.characteristics import BatteryLevelCharacteristic

battery = BatteryLevelCharacteristic()

peripheral = simplepyble.Peripheral()
peripheral.connect_to_address(device_address)

for service in peripheral.services():
    for characteristic in service.characteristics():
        try:
            data = peripheral.read(service.uuid(), characteristic.uuid())
            value = translator.parse_characteristic(
                characteristic.uuid(), data
            )
            info = translator.get_characteristic_info_by_uuid(
                characteristic.uuid()
            )
            if info:
                print(f"{info.name}: {value} {info.unit or ''}")
        except Exception as e:
            print(f"Error reading {characteristic.uuid()}: {e}")

Troubleshooting

  • Unknown UUID: Ensure you’re using the correct SIG UUID (short or full).

  • Parse failed: Check spec constraints (length, range); result.error_message explains the issue.

  • Descriptor-dependent parsing: Pass descriptor bytes via descriptor_data or use the example adapters.

  • Advertising data: Use AdvertisingPDUParser for parsing advertising packets—see Advertising Parsing Guide.

  • Type hints not working: Ensure you’re using Python 3.10+ for best type checking support.

Next Steps

Pairing Dependent Characteristics (Blood Pressure Example)

Some BLE profiles send related data in separate notifications that must be paired. For example, during a Blood Pressure measurement, the device sends Intermediate Cuff Pressure notifications repeatedly as the cuff inflates, then a final Blood Pressure Measurement. If multiple measurement sessions overlap (or you have multiple devices), you need to pair notifications correctly.

The DependencyPairingBuffer helper buffers notifications and groups them using a caller-defined key (timestamp, sequence number, session ID, etc.).

Real-World Scenario: Blood Pressure Monitor

# SKIP: Requires BLE hardware connection
from datetime import datetime

from bluetooth_sig import BluetoothSIGTranslator
from bluetooth_sig.stream import DependencyPairingBuffer
from bluetooth_sig.types.gatt_enums import CharacteristicName

translator = BluetoothSIGTranslator()

bpm_uuid = "2A35"
icp_uuid = "2A36"


def group_by_timestamp(uuid: str, parsed) -> datetime:
    """Group notifications by their timestamp - same session = same timestamp.

    Args:
        uuid: The characteristic UUID (for reference)
        parsed: The parsed value directly (BloodPressureMeasurement or IntermediateCuffPressure)
    """
    # Both characteristics have optional timestamp fields
    if hasattr(parsed, "optional_fields") and parsed.optional_fields.timestamp:
        return parsed.optional_fields.timestamp

    # Fallback: use current time or synthetic session tracking
    raise ValueError("Timestamp required for grouping")


def on_measurement_complete(results):
    """Called when both BPM and ICP arrive for the same timestamp.

    Results is dict[str, Any] where values are parsed dataclasses directly.
    """
    bpm = results[bpm_uuid]  # BloodPressureMeasurement directly
    icp = results[icp_uuid]  # IntermediateCuffPressure directly

    print(f"Blood Pressure: {bpm.systolic}/{bpm.diastolic} mmHg")
    print(f"Max Cuff Pressure: {icp.current_cuff_pressure} mmHg")

    # Store to database, update UI, etc.


buffer = DependencyPairingBuffer(
    translator=translator,
    required_uuids={bpm_uuid, icp_uuid},
    group_key=group_by_timestamp,
    on_pair=on_measurement_complete,
)

# In your notification handlers (notifications arrive in any order)
async with BleakClient(device_address) as client:
    await client.start_notify(
        bpm_uuid, lambda s, d: buffer.ingest(bpm_uuid, d)
    )
    await client.start_notify(
        icp_uuid, lambda s, d: buffer.ingest(icp_uuid, d)
    )

    # Buffer automatically pairs when both arrive with matching timestamps
    await asyncio.sleep(30)  # Wait for measurements

Why This is Useful

Without the buffer: You’d manually track which ICP belongs to which BPM, especially when:

  • Multiple measurement sessions overlap (nurse takes measurements from multiple patients)

  • Notifications arrive out of order (common with BLE)

  • Network delays cause interleaving

With the buffer: Automatically groups by timestamp, handles out-of-order arrival, and triggers your callback only when complete pairs arrive.

Paired Characteristics Example

Group characteristics by a key (e.g., timestamp, sequence number) and trigger a callback when all required characteristics arrive:

from bluetooth_sig import BluetoothSIGTranslator
from bluetooth_sig.stream import DependencyPairingBuffer

translator = BluetoothSIGTranslator()

hr_uuid = "2A37"  # Heart Rate Measurement
bat_uuid = "2A19"  # Battery Level

received_pairs = []


def on_pair(results):
    """Handle complete pair of characteristics."""
    hr = results[hr_uuid]  # HeartRateData
    bat = results[bat_uuid]  # int
    received_pairs.append(results)
    print(f"Heart Rate: {hr.heart_rate} bpm, Battery: {bat}%")


buffer = DependencyPairingBuffer(
    translator=translator,
    required_uuids={hr_uuid, bat_uuid},
    group_key=lambda uuid, parsed: 0,  # All go to same group
    on_pair=on_pair,
)

# Example data (in real use, comes from BLE notifications)
hr_bytes = bytearray([0x00, 75])  # Heart rate 75 bpm
bat_bytes = bytearray([85])  # Battery 85%

# Ingest data (order-independent)
buffer.ingest(hr_uuid, hr_bytes)
buffer.ingest(bat_uuid, bat_bytes)  # Triggers on_pair callback

print(f"Total received: {len(received_pairs)}")

Notes

  • Generic: Works with any paired characteristics - you define the grouping key

  • Validation: Implement profile-specific validation (sequence matching, etc.) in your on_pair callback

  • Backend-agnostic: Buffer only handles UUID strings and bytes; keep subscription management in your BLE client

  • Order-independent: Notifications can arrive in any order; buffer waits for both before calling your callback