π· Key Concepts Covered
What Is the Call Control Profile?
Imagine you are on a phone call and you press a button on your wireless headset to put the call on hold. Behind that single button press there is a well-defined Bluetooth protocol exchange. That exchange is governed by the Call Control Profile (CCP).
CCP is a BLE-based profile that sits on top of the Generic Attribute Profile (GATT). It defines the rules β the roles, the characteristics to read/write, and the procedures to follow β so that two Bluetooth devices can together manage telephone calls: answering, hanging up, placing on hold, merging, and originating calls.
The spec was adopted by the Bluetooth SIG in March 2021 and is part of the Bluetooth LE Audio ecosystem (Bluetooth Core Spec 5.2 or later required).
Think of a hotel front-desk phone system. The front desk (a Call Control Server) manages all the phone lines β it knows which lines are ringing, which are on hold, and which are active. The guest rooms (a Call Control Client) have a handset that can pick up a ringing line, ask to hold a call, or dial out β but the actual phone carrier is managed at the front desk.
In the Bluetooth world, your smartphone is the front desk (Server) and your wireless headset is the guest-room handset (Client). The “phone lines” are the bearer services (TBS/GTBS).
π What You Will Learn
Theory
- Roles: Server vs Client
- Services: TBS and GTBS
- Profile dependencies (GATT)
- Call state machine
- GATT sub-procedure requirements
- Security requirements (BLE & BR/EDR)
- GAP connection procedures
Practical
- How call procedures map to GATT writes
- All 7 Call Control Point opcodes
- Characteristic notifications
- BlueZ gatttool / bluetoothctl usage
- C code sketch for GATT read/write
- Security setup in BlueZ
- Connection interval tuning
Before learning the profile itself, it is important to see where CCP fits in the overall Bluetooth architecture. CCP is an application-layer profile that relies on GATT for its transport. GATT in turn sits on top of ATT (Attribute Protocol), which runs on the BLE L2CAP layer.
| π Call Control Profile (CCP) β Application Layer |
| Telephone Bearer Service (TBS) & Generic TBS (GTBS) |
| GATT β Generic Attribute Profile |
| ATT β Attribute Protocol |
| L2CAP β Logical Link Control and Adaptation Protocol |
| BLE Link Layer / HCI |
Figure 1: CCP within the Bluetooth protocol stack
CCP defines exactly two roles. Every device involved in a CCP interaction must play one of them.
π± Call Control Server
- Acts as a GATT Server
- Hosts the TBS and/or GTBS services
- Has actual phone bearers (SIM / VoIP)
- Responds to call control commands
- Sends notifications on call state changes
Examples: smartphones, tablets, laptops with cellular/VoIP apps
π§ Call Control Client
- Acts as a GATT Client
- Discovers and reads bearer characteristics
- Writes to the Call Control Point
- Receives call state notifications
- No phone bearer of its own
Examples: wireless headsets, earbuds, smartwatches, car infotainment systems
|
Call Control Client
GATT Client
Headset / Watch |
β Write Call Control Point
β Notifications / Reads
|
Call Control Server
GATT Server
Smartphone GTBS (M) TBS (O)
|
Figure 2: CCP Role interaction β Client writes commands, Server sends back notifications
CCP does not define the GATT characteristics directly β those are defined in a companion document called the Telephone Bearer Service (TBS) specification. CCP tells you which procedures to use and when; TBS tells you what the characteristics look like.
What Is a Bearer?
A bearer is a specific telephone channel. Your phone might have a cellular bearer (your SIM card) and a VoIP bearer (a WhatsApp/Teams call). Each bearer is an independent path for making calls.
TBS β Telephone Bearer Service
- Represents one specific bearer (e.g., your cellular SIM)
- A Server can have zero or more TBS instances
- Each TBS instance is an independent GATT primary service
- Useful when the Client wants per-bearer control
- Example: control only the cellular call, not the VoIP call
GTBS β Generic TBS
- A single, unified view of all bearers
- Always exactly one instance on the Server (mandatory)
- Lightweight clients use only GTBS
- Acts as a single point of access for all call activity
- Example: headset just wants to answer/hang up β doesn’t care which bearer
| Call Control Server (e.g., your Smartphone) | |||
| GTBS Mandatory (Γ1) |
TBS #1 Cellular |
TBS #2 VoIP/Teams |
TBS #n … |
| All instances are GATT Primary Services discoverable via Service Discovery | |||
Figure 3: GTBS (mandatory, Γ1) + multiple optional TBS instances on a single Server
The Client must implement a specific set of GATT sub-procedures. Think of these as the “vocabulary” the Client uses to talk to the Server. The table below summarises requirements (M = Mandatory, O = Optional, C = Conditional).
| # | GATT Sub-Procedure | Requirement | Notes |
|---|---|---|---|
| 1 | Discover All Primary Services | C.1 | Needed to find TBS/GTBS |
| 2 | Discover Primary Services by UUID | C.2 | Alternative to row 1; at least one is mandatory |
| 3 | Find Included Services | O | |
| 4 | Discover All Characteristics of a Service | C.3 | |
| 5 | Discover Characteristics by UUID | C.4 | |
| 6 | Discover All Characteristic Descriptors | M | Needed to find CCCDs |
| 7 | Notifications | M | Core mechanism for call state updates |
| 8 | Read Characteristic Value | M | Read current call state, bearer info, etc. |
| 9 | Read Long Characteristic Values | M | For values > ATT_MTUβ3 bytes |
| 10 | Write Characteristic Value | C.5 | At least one of Write / Write Without Response |
| 11 | Write Without Response | C.5 | At least one of Write / Write Without Response |
| 12 | Read Characteristic Descriptors | M | |
| 13 | Write Characteristic Descriptors | M | Needed to enable notifications (write CCCD = 0x0001) |
0x01 0x00 to enable notifications, 0x00 0x00 to disable them.BlueZ β Enabling Notifications via gatttool
After discovering the CCCD handle for the Call State characteristic, enable notifications:
# Step 1: Connect to the CCP Server (smartphone / peripheral)
gatttool -b AA:BB:CC:DD:EE:FF -I
# Step 2: Inside gatttool interactive shell
[AA:BB:CC:DD:EE:FF][LE]> connect
Connection successful
# Step 3: Discover all primary services to find GTBS
[AA:BB:CC:DD:EE:FF][LE]> primary
# Look for GTBS UUID: 0x184C (assigned number)
# Look for TBS UUID: 0x184B
# Step 4: Discover characteristics within GTBS service handle range
[AA:BB:CC:DD:EE:FF][LE]> characteristics 0x0010 0x0040
# Step 5: Find the CCCD handle for Call State characteristic
# Call State char UUID = 0x2BBE
[AA:BB:CC:DD:EE:FF][LE]> char-desc 0x0020 0x0025
# Identify the descriptor handle (type = 0x2902 = CCCD)
# Step 6: Write 0x0100 to CCCD handle to enable notifications
[AA:BB:CC:DD:EE:FF][LE]> char-write-req 0x0024 0100
# Server will now push Call State updates whenever a call changes
BlueZ C β Reading a Characteristic Value (GLib/D-Bus pattern)
/*
* Sketch: Read the Bearer Provider Name characteristic using BlueZ D-Bus API.
* Real BlueZ GATT client code uses GDBus; this shows the logical flow.
*
* Characteristic UUID: Bearer Provider Name = 0x2BB3
*/
#include <gio/gio.h>
#include <stdio.h>
void read_bearer_provider_name(GDBusConnection *conn,
const char *char_path)
{
GError *err = NULL;
GVariant *ret = NULL;
/* Call org.bluez.GattCharacteristic1.ReadValue */
ret = g_dbus_connection_call_sync(
conn,
"org.bluez", /* bus name */
char_path, /* object path, e.g. /org/bluez/hci0/dev_AA_.../serviceXX/charYY */
"org.bluez.GattCharacteristic1",
"ReadValue",
g_variant_new("(a{sv})", NULL), /* options dict, empty */
G_VARIANT_TYPE("(ay)"), /* returns byte array */
G_DBUS_CALL_FLAGS_NONE,
-1, NULL, &err);
if (err) {
g_printerr("ReadValue failed: %s\n", err->message);
g_clear_error(&err);
return;
}
GVariant *bytes_v;
g_variant_get(ret, "(@ay)", &bytes_v);
gsize len;
const guint8 *data = g_variant_get_fixed_array(bytes_v, &len, 1);
printf("Bearer Provider Name (%zu bytes): ", len);
for (gsize i = 0; i < len; i++)
printf("%c", (char)data[i]); /* UTF-8 string */
printf("\n");
g_variant_unref(bytes_v);
g_variant_unref(ret);
}
All characteristics are defined in the TBS specification, but CCP’s procedures revolve around them. Here is a quick reference of the key characteristics you will encounter as a developer.
| Characteristic | UUID | Access | Notify | Purpose |
|---|---|---|---|---|
| Bearer Provider Name | 0x2BB3 | Read | β | Carrier name (e.g., “Airtel”, “Teams”) |
| Bearer UCI | 0x2BB4 | Read | Uniform Caller Identifier for the bearer | |
| Bearer Technology | 0x2BB5 | Read | β | Network type: 3G, LTE, VoIP, etc. |
| Bearer Signal Strength | 0x2BB7 | Read | β | Signal level 0β100 (0xFF = no signal) |
| Bearer List Current Calls | 0x2BB9 | Read | β | List of all ongoing calls with their states |
| Status Flags | 0x2BBB | Read | β | Inband ringtone, silent mode flags |
| Call State | 0x2BBE | Read | β | State of each call (Incoming/Active/Heldβ¦) |
| Call Control Point | 0x2BBF | Write / W-NR | β | Main control interface β send opcodes here |
| Termination Reason | 0x2BC0 | Notify only | β | Why a call ended (busy, failed, etc.) |
| Incoming Call | 0x2BC1 | Read | β | URI/number of incoming call |
| Call Friendly Name | 0x2BC2 | Read | β | Display name for the call (contact name) |
| Content Control ID (CCID) | 0x2BBA | Read | Links this service to audio content control |
ATT_MTU β 3 bytes (default MTU is 23, so max payload = 20 bytes), a notification only delivers the first ATT_MTUβ3 bytes. The Client must use the Read Long Characteristic Value procedure to get the rest.Each call on a bearer has a state. The Call State characteristic encodes the current state of every active call. State changes are delivered to the Client via GATT notifications. Here are the fundamental call states:
| Call State Transitions | ||||
|
INCOMING
|
β |
ACTIVE
|
Answer opcode | |
| β | β | |||
|
DIALING
|
Terminate |
ALERTING
|
Hold/Retrieve |
LOCALLY HELD
|
| β | ||||
|
REMOTELY HELD
|
LOCALLY & REMOTELY HELD
|
β transitions both ways | ||
| Any state β TERMINATED via Terminate opcode | ||||
Figure 4: Call state transitions (simplified)
Each entry in the Call State characteristic contains three fields: Call_Index (1 byte, unique ID for the call), Call_State (1 byte, the state enum above), and Call_Flags (1 byte, whether URI/name is withheld).
The Call Control Point (CCP) characteristic is the command channel. The Client writes an opcode plus parameters to this characteristic to perform a call action. The Server responds with a notification on the same characteristic (if the Client has enabled notifications on it).
[Opcode (1 byte)] [Call_Index (1 byte)] [optional parameters]Call_Index of the ringing call. The Server transitions that call from INCOMING β ACTIVE. The headset user pressed the green button.Call_Index to end a call in any state. Works for rejecting an incoming call, ending an active call, or cancelling a dialling attempt.tel:+919876543210). No Call_Index is needed β the Server assigns one and notifies the Client.Call_Index values. Calls with state INCOMING must not be in the join list.BlueZ β Writing an Opcode to the Call Control Point
/*
* Write "Terminate" opcode (0x01) for Call_Index 0x01
* to the Call Control Point characteristic.
*
* Using gatttool command-line:
* char-write-req <handle> <value>
*
* Byte format: [opcode] [call_index]
* 0x01 0x01
*/
/* ---- gatttool interactive session ---- */
// [AA:BB:CC:DD:EE:FF][LE]> char-write-req 0x0030 0101
// Characteristic value was written successfully
// notification handle = 0x0030 value: 01 01 00
// ^-- Server responded: opcode=Accept(0x00?), index=0x01, result=Success(0x00)
/*
* C snippet: write using BlueZ D-Bus GattCharacteristic1.WriteValue
*/
#include <gio/gio.h>
#define CCP_OPCODE_TERMINATE 0x01
#define CCP_OPCODE_ANSWER 0x00
#define CCP_OPCODE_LOCAL_HOLD 0x02
#define CCP_OPCODE_ORIGINATE 0x04
int ccp_write_opcode(GDBusConnection *conn,
const char *ccp_char_path,
guint8 opcode,
guint8 call_index)
{
GError *err = NULL;
guint8 buf[2] = { opcode, call_index };
GVariant *bytes = g_variant_new_fixed_array(
G_VARIANT_TYPE_BYTE, buf, 2, 1);
GVariant *opts = g_variant_new("a{sv}", NULL);
GVariant *params = g_variant_new("(@ay@a{sv})", bytes, opts);
g_dbus_connection_call_sync(
conn,
"org.bluez",
ccp_char_path,
"org.bluez.GattCharacteristic1",
"WriteValue",
params,
NULL,
G_DBUS_CALL_FLAGS_NONE,
-1, NULL, &err);
if (err) {
g_printerr("CCP WriteValue error: %s\n", err->message);
g_clear_error(&err);
return -1;
}
return 0;
}
/* Usage: terminate call index 1 */
// ccp_write_opcode(conn, "/org/bluez/.../char0030",
// CCP_OPCODE_TERMINATE, 0x01);
Many TBS/GTBS characteristics are notify-capable. The Client should subscribe to the ones it cares about so the Server can push updates without the Client polling. This is critical for real-time call management.
| Call Control Client (Headset) | Call Control Server (Phone) | |
|---|---|---|
| 1. GATT Connect | β | |
| 2. Discover GTBS service | β | |
| β | 3. Return service handle range | |
| 4. Discover chars & descriptors | β | |
| 5. Write CCCD 0x0001 for Call State & Termination Reason |
β | |
| 6. Incoming call detected! | β | 6. NOTIFY: Call State = INCOMING, idx=1 |
| 7. Write CCP: Accept, idx=1 | β | |
| β | 8. NOTIFY: Call State = ACTIVE, idx=1 | |
| β | 9. NOTIFY CCP: opcode=0, idx=1, result=Success |
Figure 5: Typical notification flow β Client subscribes, Server pushes call state updates
Call_Index and a reason code (e.g., call ended by remote party, line busy, no answer, etc.).BlueZ β Receiving Notifications via bluetoothctl
# Using bluetoothctl (BlueZ 5.x) to monitor GATT notifications
$ bluetoothctl
# Scan and connect
[bluetooth]# scan on
[bluetooth]# connect AA:BB:CC:DD:EE:FF
[bluetooth]# menu gatt
# List attributes discovered after connection
[AA:BB:CC:DD:EE:FF]# list-attributes AA:BB:CC:DD:EE:FF
# Select the Call State characteristic
[AA:BB:CC:DD:EE:FF]# select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0010/char001e
# Enable notifications (writes 0x0100 to CCCD automatically)
[AA:BB:CC:DD:EE:FF:/service0010/char001e]# notify on
# Now incoming calls / state changes appear in the terminal:
# [CHG] Attribute /org/bluez/.../char001e Value:
# 01 01 00
# Call_Index=0x01, Call_State=0x00 (INCOMING), Call_Flags=0x00
# Answer the call β write Accept opcode
[AA:BB:CC:DD:EE:FF:/service0010/char001e]# back
# Select the Call Control Point characteristic
[AA:BB:CC:DD:EE:FF]# select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0010/char0030
[AA:BB:CC:DD:EE:FF:/service0010/char0030]# write 0x00 0x01
# 0x00 = Accept opcode, 0x01 = Call_Index
Phone calls contain sensitive information. CCP enforces encryption and bonding. Here is what you need to know as a developer.
BLE (LE Transport)
- Minimum: Security Mode 1 Level 2 (encrypted link, bonded)
- Encryption key must have 128-bit entropy
- Key source: LE Secure Connections, CTKD from BR/EDR SC, or OOB
- Both Client and Server must support bondable mode
- Link Layer Privacy should be used
- SM1 L1 (no encryption) is excluded
BR/EDR Transport
- Minimum: Security Mode 4 Level 2
- Encryption key must have 128-bit entropy
- Key source: BR/EDR Secure Connections, CTKD from LE SC, or OOB
- Devices should bond during connection
- Initiating device should start bonding procedure
0x0F (Insufficient Encryption) or 0x05 (Insufficient Authentication). Make sure pairing and bonding complete before accessing any characteristic.BlueZ β Setting Up Pairing and Bonding
# Enable Bluetooth agent for pairing
$ bluetoothctl
[bluetooth]# agent on
[bluetooth]# default-agent
# Set pairing capability: KeyboardDisplay for SSP/LE Secure Connections
[bluetooth]# power on
[bluetooth]# pairable on
[bluetooth]# scan on
# Once device appears:
[bluetooth]# pair AA:BB:CC:DD:EE:FF
# Confirm passkey / accept pairing request when prompted
# Trust the device so it auto-connects in future
[bluetooth]# trust AA:BB:CC:DD:EE:FF
# Verify security: check if link is encrypted
$ hcitool con
# Should show: <handle> AA:BB:CC:DD:EE:FF type:LE encrypted:yes
# In BlueZ source (C): enforce minimum security level before GATT operations
# bt_att_set_security(att, BT_SECURITY_MEDIUM);
# /* BT_SECURITY_MEDIUM = SM1 L2 = encrypted + bonded */
The Generic Access Profile (GAP) layer handles device discovery and connection setup. CCP has specific requirements at this layer.
Server (Peripheral) β Advertising
- 1
Must use extended advertising PDUs (not legacy). Advertise in Limited or General Discoverable mode when looking for new connections.
- 2
Should include Service UUID AD type containing TBS UUID (0x184B) and/or GTBS UUID (0x184C) so Clients can discover it efficiently.
- 3
For bonded devices: use Directed Connectable mode or Undirected Connectable mode with an advertising filter to accept only the bonded peer.
Client (Central) β Connection
- 1
Use Limited or General Discovery procedure to find the Server.
- 2
Connect using any GAP connection procedure (Auto, General, Selective, or Direct).
- 3
Negotiate a tight connection interval to reduce call control latency.Recommended Connection Interval: 10 ms β 30 ms
BlueZ β Requesting Connection Parameters
/*
* Request a tighter connection interval after connecting.
* Uses HCI LE Connection Update command via BlueZ socket API.
* Values are in units of 1.25 ms.
* 10 ms = 0x0008 (8 * 1.25 = 10 ms)
* 30 ms = 0x0018 (24 * 1.25 = 30 ms)
*/
#include <bluetooth/bluetooth.h>
#include <bluetooth/hci.h>
#include <bluetooth/hci_lib.h>
void request_ccp_connection_params(int hci_sock, uint16_t conn_handle)
{
struct hci_request rq = { 0 };
le_connection_update_cp cp = { 0 };
uint8_t status;
cp.handle = htobs(conn_handle);
cp.min_interval = htobs(0x0008); /* 10 ms */
cp.max_interval = htobs(0x0018); /* 30 ms */
cp.latency = htobs(0x0000); /* no slave latency */
cp.supervision_timeout = htobs(0x01F4); /* 5 s */
cp.min_ce_length = htobs(0x0000);
cp.max_ce_length = htobs(0x0000);
rq.ogf = OGF_LE_CTL;
rq.ocf = OCF_LE_CONN_UPDATE;
rq.cparam = &cp;
rq.clen = LE_CONN_UPDATE_CP_SIZE;
rq.rparam = &status;
rq.rlen = 1;
if (hci_send_req(hci_sock, &rq, 2000) < 0)
perror("hci_send_req LE_CONN_UPDATE failed");
}
Here is the full lifecycle from boot to an answered call, showing every layer of CCP working together.
| Complete CCP Session Lifecycle |
|
PHASE 1: DISCOVERY Server advertises with GTBS UUID β Client scans β Client finds Server by UUID filter
PHASE 2: CONNECTION BLE connection established β Connection interval negotiated (10β30 ms)
PHASE 3: SECURITY Pairing β Bonding β LE Secure Connections β 128-bit encrypted link established
PHASE 4: GATT SETUP Discover GTBS primary service β Discover all chars β Discover CCCDs β Write CCCDs to enable notifications
PHASE 5: READ INITIAL STATE Read Bearer Provider Name β Read Bearer Technology β Read Call State β Sync initial state
PHASE 6: CALL ARRIVES Server sends Call State notification (INCOMING) β Client rings/vibrates β User presses button
PHASE 7: CALL CONTROL Client writes Accept opcode β Server confirms via CCP notification β Call State β ACTIVE
PHASE 8: CALL ENDS Client writes Terminate β Server sends Termination Reason notification β Call State characteristic cleared
|
Termination Reason has no readable value β it is notify-only. Developers sometimes skip writing its CCCD, then wonder why they never know why a call ended. Always write
0x0001 to its CCCD.Writing an opcode to the Call Control Point sends the command, but the Server’s response (success or error code) comes back as a notification on the same characteristic. If you didn’t enable CCP notifications, you won’t know if your command succeeded.
The spec explicitly states: “The list of Call_Index fields provided in the Join opcode write shall not include any Call_Index fields with the Call State of Incoming.” Including one will result in an error response from the Server.
Only GTBS is mandatory on the Server. TBS instances are optional and may be zero. Always check via service discovery before trying to interact with TBS.
While doing a Read Long Characteristic Values procedure (for values > ATT_MTUβ3), if you get ATT error
Value Changed During Read Long, the partial data you already read is invalid. Restart from offset 0 with a fresh Read.Service UUIDs
| Service | UUID |
|---|---|
| TBS | 0x184B |
| GTBS | 0x184C |
CCP Opcodes
| Opcode | Value |
|---|---|
| Accept | 0x00 |
| Terminate | 0x01 |
| Local Hold | 0x02 |
| Local Retrieve | 0x03 |
| Originate | 0x04 |
| Join | 0x05 |
Call States
| State | Value |
|---|---|
| Incoming | 0x00 |
| Dialing | 0x01 |
| Alerting | 0x02 |
| Active | 0x03 |
| Locally Held | 0x04 |
| Remotely Held | 0x05 |
| Locally & Remotely Held | 0x06 |
Security Summary
| Transport | Min Security |
|---|---|
| LE | SM1 Level 2 + 128b key |
| BR/EDR | SM4 Level 2 + 128b key |
| Connection Interval | 10 β 30 ms |
Keep Learning at EmbeddedPathashala
Explore more BLE profile tutorials, Linux kernel programming, and hands-on BlueZ guides β all free, all practical.
