Chapter 6 — Implementing TBS in BlueZ

 

TBS Tutorial HomeChapter 5 › Chapter 6: BlueZ Implementation

Chapter 6 — Implementing TBS in BlueZ
Full GATT service registration, CCP handler, notification dispatch, and live testing on Linux
tbs.c
BlueZ Source
16
Characteristics
6
CCP Opcodes
hciconfig
Test Tool

What You Will Learn in This Chapter
BlueZ GATT Server Setup TBS Service Registration gatt_db_add_service Characteristic Read Callbacks CCP Write Handler bt_gatt_server_send_notification Call State Notification Incoming Call Flow nRF Connect Testing bluetoothctl BLE Server

The previous five chapters walked you through every detail of the TBS specification — architecture, characteristics, call state machine, and the Call Control Point. Now it is time to put all of that together into actual C code using the BlueZ stack on Linux.

BlueZ exposes a layered GATT server API. You register a service with gatt_db_add_service(), add characteristics with gatt_db_service_add_characteristic(), attach read and write callbacks, and send notifications with bt_gatt_server_send_notification(). TBS fits this model perfectly because the phone (Linux machine in our case) acts as the GATT server and a BLE peripheral like earbuds or nRF Connect acts as the GATT client.

This chapter builds a minimal but functionally complete TBS server in BlueZ step by step. By the end you will have code that registers GTBS, handles an incoming call, notifies the client, accepts a CCP write, and confirms success via a CCP notification — the full round trip.

1. BlueZ Source File Layout for TBS

In the BlueZ source tree, audio profiles live under profiles/audio/. The TBS implementation files you will be working with are:

BlueZ TBS Source File Layout
File Role
profiles/audio/tbs.c GATT service registration, characteristic callbacks, CCP handler, notification dispatch
profiles/audio/tbs.h Public API — tbs_init(), tbs_free(), tbs_add_call(), tbs_remove_call(), tbs_update_call_state()
src/adapter.c Hooks that call tbs_init() when a BLE adapter is registered and tbs_free() on cleanup
lib/bluetooth.h UUID macros and Bluetooth type definitions used throughout

When you build BlueZ from source with ./configure --enable-experimental --prefix=/usr and make, these files compile into the bluetoothd daemon that runs in the background. Our custom TBS code slots into tbs.c and tbs.h.

2. Header File — tbs.h

The header defines the TBS instance structure, call entry structure, and the public API. Every piece of call state tracked in the spec maps to a field here.

tbs.h — Core Data Structures and API
/* profiles/audio/tbs.h */ #pragma once #include <stdint.h> #include <stdbool.h> #include “src/shared/gatt-db.h” #include “src/shared/gatt-server.h” /* —- TBS / GTBS Service UUIDs (Bluetooth Assigned Numbers) —- */ #define TBS_UUID 0x184B /* Telephone Bearer Service */ #define GTBS_UUID 0x184C /* Generic Telephone Bearer Service */ /* —- Characteristic UUIDs —- */ #define UUID_BEARER_PROVIDER_NAME 0x2BB3 #define UUID_BEARER_UCI 0x2BB4 #define UUID_BEARER_TECHNOLOGY 0x2BB5 #define UUID_BEARER_URI_SCHEMES 0x2BB6 #define UUID_BEARER_SIGNAL_STRENGTH 0x2BB7 #define UUID_BEARER_SIGNAL_INTERVAL 0x2BB8 #define UUID_BEARER_LIST_CALLS 0x2BB9 #define UUID_CCID 0x2BBA #define UUID_STATUS_FLAGS 0x2BBB #define UUID_INCOMING_TARGET_URI 0x2BBC #define UUID_CALL_STATE 0x2BBD #define UUID_CALL_CONTROL_POINT 0x2BBE #define UUID_CCP_OPTIONAL_OPCODES 0x2BBF #define UUID_TERMINATION_REASON 0x2BC0 #define UUID_INCOMING_CALL 0x2BC1 #define UUID_CALL_FRIENDLY_NAME 0x2BC2 /* —- Call States (Table 3.6 in spec) —- */ #define TBS_CALL_STATE_INCOMING 0x00 #define TBS_CALL_STATE_DIALING 0x01 #define TBS_CALL_STATE_ALERTING 0x02 #define TBS_CALL_STATE_ACTIVE 0x03 #define TBS_CALL_STATE_LOCALLY_HELD 0x04 #define TBS_CALL_STATE_REMOTELY_HELD 0x05 #define TBS_CALL_STATE_LOCAL_REMOTE_HELD 0x06 /* —- CCP Opcodes (Table 3.9) —- */ #define CCP_OP_ACCEPT 0x00 #define CCP_OP_TERMINATE 0x01 #define CCP_OP_LOCAL_HOLD 0x02 #define CCP_OP_LOCAL_RETRIEVE 0x03 #define CCP_OP_ORIGINATE 0x04 #define CCP_OP_JOIN 0x05 /* —- CCP Result Codes (Table 3.11) —- */ #define CCP_RES_SUCCESS 0x00 #define CCP_RES_OPCODE_NOT_SUPPORTED 0x01 #define CCP_RES_OP_NOT_POSSIBLE 0x02 #define CCP_RES_INVALID_CALL_INDEX 0x03 #define CCP_RES_STATE_MISMATCH 0x04 #define CCP_RES_LACK_OF_RESOURCES 0x05 #define CCP_RES_INVALID_OUTGOING_URI 0x06 /* —- Termination Reason Codes (Table 3.14) —- */ #define TBS_TERM_INVALID_URI 0x00 #define TBS_TERM_CALL_FAILED 0x01 #define TBS_TERM_REMOTE_ENDED 0x02 #define TBS_TERM_SERVER_ENDED 0x03 #define TBS_TERM_LINE_BUSY 0x04 #define TBS_TERM_NETWORK_CONGESTION 0x05 #define TBS_TERM_CLIENT_ENDED 0x06 #define TBS_TERM_NO_SERVICE 0x07 #define TBS_TERM_NO_ANSWER 0x08 #define TBS_TERM_UNSPECIFIED 0x09 /* —- Status Flags bits —- */ #define TBS_STATUS_INBAND_RINGTONE (1 << 0) #define TBS_STATUS_SILENT_MODE (1 << 1) /* —- CCP Optional Opcodes bits —- */ #define CCP_OPT_LOCAL_HOLD (1 << 0) #define CCP_OPT_JOIN (1 << 1) /* —- Per-call entry —- */ struct tbs_call { uint8_t index; /* 1–255; 0 is reserved */ uint8_t state; /* TBS_CALL_STATE_xxx */ uint8_t flags; /* bit0=outgoing, bit1=withheld */ char uri[128]; /* null-terminated UTF-8 URI */ char friendly[64]; /* display name, may be empty */ }; /* —- TBS instance (one per bearer or one GTBS) —- */ struct tbs_instance { bool is_gtbs; /* GATT handles populated during registration */ struct gatt_db_attribute *service; struct gatt_db_attribute *provider_name_attr; struct gatt_db_attribute *uci_attr; struct gatt_db_attribute *technology_attr; struct gatt_db_attribute *uri_schemes_attr; struct gatt_db_attribute *signal_strength_attr; struct gatt_db_attribute *signal_interval_attr; struct gatt_db_attribute *list_calls_attr; struct gatt_db_attribute *ccid_attr; struct gatt_db_attribute *status_flags_attr; struct gatt_db_attribute *incoming_target_uri_attr; struct gatt_db_attribute *call_state_attr; struct gatt_db_attribute *ccp_attr; struct gatt_db_attribute *ccp_optional_attr; struct gatt_db_attribute *term_reason_attr; struct gatt_db_attribute *incoming_call_attr; struct gatt_db_attribute *friendly_name_attr; /* Live state */ char provider_name[64]; char uci[32]; /* e.g. “tel”, “skype” */ uint8_t technology; /* 0x00–0x08 per assigned numbers */ char uri_schemes[64]; /* e.g. “tel,sip” */ uint8_t signal_strength; uint8_t signal_interval; uint16_t status_flags; uint8_t ccid; uint8_t ccp_optional_opcodes; /* bitmask */ struct tbs_call calls[16]; /* up to 16 concurrent calls */ uint8_t num_calls; uint8_t next_call_index; /* rolls 1–255 */ struct bt_gatt_server *server; }; /* —- Public API —- */ struct tbs_instance *tbs_init(struct gatt_db *db, struct bt_gatt_server *server, bool is_gtbs, uint8_t ccid); void tbs_free(struct tbs_instance *tbs); uint8_t tbs_add_incoming_call(struct tbs_instance *tbs, const char *uri, const char *friendly_name); int tbs_originate_call(struct tbs_instance *tbs, const char *uri); int tbs_update_call_state(struct tbs_instance *tbs, uint8_t call_index, uint8_t new_state); int tbs_terminate_call(struct tbs_instance *tbs, uint8_t call_index, uint8_t reason_code);

