Broadcast Receive State — Field Guide, State Machines & BlueZ Client

LE Audio Series · Part 3

Broadcast Receive State — Field Guide, State Machines & BlueZ Client

Complete breakdown of the Broadcast Receive State characteristic: every field, the PA sync state machine, BIG encryption states, and a full BlueZ GATT notification handler in C.

Series recap: Part 1 covered roles and architecture. Part 2 covered all 6 Control Point opcodes. This part focuses on what the server tells you back through the Broadcast Receive State characteristic.

1. What Is the Broadcast Receive State Characteristic?

Think of the Broadcast Receive State (BRS) characteristic as the server’s live status dashboard for a single broadcast source. Every time something changes — the server found the PA, lost sync, got an encryption key, started decrypting — it updates this characteristic and sends a GATT notification to all subscribed clients.

A server can have multiple BRS characteristics, one for each broadcast source it is tracking. Each instance is independent and carries the state for one source. If the server is not tracking any source on a particular BRS characteristic, that characteristic will have a zero-length value.

2. Broadcast Receive State — Full Field Layout

Scroll right to see all fields →

B0 B1 B2–B7 B8 B9–B11 B12 B13 B14–B29* B15* B16–B19* B20* B21+*
Source
_ID
Src
Addr
Type
Source
Address
(6B)
Src
Adv
SID
Broadcast
_ID (3B)
PA
Sync
State
BIG
Enc
Bad
Code
(0 or 16B)
Num
Sub
BIS
Sync
State[i]
Meta
Len[i]
Meta
[i]
* Byte offsets marked with * shift depending on Bad_Code length and are per-subgroup for the last three fields.

Field Bytes Values and meaning
Source_ID 1 Server-assigned unique ID for this source. Range 0x00–0xFF. You use this in Modify Source, Set Broadcast_Code, and Remove Source operations.
Source_Address_Type 1 0x00 = Public address  |  0x01 = Random address. Mirrors what you sent in Add Source.
Source_Address 6 BD_ADDR of the Broadcast Source. The server updates this if it learns the address has changed (e.g. from a PAST transfer).
Source_Adv_SID 1 The Advertising SID from the broadcast source’s extended advertisement. Range 0x00–0x0F.
Broadcast_ID 3 3-byte broadcast identifier. Little-endian.
PA_Sync_State 1 0x00 Not synced  |  0x01 SyncInfo Request  |  0x02 Synced to PA  |  0x03 Failed to sync  |  0x04 No PAST
BIG_Encryption 1 0x00 Not encrypted  |  0x01 Broadcast_Code required  |  0x02 Decrypting  |  0x03 Bad_Code (wrong key)
Bad_Code 0 or 16 Empty (0 bytes) unless BIG_Encryption = 0x03. When 0x03, this field contains the incorrect 16-byte key the client sent, so you know which one failed.
Num_Subgroups 1 Number of subgroups in the BIG. If 0, no BIS_Sync_State or Metadata fields follow.
BIS_Sync_State[i] 4 Per-subgroup bitfield. Bit 0 = BIS index 1, bit 1 = BIS index 2, etc. 1 = synced to that BIS, 0 = not synced. 0xFFFFFFFF = failed to sync to the BIG.
Metadata_Length[i] 1 Length of Metadata field for this subgroup. 0 if none.
Metadata[i] Varies LTV-formatted metadata for this subgroup. Only present if Metadata_Length > 0.

3. PA_Sync_State Machine

The PA_Sync_State field transitions between 5 states. Understanding these transitions is critical for writing a robust BASS client that reacts correctly to every notification.

0x00
Not Synchronized
to PA
Initial state
0x01
SyncInfo Request
Waiting for PAST from client

Add/Modify Source
(PA_Sync ≠ 0x00)

timeout → 0x04 No PAST
Add/Modify Source
(PA_Sync=0x01 and
server supports PAST)
0x03
Failed to Sync
to PA

PA found → synced
sync failed
0x02
Synchronized
to PA ✓
(reset to Not Synced
when synced state
is entered again)
Modify Source (PA_Sync=0x00)
or server loses PA
→ back to 0x00 Not Synced
0x04
No PAST
PAST timeout or
PAST not supported

