Why Did Bluetooth Audio Need New Control Specs?
Old Bluetooth Classic profiles used the AT command set — a protocol invented in 1981 for landline modems, then carried forward into GSM phones and eventually Bluetooth Headset/Hands-Free profiles in the early 2000s. It was a hack on top of a hack.
By the time LE Audio was designed, one device could simultaneously handle Zoom, WhatsApp, Spotify and Netflix. The old AT commands had no clean way to deal with this. LE Audio took the opportunity to redesign from scratch — universal state machines that work equally well for a cellular call, a VoIP call, a local music track, or a streamed movie.
The big architectural decision: control and audio are completely separate. You can answer a call on your earbuds without audio being involved in the control protocol at all. The application on the phone decides what audio stream to start — that is not TBS’s job.
Two pairs — one for telephony, one for media. In each pair, the Service holds state on the phone/player side; the Profile is what the remote control (earbuds, watch, carkit) implements.
| Domain | Service (lives on the phone) | Profile (lives on earbuds/watch) |
|---|---|---|
| 📞 Telephony | TBS — Telephone Bearer Service Holds call state. Notifies clients of every change. |
CCP — Call Control Profile Writes commands to TBS (accept, reject, hold…) |
| 🎵 Media | MCS — Media Control Service Holds playback state. Notifies clients of changes. |
MCP — Media Control Profile Writes commands to MCS (play, pause, skip…) |
In audio stream chapters we talked about Initiators and Acceptors. For control we switch to Client and Server, because a device that never carries audio — a smartwatch — can still fully control calls and music.
- Server = phone, tablet, laptop. Holds and exposes the state machine. Always the Initiator.
- Client = earbuds, watch, carkit, remote control. Reads state, writes commands.
Multiple clients can operate on the same server simultaneously. You could accept a call on your earbud and hang it up from your watch — both are valid CCP clients connected to the same TBS server on your phone. Whichever acts first triggers a notification to all others.
A phone might run Skype, WhatsApp, Zoom and cellular all at once. Each gets its own TBS instance. But earbuds have two or three buttons — they cannot choose between four TBS instances.
Solution: a Generic Telephone Bearer Service (GTBS) and a Generic Media Control Service (GMCS). These are single-interface wrappers. The phone’s software maps incoming commands to the correct app. The earbud talks to one place; the phone sorts the rest.
|
📞 Telephony
|
🎵 Media
|
Every device must include one GTBS or GMCS instance. Additional per-app TBS/MCS instances are optional — they let complex clients like carkits control specific apps directly using their Content Control IDs (CCIDs).
This diagram shows how everything fits together in a typical phone + earbuds + watch scenario:
|
📱 Phone (Initiator)
Server Role TBS — Call State
MCS — Media State
|
⇄
GATT read/write
+ notifications |
🎧 Earbuds (Acceptor)
Client Role CCP Client
MCP Client
|
| ↑ Also connects ↑ | ||
|
⌚ Watch (Commander)
Client Role — no audio needed |
||
Critical design rule: TBS/MCS (control plane) and BAP/ASCS (audio data plane) are completely independent. Accepting a call in TBS does NOT automatically start an audio stream. The application on the phone is responsible for linking the two. This is intentional — it allows TBS/CCP to work with wired headsets, speakerphones, and future audio technologies too.
Part 1: Telephony Control — TBS and CCP
Every phone call goes through a defined sequence of states. TBS holds the current state and notifies all connected CCP clients whenever the state changes.
|
IDLE (Call Ended)
Starting point |
||||
| Originate ↓ | ↓ Incoming | ← Terminate (from ANY state) | ||
|
DIALING
You dialled; remote not yet ringing
|
→ |
INCOMING
Your phone is ringing
|
→ |
LOCALLY HELD
You pressed Hold
|
| ↓ Remote Alert* | ↓ Accept | ↕ Remote Hold* / Remote Retrieve* | ||
|
ALERTING
Remote party’s phone is ringing
|
→ |
✅ ACTIVE
Call is live — both connected
|
→ |
REMOTELY HELD
Other end pressed Hold
|
|
LOCALLY & REMOTELY HELD
Both sides on hold simultaneously |
||||
Transitions happen because of: an incoming call arriving, a CCP client writing to the control point, a user tapping a button on the phone, or the remote caller doing something. The Call State characteristic is notified to all subscribed clients every time any state changes.
When any call leaves the Idle state, TBS assigns it a Call_Index (a number 1–255) and includes it in the Call State characteristic. The characteristic is an array — one 3-byte entry per active call.
| Call_Index 1 byte (1–255) Unique per-call ID assigned by TBS |
State 1 byte (0x00–0x06) Current state of this call |
Call_Flags 1 byte (bitfield) Direction, withheld info |
State byte values:
| Value | State Name | Plain English |
|---|---|---|
| 0x00 | Incoming | Phone is ringing — you have an incoming call |
| 0x01 | Dialing | You dialled, but remote party not yet ringing (dialtone) |
| 0x02 | Alerting | Remote party’s phone is now ringing |
| 0x03 | Active | Call is established — both parties talking |
| 0x04 | Locally Held | You pressed Hold — can retrieve with Local Retrieve opcode |
| 0x05 | Remotely Held | Other party pressed Hold — you can only Terminate locally |
| 0x06 | Locally & Remotely Held | Both sides on hold at the same time |
Call_Flags byte — bit breakdown:
| Bit | Meaning | Bit = 0 | Bit = 1 |
|---|---|---|---|
| 0 | Call Direction | Incoming call | Outgoing call |
| 1 | Info withheld by server | Caller ID available | Server deliberately hiding it (private setting) |
| 2 | Info withheld by network | Network provides caller ID | Network blocked it (e.g., number withheld) |
| 3–7 | Reserved for future use | ||
Two terms used throughout TBS:
| UCI — Uniform Caller Identifier Identifies the bearer app |
URI — Uniform Resource Identifier UCI + actual caller ID or number |
skype → Skype callwtsap → WhatsApp calltel: or E.164 → regular phone number |
skype:john.doewtsap:+919876543210tel:+919876543210Expressed as a UTF-8 string
|
When originating a call, the URI in the control point tells the phone which app to use to make the call. When a call comes in, the URI in the Incoming Call characteristic tells your earbuds who is calling — and if the earbud has text-to-speech, it can speak the name aloud.
A CCP client writes to the Call Control Point characteristic to drive state transitions. Format: one opcode byte, followed by a parameter that varies by opcode.
| Opcode 1 byte |
Parameter Variable — depends on the opcode |
| Opcode | Name | Parameter | What it does |
|---|---|---|---|
| 0x00 | Accept | Call_Index | Answer an incoming call |
| 0x01 | Terminate | Call_Index | End the call — works from any state |
| 0x02 | Local Hold | Call_Index | Put an Active or Incoming call on hold locally |
| 0x03 | Local Retrieve | Call_Index | Bring a locally held call back to Active |
| 0x04 | Originate | URI (UTF-8 string) | Start a new outgoing call to this URI |
| 0x05 | Join | List of Call_Index values | Merge calls into a conference |
If a write fails, the server sends a notification back: [Requested Opcode | Call_Index | Result Code]. The server can also advertise which optional opcodes it supports via the Call Control Optional Opcodes characteristic — a client should check this before attempting Local Hold or Join.
Here is how a CCP client (earbuds, watch) would interact with TBS on a phone using BlueZ’s GATT client API:
/* ============================================================
* CCP Client: Read Call State + Write to Call Control Point
* BlueZ 5.65+, LE Audio / TBS (GTBS) over GATT
* ============================================================ */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "lib/bluetooth.h"
#include "src/shared/gatt-client.h"
/* GTBS Service UUID (16-bit, from Bluetooth Assigned Numbers) */
#define UUID_GTBS 0x184C
/* TBS Characteristic UUIDs */
#define UUID_CALL_STATE 0x2BBD /* Call State characteristic */
#define UUID_CALL_CTRL_POINT 0x2BBE /* Call Control Point */
#define UUID_INCOMING_CALL 0x2BBF /* Incoming Call URI + index */
#define UUID_TERMINATION_RSN 0x2BC0 /* Termination Reason */
#define UUID_STATUS_FLAGS 0x2BC5 /* Inband ring / silent mode */
/* Call state values */
#define TBS_STATE_INCOMING 0x00
#define TBS_STATE_DIALING 0x01
#define TBS_STATE_ALERTING 0x02
#define TBS_STATE_ACTIVE 0x03
#define TBS_STATE_LOCAL_HOLD 0x04
#define TBS_STATE_REMOTE_HOLD 0x05
#define TBS_STATE_BOTH_HOLD 0x06
/* CCP opcodes */
#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
/* Status Flags bits */
#define STATUS_FLAG_INBAND_RING (1 << 0)
#define STATUS_FLAG_SILENT_MODE (1 << 1)
static const char *tbs_state_str(uint8_t state)
{
switch (state) {
case TBS_STATE_INCOMING: return "Incoming";
case TBS_STATE_DIALING: return "Dialing";
case TBS_STATE_ALERTING: return "Alerting";
case TBS_STATE_ACTIVE: return "Active";
case TBS_STATE_LOCAL_HOLD: return "Locally Held";
case TBS_STATE_REMOTE_HOLD: return "Remotely Held";
case TBS_STATE_BOTH_HOLD: return "Locally & Remotely Held";
default: return "Unknown";
}
}
/*
* Called when a Call State notification arrives.
* Each call = 3 bytes: Call_Index | State | Flags
*/
static void call_state_notify_cb(uint16_t value_handle,
const uint8_t *value,
uint16_t length,
void *user_data)
{
int num_calls = length / 3;
printf("=== Call State Update: %d active call(s) ===\n", num_calls);
for (int i = 0; i < num_calls; i++) {
uint8_t call_index = value[i * 3 + 0];
uint8_t state = value[i * 3 + 1];
uint8_t flags = value[i * 3 + 2];
/* Bit 0 of flags: 0=incoming, 1=outgoing */
const char *direction = (flags & 0x01) ? "Outgoing" : "Incoming";
/* Bit 1: caller ID withheld by server */
const char *withheld = (flags & 0x02) ? " [ID withheld]" : "";
printf(" Call[%d]: index=%d, state=%s, dir=%s%s\n",
i, call_index,
tbs_state_str(state),
direction, withheld);
/* If a call just went Active, we might start an audio stream here */
if (state == TBS_STATE_ACTIVE)
printf(" --> Call is active. App should establish audio stream.\n");
}
}
/*
* Accept call: write [0x00, call_index] to Call Control Point
*/
static void ccp_accept(struct bt_gatt_client *client,
uint16_t ctrl_handle, uint8_t call_index)
{
uint8_t pdu[2] = { CCP_OP_ACCEPT, call_index };
bt_gatt_client_write_value(client, ctrl_handle,
pdu, sizeof(pdu),
NULL, NULL, NULL);
printf("CCP: Accept sent for call_index=%d\n", call_index);
}
/*
* Originate a call to a URI.
* URI format: "tel:+919876543210" or "skype:john.doe"
* Do NOT null-terminate — send raw UTF-8 bytes.
*/
static void ccp_originate(struct bt_gatt_client *client,
uint16_t ctrl_handle,
const char *uri)
{
size_t uri_len = strlen(uri);
uint8_t pdu[1 + uri_len];
pdu[0] = CCP_OP_ORIGINATE;
memcpy(&pdu[1], uri, uri_len); /* URI as UTF-8, no null terminator */
bt_gatt_client_write_value(client, ctrl_handle,
pdu, 1 + uri_len,
NULL, NULL, NULL);
printf("CCP: Originate call to '%s'\n", uri);
}
/*
* Read Status Flags: check if inband ringtone is enabled
* or phone is in silent mode.
*/
static void status_flags_read_cb(bool success, uint8_t att_ecode,
const uint8_t *value, uint16_t length,
void *user_data)
{
if (!success || length < 2)
return;
uint16_t flags;
memcpy(&flags, value, 2);
printf("Status Flags:\n");
printf(" Inband Ringtone: %s\n",
(flags & STATUS_FLAG_INBAND_RING) ? "Enabled" : "Disabled");
printf(" Silent Mode: %s\n",
(flags & STATUS_FLAG_SILENT_MODE) ? "Enabled" : "Disabled");
/*
* If inband ring is DISABLED:
* - Play a local ringtone when Call State goes to Incoming
* - Audio stream is NOT needed just for the ring
* If inband ring is ENABLED:
* - Phone will stream the ringtone over BLE
* - A new audio stream will be set up for the ring audio
*/
}
Code walkthrough:
- The CCP client registers for notifications on the Call State characteristic. Every time any call changes state,
call_state_notify_cbfires. - Call State is an array — if there are 2 active calls, the notification is 6 bytes (2 × 3).
- To answer a call, write
[0x00, call_index]to the control point. The server then transitions the call to Active. - To dial out, write
[0x04, ...]followed by the raw UTF-8 bytes of the URI — no null terminator. - Status Flags is a separate read to decide whether to play a local ring or wait for an inband audio stream.
When a call arrives while wearing earbuds, how should the ring be handled? This turns out to be a real design decision with trade-offs:
| Aspect | 🔊 Inband Ringtone | 📡 Out-of-Band Ringtone |
|---|---|---|
| Mechanism | Phone streams the actual ringtone as a BLE audio stream | Earbud generates its own local ring sound when notified of Incoming state |
| BLE audio stream needed? | Yes — a new stream must be set up | No — earbud just plays locally |
| Problem: Watching TV on earbuds | TV stream torn down to ring → if you reject the call, must reconnect TV — poor UX | Earbud mixes local ring into TV audio — TV stream never touched |
| Sounds identical to phone? | Yes | No — earbud makes its own sound |
| Caller ID announcement | From stream metadata | Earbud reads Incoming Call characteristic (has URI/caller ID). With text-to-speech, can speak the name. |
The earbud reads the Status Flags characteristic (Bit 0 = inband ring enabled/disabled, Bit 1 = phone in silent mode) and decides which approach to use. If both inband and out-of-band are enabled, it’s the earbud’s choice.
When a call ends for any reason, the server sends a Termination Reason characteristic notification. Format is just 2 bytes: [Call_Index | Reason_Code]. If multiple calls end at once (e.g., a conference call drops), a separate notification is sent for each.
| Code | Reason |
|---|---|
| 0x00 | Badly formed URI when originating (typo in number format) |
| 0x01 | Call failed |
| 0x02 | Remote party hung up |
| 0x03 | The server (phone/app) ended the call |
| 0x04 | Line busy |
| 0x05 | Network congestion |
| 0x06 | A CCP client (earbuds/watch) ended it |
| 0x07 | No service (out of range) |
| 0x08 | No answer |
| 0x09 | Unspecified |
Part 2: Media Control — MCS and MCP
MCS defines four states for media playback. The Active state for telephony maps to Playing for media — it means audio is flowing.
|
INACTIVE
No track selected |
||||
| ↓ User selects a valid track | ||||
| ← Play |
▶ PLAYING
|
⇄ |
⏸ PAUSED
|
Pause → |
| ↓ FF or Rewind | FF or Rewind ↓ | |||
|
⏩ SEEKING
Fast Forward or Rewind in progress |
||||
|
Stop (from any non-Inactive state) → invalidates current track → back to Inactive
|
||||
|
Current Track becomes invalid (e.g., playlist ends) → Inactive
|
||||
Why is there no “Stop” state? For digital audio, Stop and Pause do the same thing in memory — there is no cassette motor to switch off. The only functional difference is that after a Stop command, the current track becomes invalid, and the user must select a new track before anything can play again.
No audio data is transmitted while in the Seeking state. However, the Context Type for the audio stream does not change during seeking — it is still “media”.
MCS organises media content in a hierarchy. Think of it as a music library:
|
| |
Track Position Concepts
Track Position resolution: 10ms
Duration stored as signed 32-bit int in 0.01s units. −1 = unknown (live stream).
|
|||||||||||||||||
Groups can be nested — a parent group (all of an artist’s work) contains child groups (albums), which contain tracks, which may contain named segments. All state machine operations in MCS act on the Current Track. When you move to a new group, the first track of that group becomes current.
Clients write to the Media Control Point characteristic to change state. Opcodes are organised in three groups:
Group 1 — Basic Playback Control
| Opcode | Name | State it leads to |
|---|---|---|
| 0x01 | Play | Playing |
| 0x02 | Pause | Paused |
| 0x03 | Fast Rewind | Seeking (backward) |
| 0x04 | Fast Forward | Seeking (forward) |
| 0x05 | Stop | Inactive (current track becomes invalid) |
Group 2 — Move Within the Current Track (Segments)
| Opcode | Name | Parameter | Notes |
|---|---|---|---|
| 0x10 | Move Relative | Signed 32-bit ms offset | Positive = forward, negative = back. Clamped at track boundaries. |
| 0x20 | Previous Segment | None | Jump to previous segment in this track |
| 0x21 | Next Segment | None | Jump to next segment in this track |
| 0x22 | First Segment | None | |
| 0x23 | Last Segment | None | |
| 0x24 | Goto Segment | Signed 32-bit index | Positive from start, negative from end. Numbered from 0. |
Group 3 — Move to a Different Track or Group
| Within Current Group | Across Groups | ||||
|---|---|---|---|---|---|
| Code | Name | Param | Code | Name | Param |
| 0x30 | Prev Track | — | 0x40 | Prev Group | — |
| 0x31 | Next Track | — | 0x41 | Next Group | — |
| 0x32 | First Track | — | 0x42 | First Group | — |
| 0x33 | Last Track | — | 0x43 | Last Group | — |
| 0x34 | Goto Track | int32 | 0x44 | Goto Group | int32 |
Goto operations: positive n = go to first then move forward (n−1) times; negative n = go to last then move backward (n−1) times. All indices start at 0. Moving to a new track puts it in the Paused state.
A client should check the Media Control Point Opcodes Supported characteristic (bitfield) before sending commands, since only one opcode is technically mandatory. In practice every real product supports all basic ones.
MCS allows a client to request faster or slower playback by writing to the Playback Speed characteristic. The parameter is an 8-bit signed integer p (range: −128 to 127). The formula:
| p value | Calculation | Resulting Speed |
|---|---|---|
| −128 | 2^(−128/64) = 2^(−2) | 0.25× — quarter speed |
| −64 | 2^(−64/64) = 2^(−1) | 0.5× — half speed |
| 0 | 2^(0/64) = 2^0 | 1× — normal speed |
| 64 | 2^(64/64) = 2^1 | 2× — double speed |
| 127 | 2^(127/64) ≈ 2^1.98 | ≈3.96× — nearly 4× speed |
This is a blind request — the server notifies back the actual speed it set (which may differ if the requested value is outside its supported range). For live or streamed sources, the server may fix speed to 1×.
The Seeking Speed characteristic works as a multiplier on the current playback speed during Fast Forward/Rewind. Positive = forward, negative = backward. A value of 0 means seeking at normal playback speed. The Track Position characteristic is notified periodically during seeking so clients can show a progress indicator.
The Playing Order characteristic controls how the media player moves through tracks in a group. There are ten options. The server’s supported options are exposed as a 2-octet bitfield in the Playing Orders Supported characteristic.
| Value | Name | What it does |
|---|---|---|
| 0x01 | Single once | Play current track once, then advance to next |
| 0x02 | Single repeat | Loop the current track forever |
| 0x03 | In order once | Play all tracks in group in order, once |
| 0x04 | In order repeat | Play all tracks in order, then loop |
| 0x05 | Oldest once | Oldest tracks first (ascending date), once |
| 0x06 | Oldest repeat | Oldest first, then loop |
| 0x07 | Newest once | Newest tracks first (descending date), once |
| 0x08 | Newest repeat | Newest first, then loop |
| 0x09 | Shuffle once | Random order, each track played once |
| 0x0A | Shuffle repeat | Random order, loop forever |
Shuffle randomisation is left to the implementation. In practice, players use weighted randomisation so the same track does not come up twice in a row at the start of a new cycle. If the server does not know track age, it should ignore Oldest/Newest commands.
Here is a representative MCP client that reads track metadata from an MCS server and sends playback commands over BLE GATT:
/* ============================================================
* MCP Client: Read Track Metadata + Send Playback Commands
* BlueZ 5.65+, LE Audio / MCS (GMCS) over GATT
* ============================================================ */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "lib/bluetooth.h"
#include "src/shared/gatt-client.h"
/* GMCS Service UUID */
#define UUID_GMCS 0x1849
/* MCS Characteristic UUIDs */
#define UUID_MEDIA_STATE 0x2BA3
#define UUID_TRACK_TITLE 0x2B97
#define UUID_TRACK_DURATION 0x2B98 /* Signed 32-bit, units of 0.01s */
#define UUID_TRACK_POSITION 0x2B99 /* Signed 32-bit, units of 0.01s */
#define UUID_PLAYBACK_SPEED 0x2B9C /* Signed 8-bit: speed = 2^(p/64) */
#define UUID_MEDIA_CTRL_POINT 0x2BA2
#define UUID_PLAYING_ORDER 0x2BA4 /* 1 byte, values 0x01–0x0A */
/* MCS state values */
#define MCS_STATE_INACTIVE 0x00
#define MCS_STATE_PLAYING 0x01
#define MCS_STATE_PAUSED 0x02
#define MCS_STATE_SEEKING 0x03
/* MCP basic opcodes */
#define MCP_PLAY 0x01
#define MCP_PAUSE 0x02
#define MCP_FAST_REWIND 0x03
#define MCP_FAST_FORWARD 0x04
#define MCP_STOP 0x05
#define MCP_PREV_TRACK 0x30
#define MCP_NEXT_TRACK 0x31
static const char *mcs_state_str(uint8_t state)
{
switch (state) {
case MCS_STATE_INACTIVE: return "Inactive";
case MCS_STATE_PLAYING: return "Playing";
case MCS_STATE_PAUSED: return "Paused";
case MCS_STATE_SEEKING: return "Seeking";
default: return "Unknown";
}
}
/*
* Notification callback for Media State.
* Fires every time state changes (e.g., Playing → Paused).
*/
static void media_state_notify_cb(uint16_t value_handle,
const uint8_t *value,
uint16_t length,
void *user_data)
{
if (length < 1)
return;
printf("Media State: %s\n", mcs_state_str(value[0]));
}
/*
* Notification callback for Track Position during Seeking.
* Position is in 0.01-second units from start of track.
*/
static void track_position_notify_cb(uint16_t value_handle,
const uint8_t *value,
uint16_t length,
void *user_data)
{
if (length < 4)
return;
int32_t pos_cs;
memcpy(&pos_cs, value, 4);
if (pos_cs == -1) {
printf("Track Position: Not available\n");
} else {
int total_ms = pos_cs * 10; /* convert 0.01s to ms */
int seconds = (total_ms / 1000) % 60;
int minutes = (total_ms / 60000);
printf("Track Position: %02d:%02d\n", minutes, seconds);
}
}
/* Read track title */
static void track_title_read_cb(bool success, uint8_t att_ecode,
const uint8_t *value, uint16_t length,
void *user_data)
{
if (!success) {
printf("Track title read error: 0x%02x\n", att_ecode);
return;
}
/* UTF-8 string — NOT null-terminated. Use length explicitly. */
printf("Now Playing: %.*s\n", (int)length, (const char *)value);
}
/* Read track duration */
static void track_duration_read_cb(bool success, uint8_t att_ecode,
const uint8_t *value, uint16_t length,
void *user_data)
{
if (!success || length < 4)
return;
int32_t dur_cs;
memcpy(&dur_cs, value, 4);
if (dur_cs == -1) {
printf("Duration: Unknown (live stream)\n");
} else {
int total_sec = dur_cs / 100;
printf("Duration: %d min %d sec\n", total_sec / 60, total_sec % 60);
}
}
/* Send a no-parameter opcode (Play, Pause, Stop, Prev, Next…) */
static void mcp_send(struct bt_gatt_client *client,
uint16_t mcp_handle,
uint8_t opcode)
{
uint8_t pdu[1] = { opcode };
bt_gatt_client_write_value(client, mcp_handle,
pdu, sizeof(pdu),
NULL, NULL, NULL);
printf("MCP: Opcode 0x%02x sent\n", opcode);
}
/*
* Set playback speed.
* Normal speed = p=0 (2^0 = 1.0)
* Double speed = p=64 (2^1 = 2.0)
* Half speed = p=-64 (2^-1 = 0.5)
*/
static void mcp_set_speed(struct bt_gatt_client *client,
uint16_t speed_handle,
int8_t p)
{
uint8_t pdu[1] = { (uint8_t)p };
bt_gatt_client_write_value(client, speed_handle,
pdu, sizeof(pdu),
NULL, NULL, NULL);
printf("MCP: Requested playback speed p=%d (%.2fx)\n",
p, /* approximate: */ (p == 0 ? 1.0 : (p > 0 ? 2.0 : 0.5)));
}
/* Full setup: subscribe to notifications + read current track info */
static void setup_mcp_client(struct bt_gatt_client *client,
uint16_t state_h, uint16_t pos_h,
uint16_t title_h, uint16_t dur_h,
uint16_t ctrl_h, uint16_t speed_h)
{
/* Subscribe to state changes */
bt_gatt_client_register_notify(client, state_h,
NULL, NULL,
media_state_notify_cb,
NULL, NULL);
/* Subscribe to track position (useful during seek) */
bt_gatt_client_register_notify(client, pos_h,
NULL, NULL,
track_position_notify_cb,
NULL, NULL);
/* Read current track metadata */
bt_gatt_client_read_value(client, title_h,
track_title_read_cb, NULL, NULL);
bt_gatt_client_read_value(client, dur_h,
track_duration_read_cb, NULL, NULL);
/* Start playback at normal speed */
mcp_send(client, ctrl_h, MCP_PLAY);
mcp_set_speed(client, speed_h, 0); /* p=0 → 1× speed */
}
What to watch out for when writing MCP code:
- Track title comes as UTF-8 without a null terminator — always use the returned length, never
strlen(). - Duration and position use 0.01-second units as a signed 32-bit integer. −1 means unknown (live streams).
- Register for Track Position notifications — the server sends these during seeking so your UI can show a seek bar moving.
- After writing Play, the server will send a Media State notification confirming the transition to Playing state.
- If you send an unsupported opcode, the server sends back a notification on the Media Control Point with result code
Opcode not supported.
| Concept | Remember This |
|---|---|
| TBS | Server-side call state service, lives on the phone (Initiator) |
| CCP | Client profile for controlling TBS (earbuds, watch, carkit) |
| MCS | Server-side media state service, lives on the media player |
| MCP | Client profile for controlling MCS |
| GTBS / GMCS | Generic versions — mandatory single interface over all apps |
| Call State format | 3 bytes per call: Call_Index | State | Call_Flags |
| CCP Accept | Write [0x00, call_index] to Call Control Point |
| Control ≠ Audio | TBS/MCS state and BLE audio streams are completely separate — app links them |
| Inband ring | Phone streams ringtone as audio — needs a BLE audio stream to be set up |
| Out-of-band ring | Earbud generates its own ring when notified — no audio stream needed |
| UCI / URI | UCI = which app (skype, wtsap, tel:); URI = UCI + caller ID |
| Playback Speed | speed = 2^(p/64); range 0.25× (p=−128) to ~4× (p=127) |
| Stop vs Pause | Stop invalidates the current track (must select a new one). Pause keeps it. |
| Playing Order | 10 options from single-repeat to shuffle-repeat — check Supported bitfield first |
Chapter 10 covers Volume Control, Audio Input and Microphone — more state machines, more GATT characteristics, and how the whole audio chain is controlled end to end.
