Skip to content

Index

BLE Codec

Introduction

The BLE Codec is designed to streamline communication with BLE devices by abstracting the complexity of BLE services, characteristics, and their data fields into a user-friendly API.

Key Concepts in BLE

  1. GATT (Generic Attribute Profile): Defines how data is organized and exchanged between BLE devices.
  2. Profiles: Collections of services that define the behavior of a device for a specific use case.
  3. Services: Collections of characteristics and relationships to other services that encapsulate the behavior of part of a device.
  4. Characteristics: Data containers that include a value, properties, and descriptors.
  5. Descriptors: Additional attributes that provide more information about characteristics.

The Zelos BLE Codec simplifies these concepts into two main abstractions, making interaction straightforward and efficient:

  • Messages: Represent individual BLE characteristics and handle their communication.
  • Signals: Represent individual data fields within characteristics, supporting read/write operations.

Read these!

To get a full understanding of about this, please read through the BLE Schema and BLE Link documentation.

Messages

Messages in the BLE codec represent individual characteristics. They handle the encoding/decoding of data and manage notifications.

Signals

Signals represent individual data fields within characteristics, supporting read/write operations.

Example Configuration

Consider a BLE device configuration for a fitness tracker:

fitness_profile.yml

# Here is an example BLE codec configuration file. It contains the following:
#
# Services:
# - UUID: A unique identifier for the service.
# - Characteristics: A list of characteristics that belong to this service.
#
# Characteristics:
# - UUID: A unique identifier for the characteristic.
# - Service UUID: A associated unique identifier for the characteristics services.
# - Properties: A set of properties that describe the behavior of the characteristic (e.g., read, write, notify, indicate).
# - Fields: The actual data contained in the characteristic. Essentially a representation of the raw bytes payload
#
# Characteristics Fields:
# - Type: The data type of the field
# - Offset: The offset of the field in the characteristic value
# - Length: The length of the field in bytes
# - Default: The default value of the field
# - Unit: The unit of the field
# - Min: The minimum value for the field (if applicable)
# - Max: The maximum value for the field (if applicable)
# - Array Size: The array size for the field (if applicable)
description: "Fitness Tracker BLE Codec Configuration Example"
byteorder: "little"

services:
  heart_rate_service:
    uuid: "0000180D-0000-1000-8000-00805F9B34FB"
    characteristics:
      - heart_rate_measurement
      - body_sensor_location

  fitness_service:
    uuid: "00001826-0000-1000-8000-00805F9B34FB"
    characteristics:
      - step_count
      - calories_burned
      - distance_traveled
      - fitness_goal
      - user_settings

characteristics:
  heart_rate_measurement:
    uuid: "00002A37-0000-1000-8000-00805F9B34FB"
    service_uuid: "0000180D-0000-1000-8000-00805F9B34FB"
    properties: ["read", "notify"]
    fields:
      heart_rate:
        datatype: "uint8"
        offset: 0
        length: 1
        unit: "bpm"
      energy_expended:
        datatype: "uint16"
        offset: 1
        length: 2
        array_size: 2
        unit: "kJ"

  body_sensor_location:
    uuid: "00002A38-0000-1000-8000-00805F9B34FB"
    service_uuid: "0000180D-0000-1000-8000-00805F9B34FB"
    properties: ["read"]
    fields:
      location:
        datatype: "enum"
        value_table: "body_sensor_location"
        offset: 0
        length: 1

  step_count:
    uuid: "00002A53-0000-1000-8000-00805F9B34FB"
    service_uuid: "00001826-0000-1000-8000-00805F9B34FB"
    properties: ["read", "notify"]
    fields:
      steps:
        datatype: "uint32"
        offset: 0
        length: 4
        unit: "steps"

  calories_burned:
    uuid: "00002A66-0000-1000-8000-00805F9B34FB"
    service_uuid: "00001826-0000-1000-8000-00805F9B34FB"
    properties: ["read", "notify"]
    fields:
      calories:
        datatype: "uint16"
        offset: 0
        length: 2
        unit: "kcal"

  distance_traveled:
    uuid: "00002A67-0000-1000-8000-00805F9B34FB"
    service_uuid: "00001826-0000-1000-8000-00805F9B34FB"
    properties: ["read", "notify"]
    fields:
      distance:
        datatype: "float"
        offset: 0
        length: 4
        unit: "meters"

  fitness_goal:
    uuid: "00002A7E-0000-1000-8000-00805F9B34FB"
    service_uuid: "00001826-0000-1000-8000-00805F9B34FB"
    properties: ["read", "write"]
    fields:
      daily_steps_goal:
        datatype: "uint32"
        offset: 0
        length: 4
        unit: "steps"
      daily_calories_goal:
        datatype: "uint16"
        offset: 4
        length: 2
        unit: "kcal"
      daily_distance_goal:
        datatype: "float"
        offset: 6
        length: 4
        unit: "meters"

  user_settings:
    uuid: "00002A80-0000-1000-8000-00805F9B34FB"
    service_uuid: "00001826-0000-1000-8000-00805F9B34FB"
    properties: ["read", "write"]
    fields:
      age:
        datatype: "uint8"
        offset: 0
        length: 1
        unit: "years"
      weight:
        datatype: "uint8"
        offset: 1
        length: 1
        unit: "kg"
      height:
        datatype: "uint8"
        offset: 2
        length: 1
        unit: "cm"
      preferences:
        datatype: "user_preferences"
        offset: 3
        length: 4