3. GATT Service Registration

The first job inside tbs_init() is to build the GATT attribute database entry for the service and add every characteristic. Each call to gatt_db_service_add_characteristic() binds a UUID, permission flags, property flags, and optional read/write callbacks. The permissions enforce the spec requirement that all TBS characteristics need an encrypted link.

TBS GATT Attribute Database — Handle Layout Diagram
# Handle Attribute Type UUID Properties
H+0 Primary Service 0x184C (GTBS) or 0x184B (TBS)
H+1 Bearer Provider Name 0x2BB3 Read, Notify + CCCD
H+3 Bearer UCI 0x2BB4 Read
H+4 Bearer Technology 0x2BB5 Read, Notify + CCCD
H+6 Bearer URI Schemes Supported List 0x2BB6 Read, Notify + CCCD
H+8 Bearer Signal Strength 0x2BB7 Read, Notify + CCCD
H+10 Bearer Signal Strength Reporting Interval 0x2BB8 Read, Write, Write W/R
H+11 Bearer List Current Calls 0x2BB9 Read, Notify + CCCD
H+13 Content Control ID (CCID) 0x2BBA Read
H+14 Status Flags 0x2BBB Read, Notify + CCCD
H+16 Incoming Call Target Bearer URI 0x2BBC Read, Notify + CCCD
H+18 Call State 0x2BBD Read, Notify + CCCD
H+20 Call Control Point (CCP) 0x2BBE Write, Write W/R, Notify + CCCD
H+22 CCP Optional Opcodes 0x2BBF Read
H+23 Termination Reason 0x2BC0 Notify + CCCD
H+25 Incoming Call 0x2BC1 Read, Notify + CCCD
H+27 Call Friendly Name 0x2BC2 Read, Notify + CCCD

Each characteristic that supports notifications also needs a Client Characteristic Configuration Descriptor (CCCD) at the handle immediately following the characteristic value handle. That is why the handle numbers above jump by 2 for notify-capable characteristics.

