BASS Control Point — Every Operation Explained with BlueZ Code

LE Audio Series · Part 2

BASS Control Point — Every Operation Explained with BlueZ Code

Deep dive into all 6 opcodes: packet byte layouts, message flow diagrams, error handling, and real BlueZ GATT write examples.

Quick recap from Part 1: The BASS Client (phone) writes commands to the Broadcast Audio Scan Control Point characteristic to control what the BASS Server (hearing aid) does. This part covers all six commands in detail.

1. Control Point Wire Format

Every write to the Control Point characteristic starts with a 1-byte opcode, followed by zero or more parameter bytes depending on the operation. Think of the opcode as a command code and everything after it as the arguments to that command.

Byte 0 Byte 1 Byte 2 Byte N
Opcode
1 byte
Parameters (0 or more bytes, specific to each opcode)

All 6 Opcodes at a Glance

Opcode Operation Params (bytes) One-line description
0x00 Remote Scan Stopped 0 Tell server: “I stopped scanning for you”
0x01 Remote Scan Started 0 Tell server: “I am scanning for you now”
0x02 Add Source 15+ (variable) Give server a broadcast source to sync to
0x03 Modify Source 9+ (variable) Update an existing source (BIS list, metadata, PA sync)
0x04 Set Broadcast_Code 17 (fixed) Hand over the 16-byte decryption key for an encrypted BIS
0x05 Remove Source 1 (fixed) Delete a source from the server’s tracking list
⚠️ All or Nothing rule: Opcodes 0x00 through 0x03 and 0x05 are conditional together — if a server supports any one of them, it must support all of them. Opcode 0x04 (Set Broadcast_Code) is mandatory on its own regardless.

2. Remote Scan Stopped (0x00)

The simplest operation. The phone tells the hearing aid “I have stopped scanning for broadcast sources on your behalf.” The hearing aid may then decide to start scanning itself, or just wait.

Byte 0
0x00
Opcode: Remote Scan Stopped
No parameters — total write = 1 byte

BlueZ — Write Remote Scan Stopped

/* Remote Scan Stopped — write 1 byte: opcode 0x00
 * Using gatttool on your Linux machine.
 * Replace XX with the handle of the Control Point characteristic.
 * Find it first with:  gatttool -b AA:BB:CC:DD:EE:FF --characteristics
 */

/* Using gatttool (command line) */
gatttool -b AA:BB:CC:DD:EE:FF --char-write-req --handle=0x0012 --value=00

/* ------------------------------------------------------------------ */
/* Using BlueZ GATT D-Bus API in C                                     */
/* Assumes you already have a GattCharacteristic1 proxy object 'cp'   */
/* ------------------------------------------------------------------ */
#include <gio/gio.h>

static void write_remote_scan_stopped(GDBusProxy *cp)
{
    GVariant   *ret;
    GError     *err   = NULL;
    guint8      data  = 0x00;   /* opcode Remote Scan Stopped */

    GVariant *value = g_variant_new_fixed_array(
                          G_VARIANT_TYPE_BYTE, &data, 1, sizeof(guint8));

    /* No options needed for a simple write */
    GVariant *options = g_variant_new("a{sv}", NULL);

    ret = g_dbus_proxy_call_sync(cp,
                                 "WriteValue",
                                 g_variant_new("(@aya{sv})", value, options),
                                 G_DBUS_CALL_FLAGS_NONE,
                                 -1, NULL, &err);
    if (err) {
        g_printerr("Write failed: %s\n", err->message);
        g_error_free(err);
        return;
    }
    g_print("Remote Scan Stopped written successfully\n");
    if (ret) g_variant_unref(ret);
}

3. Remote Scan Started (0x01)

The phone writes this to tell the hearing aid “I am now scanning on your behalf.” The hearing aid can then reduce or stop its own radio scanning to conserve battery. This is the first thing a BASS client should write after connecting and enabling notifications.

Byte 0
0x01
Opcode: Remote Scan Started
No parameters — total write = 1 byte

4. Add Source (0x02) — The Most Important Operation

This is where the real work happens. After the phone finds a Broadcast Source while scanning, it hands all the information to the hearing aid using Add Source. This is a variable-length write because the number of subgroups (BIS groups) can vary.

Add Source Packet Field Layout

