What is the Volume Control Service?
When you press the volume button on your phone while connected to a BLE speaker or TWS earbuds, the phone is writing to a GATT characteristic on the audio device — specifically the Volume Control Point of the Volume Control Service (VCS).
VCS is a Bluetooth SIG standard GATT service adopted in December 2020 by the Generic Audio Working Group. It lives on the audio output device (the server — your speaker, headset, or hearing aid) and is consumed by a controller (the client — your phone or laptop). The service exposes the device’s current volume level, mute state, and a control point to issue volume commands.
A key design decision in VCS is that only one instance of the service is allowed per device. If a device has multiple speakers (e.g., stereo pair), per-speaker volume offsets are handled by the included Volume Offset Control Service (VOCS), not by multiple VCS instances.
VCS does not stand alone. It is part of a family of audio-related GATT services introduced with Bluetooth 5.2’s Generic Audio framework. VCS may include zero or more instances of:
- VOCS (Volume Offset Control Service) — adjusts per-output volume relative to the main VCS setting. For example, left vs. right speaker balance.
- AICS (Audio Input Control Service) — controls individual audio inputs (microphone, HDMI, Bluetooth audio) including per-input mute.
Example VCS Topology (from spec §2.2.1)
|
Audio Input Control Service
Bluetooth Audio Audio Input Control Service
HDMI Audio Audio Input Control Service
Microphone Audio |
→
|
Volume Control Service
(VCS) |
→
|
Volume Offset Control Service
VOCS 🔊
Speaker 1 Volume Offset Control Service
VOCS 🔊
Speaker 2 |
AICS instances are included in VCS for per-input control. VOCS instances are included for per-output volume offsets.
Key Terms in This Post
The Three VCS Characteristics at a Glance
VCS exposes exactly three characteristics, all of which are mandatory and all require an encrypted connection:
| Characteristic | Properties | Security | Purpose |
|---|---|---|---|
| Volume State | Read, Notify | Encrypted | Current volume level, mute state, change counter |
| Volume Control Point | Write | Encrypted | Issue volume commands (7 opcodes) |
| Volume Flags | Read, Notify (optional) | Encrypted | Whether volume was set by a user or is still at factory default |
All three characteristics require an encrypted link before the client can read or write them. This is enforced at the ATT layer — unencrypted reads will receive an ATT error.
The Volume State characteristic is the core read/notify source of truth for the audio device’s current volume condition. It is a 3-byte value. The client reads it on connection and then subscribes to notifications so it stays in sync whenever the state changes — whether from a command the client itself sent, or from a hardware volume knob on the device.
| Byte | Field | Type | Range |
|---|---|---|---|
| 0 | Volume_Setting | uint8 | 0 (min) – 255 (max) |
| 1 | Mute | uint8 | 0 = Not Muted, 1 = Muted |
| 2 | Change_Counter | uint8 | 0–255, wraps around |
Reading a raw Volume State response
When you read the Volume State characteristic, the device returns 3 bytes in little-endian order. Here is how to interpret a real response:
# Read Volume State using bluetoothctl on Linux (BlueZ)
# The Volume State UUID is 0x2B7D
$ bluetoothctl
[bluetooth]# connect AA:BB:CC:DD:EE:FF
[bluetooth]# menu gatt
[bluetooth]# list-attributes AA:BB:CC:DD:EE:FF
# Look for: Characteristic - 00002b7d-0000-1000-8000-00805f9b34fb
[bluetooth]# select-attribute /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/serviceXX/charYY
[bluetooth]# read
# Example response (3 bytes):
# Attribute Hex: 64 00 03
# Byte 0 -> Volume_Setting = 0x64 = 100 (about 39% of max 255)
# Byte 1 -> Mute = 0x00 (Not Muted)
# Byte 2 -> Change_Counter = 0x03 (changed 3 times since init)
Enabling notifications in Python via BlueZ D-Bus
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
# Volume State UUID: 00002b7d-0000-1000-8000-00805f9b34fb
# Replace charYY with the actual path discovered via introspect
CHAR_PATH = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/serviceXX/charYY'
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
def properties_changed(iface, changed, invalidated):
if 'Value' in changed:
val = changed['Value']
print(f"Volume_Setting : {val[0]}")
print(f"Mute : {'Muted' if val[1] else 'Not Muted'}")
print(f"Change_Counter : {val[2]}")
char_obj = bus.get_object('org.bluez', CHAR_PATH)
char_iface = dbus.Interface(char_obj, 'org.bluez.GattCharacteristic1')
props_iface = dbus.Interface(char_obj, 'org.freedesktop.DBus.Properties')
# Subscribe to PropertiesChanged — BlueZ fires this on each notification
bus.add_signal_receiver(
properties_changed,
dbus_interface='org.freedesktop.DBus.Properties',
signal_name='PropertiesChanged',
path=CHAR_PATH
)
# Enable notification (writes CCCD = 0x0001 under the hood)
char_iface.StartNotify()
loop = GLib.MainLoop()
loop.run()
BlueZ handles writing the Client Characteristic Configuration Descriptor (CCCD) automatically when you call StartNotify(). You do not need to write 0x0001 to the CCCD manually.
Volume_Setting (Byte 0)
This is a unitless 0–255 linear scale. The spec deliberately leaves the step size and mapping to actual dB levels implementation-specific. A value of 0 is silence (minimum), 255 is maximum. Importantly, the mapping does not have to be linear in dB — the device decides how each step corresponds to perceived loudness.
Practical implication: a value of 128 on one device may be noticeably different in loudness from a value of 128 on another device from a different manufacturer.
Mute (Byte 1)
The Mute field is a device-level mute that silences all audio outputs controlled by this VCS instance. A critical rule from the spec: muting must never change the Volume_Setting value. If the Volume_Setting is 200 when muted, it must still be 200 after unmuting.
This is separate from the per-input mute in AICS. You can mute the microphone input (via AICS) without muting the speaker output (VCS Mute), and vice versa.
Change_Counter (Byte 2)
This is the most important field for safe concurrent access. The Change_Counter is a monotonic counter that the server increments by 1 every time Volume_Setting or Mute changes. It wraps from 255 back to 0.
Every write to the Volume Control Point must include the current Change_Counter value as an operand. The server rejects the write with an Invalid Change Counter (0x80) ATT error if the value does not match. This prevents a stale command from taking effect after the state has already changed due to a concurrent update.
Change_Counter Race Condition Prevention — Flow
Counter = 5Counter → 6Counter = 5Change Counter
The client must re-read Volume State to get Counter = 6, then retry the command.
VCS requires the following GATT sub-procedures beyond the default GATT server baseline (for Unenhanced ATT bearers):
Notifications over an Unenhanced ATT bearer are unreliable (no acknowledgement). An Enhanced ATT (EATT) bearer can be negotiated for reliable notifications, but that is specified at the profile level above VCS.
Coming Up in Part 2
Part 2 covers the Volume Control Point in detail — all 7 opcodes, the Relative vs Absolute volume procedures, the Volume Flags characteristic, application error handling, and a complete BlueZ Python example that sends mute/unmute and volume commands.