tbs_register_service() — GATT DB Registration
/* profiles/audio/tbs.c — Part 1: service registration */ #include <string.h> #include <stdlib.h> #include “lib/bluetooth.h” #include “lib/uuid.h” #include “src/shared/att.h” #include “src/shared/gatt-db.h” #include “src/shared/gatt-server.h” #include “tbs.h” /* Shorthand macro: build a 16-bit Bluetooth UUID */ #define BT_UUID16_INIT(val) \ { .type = BT_UUID16, .value.u16 = (val) } static void tbs_register_service(struct tbs_instance *tbs, struct gatt_db *db) { struct bt_uuid service_uuid = BT_UUID16_INIT(tbs->is_gtbs ? GTBS_UUID : TBS_UUID); /* —- Add primary service —- */ tbs->service = gatt_db_add_service(db, &service_uuid, true, /* primary */ 40); /* handle count */ if (!tbs->service) { fprintf(stderr, “TBS: failed to add service\n”); return; } /* Permission and property shorthand */ uint32_t perm_r = BT_ATT_PERM_READ_ENCRYPT; uint32_t perm_rw = BT_ATT_PERM_READ_ENCRYPT | BT_ATT_PERM_WRITE_ENCRYPT; uint8_t prop_rn = BT_GATT_CHRC_PROP_READ | BT_GATT_CHRC_PROP_NOTIFY; uint8_t prop_r = BT_GATT_CHRC_PROP_READ; uint8_t prop_wn = BT_GATT_CHRC_PROP_WRITE | BT_GATT_CHRC_PROP_WRITE_WITHOUT_RESP | BT_GATT_CHRC_PROP_NOTIFY; uint8_t prop_rw = BT_GATT_CHRC_PROP_READ | BT_GATT_CHRC_PROP_WRITE | BT_GATT_CHRC_PROP_WRITE_WITHOUT_RESP; uint8_t prop_n = BT_GATT_CHRC_PROP_NOTIFY; struct bt_uuid u; /* Bearer Provider Name 0x2BB3 */ u = BT_UUID16_INIT(UUID_BEARER_PROVIDER_NAME); tbs->provider_name_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, provider_name_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Bearer UCI 0x2BB4 */ u = BT_UUID16_INIT(UUID_BEARER_UCI); tbs->uci_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_r, uci_read_cb, NULL, tbs); /* Bearer Technology 0x2BB5 */ u = BT_UUID16_INIT(UUID_BEARER_TECHNOLOGY); tbs->technology_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, technology_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Bearer URI Schemes Supported List 0x2BB6 */ u = BT_UUID16_INIT(UUID_BEARER_URI_SCHEMES); tbs->uri_schemes_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, uri_schemes_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Bearer Signal Strength 0x2BB7 */ u = BT_UUID16_INIT(UUID_BEARER_SIGNAL_STRENGTH); tbs->signal_strength_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, signal_strength_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Bearer Signal Strength Reporting Interval 0x2BB8 */ u = BT_UUID16_INIT(UUID_BEARER_SIGNAL_INTERVAL); tbs->signal_interval_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_rw, prop_rw, signal_interval_read_cb, signal_interval_write_cb, tbs); /* Bearer List Current Calls 0x2BB9 */ u = BT_UUID16_INIT(UUID_BEARER_LIST_CALLS); tbs->list_calls_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, list_calls_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Content Control ID 0x2BBA */ u = BT_UUID16_INIT(UUID_CCID); tbs->ccid_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_r, ccid_read_cb, NULL, tbs); /* Status Flags 0x2BBB */ u = BT_UUID16_INIT(UUID_STATUS_FLAGS); tbs->status_flags_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, status_flags_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Incoming Call Target Bearer URI 0x2BBC */ u = BT_UUID16_INIT(UUID_INCOMING_TARGET_URI); tbs->incoming_target_uri_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, incoming_target_uri_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Call State 0x2BBD */ u = BT_UUID16_INIT(UUID_CALL_STATE); tbs->call_state_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, call_state_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Call Control Point 0x2BBE */ u = BT_UUID16_INIT(UUID_CALL_CONTROL_POINT); tbs->ccp_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_rw, prop_wn, NULL, ccp_write_cb, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* CCP Optional Opcodes 0x2BBF */ u = BT_UUID16_INIT(UUID_CCP_OPTIONAL_OPCODES); tbs->ccp_optional_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_r, ccp_optional_read_cb, NULL, tbs); /* Termination Reason 0x2BC0 */ u = BT_UUID16_INIT(UUID_TERMINATION_REASON); tbs->term_reason_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_n, NULL, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Incoming Call 0x2BC1 */ u = BT_UUID16_INIT(UUID_INCOMING_CALL); tbs->incoming_call_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, incoming_call_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Call Friendly Name 0x2BC2 */ u = BT_UUID16_INIT(UUID_CALL_FRIENDLY_NAME); tbs->friendly_name_attr = gatt_db_service_add_characteristic(tbs->service, &u, perm_r, prop_rn, friendly_name_read_cb, NULL, tbs); gatt_db_service_add_ccc(tbs->service, perm_rw); /* Mark service complete */ gatt_db_service_set_active(tbs->service, true); }

4. Characteristic Read Callbacks

Every readable characteristic needs a callback that BlueZ calls when a connected client issues an ATT Read Request. The callback must call gatt_db_attribute_read_result() to send the response. Let us look at the three most representative callbacks.

Read Callbacks — Provider Name, Signal Strength, Call State
/* —- Bearer Provider Name read callback —- */ static void provider_name_read_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; size_t len = strlen(tbs->provider_name); /* Spec: first (ATT_MTU – 3) octets if value exceeds MTU */ gatt_db_attribute_read_result(attrib, id, 0, (const uint8_t *)tbs->provider_name, len); } /* —- Bearer Signal Strength read callback —- */ static void signal_strength_read_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; /* 1-octet value: 0=no service, 1-99=scaled, 100=max, 255=N/A */ gatt_db_attribute_read_result(attrib, id, 0, &tbs->signal_strength, 1); } /* —- Call State read callback —- */ static void call_state_read_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; uint8_t buf[48]; /* up to 16 calls x 3 bytes each */ size_t pos = 0; for (int i = 0; i < tbs->num_calls; i++) { struct tbs_call *c = &tbs->calls[i]; buf[pos++] = c->index; /* Call_Index */ buf[pos++] = c->state; /* State */ buf[pos++] = c->flags; /* Call_Flags */ } gatt_db_attribute_read_result(attrib, id, 0, buf, pos); } /* —- CCID read callback —- */ static void ccid_read_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; gatt_db_attribute_read_result(attrib, id, 0, &tbs->ccid, 1); } /* —- CCP Optional Opcodes read callback —- */ static void ccp_optional_read_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; uint8_t val[2]; val[0] = tbs->ccp_optional_opcodes & 0xFF; /* LSO first */ val[1] = (tbs->ccp_optional_opcodes >> 8) & 0xFF; gatt_db_attribute_read_result(attrib, id, 0, val, 2); } /* —- Status Flags read callback —- */ static void status_flags_read_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; uint8_t val[2]; val[0] = tbs->status_flags & 0xFF; val[1] = (tbs->status_flags >> 8) & 0xFF; gatt_db_attribute_read_result(attrib, id, 0, val, 2); }

