Bluetooth LE Volume Control Profile (VCP)

Bluetooth LE Volume Control Profile (VCP)
Spec-to-Code: How a phone controls speaker volume over BLE using GATT
2
Profile Roles
3
GATT Services
v1.0
Spec (Dec 2020)
5.2+
BT Core Required

What is the Volume Control Profile?

The Volume Control Profile (VCP) is a Bluetooth LE profile that defines a standardised way for one device to read and change the audio volume of another. Before VCP, every vendor invented their own GATT service for volume — meaning a phone and a speaker from different companies could not talk the same volume language. VCP solves that.

VCP is part of the LE Audio stack alongside BAP, CSIP, and MCP. It sits entirely on top of GATT and delegates all the actual data storage to three companion services: the mandatory Volume Control Service (VCS), the optional Volume Offset Control Service (VOCS) for per-output balance/fade, and the optional Audio Input Control Service (AICS) for per-input gain. This tutorial walks through the spec and then shows working BlueZ D-Bus code you can run on a Linux host.

Keywords Covered

VCP VCS VOCS AICS Volume Renderer Volume Controller Change_Counter Volume Control Point GATT Notify Set Absolute Volume BlueZ D-Bus LE Audio

🎭 Chapter 1 — The Two Profile Roles

VCP defines exactly two roles. Every device in a VCP interaction must play one of them.

📱 Volume Controller
Role: GATT Client
Discovers & reads characteristics
Writes to Volume Control Point
Subscribes to NOTIFY
e.g. Smartphone, remote control, laptop
BLE ACL
GATT over ATT
🔊 Volume Renderer
Role: GATT Server — hosts the services below

Volume Control Service (VCS)  —  Mandatory

Volume Offset Control Service (VOCS)  —  Optional, 0..N instances

Audio Input Control Service (AICS)  —  Optional, 0..N instances
e.g. BLE speaker, wireless headset, smart TV

A key design rule: VOCS and AICS are included services inside VCS, not standalone primaries. The Volume Controller discovers them by performing a Find Included Services GATT sub-procedure on VCS.

📋 Chapter 2 — VCS Characteristics

Once the Volume Controller connects and discovers VCS, it works with three characteristics. The Volume Controller must discover Volume State and Volume Control Point; Volume Flags is optional.

Characteristic Short UUID Properties Payload / Fields
Volume State 0x2B7D Read, Notify Volume_Setting (uint8, 0–255) Mute (0=Not Muted, 1=Muted) Change_Counter (uint8, wraps 0–255)
Volume Control Point 0x2B7E Write (no response) Opcode (uint8) Change_Counter (uint8) [Volume_Setting] (only for Set Abs Vol)
Volume Flags 0x2B7F Read, Notify (opt.) Volume_Setting_Persisted bit (0=Reset, 1=User Set)

Volume_Setting is a linear scale from 0 to 255. The renderer decides how that maps to actual dB — a value of 128 does not necessarily mean −6 dB. The Volume Flags characteristic tells the controller whether the renderer remembers the last volume across power cycles (Volume_Setting_Persisted = 1) or resets to a default on boot (Volume_Setting_Persisted = 0).

🔄 Chapter 3 — The Change_Counter Sync Mechanism

Every write to the Volume Control Point must include the server’s current Change_Counter value. This is a one-byte optimistic concurrency token: whenever the server updates Volume State (due to any client or a local button press), it increments the counter. If the controller sends a stale counter value, the server rejects the command with ATT application error code 0x80 (Invalid Change Counter), and the controller must re-read Volume State and retry.

This prevents a race condition where two controllers try to change volume simultaneously and one overwrites the other’s change without knowing the state has already moved.

Volume Controller (Client) GATT/ATT Volume Renderer (Server)
Step 1: Read current state before issuing a command
Read Request: Volume State
Read Response: [Vol=80, Mute=0, Counter=5]
Step 2: Send command with matching Change_Counter — success path
Write VCP: [Op=0x05 (Set Abs Vol), Counter=5, NewVol=200]
Write Response OK — counter increments to 6
Failure path: another client changed volume between step 1 and step 2
Write VCP: [Op=0x05, Counter=5, NewVol=200] ← counter is now stale
ATT Error 0x80: Invalid Change Counter
Controller re-reads Volume State to get the updated counter, then retries
Read Request: Volume State (retry read)
Read Response: [Vol=120, Mute=0, Counter=6]
Write VCP: [Op=0x05, Counter=6, NewVol=200] ← retry with fresh counter
Write Response OK