B0 B1 B2–B7 B8 B9–B11 B12 B13–B14 B15 B16–B19 B20 B21…
0x02
Opcode
Adv
Addr
Type
Adv
Address
(6 bytes)
Adv
SID
Broadcast
_ID
(3 bytes)
PA
Sync
PA
Interval
(2 bytes)
Num
Sub
groups
BIS_Sync[0]
(4 bytes)
Meta
Len[0]
Meta[0]…
BIS_Sync[1]…

Field Bytes What to put here
Advertiser_Address_Type 1 0x00 = Public address; 0x01 = Random address. Copy from the advertisement you scanned.
Advertiser_Address 6 The 6-byte BD_ADDR of the Broadcast Source device, little-endian.
Advertising_SID 1 The SID (0x00–0x0F) from the ADI field in the AUX_ADV_IND PDU. Used to identify which advertising set the PA belongs to.
Broadcast_ID 3 3-byte channel identifier from the Broadcast Source’s advertising data. Little-endian.
PA_Sync 1 0x00: Don’t sync to PA  |  0x01: Sync via PAST (transfer available)  |  0x02: Sync directly (no PAST)
PA_Interval 2 SyncInfo Interval value in 1.25 ms units. Set to 0xFFFF if you do not know it yet.
Num_Subgroups 1 How many subgroups (BIS groups) you are configuring. Often 1. If 0, no BIS_Sync or Metadata fields follow.
BIS_Sync[i] 4 Bitfield: bit 0 = BIS index 1, bit 1 = BIS index 2, etc. Set a bit to 1 to request sync to that BIS. 0xFFFFFFFF means “sync to anything you can.”
Metadata_Length[i] 1 Length in bytes of the Metadata field that follows. Set to 0 if you have no metadata.
Metadata[i] Varies LTV (Length-Type-Value) formatted metadata for this subgroup. Only present if Metadata_Length > 0.

Add Source — Message Flow

📱 BASS Client
(Smartphone)
─── GATT over BLE ─── 🎧 BASS Server
(Hearing Aid)
Scans and finds
broadcast source

Write CP: Add Source (0x02)
addr, SID, Broadcast_ID, PA_Sync=0x02, BIS_Sync

Server creates BRS entry, starts PA scan

Notify: BRS — PA_Sync_State = Synchronized

PA found and synced
Server syncs to BIG and BIS streams

Notify: BRS — BIS_Sync_State bit set, BIG_Encryption = 0x00

BIS synced, audio flowing 🎵

BlueZ — Build and Write an Add Source Packet

#include <gio/gio.h>
#include <string.h>
#include <stdint.h>

/*
 * build_add_source_packet()
 *
 * Constructs the raw byte array for an Add Source operation.
 * This example:
 *   - Advertiser address type: Random (0x01)
 *   - Advertiser address:      AA:BB:CC:DD:EE:FF
 *   - Advertising SID:         0x05
 *   - Broadcast_ID:            0x123456
 *   - PA_Sync:                 0x02 (sync directly, no PAST)
 *   - PA_Interval:             0xFFFF (unknown)
 *   - Num_Subgroups:           1
 *   - BIS_Sync[0]:             0xFFFFFFFF (no preference, sync any)
 *   - Metadata_Length[0]:      0x00 (no metadata)
 */
static GVariant *build_add_source_packet(void)
{
    uint8_t pkt[20];  /* adjust size if metadata is non-zero */
    int     i = 0;

    /* Opcode */
    pkt[i++] = 0x02;  /* Add Source */

    /* Advertiser_Address_Type */
    pkt[i++] = 0x01;  /* Random address */

    /* Advertiser_Address  (little-endian: FF EE DD CC BB AA) */
    pkt[i++] = 0xFF;
    pkt[i++] = 0xEE;
    pkt[i++] = 0xDD;
    pkt[i++] = 0xCC;
    pkt[i++] = 0xBB;
    pkt[i++] = 0xAA;

    /* Advertising_SID */
    pkt[i++] = 0x05;

    /* Broadcast_ID (3 bytes, little-endian: 56 34 12) */
    pkt[i++] = 0x56;
    pkt[i++] = 0x34;
    pkt[i++] = 0x12;

    /* PA_Sync: 0x02 = Synchronize to PA, PAST not available */
    pkt[i++] = 0x02;

    /* PA_Interval (2 bytes, little-endian): 0xFFFF = unknown */
    pkt[i++] = 0xFF;
    pkt[i++] = 0xFF;

    /* Num_Subgroups */
    pkt[i++] = 0x01;

    /* BIS_Sync[0]: 4 bytes, little-endian. 0xFFFFFFFF = no preference */
    pkt[i++] = 0xFF;
    pkt[i++] = 0xFF;
    pkt[i++] = 0xFF;
    pkt[i++] = 0xFF;

    /* Metadata_Length[0]: 0x00 = no metadata */
    pkt[i++] = 0x00;

    return g_variant_new_fixed_array(
               G_VARIANT_TYPE_BYTE, pkt, i, sizeof(uint8_t));
}

