Bluetooth Media Control Service (MCS) – Part 2

BLE GATT SERVICE

Bluetooth Media Control Service (MCS) – Part 2

State machine, control opcodes, search, BlueZ code, and SDP interoperability — all explained step by step.

State Machine Control Opcodes BlueZ Code Search API

Quick Recap from Part 1: MCS is a BLE GATT service that lets a client device (earbuds, watch) control a media player on a server device (phone). The server exposes 22 characteristics grouped into player info, track info, playback control, and state. In Part 2 we look at how control actually works.

The Media State Machine

A media player is always in exactly one of four states. Here is how transitions happen between those states. Read each arrow label to understand what triggers the move.

Track becomes invalid

▶️
PLAYING
0x01

💤
INACTIVE
0x00

FF or Rew ↓

← Play
Pause →

Select Valid Track

SEEKING
0x03

← FF or Rew
Pause / Stop →

⏸️
PAUSED
0x02
← Next Track / Prev Track / Goto Track commands also land in Paused (Track Position = 0)

Play ↑
Stop opcode: Playing/Seeking/Paused → Paused (Track Position resets to 0)

State Transition Summary
From State Trigger To State
Any (except Inactive) Current track becomes invalid Inactive
Inactive Valid track selected Paused
Paused / Seeking Play opcode Playing
Playing Pause opcode Paused
Seeking Pause opcode Paused (position = where seeking stopped)
Playing / Paused Fast Forward or Fast Rewind opcode Seeking
Seeking Play opcode Playing
Playing / Paused / Seeking Stop opcode Paused (position reset to 0)

Media Control Point — All 21 Opcodes

The Media Control Point is the “remote control” characteristic. You write an opcode byte (plus optional parameters) to it, and the server performs the action then sends a notification back with the result.

/* Write format: */
uint8_t opcode; /* 1 byte: the command */
int32_t parameter; /* 0 or 4 bytes: for Move Relative, Goto Segment, Goto Track, Goto Group */

/* Response notification format: */
uint8_t requested_opcode; /* echo of what was sent */
uint8_t result_code; /* 0x01=SUCCESS 0x02=NOT_SUPPORTED 0x03=INACTIVE 0x04=CANNOT_COMPLETE */

① Basic Playback Controls
Opcode Name Parameters What It Does
0x01 Play None Start playing. Paused/Seeking → Playing. Already Playing → no change.
0x02 Pause None Pause. Playing → Paused. Seeking → Paused at current seek position. Already Paused → no change.
0x03 Fast Rewind None Begin seeking backwards. Each additional call may increase rewind speed.
0x04 Fast Forward None Begin seeking forward. Each additional call may increase FF speed.
0x05 Stop None Stop all activity → Paused state. Track position resets to 0.

② Position Control
Opcode Name Parameters What It Does
0x10 Move Relative offset (sint32) Jump forward/back by offset (in 0.01s). Clamped to 0 or track end — no wrapping.

③ Segment Navigation (within a track)
Segment = a named chapter within a track. Example: a podcast might have chapters “Intro”, “Main Content”, “Q&A”. Each segment has a name and a time offset.
Opcode Name Params What It Does
0x20 Previous Segment None Go to start of previous chapter (like double-tap back on earbuds)
0x21 Next Segment None Go to start of next chapter
0x22 First Segment None Jump to very first chapter of the track
0x23 Last Segment None Jump to last chapter of the track
0x24 Goto Segment n (sint32) Jump to nth chapter. Positive n = from start; negative n = from end; n=0 = no change.

④ Track Navigation
Opcode Name Params What It Does
0x30 Previous Track None Previous track per playing order. Position → 0.
0x31 Next Track None Next track per playing order. Position → 0.
0x32 First Track None Jump to first track. Position → 0.
0x33 Last Track None Jump to last track. Position → 0.
0x34 Goto Track n (sint32) Jump to nth track. Positive = from first; negative = from last; n=0 = no change. Position → 0.

⑤ Group Navigation
Groups are like albums/playlists. Changing the group also changes the current track to the first track of the new group, and Track Position resets to 0.
Opcode Name Params What It Does
0x40 Previous Group None Previous group within parent group
0x41 Next Group None Next group within parent group
0x42 First Group None Jump to first group in parent
0x43 Last Group None Jump to last group in parent
0x44 Goto Group n (sint32) Jump to nth group. Positive = from first; negative = from last; n=0 = no change.