🎛️ Chapter 4 — Volume Control Point Opcodes

The Volume Controller writes one of the following opcodes to the Volume Control Point characteristic. All opcodes take at minimum the Change_Counter as a second byte. Only Set Absolute Volume requires a third byte for the target volume level.

Opcode Name Parameters Effect on Volume Renderer
0x01 Relative Volume Down Op, Counter Decreases Volume_Setting by a renderer-defined step
0x02 Relative Volume Up Op, Counter Increases Volume_Setting by a renderer-defined step
0x03 Unmute/Relative Vol Down Op, Counter Volume down and clears Mute flag (device-wide)
0x04 Unmute/Relative Vol Up Op, Counter Volume up and clears Mute flag (device-wide)
0x05 Set Absolute Volume ★ Op, Counter, NewVol Sets Volume_Setting to an exact value (0–255)
0x06 Unmute Op, Counter Clears Mute flag, volume unchanged (device-wide mute point)
0x07 Mute Op, Counter Sets Mute flag, volume unchanged (device-wide mute point)

⚠️ Mute/Unmute via VCS is device-wide — it silences the entire renderer. To mute an individual audio input (e.g. mute only the Bluetooth mic but not HDMI), use the AICS Audio Input Control Point mute opcode instead.

💻 Chapter 5 — BlueZ Exploration with bluetoothctl

Before writing code, use bluetoothctl to manually discover and read VCS on any compliant BLE speaker. This is the fastest way to verify the device exposes VCS properly.

# Start the BlueZ interactive shell
$ bluetoothctl

# Power on adapter and scan for LE devices
[bluetooth]# power on
[bluetooth]# scan on
[NEW] Device AA:BB:CC:DD:EE:FF BLE_Speaker

[bluetooth]# scan off
[bluetooth]# connect AA:BB:CC:DD:EE:FF

# Switch to GATT menu and list all attributes
[BLE_Speaker]# menu gatt
[BLE_Speaker]# list-attributes AA:BB:CC:DD:EE:FF

# Look for VCS primary service UUID: 00001844-0000-1000-8000-00805f9b34fb
Primary Service
    /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service002a
    00001844-0000-1000-8000-00805f9b34fb Volume Control Service

# Select and read Volume State (UUID: 00002b7d-...)
[BLE_Speaker]# select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service002a/char002b
[BLE_Speaker:/...char002b]# read
Attribute /org/bluez/.../char002b (Volume State)
  Value:  50 00 03  # Volume_Setting=80 (0x50), Mute=0 (Not Muted), Change_Counter=3

# Subscribe to Volume State notifications
[BLE_Speaker:/...char002b]# notify on

# Write to Volume Control Point: Set Absolute Volume to 200 (0xC8)
# Payload: [Opcode=0x05] [Change_Counter=0x03] [NewVol=0xC8]
[BLE_Speaker]# select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service002a/char002d
[BLE_Speaker:/...char002d]# write "0x05 0x03 0xC8"

🐍 Chapter 6 — BlueZ D-Bus Python: Volume Controller Implementation

The following Python script implements a minimal Volume Controller using the BlueZ D-Bus API. It demonstrates service/characteristic discovery, reading Volume State, writing to the Volume Control Point with proper Change_Counter handling, and subscribing to notifications.

ℹ️ Prerequisites on Ubuntu: sudo apt install python3-dbus python3-gi. The BLE speaker must already be paired/connected (bluetoothctl connect <ADDR>).

#!/usr/bin/env python3
"""
vcp_controller.py  -  Minimal BLE Volume Controller using BlueZ D-Bus
Target: Linux host with BlueZ 5.x (tested on BlueZ 5.66, Ubuntu 22.04)

Usage:
    python3 vcp_controller.py --addr AA:BB:CC:DD:EE:FF --volume 200
    python3 vcp_controller.py --addr AA:BB:CC:DD:EE:FF --mute
"""
import dbus
import dbus.mainloop.glib
import argparse
from gi.repository import GLib

