Implementing an Apple Media Service Client on Zephyr RTOS

Apple Media Service (AMS) provides a wireless interface for peripherals to control iOS devices’ media player. It is based on the Bluetooth Low Energy (BLE) protocol, which is supported by Zephyr RTOS and many embedded systems. If you want to learn more about BLE, adafruit has a great Introduction to Bluetooth Low Energy article.

Prerequisites

Hardware

  • An embedded system that supports BLE and Zephyr
  • An iOS device
  • A USB to TTL Serial Cable for console output (optional). It makes debugging a lot easier.

Software

  • Setup a Zephyr development environment (Getting Started Guide)
  • Install a BLE toolbox app for pairing the iOS device and the peripheral (i.e. the embedded system). Nordic’s nRF Connect and Punch Through’s LightBlue are great apps for working with BLE devices.

A Basic Zephyr Application

This article assumes you already know how to create one. If you have never created a Zephyr application before, check out the Application Development Primer on Zephyr project’s website and enjoy.

Enabling Bluetooth

Since Zephyr is a modular system, Bluetooth is not included by default. Some project configuration needs to be set for a BLE peripheral application.

# Enable prink for debug logging
CONFIG_PRINTK=y

# Enable bluetooth for the application
CONFIG_BT=y

# Turn on the bluetooth's debug log
CONFIG_BT_DEBUG_LOG=y

# Turn on the support for BLE peripheral role
CONFIG_BT_PERIPHERAL=y

# Device name
CONFIG_BT_DEVICE_NAME="AMS Remote"

Bluetooth also needs to be enabled during runtime by calling bt_enable. The bluetooth can be enabled synchronously by passing NULL as the argument. It can also be enabled asynchronously by passing a callback pointer.

#include <zephyr.h>
#include <bluetooth/bluetooth.h>

void main(void)
{
    int error = bt_enable(NULL);
    if (error) {
        printk("Bluetooth failed to enable (err %d)\n", error);
        return;
    }
}

Broadcasting as a BLE Peripheral

The first step of connecting to an iOS device is advertising information about the peripheral. This allows the iOS device to discover the peripheral. A peripheral broadcasts two types of data to nearby central devices: advertising data and scan response data.

Advertising Data

Central devices can listen to advertising data in passive scanning. Because the peripheral is repeatedly broadcasting the advertising data, the data should be minimal to reduce energy consumption. Per Apple’s design guidelines, advertising data should contain flags and primary services.

The following code defines the minimal advertising data for a Zephyr application. BT_LE_AD_GENERAL means the peripheral is visible to general discovery. BT_LE_AD_NO_BREDR indicates it does not support BR/EDR connection.

static const struct bt_data advertising_data[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
};

Scanning Response Data

Scan response data generally contain more information than advertising data. The peripheral only sends scan response data when a central request a scan response. According to the guidelines, it should include the local name and TX power level.

#define DEVICE_NAME CONFIG_BT_DEVICE_NAME
#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1)

static const struct bt_data scan_response_data[] = {
    BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN),
    BT_DATA_BYTES(BT_DATA_TX_POWER, 0x0),
};

TX power is the measured signal strength at 1 meter away from the peripheral. The TX power level depends on the design of the peripheral device. The TX power level of an Arduino 101 is around 0 dBm.

Starting BLE Advertising

BLE advertising can start by calling bt_le_adv_start function like the following example. It is important to use BT_LE_ADV_CONN as the advertising parameter or set the BT_LE_ADV_OPT_CONNECTABLE flag to indicate the peripheral is connectable.

int error = bt_enable(NULL);
...

error = bt_le_adv_start(BT_LE_ADV_CONN,
                        advertising_data, ARRAY_SIZE(advertising_data),
                        scan_response_data, ARRAY_SIZE(scan_response_data));
if (error) {
    printk("Advertising failed to start (err %d)\n", error);
    return;
}

Discovering the Peripheral

If you compile and flash the Zephyr application to your controller at this point, the peripheral should be visible to your iOS device. BLE toolbox app, such as Nordic’s nRF Connect, should show the peripheral is connectable.