Control Point Response Result Codes
0x01
SUCCESS
Command executed OK
0x02
NOT SUPPORTED
Opcode not implemented
0x03
PLAYER INACTIVE
State was Inactive
0x04
CANNOT COMPLETE
Internal player error

Media Control Point Opcodes Supported — Bitmask

Before sending any opcode, a smart client reads the Media Control Point Opcodes Supported characteristic — a 32-bit bitmask that tells you which opcodes this server actually implements. If bit N is set, opcode N is supported.

/* Example: reading opcodes supported in BlueZ / C */
uint32_t supported = read_gatt_characteristic(MCS_OPCODES_SUPPORTED_UUID);

if (supported & 0x00000001) printf(“Play supported\n”);
if (supported & 0x00000002) printf(“Pause supported\n”);
if (supported & 0x00001000) printf(“Next Track supported\n”);
if (supported & 0x00100000) printf(“Goto Group supported\n”);

Bit Value Opcode Name Bit Value Opcode Name
0x00000001 Play 0x00000800 Previous Track
0x00000002 Pause 0x00001000 Next Track
0x00000004 Fast Rewind 0x00002000 First Track
0x00000008 Fast Forward 0x00004000 Last Track
0x00000010 Stop 0x00008000 Goto Track
0x00000020 Move Relative 0x00010000 Previous Group
0x00000040 Previous Segment 0x00020000 Next Group
0x00000080 Next Segment 0x00040000 First Group
0x00000100 First Segment 0x00080000 Last Group
0x00000200 Last Segment 0x00100000 Goto Group
0x00000400 Goto Segment 0x00200000+ RFU

Search Control Point — Searching Your Media Library

The Search Control Point lets a client trigger a search over the media library. Search criteria are encoded as TLV (Type-Length-Value) items chained together into a single write (max 64 bytes).

Search Control Item Structure:
1 octet
Length
(= size of Type + Parameter)
1 octet
Type
(search category)
Length – 1 octets
Parameter
(search string or empty)
Multiple items are simply concatenated. The server treats them as AND conditions — results must satisfy ALL items.
Type Value Search Category Parameter
0x01 Track Name UTF-8 string (e.g. “Bohemian”)
0x02 Artist Name UTF-8 string (e.g. “Queen”)
0x03 Album Name UTF-8 string
0x04 Group Name UTF-8 string
0x05 Earliest Year UTF-8 string (e.g. “1990”)
0x06 Latest Year UTF-8 string (e.g. “1999”)
0x07 Genre UTF-8 string (e.g. “Rock”)
0x08 Only Tracks None (filter to tracks only)
0x09 Only Groups None (filter to groups/albums only)

/* * Example: Find all tracks from year 1999, genre “Pop” * (Earliest Year=1999) AND (Latest Year=1999) AND (Genre=”Pop”) AND (Only Tracks) * * Byte encoding: * Item 1: [Length=5][Type=0x05][“1999”] → Earliest Year * Item 2: [Length=5][Type=0x06][“1999”] → Latest Year * Item 3: [Length=4][Type=0x07][“Pop”] → Genre * Item 4: [Length=1][Type=0x08] → Only Tracks */

uint8_t search[] = {
5, 0x05, ‘1’,’9′,’9′,’9′, /* Earliest Year = 1999 */
5, 0x06, ‘1’,’9′,’9′,’9′, /* Latest Year = 1999 */
4, 0x07, ‘P’,’o’,’p’, /* Genre = “Pop” */
1, 0x08 /* Only Tracks (no parameter) */
};
write_gatt_characteristic(SEARCH_CONTROL_POINT_UUID, search, sizeof(search));

After writing, the server sends a Search Control Point Notification with result code 0x01 (SUCCESS, search started) or 0x02 (FAILURE). When the search finishes, it notifies the Search Results Object ID characteristic — which you then use via OTS to fetch the list of matching tracks/groups.

BlueZ — Interacting with MCS from Linux

BlueZ (the Linux Bluetooth stack) supports MCS as part of its LE Audio implementation. Here’s how you can interact with MCS characteristics using BlueZ’s D-Bus API and the bluetoothctl tool.

