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.
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
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]… |
Add Source — Message Flow
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]… |
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 |
8. Error Handling — What the Server Sends Back
The server’s response depends on which GATT write method the client uses:
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
gatttool is deprecated in newer BlueZ versions but still works for quick debugging.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.