/* Write the packet to the Control Point characteristic */
static void write_add_source(GDBusProxy *cp)
{
    GVariant *payload = build_add_source_packet();
    GVariant *options = g_variant_new("a{sv}", NULL);
    GError   *err     = NULL;

    GVariant *ret = g_dbus_proxy_call_sync(
                        cp, "WriteValue",
                        g_variant_new("(@aya{sv})", payload, options),
                        G_DBUS_CALL_FLAGS_NONE, -1, NULL, &err);

    if (err) {
        g_printerr("Add Source write failed: %s\n", err->message);
        g_error_free(err);
    } else {
        g_print("Add Source written OK\n");
        if (ret) g_variant_unref(ret);
    }
}

5. Modify Source (0x03)

After you have added a source with opcode 0x02, you might want to change which BISes the server is syncing to, or update the metadata. Modify Source lets you do that without removing and re-adding the source. You identify the source using the Source_ID that the server assigned when it accepted Add Source.

B0 B1 B2 B3–B4 B5 B6–B9 B10 B11…
0x03
Opcode
Source
_ID
PA
Sync
PA
Interval
Num
Sub
BIS_Sync[0]
(4 bytes)
Meta
Len[0]
Meta[0]…
Key difference from Add Source: Modify Source includes the Source_ID as the first parameter — this is the ID the server assigned when it accepted your Add Source. You must read the Broadcast Receive State characteristic to get this ID.

To stop syncing entirely without removing the source, write PA_Sync = 0x00 and BIS_Sync[i] = 0x00000000 for all subgroups. To switch to different BISes, just update the BIS_Sync bitfield.

6. Set Broadcast_Code (0x04) — Handing Over the Key

When the Broadcast Receive State characteristic shows BIG_Encryption = 0x01 (Broadcast_Code required), the server is saying “I can hear the broadcast but I cannot decrypt it. Please give me the key.”

The phone then writes the 16-byte Broadcast_Code (received from the content provider, e.g. printed on a concert ticket QR code) using this operation.

B0 B1 B2 … B17
0x04
Opcode
Source
_ID
Broadcast_Code — 16 bytes of raw key material

/*
 * write_broadcast_code()
 *
 * Writes opcode 0x04 + Source_ID + 16-byte Broadcast_Code.
 * source_id   : the ID returned by the server in Broadcast Receive State
 * bcast_code  : pointer to 16-byte key
 */
static void write_broadcast_code(GDBusProxy *cp,
                                  uint8_t     source_id,
                                  const uint8_t *bcast_code)
{
    uint8_t pkt[18];

    pkt[0] = 0x04;              /* opcode: Set Broadcast_Code */
    pkt[1] = source_id;         /* Source_ID from Broadcast Receive State */
    memcpy(&pkt[2], bcast_code, 16); /* 16-byte Broadcast_Code */

    GVariant *payload = g_variant_new_fixed_array(
                            G_VARIANT_TYPE_BYTE, pkt, 18, sizeof(uint8_t));
    GVariant *options = g_variant_new("a{sv}", NULL);
    GError   *err     = NULL;

    GVariant *ret = g_dbus_proxy_call_sync(
                        cp, "WriteValue",
                        g_variant_new("(@aya{sv})", payload, options),
                        G_DBUS_CALL_FLAGS_NONE, -1, NULL, &err);

    if (err) {
        g_printerr("Set Broadcast_Code failed: %s\n", err->message);
        g_error_free(err);
    } else {
        g_print("Broadcast_Code sent to server (Source_ID=0x%02X)\n",
                source_id);
        if (ret) g_variant_unref(ret);
    }
}