State Name What your client should do when it sees this
0x00 Not Synchronized Normal idle state. Can also happen after losing sync. No action needed unless you want to retry.
0x01 SyncInfo Request Server wants you to do a PAST transfer. If you are already synced to the PA, initiate the PAST procedure immediately. This is time-sensitive — the server has a timeout.
0x02 Synchronized to PA Good. The server is synced to the periodic advertising train. Check BIG_Encryption and BIS_Sync_State next.
0x03 Failed to Sync to PA Source not reachable. Maybe the source moved out of range or the address was wrong. Consider sending Modify Source with updated info, or Remove Source.
0x04 No PAST The server needed a PAST transfer but the timeout expired before you did it, or the server does not support PAST. Send Modify Source with PA_Sync = 0x02 (direct sync) instead.

4. BIG_Encryption State Machine

The BIG_Encryption field tells you the encryption status of the broadcast stream. This only becomes meaningful once the server has synced to the PA (PA_Sync_State = 0x02).

0x00 — Not Encrypted
BIS streams have no encryption
server detects
unencrypted BIS

0x01 — Broadcast_Code Required
Server found encrypted BIS, waiting for key
Server detects encrypted BIS and has no key
Client writes Set Broadcast_Code operation
0x02 — Decrypting ✓
Correct key, audio flowing
↙   ↘
0x03 — Bad_Code ✗
Wrong key sent; Bad_Code field shows it
💡 Bad_Code handling: When you see BIG_Encryption = 0x03, the server also writes the wrong key you sent into the Bad_Code field (16 bytes). This lets you confirm which key failed and try another one. Simply send Set Broadcast_Code again with the correct key.

5. Notification Behaviour — When Does the Server Notify?

Trigger Server Action
Any BRS field changes value Sends a GATT notification to all connected clients that have enabled notifications on that BRS characteristic.
A bonded client reconnects If the BRS is non-empty, the server immediately notifies the current BRS value. This keeps the client in sync after a reconnection without needing a read.
Server accepts an Add Source operation Creates a new BRS entry. Notifies. Then notifies again each time sync state changes (PA syncing, BIS syncing, etc.).
Server autonomously syncs (without a client command) Server can discover and sync to broadcasts on its own. It creates a BRS entry and notifies. Source_ID is still assigned by the server.
Server accepts a Remove Source operation Clears the BRS characteristic to zero-length. Notifies with empty value.

6. BlueZ GATT Client — Subscribe and Parse Notifications

Here is a complete BlueZ C program that connects to a BASS server, enables notifications on all Broadcast Receive State characteristics, parses each notification, and prints human-readable state information. This is the kind of code you would run on your Ubuntu Linux development machine to debug a BASS server implementation.

/* bass_client.c
 * BlueZ D-Bus GATT client for Broadcast Audio Scan Service (BASS)
 *
 * Compile:
 *   gcc -o bass_client bass_client.c \
 *       $(pkg-config --cflags --libs gio-2.0 glib-2.0)
 *
 * Usage:
 *   ./bass_client AA:BB:CC:DD:EE:FF
 *
 * Requires: BlueZ 5.50+, device already paired.
 */

#include <gio/gio.h>
#include <glib.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

/* BASS UUID strings (128-bit form for D-Bus path matching) */
#define BASS_SERVICE_UUID   "0000184f-0000-1000-8000-00805f9b34fb"
#define BRS_CHAR_UUID       "00002bc1-0000-1000-8000-00805f9b34fb"
#define CP_CHAR_UUID        "00002bc0-0000-1000-8000-00805f9b34fb"

/* ─── PA Sync State human-readable labels ────────────────────── */
static const char *pa_sync_state_str(uint8_t state)
{
    switch (state) {
    case 0x00: return "Not synchronized to PA";
    case 0x01: return "SyncInfo Request (send PAST!)";
    case 0x02: return "Synchronized to PA";
    case 0x03: return "Failed to synchronize to PA";
    case 0x04: return "No PAST (timeout)";
    default:   return "RFU";
    }
}

/* ─── BIG Encryption state human-readable labels ─────────────── */
static const char *big_enc_str(uint8_t enc)
{
    switch (enc) {
    case 0x00: return "Not encrypted";
    case 0x01: return "Broadcast_Code required";
    case 0x02: return "Decrypting (correct key)";
    case 0x03: return "Bad_Code (wrong key!)";
    default:   return "RFU";
    }
}