5. Notification Dispatch Helpers

Whenever the server-side state changes — a call arrives, a call state transitions, signal strength updates — the server must send GATT notifications to any client that has enabled them via the CCCD. The helper function below wraps bt_gatt_server_send_notification() and takes the attribute handle from the stored attribute pointer.

Generic Notification Helper and Per-Characteristic Notifiers
/* —- Generic notify wrapper —- */ static void tbs_notify(struct tbs_instance *tbs, struct gatt_db_attribute *attr, const uint8_t *value, size_t len) { if (!tbs->server || !attr) return; uint16_t handle = gatt_db_attribute_get_handle(attr); bt_gatt_server_send_notification(tbs->server, handle, value, len, false); /* false = not indication */ } /* —- Notify Call State —- */ static void tbs_notify_call_state(struct tbs_instance *tbs) { uint8_t buf[48]; size_t pos = 0; for (int i = 0; i < tbs->num_calls; i++) { struct tbs_call *c = &tbs->calls[i]; buf[pos++] = c->index; buf[pos++] = c->state; buf[pos++] = c->flags; } tbs_notify(tbs, tbs->call_state_attr, buf, pos); } /* —- Notify Incoming Call (Call_Index + URI) —- */ static void tbs_notify_incoming_call(struct tbs_instance *tbs, struct tbs_call *call) { uint8_t buf[130]; buf[0] = call->index; size_t uri_len = strlen(call->uri); memcpy(buf + 1, call->uri, uri_len); tbs_notify(tbs, tbs->incoming_call_attr, buf, 1 + uri_len); } /* —- Notify Bearer List Current Calls —- */ static void tbs_notify_list_calls(struct tbs_instance *tbs) { uint8_t buf[512]; size_t pos = 0; for (int i = 0; i < tbs->num_calls; i++) { struct tbs_call *c = &tbs->calls[i]; size_t uri_len = strlen(c->uri); /* List_Item_Length excludes the length field octet itself */ buf[pos++] = (uint8_t)(3 + uri_len); /* index+state+flags+uri */ buf[pos++] = c->index; buf[pos++] = c->state; buf[pos++] = c->flags; memcpy(buf + pos, c->uri, uri_len); pos += uri_len; } tbs_notify(tbs, tbs->list_calls_attr, buf, pos); } /* —- Notify CCP result —- */ static void tbs_notify_ccp(struct tbs_instance *tbs, uint8_t requested_opcode, uint8_t call_index, uint8_t result_code) { uint8_t buf[3]; buf[0] = requested_opcode; buf[1] = call_index; buf[2] = result_code; tbs_notify(tbs, tbs->ccp_attr, buf, 3); } /* —- Notify Termination Reason —- */ static void tbs_notify_termination(struct tbs_instance *tbs, uint8_t call_index, uint8_t reason_code) { uint8_t buf[2] = { call_index, reason_code }; tbs_notify(tbs, tbs->term_reason_attr, buf, 2); } /* —- Notify Provider Name change —- */ static void tbs_notify_provider_name(struct tbs_instance *tbs) { tbs_notify(tbs, tbs->provider_name_attr, (const uint8_t *)tbs->provider_name, strlen(tbs->provider_name)); } /* —- Notify Status Flags change —- */ static void tbs_notify_status_flags(struct tbs_instance *tbs) { uint8_t val[2]; val[0] = tbs->status_flags & 0xFF; val[1] = (tbs->status_flags >> 8) & 0xFF; tbs_notify(tbs, tbs->status_flags_attr, val, 2); }

6. Incoming Call Flow — Adding a Call

When the phone receives an incoming call from the network, the TBS server must allocate a Call Index, set the state to Incoming, and then fire several notifications in the right order. The spec is strict: Call State must be notified before any other characteristic that references the new Call Index. This prevents the client from seeing an Incoming Call notification that refers to a Call Index it has never seen in Call State yet.