① Discover MCS using bluetoothctl
# Scan and connect to a device exposing MCS
$ bluetoothctl
[bluetooth]# scan on
[bluetooth]# connect AA:BB:CC:DD:EE:FF

# List GATT services — look for MCS UUID
[bluetooth]# menu gatt
[bluetooth]# list-attributes AA:BB:CC:DD:EE:FF

# MCS Primary Service UUID: 1848 (Media Control Service)
# GMCS Primary Service UUID: 1849 (Generic Media Control Service)
# Media Control Point UUID: 2BC1
# Media State UUID: 2BA3
# Track Title UUID: 2B97

② Read Track Title and Media State via BlueZ D-Bus in C
/* * BlueZ MCS client example using GLib D-Bus API. * Reads Track Title and sends Play opcode via Media Control Point. * Compile: gcc mcs_client.c $(pkg-config –cflags –libs gio-2.0) -o mcs_client */ #include <gio/gio.h> #include <stdio.h> #include <stdint.h> /* UUIDs from Bluetooth Assigned Numbers */ #define TRACK_TITLE_UUID “00002b97-0000-1000-8000-00805f9b34fb” #define MEDIA_STATE_UUID “00002ba3-0000-1000-8000-00805f9b34fb” #define MEDIA_CTRL_PT_UUID “00002bc1-0000-1000-8000-00805f9b34fb” /* Media State values */ #define MCS_STATE_INACTIVE 0x00 #define MCS_STATE_PLAYING 0x01 #define MCS_STATE_PAUSED 0x02 #define MCS_STATE_SEEKING 0x03 /* Media Control Point opcodes */ #define MCS_OPC_PLAY 0x01 #define MCS_OPC_PAUSE 0x02 #define MCS_OPC_FAST_RW 0x03 #define MCS_OPC_FAST_FW 0x04 #define MCS_OPC_STOP 0x05 #define MCS_OPC_NEXT_TRACK 0x31 #define MCS_OPC_PREV_TRACK 0x30 /* * Helper: call GattCharacteristic1.ReadValue via D-Bus. * char_path is the D-Bus object path of the characteristic, * e.g. /org/bluez/hci0/dev_AA_BB…/service001a/char001b */ GVariant *mcs_read_characteristic(GDBusConnection *conn, const char *char_path) { GVariant *options = g_variant_new(“(a{sv})”, g_variant_new_parsed(“@a{sv} {}”)); GVariant *result = g_dbus_connection_call_sync( conn, “org.bluez”, char_path, “org.bluez.GattCharacteristic1”, “ReadValue”, options, G_VARIANT_TYPE(“(ay)”), G_DBUS_CALL_FLAGS_NONE, -1, NULL, NULL); return result; } /* * Helper: call GattCharacteristic1.WriteValue via D-Bus. * data/len is the opcode + optional parameters. */ void mcs_write_characteristic(GDBusConnection *conn, const char *char_path, const uint8_t *data, size_t len) { GVariantBuilder builder; g_variant_builder_init(&builder, G_VARIANT_TYPE(“ay”)); for (size_t i = 0; i < len; i++) g_variant_builder_add(&builder, “y”, data[i]); GVariant *value = g_variant_builder_end(&builder); GVariant *options = g_variant_new_parsed(“@a{sv} {}”); g_dbus_connection_call_sync( conn, “org.bluez”, char_path, “org.bluez.GattCharacteristic1”, “WriteValue”, g_variant_new(“(@aya{sv})”, value, options), NULL, G_DBUS_CALL_FLAGS_NONE, -1, NULL, NULL); } int main(void) { GDBusConnection *conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, NULL); /* — Read Track Title — */ GVariant *title_val = mcs_read_characteristic(conn, “/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service001a/char001b”); if (title_val) { GVariant *inner; gsize n; g_variant_get(title_val, “(@ay)”, &inner); const uint8_t *bytes = g_variant_get_fixed_array(inner, &n, 1); printf(“Track Title: %.*s\n”, (int)n, bytes); g_variant_unref(title_val); } /* — Read Media State — */ GVariant *state_val = mcs_read_characteristic(conn, “/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service001a/char0020”); if (state_val) { gsize n; GVariant *inner; g_variant_get(state_val, “(@ay)”, &inner); const uint8_t *b = g_variant_get_fixed_array(inner, &n, 1); const char *state_names[] = { “Inactive”,“Playing”,“Paused”,“Seeking” }; printf(“Media State : %s (0x%02X)\n”, b[0] < 4 ? state_names[b[0]] : “Unknown”, b[0]); g_variant_unref(state_val); } /* — Send PLAY opcode to Media Control Point — */ uint8_t play_cmd[] = { MCS_OPC_PLAY }; mcs_write_characteristic(conn, “/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service001a/char0025”, play_cmd, sizeof(play_cmd)); printf(“Sent PLAY opcode to Media Control Point\n”); /* — Send NEXT TRACK opcode — */ uint8_t next_cmd[] = { MCS_OPC_NEXT_TRACK }; mcs_write_characteristic(conn, “/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service001a/char0025”, next_cmd, sizeof(next_cmd)); printf(“Sent NEXT TRACK opcode\n”); g_object_unref(conn); return 0; }
Important: Replace the D-Bus object paths (e.g. /org/bluez/hci0/dev_.../service.../char...) with the actual paths from your BlueZ instance. Use bluetoothctlmenu gattlist-attributes to find them after connecting.