/* ─── Parse and print a Broadcast Receive State value ────────── */
static void parse_brs(const uint8_t *data, gsize len)
{
    if (len == 0) {
        printf("  [BRS] Empty — no source tracked\n");
        return;
    }
    if (len < 15) {
        printf("  [BRS] Truncated packet (len=%zu)\n", len);
        return;
    }

    int i = 0;

    uint8_t source_id      = data[i++];
    uint8_t addr_type      = data[i++];
    /* Address is 6 bytes, little-endian */
    const uint8_t *addr    = &data[i]; i += 6;
    uint8_t adv_sid        = data[i++];
    /* Broadcast_ID is 3 bytes little-endian */
    uint32_t broadcast_id  = data[i] | (data[i+1]<<8) | (data[i+2]<<16);
    i += 3;
    uint8_t pa_sync        = data[i++];
    uint8_t big_enc        = data[i++];

    printf("\n  ┌─────────────────────────────────────────\n");
    printf("  │ Source_ID        : 0x%02X\n", source_id);
    printf("  │ Address          : %02X:%02X:%02X:%02X:%02X:%02X (%s)\n",
           addr[5], addr[4], addr[3], addr[2], addr[1], addr[0],
           addr_type == 0 ? "Public" : "Random");
    printf("  │ Adv SID          : 0x%02X\n", adv_sid);
    printf("  │ Broadcast_ID     : 0x%06X\n", broadcast_id);
    printf("  │ PA_Sync_State    : 0x%02X — %s\n",
           pa_sync, pa_sync_state_str(pa_sync));
    printf("  │ BIG_Encryption   : 0x%02X — %s\n",
           big_enc, big_enc_str(big_enc));

    /* Bad_Code: present only when BIG_Encryption == 0x03 */
    if (big_enc == 0x03) {
        if (i + 16 <= (int)len) {
            printf("  │ Bad_Code         : ");
            for (int k = 0; k < 16; k++)
                printf("%02X ", data[i + k]);
            printf("\n");
        }
        i += 16;
    }

    if (i >= (int)len) {
        printf("  └─────────────────────────────────────────\n");
        return;
    }

    uint8_t num_subgroups = data[i++];
    printf("  │ Num_Subgroups    : %d\n", num_subgroups);

    for (int sg = 0; sg < num_subgroups && i + 5 <= (int)len; sg++) {
        uint32_t bis_sync = data[i]    | (data[i+1]<<8)  |
                            (data[i+2]<<16) | (data[i+3]<<24);
        i += 4;
        uint8_t meta_len = data[i++];

        printf("  │   Subgroup[%d]\n", sg);
        if (bis_sync == 0xFFFFFFFF)
            printf("  │     BIS_Sync_State: 0xFFFFFFFF — Failed to sync to BIG\n");
        else
            printf("  │     BIS_Sync_State: 0x%08X (bits = synced BIS indexes)\n",
                   bis_sync);
        printf("  │     Metadata_Len  : %d bytes\n", meta_len);
        i += meta_len;  /* skip metadata payload */
    }
    printf("  └─────────────────────────────────────────\n");
}

/* ─── D-Bus signal handler for PropertiesChanged ─────────────── */
static void on_properties_changed(GDBusProxy *proxy,
                                   GVariant   *changed,
                                   GStrv       invalidated,
                                   gpointer    user_data)
{
    GVariant *val = g_variant_lookup_value(changed, "Value", NULL);
    if (!val) return;

    gsize    len;
    const uint8_t *data = g_variant_get_fixed_array(
                              val, &len, sizeof(uint8_t));

    printf("\n[NOTIFICATION] Broadcast Receive State updated:\n");
    parse_brs(data, len);
    g_variant_unref(val);
}

/* ─── Enable notifications (write 0x0001 to CCCD) ────────────── */
static void enable_notifications(GDBusProxy *char_proxy)
{
    GError   *err     = NULL;
    uint16_t  cccd    = 0x0001;  /* notifications enable */
    GVariant *options = g_variant_new("a{sv}", NULL);

    /* Use BlueZ StartNotify method on the characteristic */
    GVariant *ret = g_dbus_proxy_call_sync(char_proxy,
                                            "StartNotify",
                                            NULL,
                                            G_DBUS_CALL_FLAGS_NONE,
                                            -1, NULL, &err);
    if (err) {
        g_printerr("StartNotify failed: %s\n", err->message);
        g_error_free(err);
        return;
    }
    printf("[+] Notifications enabled on BRS characteristic\n");
    if (ret) g_variant_unref(ret);
    (void)cccd; (void)options;
}