Incoming Call — Server-Side Notification Sequence
Step Action (Server) GATT Notification Sent
1 Allocate Call Index (next_call_index, rolls 1–255)
2 Fill tbs_call: index, state=INCOMING, flags=0, uri, friendly
3 Notify Call State FIRST (spec requirement) [idx][0x00][flags]
4 Notify Bearer List Current Calls [len][idx][state][flags][uri…]
5 Notify Incoming Call [idx][tel:+9xxxxxxxxxx]
6 Notify Call Friendly Name (if available) [idx][Ravi Kalluri]
tbs_add_incoming_call() Implementation
/* profiles/audio/tbs.c — adding an incoming call */ uint8_t tbs_add_incoming_call(struct tbs_instance *tbs, const char *uri, const char *friendly_name) { if (tbs->num_calls >= 16) return 0; /* no slot available */ /* Allocate a Call Index; 0 is reserved, roll 255 -> 1 */ uint8_t idx = tbs->next_call_index; tbs->next_call_index = (idx >= 255) ? 1 : idx + 1; struct tbs_call *c = &tbs->calls[tbs->num_calls]; memset(c, 0, sizeof(*c)); c->index = idx; c->state = TBS_CALL_STATE_INCOMING; c->flags = 0; /* bit0=0: incoming call */ strncpy(c->uri, uri, sizeof(c->uri) – 1); if (friendly_name) strncpy(c->friendly, friendly_name, sizeof(c->friendly) – 1); tbs->num_calls++; /* * ORDERING REQUIREMENT (spec §3.11.1): * Call State MUST be notified before any other characteristic * that references this new Call Index. */ tbs_notify_call_state(tbs); /* Step 1 — mandatory first */ tbs_notify_list_calls(tbs); /* Step 2 */ tbs_notify_incoming_call(tbs, c); /* Step 3 */ if (c->friendly[0] != ‘\0’) { uint8_t buf[66]; buf[0] = c->index; size_t fn_len = strlen(c->friendly); memcpy(buf + 1, c->friendly, fn_len); tbs_notify(tbs, tbs->friendly_name_attr, buf, 1 + fn_len); /* Step 4 */ } return idx; /* return the assigned Call Index */ }

7. Call Control Point Write Handler

The CCP write handler is the heart of TBS. When a headset wants to accept, terminate, or hold a call, it writes an opcode and optional parameters to the CCP characteristic. The server validates the opcode, checks the current call state, performs the operation, sends a CCP notification with the result, and (if state changed) notifies Call State.