# ── VCS UUIDs (Bluetooth SIG Assigned Numbers) ──────────────────────────────
VCS_SERVICE_UUID  = "00001844-0000-1000-8000-00805f9b34fb"
VOL_STATE_UUID    = "00002b7d-0000-1000-8000-00805f9b34fb"
VOL_CP_UUID       = "00002b7e-0000-1000-8000-00805f9b34fb"
VOL_FLAGS_UUID    = "00002b7f-0000-1000-8000-00805f9b34fb"

# ── Volume Control Point opcodes (from VCS Specification) ───────────────────
OP_REL_VOL_DOWN        = 0x01
OP_REL_VOL_UP          = 0x02
OP_UNMUTE_REL_DOWN     = 0x03
OP_UNMUTE_REL_UP       = 0x04
OP_SET_ABSOLUTE_VOLUME = 0x05
OP_UNMUTE              = 0x06
OP_MUTE                = 0x07

# ── D-Bus helpers ────────────────────────────────────────────────────────────
def get_managed_objects(bus):
    manager = dbus.Interface(
        bus.get_object("org.bluez", "/"),
        "org.freedesktop.DBus.ObjectManager"
    )
    return manager.GetManagedObjects()

def find_char(objects, device_addr, char_uuid):
    """Return the D-Bus object path for a characteristic on a specific device."""
    addr_suffix = device_addr.replace(":", "_").upper()
    for path, ifaces in objects.items():
        if "org.bluez.GattCharacteristic1" not in ifaces:
            continue
        if addr_suffix not in path:
            continue
        props = ifaces["org.bluez.GattCharacteristic1"]
        if props.get("UUID") == char_uuid:
            return path
    return None

def char_iface(bus, path):
    return dbus.Interface(
        bus.get_object("org.bluez", path),
        "org.bluez.GattCharacteristic1"
    )

# ── Core VCS operations ──────────────────────────────────────────────────────
def read_volume_state(bus, objects, addr):
    """
    Read Volume State characteristic.
    Returns (volume_setting: int, mute: int, change_counter: int)
    Volume_Setting is 0-255; Mute is 0=Not Muted, 1=Muted.
    """
    path = find_char(objects, addr, VOL_STATE_UUID)
    if not path:
        raise RuntimeError("Volume State characteristic not found – is device connected?")
    value = char_iface(bus, path).ReadValue({})
    return int(value[0]), int(value[1]), int(value[2])

def write_vcp(bus, objects, addr, payload):
    """
    Write a formatted payload to the Volume Control Point with retry on
    Invalid Change Counter (ATT application error 0x80).
    payload must be a list like [opcode, change_counter] or
    [opcode, change_counter, volume_setting].
    """
    cp_path = find_char(objects, addr, VOL_CP_UUID)
    if not cp_path:
        raise RuntimeError("Volume Control Point not found")
    cp = char_iface(bus, cp_path)

    try:
        cp.WriteValue([dbus.Byte(b) for b in payload], {})
    except dbus.DBusException as exc:
        if "InvalidChangeCounter" in str(exc) or "0x80" in str(exc):
            # Re-read counter and retry exactly once
            print("  [!] Change_Counter mismatch — re-reading and retrying")
            _, _, fresh_counter = read_volume_state(bus, objects, addr)
            payload[1] = fresh_counter
            cp.WriteValue([dbus.Byte(b) for b in payload], {})
        else:
            raise

def set_absolute_volume(bus, objects, addr, target_vol):
    """Set volume to an absolute value 0-255."""
    vol, mute, counter = read_volume_state(bus, objects, addr)
    print(f"  Current state: vol={vol}, mute={'Yes' if mute else 'No'}, counter={counter}")
    write_vcp(bus, objects, addr, [OP_SET_ABSOLUTE_VOLUME, counter, target_vol])
    print(f"  Volume set to {target_vol}")

def relative_volume_up(bus, objects, addr):
    """Increase volume by the renderer's internal step size."""
    _, _, counter = read_volume_state(bus, objects, addr)
    write_vcp(bus, objects, addr, [OP_REL_VOL_UP, counter])
    print("  Relative volume up sent")

def mute_device(bus, objects, addr):
    """Mute the entire device (device-wide mute point)."""
    _, _, counter = read_volume_state(bus, objects, addr)
    write_vcp(bus, objects, addr, [OP_MUTE, counter])
    print("  Device muted")

def unmute_device(bus, objects, addr):
    _, _, counter = read_volume_state(bus, objects, addr)
    write_vcp(bus, objects, addr, [OP_UNMUTE, counter])
    print("  Device unmuted")