/* ─── main ───────────────────────────────────────────────────── */
int main(int argc, char **argv)
{
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <BT-addr>\n", argv[0]);
        return 1;
    }

    GError    *err  = NULL;
    GMainLoop *loop = g_main_loop_new(NULL, FALSE);

    /* Connect to BlueZ object manager over system D-Bus */
    GDBusObjectManager *mgr =
        g_dbus_object_manager_client_new_for_bus_sync(
            G_BUS_TYPE_SYSTEM,
            G_DBUS_OBJECT_MANAGER_CLIENT_FLAGS_NONE,
            "org.bluez", "/",
            NULL, NULL, NULL, NULL, &err);

    if (err) {
        g_printerr("Failed to connect to BlueZ: %s\n", err->message);
        return 1;
    }

    /* Walk all objects, find BRS characteristics for our device */
    GList *objects = g_dbus_object_manager_get_objects(mgr);
    for (GList *l = objects; l; l = l->next) {
        GDBusObject    *obj  = G_DBUS_OBJECT(l->data);
        GDBusInterface *iface = g_dbus_object_get_interface(
                                    obj, "org.bluez.GattCharacteristic1");
        if (!iface) continue;

        GDBusProxy *proxy = G_DBUS_PROXY(iface);
        GVariant   *uuid_v = g_dbus_proxy_get_cached_property(proxy, "UUID");
        if (!uuid_v) { g_object_unref(iface); continue; }

        const gchar *uuid = g_variant_get_string(uuid_v, NULL);

        if (g_str_equal(uuid, BRS_CHAR_UUID)) {
            printf("[+] Found BRS characteristic: %s\n",
                   g_dbus_proxy_get_object_path(proxy));

            /* Connect to PropertiesChanged for live notifications */
            g_signal_connect(proxy, "g-properties-changed",
                             G_CALLBACK(on_properties_changed), NULL);

            /* Enable notifications */
            enable_notifications(proxy);

            /* Do an initial read to get current state */
            GVariant *read_ret = g_dbus_proxy_call_sync(
                proxy, "ReadValue",
                g_variant_new("(a{sv})", NULL),
                G_DBUS_CALL_FLAGS_NONE, -1, NULL, &err);

            if (read_ret) {
                GVariant *arr;
                g_variant_get(read_ret, "(@ay)", &arr);
                gsize len;
                const uint8_t *d = g_variant_get_fixed_array(
                                       arr, &len, sizeof(uint8_t));
                printf("[*] Initial BRS read:\n");
                parse_brs(d, len);
                g_variant_unref(arr);
                g_variant_unref(read_ret);
            }
        }

        g_variant_unref(uuid_v);
        g_object_unref(iface);
    }
    g_list_free_full(objects, g_object_unref);

    printf("\n[*] Listening for BRS notifications. Press Ctrl-C to quit.\n");
    g_main_loop_run(loop);

    g_object_unref(mgr);
    g_main_loop_unref(loop);
    return 0;
}

7. Reading the BIS_Sync_State Bitfield

The BIS_Sync_State is a 4-byte bitfield. Bit 0 corresponds to BIS index 1, bit 1 corresponds to BIS index 2, and so on up to bit 30 for BIS index 31. The special value 0xFFFFFFFF means the server failed to synchronise to the BIG entirely.

/*
 * decode_bis_sync_state()
 *
 * Prints which BIS indexes the server is currently synced to.
 *
 * Example:
 *   bis_sync_state = 0x00000003
 *   → Bit 0 = 1 (synced to BIS index 1)
 *   → Bit 1 = 1 (synced to BIS index 2)
 *   → Bits 2-30 = 0 (not synced to BIS indexes 3-31)
 *
 *   This means: stereo audio — both left (BIS 1) and right (BIS 2)
 *   streams are being received.
 */
static void decode_bis_sync_state(uint32_t state)
{
    if (state == 0xFFFFFFFF) {
        printf("    BIS sync: FAILED to sync to BIG\n");
        return;
    }
    if (state == 0x00000000) {
        printf("    BIS sync: not synced to any BIS\n");
        return;
    }
    printf("    BIS sync: synced to BIS indexes: ");
    for (int bit = 0; bit < 31; bit++) {
        if (state & (1u << bit))
            printf("%d ", bit + 1);  /* BIS index = bit + 1 */
    }
    printf("\n");
}

/* ─── Example BIS_Sync values and meanings ─────────────────────
 *
 *  0x00000000 = not synced to any BIS
 *  0x00000001 = synced to BIS 1 only (e.g. mono left)
 *  0x00000002 = synced to BIS 2 only (e.g. mono right)
 *  0x00000003 = synced to BIS 1 and BIS 2 (stereo)
 *  0x00000007 = synced to BIS 1, 2, 3
 *  0xFFFFFFFF = failed to sync to BIG (total failure)
 *
 * ────────────────────────────────────────────────────────────── */

8. Complete Flow — Airport PA System Example

Let us trace through a real scenario: you walk into an airport. Your hearing aid (BASS Server) wants to receive the gate announcements broadcast over LE Audio. Your phone (BASS Client) does the work.

