BLE Volume Control Service (VCS) — Part 1

BLE Volume Control Service (VCS) — Part 1
How your phone remotely controls volume on a BLE speaker using GATT
VCS v1.0
Spec Revision
3
Characteristics
LE Only
Transport

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.

Where VCS Fits in the BLE Audio Stack

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

GATT Server GATT Client Volume State Change_Counter Mute field Volume_Setting Notification Encryption Required CCCD VCS VOCS AICS

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.

Volume State Characteristic — Deep Dive

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.

Understanding the Three Fields

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

① Client reads
Volume State
Counter = 5
② User turns
hardware knob
Counter → 6
③ Client writes
Vol Up with
Counter = 5
④ ATT Error
0x80 Invalid
Change Counter

The client must re-read Volume State to get Counter = 6, then retry the command.

GATT Sub-procedure Requirements

VCS requires the following GATT sub-procedures beyond the default GATT server baseline (for Unenhanced ATT bearers):

Write Characteristic Values — Mandatory Notifications — Mandatory Read Characteristic Descriptors — Mandatory Write Characteristic Descriptors — Mandatory

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.

Read Part 2 → More BLE Tutorials

Leave a Reply

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