BLE Audio: Telephony & Media Control TBS · CCP · MCS · MCP — How LE Audio Handles Calls and Music

BLE Audio: Telephony & Media Control
TBS · CCP · MCS · MCP — How LE Audio Handles Calls and Music
4
Specifications
7
Call States
4
Media States
10
Play Orders

Key Terms in This Chapter
TBS CCP MCS MCP GTBS GMCS Call State Call Control Point Media Control Point Inband Ringtone UCI / URI Playing Order CCID Playback Speed AT Command

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.

The Four Specifications at a Glance

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.

Control Spec Pairs — Service vs Profile
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…)

Client and Server Roles

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.

Generic Services: GTBS and GMCS

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.

GTBS and GMCS — One Handle for All Apps
📞 Telephony
TBS → Skype
TBS → WhatsApp
TBS → Cellular
GTBS
🎵 Media
MCS → Spotify
MCS → Netflix
MCS → BBC Sounds
GMCS

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).

Control Topology — The Big Picture

This diagram shows how everything fits together in a typical phone + earbuds + watch scenario:

TBS/MCS Control Topology
📱 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
The watch never carries audio but can fully answer/reject calls or skip tracks — it writes directly to the phone’s TBS/MCS.

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

The TBS Call State Machine

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.

TBS Call State Machine — All Paths
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
* Asterisk = remote-side operation. Your local device cannot trigger these — they come from the other caller.

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.

Call State Characteristic — Format and Values

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 State Characteristic — 3 Bytes 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

UCI and URI — Identifying Which App and Who Is Calling

Two terms used throughout TBS:

UCI vs URI
UCI — Uniform Caller Identifier
Identifies the bearer app
URI — Uniform Resource Identifier
UCI + actual caller ID or number
skype → Skype call
wtsap → WhatsApp call
tel: or E.164 → regular phone number
skype:john.doe
wtsap:+919876543210
tel:+919876543210
Expressed 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.

TBS Call Control Point — How Clients Give Commands

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.

Call Control Point Write Format
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.

BlueZ Code: CCP Client — Read Call State and Send Commands

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_cb fires.
  • 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.

Inband vs Out-of-Band Ringtones

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:

Two Ways to Ring Your Earbuds
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.

Call Termination Reasons

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

The MCS Media Playback State Machine

MCS defines four states for media playback. The Active state for telephony maps to Playing for media — it means audio is flowing.

MCS Media State Machine
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”.

Groups, Tracks, and Segments — The Content Hierarchy

MCS organises media content in a hierarchy. Think of it as a music library:

MCS Content Hierarchy
Parent Group
e.g., All Beethoven Works
Current Group
e.g., Symphonies Album
Current Track ← Most operations target this
e.g., Symphony No.5
Segments
e.g., 1st movement, 2nd movement…
|
Track Position Concepts
◄———— Track Duration ————►
Start +ve Offset ▼ Pos −ve Offset End
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.

Media Control Point — All Opcodes

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.

Playback Speed and Seeking Speed

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:

speed = 2 ^ ( p ÷ 64 )
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.

Playing Order — 10 Ways to Play a Group

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.

BlueZ Code: MCP Client — Track Info and Playback Control

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.

Chapter Summary — One Line per Concept
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

What’s Next in the LE Audio Series?

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.

Back to Course Index All BLE Posts

Leave a Reply

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