# Actor Action BRS State After
1 Phone Write CP: Remote Scan Started (0x01) (No BRS change)
2 Phone Scans and finds airport broadcast (Broadcast_ID = 0xAB1234) (No BRS change)
3 Phone Write CP: Add Source (0x02) with PA_Sync=0x02, BIS_Sync=0xFFFFFFFF PA_Sync_State = 0x00 (scanning…)
4 Hearing Aid Finds PA, syncs to it PA_Sync_State = 0x02
BIG_Encryption = 0x00 (not encrypted)
5 Hearing Aid Syncs to BIS 1 (mono speech stream) BIS_Sync_State[0] = 0x00000001 (BIS 1 synced 🎵)
6 Phone User leaves airport, phone writes CP: Modify Source (stop sync) BIS_Sync_State[0] = 0x00000000
PA_Sync_State = 0x00
7 Phone Write CP: Remove Source (0x05) BRS = empty (zero length)

9. Common Issues and Debugging Tips

Symptom Likely cause and fix
ATT error 0x80 on every CP write Opcode not supported. Check that the server firmware has implemented the opcode you are sending (e.g. Add Source support requires all 0x00–0x03, 0x05 opcodes).
ATT error 0x81 on Modify/Remove Invalid Source_ID. Read the Broadcast Receive State first to get the current Source_ID the server assigned. You might be using a stale ID.
PA_Sync_State stuck at 0x00 Source not found. Verify the Advertiser_Address, SID, and Broadcast_ID you sent in Add Source actually match the broadcast. Use a sniffer to confirm.
PA_Sync_State = 0x01 and stays there Server is waiting for a PAST transfer. Either trigger the PAST from your phone (if you are synced to the PA), or send Modify Source with PA_Sync = 0x02 to ask the server to sync directly.
BIG_Encryption = 0x03 (Bad_Code) Wrong Broadcast_Code. Read the Bad_Code field to confirm which key failed, then send Set Broadcast_Code with the correct 16-byte key.
No notifications after pairing Enable notifications by writing 0x0001 to the CCCD (Client Characteristic Configuration Descriptor) of the BRS characteristic. Use StartNotify in the BlueZ D-Bus API.
Remove Source rejected silently Server is still synced. Send Modify Source first with PA_Sync=0x00 and all BIS_Sync[i]=0x00. Wait for notification confirming state is 0x00, then send Remove Source.

Monitoring BASS Traffic with btmon

# Run btmon in one terminal to capture all HCI traffic
sudo btmon -w bass_capture.btsnoop

# In another terminal, run your BASS client or bluetoothctl operations
# btmon output will show ATT Write Requests, Notifications, and Error Responses

# To look specifically at ATT Write Req frames (opcode 0x12):
# Filter output with grep:
sudo btmon | grep -A5 "ATT Write Request"

# After capturing, open the .btsnoop file in Wireshark for full decode:
wireshark bass_capture.btsnoop &

# In Wireshark, filter for BASS traffic:
# Filter bar:   btatt.handle == 0x0013
# Or by UUID:   btatt.uuid16 == 0x2bc0

10. Series Summary

What you learned Key takeaway
Why BASS exists To let a phone scan for broadcast sources and hand the info to a battery-constrained device (hearing aid) via GATT.
Two characteristics Control Point (client writes commands) + Broadcast Receive State (server exposes sync state and notifies on changes).
Six CP operations Remote Scan Start/Stop, Add Source, Modify Source, Set Broadcast_Code, Remove Source. Each with a specific byte layout.
PA Sync State Machine 5 states: Not Synced → SyncInfo Request → Synced / Failed / No PAST. React to 0x01 quickly (PAST timeout).
BIG Encryption States Not Encrypted / Code Required / Decrypting / Bad_Code. Watch for 0x03 and use the Bad_Code field to know which key failed.
BlueZ implementation Use the D-Bus GATT API. gatttool for quick debugging. btmon + Wireshark for tracing ATT traffic.

🎯 What to Explore Next

Now that you understand BASS completely, the natural next step is exploring the Basic Audio Profile (BAP) which defines the Broadcast Source side — how a device sets up a BIG and transmits BISes using the LE Isochronous channels. The BAP spec also covers the full coordinator role that a phone plays when it orchestrates hearing aids in a binaural hearing aid system.

Tags: LE Audio · BASS · BLE · GATT · BlueZ · Bluetooth 5.2 · Hearing Aids · BIS · BIG · Periodic Advertising

Leave a Reply

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