# ── NOTIFY: subscribe to volume state changes ────────────────────────────────
def on_volume_state_notify(iface, changed, _invalidated):
    """Callback fired whenever the renderer sends a Volume State notification."""
    if "Value" in changed:
        v = changed["Value"]
        vol, mute, counter = int(v[0]), int(v[1]), int(v[2])
        print(f"  [NOTIFY] vol={vol}, mute={'Yes' if mute else 'No'}, counter={counter}")

def enable_notifications(bus, objects, addr):
    """Subscribe to Volume State NOTIFY so the controller stays in sync."""
    path = find_char(objects, addr, VOL_STATE_UUID)
    if not path:
        return
    bus.add_signal_receiver(
        on_volume_state_notify,
        signal_name="PropertiesChanged",
        dbus_interface="org.freedesktop.DBus.Properties",
        path=path
    )
    char_iface(bus, path).StartNotify()
    print("  Subscribed to Volume State notifications")

# ── Entry point ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="BLE VCP Volume Controller")
    parser.add_argument("--addr",   required=True,  help="BT address of Volume Renderer")
    parser.add_argument("--volume", type=int, help="Set absolute volume (0-255)")
    parser.add_argument("--mute",   action="store_true")
    parser.add_argument("--unmute", action="store_true")
    parser.add_argument("--volup",  action="store_true")
    parser.add_argument("--notify", action="store_true", help="Stay alive and print NOTIFY events")
    args = parser.parse_args()

    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SystemBus()
    objects = get_managed_objects(bus)

    print(f"[VCP] Target device: {args.addr}")

    if args.notify:
        enable_notifications(bus, objects, args.addr)

    if args.volume is not None:
        set_absolute_volume(bus, objects, args.addr, args.volume)
    elif args.mute:
        mute_device(bus, objects, args.addr)
    elif args.unmute:
        unmute_device(bus, objects, args.addr)
    elif args.volup:
        relative_volume_up(bus, objects, args.addr)
    else:
        vol, mute, counter = read_volume_state(bus, objects, args.addr)
        print(f"  Volume State: vol={vol}/255, mute={'Yes' if mute else 'No'}, counter={counter}")

    if args.notify:
        print("  Listening for notifications (Ctrl+C to exit)...")
        GLib.MainLoop().run()

Example runs on your BlueZ machine (substitute your speaker’s address):

# Read current volume state
$ python3 vcp_controller.py --addr AA:BB:CC:DD:EE:FF
  [VCP] Target device: AA:BB:CC:DD:EE:FF
  Volume State: vol=80/255, mute=No, counter=3

# Set volume to 60% (≈153 out of 255)
$ python3 vcp_controller.py --addr AA:BB:CC:DD:EE:FF --volume 153
  [VCP] Target device: AA:BB:CC:DD:EE:FF
  Current state: vol=80, mute=No, counter=3
  Volume set to 153

# Watch for live volume change notifications
$ python3 vcp_controller.py --addr AA:BB:CC:DD:EE:FF --notify
  Subscribed to Volume State notifications
  Listening for notifications (Ctrl+C to exit)...
  [NOTIFY] vol=130, mute=No, counter=4
  [NOTIFY] vol=200, mute=Yes, counter=5

🔈 Chapter 7 — VOCS: Per-Output Volume Offsets

The Volume Offset Control Service (VOCS) lets the controller fine-tune the volume of individual audio outputs independently of the master VCS volume. The renderer can expose multiple VOCS instances — one per output (e.g. left speaker, right speaker, subwoofer). Common use cases include left-right balance and front-back fade in a multi-speaker setup.

AICS #1
Bluetooth Audio input
AICS #2
HDMI Audio input
AICS #3
Microphone input
VCS
Master Volume
Volume_Setting
Mute flag
VOCS #1
Speaker Left (offset ±255)
VOCS #2
Speaker Right (offset ±255)

The Volume Offset State characteristic holds a signed 16-bit Volume_Offset value ranging from −255 to +255. To shift one output up relative to another, the controller writes a Set Volume Offset opcode (0x01) to the VOCS Volume Offset Control Point — using the same Change_Counter synchronisation mechanism as VCS.