③ Enable Notifications (CCCD) in bluetoothctl
# Inside bluetoothctl, enable notifications on Media State and Track Changed
[bluetooth]# select-attribute /org/bluez/hci0/dev_…/service001a/char0020
[bluetooth]# notify on
[CHG] Attribute … Value: 0x01 ← Playing
[CHG] Attribute … Value: 0x02 ← Paused

# Read Track Title
[bluetooth]# select-attribute /org/bluez/hci0/dev_…/service001a/char001b
[bluetooth]# read
Attribute Value: 53 68 61 70 65 20 6f 66 20 59 6f 75 (= “Shape of You”)

SDP Interoperability (BR/EDR Devices)

MCS is a GATT service, but it can also be discoverable via SDP on Bluetooth Classic (BR/EDR) devices. This is useful for devices that support both BR/EDR and BLE (dual-mode). The SDP record points to the same GATT/ATT endpoint.

SDP Record Field Value Status
Service Class #0 «Generic Media Control» M
Service Class #1 «Media Control» C.1
Protocol #0 «L2CAP» (PSM = ATT) M
Protocol #1 «ATT» M
Additional Protocol (EATT) «L2CAP» (PSM = EATT) C.2
BrowseGroupList PublicBrowseRoot M
C.1: Mandatory if MCS (not just GMCS) is supported.   C.2: Mandatory if Enhanced ATT (EATT) is supported.   EATT improves notification reliability over BR/EDR.

Putting It All Together — A Complete Playback Flow

CLIENT (Earbuds) SERVER (Phone)
1
Connect & Bond — Earbuds initiate BLE connection + pairing (encryption enabled)
2
GATT Discovery — find MCS service and all characteristics ← ATT_READ_BY_GROUP_TYPE_REQ
3
Subscribe to notifications — write CCCD on: Track Changed, Media State, Track Title ← ATT_WRITE_REQ (CCCD = 0x0001)
4
Read initial state: Track Title, Duration, Media State ← ATT_READ_REQ → Response with values
5
User presses Play on earbuds → earbuds write opcode 0x01 to Media Control Point
6
Receives notification: Media Control Point result = 0x01 01 (opcode Play, SUCCESS) Phone plays audio. Sends notification → Media State = 0x01 (Playing)
7
Song changes automatically → Server sends Track Changed notification (zero-length), then updates Track Title
8
Receives Track Changed notification → reads new Track Title → updates display Sends Track Title notification with new song name

Key Takeaways

🔒
Always Encrypted
Every MCS characteristic requires a bonded (encrypted) connection. No plain-text access.
📡
Notify-Driven
Subscribe to Track Changed + Media State notifications; don’t poll. The server pushes updates.
🎛️
Check Opcodes First
Read Opcodes Supported bitmask before writing any control opcode. Avoid sending unsupported commands.
🗂️
OTS is Optional
A minimum MCS works without OTS. Only add OTS if you need icons, chapters, artwork, or search results.
🔄
State Machine Rules
Respect the state machine. FF/Rewind only go to Seeking. Play only exits Paused or Seeking.

Based on Bluetooth SIG Media Control Service Specification v1.0 (2021-03-09) · EmbeddedPathashala

Leave a Reply

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