CCP Write → Validate → Execute → Notify — Flow Diagram
Client writes CCP characteristic
[Opcode 1B] [Parameters variable]
ccp_write_cb() invoked by BlueZ GATT server
Switch on opcode → dispatch to handler
0x00 Accept | 0x01 Terminate | 0x02 Local Hold | 0x03 Local Retrieve | 0x04 Originate | 0x05 Join
Error path
Invalid opcode → OPCODE NOT SUPPORTED
Bad call index → INVALID CALL INDEX
Wrong state → STATE MISMATCH
Success path
Update call state
Notify Call State
Notify CCP → SUCCESS
ccp_write_cb() — Main Dispatch Handler
/* profiles/audio/tbs.c — CCP write callback */ static void ccp_write_cb(struct gatt_db_attribute *attrib, unsigned int id, uint16_t offset, const uint8_t *value, size_t len, uint8_t opcode, struct bt_att *att, void *user_data) { struct tbs_instance *tbs = user_data; uint8_t result = CCP_RES_SUCCESS; uint8_t ccp_opcode; uint8_t call_index = 0; if (!value || len < 1) { gatt_db_attribute_write_result(attrib, id, BT_ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LEN); return; } ccp_opcode = value[0]; switch (ccp_opcode) { case CCP_OP_ACCEPT: if (len < 2) { result = CCP_RES_OP_NOT_POSSIBLE; break; } call_index = value[1]; result = tbs_handle_accept(tbs, call_index); break; case CCP_OP_TERMINATE: if (len < 2) { result = CCP_RES_OP_NOT_POSSIBLE; break; } call_index = value[1]; result = tbs_handle_terminate(tbs, call_index); break; case CCP_OP_LOCAL_HOLD: if (!(tbs->ccp_optional_opcodes & CCP_OPT_LOCAL_HOLD)) { result = CCP_RES_OPCODE_NOT_SUPPORTED; break; } if (len < 2) { result = CCP_RES_OP_NOT_POSSIBLE; break; } call_index = value[1]; result = tbs_handle_local_hold(tbs, call_index); break; case CCP_OP_LOCAL_RETRIEVE: if (!(tbs->ccp_optional_opcodes & CCP_OPT_LOCAL_HOLD)) { result = CCP_RES_OPCODE_NOT_SUPPORTED; break; } if (len < 2) { result = CCP_RES_OP_NOT_POSSIBLE; break; } call_index = value[1]; result = tbs_handle_local_retrieve(tbs, call_index); break; case CCP_OP_ORIGINATE: if (len < 2) { result = CCP_RES_INVALID_OUTGOING_URI; break; } /* URI starts at value[1], length = len – 1 */ result = tbs_handle_originate(tbs, (const char *)(value + 1), len – 1, &call_index); break; case CCP_OP_JOIN: if (!(tbs->ccp_optional_opcodes & CCP_OPT_JOIN)) { result = CCP_RES_OPCODE_NOT_SUPPORTED; break; } if (len < 3) { result = CCP_RES_OP_NOT_POSSIBLE; break; } result = tbs_handle_join(tbs, value + 1, /* list of Call Indexes */ len – 1, &call_index); break; default: result = CCP_RES_OPCODE_NOT_SUPPORTED; call_index = 0; break; } /* Acknowledge the ATT write */ gatt_db_attribute_write_result(attrib, id, 0); /* Send CCP notification: [requested opcode][call index][result] */ tbs_notify_ccp(tbs, ccp_opcode, call_index, result); }
tbs_handle_accept() — Accept Opcode Handler
/* Accept: only valid when call state is INCOMING */ static uint8_t tbs_handle_accept(struct tbs_instance *tbs, uint8_t call_index) { struct tbs_call *target = NULL; /* Find the call entry */ for (int i = 0; i < tbs->num_calls; i++) { if (tbs->calls[i].index == call_index) { target = &tbs->calls[i]; break; } } if (!target) return CCP_RES_INVALID_CALL_INDEX; if (target->state != TBS_CALL_STATE_INCOMING) return CCP_RES_STATE_MISMATCH; /* * Spec §3.12.1.1: any currently Active calls should transition * to Locally Held; Remotely Held calls should become * Locally and Remotely Held. */ for (int i = 0; i < tbs->num_calls; i++) { struct tbs_call *c = &tbs->calls[i]; if (c == target) continue; if (c->state == TBS_CALL_STATE_ACTIVE) c->state = TBS_CALL_STATE_LOCALLY_HELD; else if (c->state == TBS_CALL_STATE_REMOTELY_HELD) c->state = TBS_CALL_STATE_LOCAL_REMOTE_HELD; } /* Move target call to Active */ target->state = TBS_CALL_STATE_ACTIVE; /* Notify state changes */ tbs_notify_call_state(tbs); tbs_notify_list_calls(tbs); return CCP_RES_SUCCESS; } /* Terminate: valid in any call state */ static uint8_t tbs_handle_terminate(struct tbs_instance *tbs, uint8_t call_index) { int found = -1; for (int i = 0; i < tbs->num_calls; i++) { if (tbs->calls[i].index == call_index) { found = i; break; } } if (found < 0) return CCP_RES_INVALID_CALL_INDEX; /* Remove the call from the active list */ int last = tbs->num_calls – 1; if (found != last) tbs->calls[found] = tbs->calls[last]; tbs->num_calls–; /* Spec §3.12.1.2: populate Termination Reason */ tbs_notify_termination(tbs, call_index, TBS_TERM_CLIENT_ENDED); /* Notify updated state (call no longer in list) */ tbs_notify_call_state(tbs); tbs_notify_list_calls(tbs); return CCP_RES_SUCCESS; } /* Local Hold: valid when INCOMING, ACTIVE, or REMOTELY_HELD */ static uint8_t tbs_handle_local_hold(struct tbs_instance *tbs, uint8_t call_index) { struct tbs_call *c = NULL; for (int i = 0; i < tbs->num_calls; i++) { if (tbs->calls[i].index == call_index) { c = &tbs->calls[i]; break; } } if (!c) return CCP_RES_INVALID_CALL_INDEX; switch (c->state) { case TBS_CALL_STATE_INCOMING: case TBS_CALL_STATE_ACTIVE: c->state = TBS_CALL_STATE_LOCALLY_HELD; break; case TBS_CALL_STATE_REMOTELY_HELD: c->state = TBS_CALL_STATE_LOCAL_REMOTE_HELD; break; default: return CCP_RES_STATE_MISMATCH; } tbs_notify_call_state(tbs); tbs_notify_list_calls(tbs); return CCP_RES_SUCCESS; }
tbs_handle_originate() — Outgoing Call Handler
/* Originate: valid at any time; URI in the parameter field */ static uint8_t tbs_handle_originate(struct tbs_instance *tbs, const char *uri_bytes, size_t uri_len, uint8_t *out_call_index) { if (uri_len == 0 || uri_len >= 128) return CCP_RES_INVALID_OUTGOING_URI; /* Validate that the URI scheme is in our supported list */ char uri[128]; memcpy(uri, uri_bytes, uri_len); uri[uri_len] = ‘\0’; char *colon = strchr(uri, ‘:’); if (!colon) return CCP_RES_INVALID_OUTGOING_URI; /* Simple scheme check against Bearer URI Schemes Supported List */ size_t scheme_len = (size_t)(colon – uri); char scheme[32]; if (scheme_len >= sizeof(scheme)) return CCP_RES_INVALID_OUTGOING_URI; memcpy(scheme, uri, scheme_len); scheme[scheme_len] = ‘\0’; if (!strstr(tbs->uri_schemes, scheme)) return CCP_RES_INVALID_OUTGOING_URI; if (tbs->num_calls >= 16) return CCP_RES_LACK_OF_RESOURCES; /* Allocate Call Index */ uint8_t idx = tbs->next_call_index; tbs->next_call_index = (idx >= 255) ? 1 : idx + 1; struct tbs_call *c = &tbs->calls[tbs->num_calls++]; memset(c, 0, sizeof(*c)); c->index = idx; c->state = TBS_CALL_STATE_DIALING; c->flags = 0x01; /* bit0=1: outgoing call */ strncpy(c->uri, uri, sizeof(c->uri) – 1); /* Move any currently Active calls to Locally Held */ for (int i = 0; i < tbs->num_calls – 1; i++) { if (tbs->calls[i].state == TBS_CALL_STATE_ACTIVE) tbs->calls[i].state = TBS_CALL_STATE_LOCALLY_HELD; } /* Notify — Call State first, then list */ tbs_notify_call_state(tbs); tbs_notify_list_calls(tbs); /* * For Originate, CCP notification returns the NEW Call Index * in the Call_Index field (spec §3.12.2). */ *out_call_index = idx; return CCP_RES_SUCCESS; }

8. tbs_init() and tbs_free()

The public entry point ties everything together. It allocates the instance, sets default values, and calls the service registration function. Notice the default values chosen: signal strength 0xFF (unavailable) is the safest default — it tells the client not to display any signal bar instead of showing a false value. The CCID must be stable across connections so it is passed in by the caller (usually from a persistent store or a fixed compile-time value per bearer).

tbs_init() and tbs_free()
/* profiles/audio/tbs.c — public init/free */ struct tbs_instance *tbs_init(struct gatt_db *db, struct bt_gatt_server *server, bool is_gtbs, uint8_t ccid) { struct tbs_instance *tbs = calloc(1, sizeof(*tbs)); if (!tbs) return NULL; tbs->is_gtbs = is_gtbs; tbs->server = server; tbs->ccid = ccid; tbs->next_call_index = 1; /* 0 is reserved by spec */ tbs->signal_strength = 0xFF; /* unavailable at startup */ tbs->signal_interval = 0; /* immediate when changed */ tbs->status_flags = 0; /* inband=off, silent=off */ /* Supports Local Hold+Retrieve and Join by default */ tbs->ccp_optional_opcodes = CCP_OPT_LOCAL_HOLD | CCP_OPT_JOIN; /* Set sensible defaults for bearer info */ strncpy(tbs->provider_name, is_gtbs ? “Generic” : “Bearer”, sizeof(tbs->provider_name) – 1); strncpy(tbs->uci, “tel”, sizeof(tbs->uci) – 1); strncpy(tbs->uri_schemes, “tel”, sizeof(tbs->uri_schemes) – 1); tbs->technology = 0x04; /* LTE — 0x04 per Bluetooth assigned nums */ /* Register service and all characteristics in the GATT DB */ tbs_register_service(tbs, db); if (!tbs->service) { free(tbs); return NULL; } return tbs; } void tbs_free(struct tbs_instance *tbs) { if (!tbs) return; if (tbs->service) gatt_db_remove_service(tbs->service); free(tbs); }