/* Example usage with a known key */
static void example_set_code(GDBusProxy *cp)
{
    uint8_t my_key[16] = {
        0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
        0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10
    };
    write_broadcast_code(cp, 0x01 /* source_id */, my_key);
}

7. Remove Source (0x05)

Tells the server to forget about a broadcast source entirely — delete the corresponding Broadcast Receive State entry. The server will only accept this if it is not currently synchronised to the PA or any BIS for that source. If it is still synced, you must first use Modify Source to stop synchronisation.

Byte 0 Byte 1
0x05
Opcode
Source_ID
1 byte
⚠️ Rejection rule: The server must reject Remove Source if the Broadcast Receive State for that Source_ID still shows PA_Sync_State = 0x02 (Synchronized) or BIS_Sync_State has any bits set. Always call Modify Source first to stop sync, wait for the notification confirming sync is stopped, then call Remove Source.

8. Error Handling — What the Server Sends Back

The server’s response depends on which GATT write method the client uses:

Write Method Used Problem Detected Server Action
Write Without Response Wrong length, bad opcode, invalid Source_ID Silently ignores the packet. No response at all.
Write Characteristic Value Total length wrong ATT Error Response: Write Request Rejected
Write Characteristic Value Opcode not recognised ATT Error Response: 0x80 Opcode Not Supported
Write Characteristic Value Source_ID does not exist on the server ATT Error Response: 0x81 Invalid Source_ID

9. Discovering BASS with BlueZ Tools

Before writing any operations, you need to find the Control Point characteristic handle. Here is how to do it from the Linux command line using bluetoothctl and gatttool:

# ── Step 1: Scan and connect using bluetoothctl ──────────────────────
bluetoothctl
[bluetooth]# scan on
[bluetooth]# connect AA:BB:CC:DD:EE:FF
[bluetooth]# pair AA:BB:CC:DD:EE:FF
[bluetooth]# trust AA:BB:CC:DD:EE:FF

# ── Step 2: List all characteristics ─────────────────────────────────
# Open a second terminal and use gatttool
gatttool -b AA:BB:CC:DD:EE:FF --characteristics

# Look for these UUIDs in the output:
#   0x2bc0  →  Broadcast Audio Scan Control Point
#   0x2bc1  →  Broadcast Receive State

# Example output line:
# handle: 0x0012, char properties: 0x0c, char value handle: 0x0013,
#   uuid: 00002bc0-0000-1000-8000-00805f9b34fb

# ── Step 3: Enable notifications on Broadcast Receive State ──────────
# CCCD handle is typically char value handle + 1 (e.g. 0x0015 + 1 = 0x0016)
gatttool -b AA:BB:CC:DD:EE:FF \
         --char-write-req \
         --handle=0x0016 \
         --value=0100         # 0x0001 = enable notifications

# ── Step 4: Write Remote Scan Started ────────────────────────────────
gatttool -b AA:BB:CC:DD:EE:FF \
         --char-write-req \
         --handle=0x0013 \
         --value=01

# ── Step 5: Write Add Source (example with known source) ─────────────
# Bytes: opcode=02, addrtype=01, addr=FF:EE:DD:CC:BB:AA (LE),
#        SID=05, Broadcast_ID=563412 (LE), PA_Sync=02,
#        PA_Interval=FFFF, Num_Subgroups=01, BIS_Sync=FFFFFFFF, MetaLen=00
gatttool -b AA:BB:CC:DD:EE:FF \
         --char-write-req \
         --handle=0x0013 \
         --value=0201FFEEDDCCBBAA05563412020xFFFF01FFFFFFFF00
💡 Pro tip: In a production BlueZ application, use the D-Bus GATT API rather than gatttool. The D-Bus API is the stable interface. gatttool is deprecated in newer BlueZ versions but still works for quick debugging.

📖 What’s next in Part 3?

We will dissect the Broadcast Receive State characteristic field by field, walk through the PA_Sync and BIG_Encryption state machines with diagrams, cover the reconnection / re-notify behaviour, and put together a complete BlueZ GATT client that subscribes to notifications and reacts to state changes.

Coming up: Broadcast Receive State field layout · PA_Sync state machine · BIG_Encryption states · BlueZ notification handler · Full client example

Leave a Reply

Your email address will not be published. Required fields are marked *