# VOCS UUIDs
VOCS_SERVICE_UUID = "00001845-0000-1000-8000-00805f9b34fb"
VOCS_STATE_UUID   = "00002b80-0000-1000-8000-00805f9b34fb"  # Volume Offset State
VOCS_CP_UUID      = "00002b82-0000-1000-8000-00805f9b34fb"  # Volume Offset Control Point
VOCS_AUDIO_LOC    = "00002b81-0000-1000-8000-00805f9b34fb"  # Audio Location (left/right/etc.)

def set_volume_offset(bus, objects, addr, vocs_service_path, offset):
    """
    Set a volume offset on one VOCS instance.
    offset: -255 to +255  (positive = louder than master, negative = quieter)
    Opcode 0x01 = Set Volume Offset; payload = [Op, Counter, OffsetLow, OffsetHigh]
    """
    # Read VOCS Volume Offset State: [Offset_Low, Offset_High, Change_Counter]
    state_path = None
    for path, ifaces in objects.items():
        if path.startswith(vocs_service_path) and \
           "org.bluez.GattCharacteristic1" in ifaces and \
           ifaces["org.bluez.GattCharacteristic1"].get("UUID") == VOCS_STATE_UUID:
            state_path = path
            break

    value = char_iface(bus, state_path).ReadValue({})
    # Volume_Offset is a signed int16 (little-endian)
    current_offset = int.from_bytes(bytes([int(value[0]), int(value[1])]),
                                    byteorder='little', signed=True)
    counter = int(value[2])
    print(f"  Current VOCS offset={current_offset}, counter={counter}")

    offset_bytes = offset.to_bytes(2, byteorder='little', signed=True)
    payload = [0x01, counter, offset_bytes[0], offset_bytes[1]]
    # (reuse the same write_vcp retry logic shown earlier)
    print(f"  Offset set to {offset}")

🔒 Chapter 8 — Security Requirements

VCP mandates encryption on all characteristic accesses — no plain-text GATT reads or writes are permitted. The exact requirements differ by transport:

Transport Security Mode / Level Minimum Key Entropy Accepted Key Sources
BLE (LE) Mode 1 Level 2 128 bits LE Secure Connections, BR/EDR SC (CTKD), OOB
BR/EDR Mode 4 Level 2 128 bits BR/EDR Secure Connections, LE SC (CTKD), OOB

Both the Volume Controller and Volume Renderer should support Bondable mode so that the encryption key is stored and the link can be re-encrypted without re-pairing on every connection. Dual-mode (BR/EDR+LE) devices must use Cross-Transport Key Derivation (CTKD) so a single pairing covers both transports.

For privacy, both roles should use LE privacy (resolvable private addresses) and distribute their Identity Address and IRK during bonding.

# Verify pairing and encryption before accessing VCS on BlueZ
$ bluetoothctl
[bluetooth]# pair AA:BB:CC:DD:EE:FF
Pairing successful
[bluetooth]# trust AA:BB:CC:DD:EE:FF
[bluetooth]# connect AA:BB:CC:DD:EE:FF

# Check security level of the connected device
[bluetooth]# info AA:BB:CC:DD:EE:FF
  ...
  Paired: yes
  Trusted: yes
  Bonded: yes
  # BlueZ will use encrypted link automatically for Mode 1 Level 2 characteristics

Summary

✅ What you have learned
VCP defines two roles: Volume Controller (GATT client) and Volume Renderer (GATT server)
Volume Renderer must implement VCS; VOCS and AICS are optional included services
All volume commands go through the Volume Control Point using opcodes 0x01–0x07
The Change_Counter synchronises concurrent clients; stale counter = ATT error 0x80, retry
Security requires Mode 1 Level 2 (BLE) / Mode 4 Level 2 (BR/EDR) with 128-bit keys
💡 Key takeaways for implementation
Always read Volume State before every VCP write to get a fresh Change_Counter
Subscribe to NOTIFY on Volume State so your app reacts instantly to hardware button presses
Use Set Absolute Volume (0x05) when you need precision; use Relative Up/Down for simple UIs
Device-wide mute (VCS) and per-input mute (AICS) are separate controls — don’t confuse them
Pair/bond before connecting — unencrypted GATT access to VCS will be rejected

Continue Learning BLE Audio

VCP is one piece of the LE Audio puzzle. Explore the full stack on EmbeddedPathashala.

📶 BLE Tutorials 🐧 Linux & BlueZ

Leave a Reply

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