nRF Connect screenshot shown the peripheral is connectable

Connecting to an iOS device

Once an iOS device notices the peripheral’s advertisement during scanning, it may notify the app (in this case, the BLE toolbox). If the user wants to connect the peripheral, the iOS device initiates the BLE connection. The peripheral may accept or deny the connection.

Connection Callbacks

If the peripheral accepts the connection, it may start to discover the iOS device’s services. The discovery of these services may trigger connection events, such as address resolution or security level upgrade.

These two lines need to be added to the configuration file (i.e. prj.conf). The first one enables the support for the Security Manager Protocol (SMP), so the application can listen to identity resolved and security changed events. The second one enables the TinyCrypt library for securing the connections.

# Enable the support for the Security Manager Protocol
CONFIG_BT_SMP=y

# Enable the TinyCrypt library for secure connections
CONFIG_BT_TINYCRYPT_ECC=y

Declare connection callback functions.

#include <bluetooth/conn.h>

static void connected(struct bt_conn* conn, u8_t err)
{
    if (err) {
        printk("Failed to connect\n");
        return;
    }
    printk("Connected\n");
}

static void disconnected(struct bt_conn* conn, u8_t err)
{
    printk("Disconnected\n");
}

static void identity_resolved(struct bt_conn* conn, const bt_addr_le_t* rpa, const bt_addr_le_t* identity)
{
    printk("Identity resolved\n");
}

static void security_changed(struct bt_conn* conn, bt_security_t level)
{
    printk("Security level changed\n");
}

static struct bt_conn_cb conn_callbacks = {
    .connected = connected,
    .disconnected = disconnected,
    .identity_resolved = identity_resolved,
    .security_changed = security_changed,
};

Registering Callbacks

Zephyr provides a callback registration function. The application may register the callbacks after the bluetooth is enabled.

int error = bt_enable(NULL);
...

bt_conn_cb_register(&conn_callbacks);

Discovering Apple Media Service and Entity Update Characteristic

Apple Media Service, a GATT service, has three characteristics: Remote Command, Entity Update, and Data Source. The peripheral can command the iOS device’s media player or listen to changes via these characteristics. This article only covers the Entity Update characteristic, which notifies the peripheral when media attributes change, but interactions with other characteristics can easily be implemented with similar patterns. More details about AMS can be found in Apple’s documentation

Enable Support for GATT Client Role

The support for the GATT client role needs to be enabled in the project configuration (i.e. prj.conf).

# Enable Support for GATT Client Role
CONFIG_BT_GATT_CLIENT=y

Service and Characteristic UUIDs

The media service and characteristics have UUIDs, which will be used to find the attribute handles of service and characteristics. The bytes should be declared in the reversed order since the location of the less significant byte is different in a UUID and an array.

#include <bluetooth/uuid.h>

// Apple Media Service: 89D3502B-0F36-433A-8EF4-C502AD55F8DC
static struct bt_uuid_128 ams_uuid =
    BT_UUID_INIT_128(0xDC, 0xF8, 0x55, 0xAD, 0x02, 0xC5, 0xF4, 0x8E,
                     0x3A, 0x43, 0x36, 0x0F, 0x2B, 0x50, 0xD3, 0x89);

// Entity Update: 2F7CABCE-808D-411F-9A0C-BB92BA96C102
static struct bt_uuid_128 entity_update_uuid =
    BT_UUID_INIT_128(0x02, 0xC1, 0x96, 0xBA, 0x92, 0xBB, 0x0C, 0x9A,
                     0x1F, 0x41, 0x8D, 0x80, 0xCE, 0xAB, 0x7C, 0x2F);

// Client Characteristic Configuration UUID
static struct bt_uuid_16 gatt_ccc_uuid = BT_UUID_INIT_16(BT_UUID_GATT_CCC_VAL);

Declaring Discover Parameters

During the service and characteristic discovery process, Zephyr is responsible for most of the work and the application is responsible for providing parameters and callbacks.

