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