value_tables:
  body_sensor_location:
    0: "OTHER"
    1: "CHEST"
    2: "WRIST"
    3: "FINGER"
    4: "HAND"
    5: "EAR_LOBE"
    6: "FOOT"

custom_types:
  user_preferences:
    mode:
      datatype: "uint8"
      offset: 0
      length: 1
    units:
      datatype: "uint8"
      offset: 1
      length: 1
    theme:
      datatype: "uint8"
      offset: 2
      length: 1
    notifications:
      datatype: "uint8"
      offset: 3
      length: 1

Configuration Details

This example fitness_profile.yml includes: - Message configurations for characteristics. - Signal configurations for characteristics data.

Quick Start Guide

This section provides practical examples to demonstrate how to use the BleCodec, BleMessage, and signals in an application.

Basic Pytest Setup

To set up a basic codec, you need to initialize it with a name, link, and configuration file. Here's a quick example to get you started:

conftest.py

import pytest
import yaml
from zeloscloud.links.ble import BleakBleLink
from zeloscloud.codecs.ble import BleCodec


@pytest.fixture(scope="session")
def ble_link():
    """Initialize and open a BLE connection."""
    link_config = {
        "address": "AA:BB:CC:11:22:33",
        "timeout": 60.0,
    }
    with BleakBleLink(name="ble-link", link_config=link_config) as ble_link:
        yield ble_link


@pytest.fixture(scope="session")
def ble_config():
    """Load the BLE config YAML which defines the characteristics."""
    with open("fitness_profile.yml", "r") as f:
        config = yaml.safe_load(f)
    yield config


@pytest.fixture(scope="session")
def ble_codec(ble_link, ble_config):
    """Create a BLE codec which will set up notifications, messages, signals, and more."""
    with BleCodec(name="ble-codec", link=ble_link, config=ble_config) as codec:
        yield codec

Pytest Fixtures

We are utilizing pytest fixtures to inject dependencies into our tests and setups. This method ensures that our components are modular and can be tested independently. The ble_link and ble_codec fixtures can also be used as standalone components.

After setting up the codec with the configuration, you can use it in your application to send and receive characteristics and their data. Here's an example showing how to interact with the BleCodec.

Writing Tests

This section provides practical examples to demonstrate how to write tests using the BleCodec, BleMessage, and signals. We will also utilize a custom check fixture to make assertions.

test_ble_codec.py

def test_heart_rate_measurement(ble_codec, check):
    """Test the HeartRateMeasurement characteristic."""
    # Receiving and checking the HeartRate value
    ble_codec.heart_rate_measurement.recv()
    check.that(ble_codec.heart_rate_measurement.heart_rate, "is greater than", 60)


def test_body_sensor_location(ble_codec, check):
    """Test the BodySensorLocation characteristic."""
    # Receiving and checking the BodySensorLocation value
    ble_codec.body_sensor_location.recv()
    check.that(ble_codec.body_sensor_location.location, "is in", ["CHEST", "WRIST", "FINGER"])


def test_step_count(ble_codec, check):
    """Test the StepCount characteristic."""
    # Receiving and checking the StepCount value
    ble_codec.step_count.recv()
    check.that(ble_codec.step_count.steps, "is less than", 10000)