The parameters only contain the callback at this point, but it will be set later. Make sure these declarations are visible to the connected function (i.e. declared before connected function).

#include <bluetooth/gatt.h>

// Function prototype
static u8_t discovered(struct bt_conn* conn, const struct bt_gatt_attr* attr,
                       struct bt_gatt_discover_params* params);

static struct bt_gatt_discover_params discover_params;

Implementing a Simple Attribute Discovered Callback

If the attribute is found, the callback will output the UUID and handle to the console.

static u8_t discovered(struct bt_conn* conn, const struct bt_gatt_attr* attr,
                       struct bt_gatt_discover_params* params)
{
    if (!attr) {
        printk("Discover complete\n");
        memset(params, 0, sizeof(*params));
        return BT_GATT_ITER_STOP;

    }
    printk("Discovered attribute - uuid: %s, handle: %u\n", bt_uuid_str(params->uuid), attr->handle);
    return BT_GATT_ITER_STOP;
}

Since the callback is invoked when the service is found, return BT_GATT_ITER_STOP to tell Zephyr to stop the discovery.

Discovering Apple Media Service

The peripheral can start to discover the AMS once it is connected to the iOS device.

static void connected(struct bt_conn* conn, u8_t err)
{
    if (err) {
        printk("Failed to connect\n");
        return;
    }
    printk("Connected\n");

    discover_params.func = discovered;
    discover_params.start_handle = 0x0001;
    discover_params.end_handle = 0xFFFF;
    discover_params.type = BT_GATT_DISCOVER_PRIMARY;
    discover_params.uuid = &ams_uuid.uuid;
    bt_gatt_discover(conn, &discover_params);
}

Discovering Entity Update Characteristic and its Descriptor

When the peripheral sends the discovery request to the central, the central will respond to the attribute handle of the service or characteristic of interest. The peripheral can use the attribute handle to read, write or subscribe value.

In the case of primary service discovery, the response also contains the service’s attribute handle range. The handle range allows the peripheral to limit the search range when it discovers the characteristics.

In this example, the peripheral reuses the discover parameters to find the attribute handle all AMS characteristics.

static u8_t discovered(struct bt_conn* conn, const struct bt_gatt_attr* attr,
                       struct bt_gatt_discover_params* params)
{
    if (!attr) {
        printk("Discover complete\n");
        memset(params, 0, sizeof(*params));
        return BT_GATT_ITER_STOP;
    }
    printk("Discovered attribute - uuid: %s, handle: %u\n", bt_uuid_str(params->uuid), attr->handle);

    if (bt_uuid_cmp(params->uuid, &ams_uuid.uuid) == 0) {
        struct bt_gatt_service_val* gatt_service = attr->user_data;
        discover_params.start_handle = attr->handle + 1;
        discover_params.end_handle = gatt_service->end_handle;
        discover_params.type = BT_GATT_DISCOVER_CHARACTERISTIC;
        discover_params.uuid = &entity_update_uuid.uuid;
        bt_gatt_discover(conn, &discover_params);
    } else if (bt_uuid_cmp(params->uuid, &entity_update_uuid.uuid) == 0) {
        discover_params.start_handle = attr->handle + 2;
        discover_params.type = BT_GATT_DISCOVER_DESCRIPTOR;
        discover_params.uuid = &gatt_ccc_uuid.uuid;
        bt_gatt_discover(conn, &discover_params);
    } else if (bt_uuid_cmp(params->uuid, &gatt_ccc_uuid.uuid) == 0) {
    }
    return BT_GATT_ITER_STOP;
}

Subscribing Entity Update

Subscribing the Entity Update to monitor media attribute changes is a prerequisite for writing to Entity Update and Entity Attribute characteristics. If the subscription is not set up before writing to Entity Update or Entity Attribute, the iOS device will return an invalid state error (0xA0).

Declaring GATT Subscription Parameters

The application needs parameters for subscribing to a GATT characteristic.

static u8_t entity_update_notify(struct bt_conn* conn,
                                 struct bt_gatt_subscribe_params* params,
                                 const void* data, u16_t length);