9. Public State Update API

The phone’s telephony stack will call into our TBS layer when the network changes a call’s state — for example when the remote party answers (Dialing → Active) or when the remote party puts the call on hold (Active → Remotely Held). These transitions come from outside the BlueZ GATT layer, so we need a clean public function for them.

tbs_update_call_state() and tbs_terminate_call()
/* profiles/audio/tbs.c — public state update functions */ int tbs_update_call_state(struct tbs_instance *tbs, uint8_t call_index, uint8_t new_state) { for (int i = 0; i < tbs->num_calls; i++) { struct tbs_call *c = &tbs->calls[i]; if (c->index != call_index) continue; if (c->state == new_state) return 0; /* no change, skip notification */ c->state = new_state; /* Always notify Call State when any state changes */ tbs_notify_call_state(tbs); tbs_notify_list_calls(tbs); return 0; } return -1; /* call not found */ } int tbs_terminate_call(struct tbs_instance *tbs, uint8_t call_index, uint8_t reason_code) { int found = -1; for (int i = 0; i < tbs->num_calls; i++) { if (tbs->calls[i].index == call_index) { found = i; break; } } if (found < 0) return -1; /* Remove call from list */ int last = tbs->num_calls – 1; if (found != last) tbs->calls[found] = tbs->calls[last]; tbs->num_calls–; /* Spec §3.12.1.2: populate Termination Reason first */ tbs_notify_termination(tbs, call_index, reason_code); /* Then update Call State (call removed from list) */ tbs_notify_call_state(tbs); tbs_notify_list_calls(tbs); return 0; } /* Update signal strength and trigger notification if interval allows */ void tbs_set_signal_strength(struct tbs_instance *tbs, uint8_t strength) { if (tbs->signal_strength == strength) return; tbs->signal_strength = strength; /* * Signal reporting interval governs minimum time between * successive notifications (spec §3.6.1). * A value of 0 means notify immediately on any change. * A real implementation would use a timer; we notify immediately * here for simplicity. */ tbs_notify(tbs, tbs->signal_strength_attr, &tbs->signal_strength, 1); }

10. Testing the TBS Server

Testing a GATT server always involves two sides: the server (your Linux machine running bluetoothd with your TBS code compiled in) and a client. The two most practical client options are nRF Connect for Mobile and bluetoothctl.

BLE Test Environment — Server / Client Roles
TBS Server — Linux Machine
hci0 — BLE adapter (USB dongle or built-in)
bluetoothd running with experimental enabled
GTBS registered in GATT DB
Advertises as connectable BLE peripheral
Responds to ATT Read / Write / Notify
BLE ATT
TBS Client — Phone or Test Tool
nRF Connect (Android / iOS)
Discovers GTBS service UUID 0x184C
Reads characteristics
Enables notifications on Call State / CCP
Writes CCP to accept / terminate calls

10.1 Setting Up the BLE Advertiser

Before any client can connect and discover the GTBS service, the adapter needs to be advertising. Use hciconfig and hcitool for basic setup, then let bluetoothd manage the advertisement once your code is running inside it.

Shell Commands — Prepare Adapter and Start bluetoothd
# Bring up the HCI adapter sudo hciconfig hci0 up # Enable BLE scanning visibility (LE General Discoverable, BR/EDR not supported) sudo hciconfig hci0 leadv 3 # Check adapter state sudo hciconfig hci0 # Start bluetoothd in experimental mode (required for LE Audio / GATT server) sudo bluetoothd –experimental –debug & # Verify the adapter is powered and advertising-capable bluetoothctl power on bluetoothctl advertise on bluetoothctl show

10.2 Testing with bluetoothctl

Once a client connects and you trigger an incoming call (by calling tbs_add_incoming_call() from a test harness or a D-Bus method), you can observe the notifications in bluetoothctl’s monitor mode and use the characteristic write commands to simulate CCP actions.

bluetoothctl — Read TBS Characteristics and Send CCP Write
# Start the interactive bluetoothctl shell bluetoothctl # Connect to the server from a second machine acting as client # (replace AA:BB:CC:DD:EE:FF with your server’s BD_ADDR) [bluetooth]# connect AA:BB:CC:DD:EE:FF # List discovered GATT attributes for the connected device [device]# gatt.list-attributes # Expected output includes: # Primary Service (0x184C) — Generic Telephone Bearer Service # Characteristic (0x2BBD) — Call State # Characteristic (0x2BBE) — Call Control Point # … (all 16 characteristics) # Read the Call State characteristic [device]# gatt.read 0x2BBD # Enable notifications on Call State [device]# gatt.notify 0x2BBD on # Enable notifications on CCP [device]# gatt.notify 0x2BBE on # Simulate an Accept opcode write on call index 1 # Format: opcode=0x00, call_index=0x01 [device]# gatt.write 0x2BBE “0x00 0x01” # Expected notifications received after write: # Call State notification: [01 03 00] = index=1, Active, flags=0 # CCP notification: [00 01 00] = Accept, index=1, SUCCESS

10.3 Testing with nRF Connect

nRF Connect provides a visual GATT browser that makes it easy to verify the entire attribute layout. Here is the step-by-step procedure:

nRF Connect Test Procedure — Step by Step
Step Action in nRF Connect What to Verify
1 Open Scanner tab → scan for devices Your Linux machine appears in the device list
2 Tap Connect → tap Discover Services Service UUID 0x184C (GTBS) visible in service list
3 Expand GTBS → tap Read on Bearer Provider Name (0x2BB3) Returns ASCII bytes for your provider_name string
4 Tap Subscribe on Call State (0x2BBD) CCCD written with 0x0100; notifications enabled
5 Tap Subscribe on CCP (0x2BBE) CCP CCCD written; ready to receive result notifications
6 On the server, call tbs_add_incoming_call(tbs, “tel:+919xxxxxxxxx”, “Test Caller”) nRF Connect shows Call State notification: [01 00 00]
7 Write 0x00 0x01 to CCP (Accept call index 1) Call State notified as [01 03 00] (Active); CCP notified [00 01 00] (SUCCESS)
8 Write 0x01 0x01 to CCP (Terminate call index 1) Termination Reason notified [01 06]; Call State notified empty []; CCP [01 01 00]

10.4 Common Debug Scenarios

TBS Debug — Common Problems and Fixes
Symptom Likely Cause Fix
Service not discovered by client gatt_db_service_set_active() not called or adapter not advertising Ensure gatt_db_service_set_active(tbs->service, true) is called after all characteristics are added
ATT error on CCP write (error 0x0F) Insufficient encryption — link is not bonded/encrypted Pair the devices first. Use bluetoothctl pair <addr> before connecting
CCP write succeeds but no notification received Client has not written CCCD to 0x0100 Enable notifications on CCP characteristic before writing; nRF Connect: use Subscribe button
Call State shows stale data after Terminate Call was removed from array but num_calls not decremented Check array compaction logic in tbs_handle_terminate(); use a debugger to print tbs->num_calls
CCP result = STATE MISMATCH (0x04) Accept written when call is already Active Read Call State first to verify current state before writing Accept; each opcode is only valid in specific states
Notification received but Call Index in notification is 0 CCP operation failed; result code is non-zero Spec: failed operations set Call_Index = 0 in CCP notification. Check the result code byte to identify the error

11. Full Round-Trip Sequence — Incoming Call to Accept

This diagram shows every packet exchanged over the ATT layer between the phone (TBS server) and a BLE headset (TBS client) for a complete incoming call lifecycle from ring to accept.

Complete ATT Sequence — Incoming Call Accepted by BLE Client
TBS Server (Linux / Phone) ATT PDU / Direction TBS Client (BLE Headset)
Phone boots; GTBS registered in GATT DB; adapter advertising Headset scans; sees advertisement
Accepts BLE connection request ← CONNECT_REQ (LE link) Sends connection request
SMP pairing completes; link encrypted ⟷ SMP Pairing Initiates pairing / bonding
Returns service list including 0x184C ← ATT_READ_BY_GROUP_TYPE_RSP Discovers primary services
Returns all 16 characteristics ← ATT_READ_BY_TYPE_RSP (×n) Discovers GTBS characteristics
Writes CCCD — confirms notify enabled → ATT_WRITE_REQ (CCCD=0x0100)
← ATT_WRITE_RSP
Enables notifications on Call State & CCP
Network delivers incoming call: “tel:+919876543210”
tbs_add_incoming_call() called
— network event — Waiting…
Sends [01 00 00] = index=1, Incoming, flags=0 ← ATT_HANDLE_VALUE_NTF (Call State) Receives Call State notification — knows call #1 is ringing
Sends [01][tel:+919876543210] ← ATT_HANDLE_VALUE_NTF (Incoming Call) Displays caller URI; plays ring tone
ATT write acknowledged → ATT_WRITE_CMD (CCP: 0x00 0x01)
[Accept, call_index=1]
User presses Answer button → writes Accept opcode
ccp_write_cb → tbs_handle_accept → state=ACTIVE → notify — internal processing —
Sends [01 03 00] = index=1, Active, flags=0 ← ATT_HANDLE_VALUE_NTF (Call State) Call State updated to Active
Sends [00 01 00] = Accept, index=1, SUCCESS ← ATT_HANDLE_VALUE_NTF (CCP) CCP result confirmed; headset starts audio path

Chapter 6 Summary

You have now built a functionally complete TBS GATT server in BlueZ. Here is what was covered:

  • tbs.h — all 16 characteristic UUID macros, call state constants, CCP opcode and result code definitions, per-call and per-instance data structures, and the public API.
  • tbs_register_service() — adds the GTBS/TBS primary service to the GATT database and attaches read/write callbacks with encryption-required permissions to all 16 characteristics.
  • Read callbacks — how each characteristic marshals its data into a byte buffer and calls gatt_db_attribute_read_result().
  • Notification helpers — a generic tbs_notify() wrapper and per-characteristic notifiers for Call State, Incoming Call, Bearer List Current Calls, CCP result, and Termination Reason.
  • tbs_add_incoming_call() — the correct notification ordering (Call State first, per spec §3.11.1).
  • ccp_write_cb() — the full switch-dispatch CCP handler with Accept, Terminate, Local Hold, Local Retrieve, Originate, and Join.
  • Testing — step-by-step procedures using nRF Connect and bluetoothctl, plus a debug table for common failure modes.
  • Complete ATT sequence diagram — every packet exchanged for an incoming call from ring to accept.

With this chapter complete, you have gone all the way from understanding why TBS was created (Chapter 1) through its architecture, all 16 characteristics, the call state machine, the CCP, and now a real BlueZ C implementation. The full six-chapter TBS series is now complete.

TBS Tutorial Series Complete!

You have finished all six chapters. Explore more BLE and Embedded Linux tutorials on EmbeddedPathashala.

View Full TBS Course Index More Tutorials

Leave a Reply

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