def test_fitness_goal(ble_codec, check):
    """Test the FitnessGoal characteristic."""
    # Setting and sending the FitnessGoal value
    ble_codec.fitness_goal.daily_steps_goal.set(8000)
    ble_codec.fitness_goal.daily_calories_goal.set(500)
    ble_codec.fitness_goal.daily_distance_goal.set(7.5)
    ble_codec.fitness_goal.send()

    # Receiving and checking the FitnessGoal value
    ble_codec.fitness_goal.recv()
    check.that(ble_codec.fitness_goal.daily_steps_goal, "==", 8000)
    check.that(ble_codec.fitness_goal.daily_calories_goal, "==", 500)
    check.that(ble_codec.fitness_goal.daily_distance_goal, "is approximately", 7.5)


def test_user_settings(ble_codec, check):
    """Test the UserSettings characteristic."""
    # Setting and sending the UserSettings value
    ble_codec.user_settings.age.set(30)
    ble_codec.user_settings.weight.set(70)
    ble_codec.user_settings.height.set(175)

    # Notice how nested members are appended by `_`
    # You can set each member value directly with `.set()``
    ble_codec.user_settings.preferences_mode.set(1)
    ble_codec.user_settings.preferences_units.set(0)
    ble_codec.user_settings.preferences_theme.set(2)
    ble_codec.user_settings.preferences_notifications.set(1)

    # Send the full payload
    ble_codec.user_settings.send()

    # Receiving and checking the UserSettings value
    ble_codec.user_settings.recv()
    check.that(ble_codec.user_settings.age, "==", 30)
    check.that(ble_codec.user_settings.weight, "==", 70)
    check.that(ble_codec.user_settings.height, "==", 175)
    check.that(ble_codec.user_settings.preferences_mode, "==", 1)
    check.that(ble_codec.user_settings.preferences_units, "==", 0)
    check.that(ble_codec.user_settings.preferences_theme, "==", 2)
    check.that(ble_codec.user_settings.preferences_notifications, "==", 1)


def test_notifications(ble_codec, check):
    """Test subscribing to notifications."""

    def on_heart_rate_notification(sender, data):
        print(f"Notification from {sender}: {data}")
        # You can do custom check.that() calls in here as well!

    # Subscribe to notifications for HeartRateMeasurement
    ble_codec.link.subscribe_to_notification(heart_rate_measurement.uuid, on_heart_rate_notification)


def test_iterate_codec_messages_signals(ble_codec, check):
    """Test iterating over codec messages and signals."""
    for msg in ble_codec.messages.values():
        print(f"Message: {msg.name}")
        # Perform some checks on each message
        check.that(msg.name, "is not empty")
        check.that(msg.uuid, "is not empty")

        check.that(msg.service_name, "is not empty")
        check.that(msg.service_uuid, "is not empty")

        for sig in msg:
            print(f"Signal: {sig.name}")

            # Perform some checks on each signal
            check.that(sig.name, "is not empty")

            # You can even call `.reset()` to set the default value (if provided)
            # and then send the message!
            sig.reset()

        if "write" in msg.properties:
            msg.send()

Notification Subscription

If we expected notifications to happen periodically, we don't need to call recv(), as the codec will automatically update signals based on the data from incoming notifications

Troubleshooting

Permissions

If you encounter permission issues when using the BLE link, you might need to adjust your system's Bluetooth settings. On Linux, you can use bluetoothctl to manage Bluetooth devices and connections.

If you already have bluetooth started, trying giving it a restart:

sudo systemctl restart bluetooth

If you don't have bluetooth enabled, turn it on:

sudo systemctl enable bluetooth
sudo systemctl start bluetooth

Once this is working, you can try enabling power/agent/scan/discovery with these:

sudo bluetoothctl
[bluetooth] power on
[bluetooth] agent on
[bluetooth] default-agent
[bluetooth] scan on
[bluetooth] discoverable on

Debugging

If you need to debug the BLE connection, you can use the bluetoothctl tool to interact with Bluetooth devices.

bluetoothctl list
bluetoothctl connect <device_address>
[bluetooth] pair <device_address>
[bluetooth] trust <device_address>
bluetoothctl info <device_address>

You can find additional information on setting up here: Managing Bluetooth on Linux with Bluetoothctl

API Reference

See zeloscloud.codecs.ble in the API Reference.