static struct bt_gatt_subscribe_params entity_update_subscribe_params = {
 .notify = entity_update_notify,
 .value = BT_GATT_CCC_NOTIFY,
};

Handling Entity Update Notifications

static u8_t entity_update_notify(struct bt_conn* conn,
                                 struct bt_gatt_subscribe_params* params,
                                 const void* data, u16_t length)
{
    const u8_t* bytes = data;
    printk("EntityID: %u, AttributeID: %u, Flags: %u, Value: ", bytes[0],
           bytes[1], bytes[2]);
    for (u16_t i = 3; i < length; ++i) {
        printk("%c", bytes[i]);
    }
    printk("\n");
    return BT_GATT_ITER_CONTINUE;
}

Configuring Entity Update

Apple Media Service exposes the information of the media player and the active track. The peripheral can request for notifications of this information by writing and reading the Entity Update characteristic. First, the peripheral sends a write request with the attribute of interest. If the write request succeeds, the peripheral may immediately receive a GATT notification contains the current value. The peripheral will also receive notifications if the attribute value changes later.

Declaring GATT Write Parameters

The application needs to prepare the parameters for the write request.

#define AMS_ENTITY_ID_TRACK 2
#define AMS_TRACK_ATTRIBUTE_ID_TITLE 2

static u8_t entity_update_command[2];

static void entity_update_write_response(struct bt_conn* conn, u8_t err,
                                         struct bt_gatt_write_params* params);

static struct bt_gatt_write_params entity_update_write_params = {
    .data = entity_update_command,
    .func = entity_update_write_response,
    .length = 2,
    .offset = 0,
};

Initializing Parameter Values

First, the zephyr needs the values (i.e. addresses) of the characteristic handle and the descriptor handle. This information is available upon the discovery of the characteristic and the descriptor, which can be stored in the parameters.

static u8_t discovered(struct bt_conn* conn, const struct bt_gatt_attr* attr,
                      struct bt_gatt_discover_params* params)
{
    // ...

    if (bt_uuid_cmp(params->uuid, &ams_uuid.uuid) == 0) {
        // ...
    } else if (bt_uuid_cmp(params->uuid, &entity_update_uuid.uuid) == 0) {
        entity_update_write_params.handle = attr->handle + 1;
        entity_update_subscribe_params.value_handle = attr->handle + 1;

        discover_params.uuid = &gatt_ccc_uuid.uuid;
        discover_params.start_handle = attr->handle + 2;
        discover_params.type = BT_GATT_DISCOVER_DESCRIPTOR;
        bt_gatt_discover(conn, &discover_params);
    } else if (bt_uuid_cmp(params->uuid, &gatt_ccc_uuid.uuid) == 0) {
        entity_update_subscribe_params.ccc_handle = attr->handle;
    }
    return BT_GATT_ITER_STOP;
}

Subscribing and Writing Configurations

Once all the parameters are set and the handles are found, the application may subscribe to the Entity Update and write the configuration to it.

static u8_t discovered(struct bt_conn* conn, const struct bt_gatt_attr* attr,
                      struct bt_gatt_discover_params* params)
{
    // ...

    if (bt_uuid_cmp(params->uuid, &ams_uuid.uuid) == 0) {
        // ...
    } else if (bt_uuid_cmp(params->uuid, &entity_update_uuid.uuid) == 0) {
        // ...
    } else if (bt_uuid_cmp(params->uuid, &gatt_ccc_uuid.uuid) == 0) {
        entity_update_subscribe_params.ccc_handle = attr->handle;

        bt_gatt_subscribe(conn, &entity_update_subscribe_params);

        entity_update_command[0] = AMS_ENTITY_ID_PLAYER;
        entity_update_command[1] = AMS_PLAYER_ATTRIBUTE_ID_NAME;
        bt_gatt_write(conn, &entity_update_write_params);
    }
    return BT_GATT_ITER_STOP;
}

Running the Application

If you have completed all the steps above, your application is ready to run. Compile and run the application. Connect your iPhone to the peripheral. You should be able to see the song title shows up on the console.

Updated:

Comments