Quick Recap from Part 1
In Part 1 we covered what VCS is, where it fits alongside VOCS and AICS, and the Volume State characteristic — the 3-byte read/notify value that carries Volume_Setting, Mute, and Change_Counter.
In this part we look at the Volume Control Point — the write-only characteristic that clients use to issue volume commands — and the Volume Flags characteristic that tells a client whether the device is still at factory-default volume or a user has already adjusted it.
Key Terms in This Post
The Volume Control Point is a Write-only GATT characteristic. The client writes an opcode plus operand(s) to it; the server executes the corresponding procedure and then notifies subscribed clients of the updated Volume State. The client never reads this characteristic — it only writes to it and observes the result via the Volume State notification.
Every command must include the current Change_Counter value as an operand (except where noted). This is the concurrency guard explained in Part 1. If the Change_Counter value in the write does not match the server’s live Change_Counter, the server rejects it with ATT error code 0x80 (Invalid Change Counter).
If the client writes an opcode that does not exist in the spec, the server returns ATT error code 0x81 (Opcode Not Supported).
| Opcode | Procedure | Operands | Affects Mute? |
|---|---|---|---|
0x00 |
Relative Volume Down | Change_Counter | No |
0x01 |
Relative Volume Up | Change_Counter | No |
0x02 |
Unmute / Relative Volume Down | Change_Counter | Yes — sets Not Muted |
0x03 |
Unmute / Relative Volume Up | Change_Counter | Yes — sets Not Muted |
0x04 |
Set Absolute Volume | Change_Counter, Volume_Setting | No |
0x05 |
Unmute | Change_Counter | Yes — sets Not Muted |
0x06 |
Mute | Change_Counter | Yes — sets Muted |
Relative Volume Down (0x00) and Up (0x01)
These two opcodes adjust volume by one Step Size — a positive integer that the server picks and keeps constant across all relative procedures. The spec does not define the step size value; it is implementation-specific.
The math the server applies:
/* Relative Volume Down */
new_volume = MAX(current_volume - step_size, 0);
/* Relative Volume Up */
new_volume = MIN(current_volume + step_size, 255);
Neither procedure touches the Mute field. If the device is muted, volume goes up/down in the background but audio stays silent. This matches user expectations on most devices — rotating the volume knob on a muted TV still adjusts the pending volume level.
The server only increments Change_Counter and sends a notification if the Volume_Setting actually changed. If the volume is already at 0 and a Volume Down is received, no change occurs and no notification is sent.
Unmute/Relative Volume Down (0x02) and Up (0x03)
These combine a volume step with an automatic unmute. The volume math is identical to 0x00/0x01, but the Mute field is also set to Not Muted. This is designed for media player scenarios: when the user presses the hardware volume-up button, the intent is both to raise volume and to come out of mute if the device was muted.
The Change_Counter is incremented once even if both the Volume_Setting and Mute field change together — the spec says increment only once when multiple fields change simultaneously.
Set Absolute Volume (0x04)
This is the only opcode that carries a second operand: the target Volume_Setting value (1 extra byte). The server sets Volume_Setting to exactly this value, regardless of the current level. This is used when a phone’s media volume slider is dragged to a specific position.
/* Set Absolute Volume write packet (3 bytes total): */
byte[0] = 0x04; /* Opcode: Set Absolute Volume */
byte[1] = change_counter; /* Must match current Change_Counter */
byte[2] = 0x80; /* Target Volume_Setting = 128 (50% of 255) */
Unmute (0x05) and Mute (0x06)
These two opcodes only affect the Mute field. Unmute sets it to Not Muted (0), Mute sets it to Muted (1). Neither touches Volume_Setting. If the device is already in the requested state, the write succeeds but no notification is generated because nothing changed.
The following example shows a complete workflow: read the current Volume State to get the live Change_Counter, then issue various Volume Control Point commands.
import dbus
# Replace these paths with the actual paths discovered via bluetoothctl
# or by introspecting the device object tree with:
# busctl introspect org.bluez /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
VOL_STATE_PATH = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/serviceXX/charYY'
VCP_PATH = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/serviceXX/charZZ'
bus = dbus.SystemBus()
# --- Step 1: Read current Volume State ---
vs_obj = bus.get_object('org.bluez', VOL_STATE_PATH)
vs_iface = dbus.Interface(vs_obj, 'org.bluez.GattCharacteristic1')
raw = vs_iface.ReadValue({})
volume_setting = int(raw[0])
mute_state = int(raw[1])
change_counter = int(raw[2])
print(f"Volume: {volume_setting}, Mute: {mute_state}, Counter: {change_counter}")
# --- Step 2: Get Volume Control Point interface ---
vcp_obj = bus.get_object('org.bluez', VCP_PATH)
vcp_iface = dbus.Interface(vcp_obj, 'org.bluez.GattCharacteristic1')
def write_vcp(opcode, change_ctr, extra_byte=None):
"""Write a Volume Control Point command."""
payload = [dbus.Byte(opcode), dbus.Byte(change_ctr)]
if extra_byte is not None:
payload.append(dbus.Byte(extra_byte))
vcp_iface.WriteValue(payload, {})
# --- Example commands ---
# Mute the device (opcode 0x06)
write_vcp(0x06, change_counter)
# Re-read counter since mute changed it
raw = vs_iface.ReadValue({})
change_counter = int(raw[2])
# Unmute the device (opcode 0x05)
write_vcp(0x05, change_counter)
# Re-read counter after unmute
raw = vs_iface.ReadValue({})
change_counter = int(raw[2])
# Set volume to exactly 50% (128 out of 255) — opcode 0x04
write_vcp(0x04, change_counter, extra_byte=128)
# Re-read counter after volume change
raw = vs_iface.ReadValue({})
change_counter = int(raw[2])
# Relative Volume Up by one step — opcode 0x01
write_vcp(0x01, change_counter)
# Unmute + Relative Volume Up — opcode 0x03
raw = vs_iface.ReadValue({})
change_counter = int(raw[2])
write_vcp(0x03, change_counter)
In production code you should wrap the WriteValue call in a try/except and check for the ATT error response. If the server returns org.bluez.Error.Failed with code 0x80, it means the Change_Counter is stale — re-read Volume State and retry.
import dbus.exceptions
def safe_write_vcp(vcp_iface, vs_iface, opcode, extra_byte=None):
"""
Retry-aware Volume Control Point write.
Handles ATT error 0x80 (Invalid Change Counter) by re-reading state.
"""
max_retries = 3
for attempt in range(max_retries):
raw = vs_iface.ReadValue({})
change_counter = int(raw[2])
payload = [dbus.Byte(opcode), dbus.Byte(change_counter)]
if extra_byte is not None:
payload.append(dbus.Byte(extra_byte))
try:
vcp_iface.WriteValue(payload, {})
return True
except dbus.exceptions.DBusException as e:
# BlueZ maps ATT 0x80 to a DBusException
print(f"Attempt {attempt+1} failed: {e}. Retrying...")
return False
# Usage: mute with auto-retry
success = safe_write_vcp(vcp_iface, vs_iface, opcode=0x06)
print("Mute command succeeded:", success)
The Volume Flags characteristic is a single-byte read characteristic that tells the client whether the current Volume_Setting is a user-configured value or the factory default.
| Bit | Field | Value 0 | Value 1 |
|---|---|---|---|
| 0 | Volume_Setting_Persisted | Reset Volume Setting (factory/default) | User Set Volume Setting |
| 1–7 | RFU | Reserved, always 0 | |
The spec defines two states for Volume_Setting_Persisted:
- Reset Volume Setting (bit 0 = 0): The device has just powered up or reset and the volume is at the implementation’s default initial value. No user has adjusted it yet.
- User Set Volume Setting (bit 0 = 1): A user change has occurred — either a client wrote a Volume Control Point command that changed Volume_Setting, or the hardware volume control on the device was used. Once set, this flag stays set even across resets if the volume value was persisted to non-volatile storage.
A practical use case: a phone app connecting to a speaker for the first time reads Volume Flags. If bit 0 is 0, the app knows the speaker is at factory default and can safely initialize its own UI to reflect that. If bit 0 is 1, the app knows someone (or the device itself) has already changed the volume, so the app should read Volume State and sync its UI to the live value rather than assuming a factory default.
# Volume Flags UUID: 0x2B7E
# Read Volume Flags on connect to decide UI initialization strategy
VOL_FLAGS_PATH = '/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/serviceXX/charWW'
flags_obj = bus.get_object('org.bluez', VOL_FLAGS_PATH)
flags_iface = dbus.Interface(flags_obj, 'org.bluez.GattCharacteristic1')
flags_raw = flags_iface.ReadValue({})
flags_byte = int(flags_raw[0])
volume_persisted = bool(flags_byte & 0x01) # bit 0
if volume_persisted:
print("Volume has been set by user — sync UI from live Volume State")
else:
print("Device is at factory default volume — safe to init UI to default")
VCS defines two ATT application-layer error codes beyond the standard ATT errors:
When building the device side (e.g., a BLE speaker) using BlueZ’s GATT server D-Bus API or directly with the kernel’s BT socket interface, you need to maintain the Volume State and handle writes to the Volume Control Point. The following shows the core state machine logic:
#include <stdint.h>
#include <stdlib.h>
#define VCS_STEP_SIZE 16 /* device chooses this — 16 means ~16 steps to max */
#define VCS_ATT_ERR_INVALID_COUNTER 0x80
#define VCS_ATT_ERR_OPCODE_NOT_SUP 0x81
/* Volume State (3 bytes serialized as little-endian for ATT) */
struct vcs_state {
uint8_t volume_setting;
uint8_t mute; /* 0 = not muted, 1 = muted */
uint8_t change_counter;
};
static struct vcs_state vcs = {
.volume_setting = 0, /* factory default */
.mute = 0,
.change_counter = 42, /* arbitrary init value per spec */
};
static uint8_t volume_flags = 0x00; /* 0 = Reset (factory default) */
static void vcs_increment_counter_and_notify(void)
{
vcs.change_counter = (vcs.change_counter + 1) & 0xFF; /* wraps at 255 */
/* TODO: send GATT notification to subscribed clients */
}
/*
* Handle a write to the Volume Control Point characteristic.
* Returns 0 on success, or VCS ATT error code on failure.
*/
uint8_t vcs_handle_vcp_write(const uint8_t *data, uint16_t len)
{
if (len < 2)
return VCS_ATT_ERR_OPCODE_NOT_SUP;
uint8_t opcode = data[0];
uint8_t counter = data[1];
/* Validate Change_Counter first */
if (counter != vcs.change_counter)
return VCS_ATT_ERR_INVALID_COUNTER;
uint8_t prev_vol = vcs.volume_setting;
uint8_t prev_mute = vcs.mute;
switch (opcode) {
case 0x00: /* Relative Volume Down */
vcs.volume_setting = (vcs.volume_setting > VCS_STEP_SIZE)
? vcs.volume_setting - VCS_STEP_SIZE : 0;
break;
case 0x01: /* Relative Volume Up */
vcs.volume_setting = (vcs.volume_setting + VCS_STEP_SIZE < 255)
? vcs.volume_setting + VCS_STEP_SIZE : 255;
break;
case 0x02: /* Unmute + Relative Volume Down */
vcs.volume_setting = (vcs.volume_setting > VCS_STEP_SIZE)
? vcs.volume_setting - VCS_STEP_SIZE : 0;
vcs.mute = 0;
break;
case 0x03: /* Unmute + Relative Volume Up */
vcs.volume_setting = (vcs.volume_setting + VCS_STEP_SIZE < 255)
? vcs.volume_setting + VCS_STEP_SIZE : 255;
vcs.mute = 0;
break;
case 0x04: /* Set Absolute Volume */
if (len < 3)
return VCS_ATT_ERR_OPCODE_NOT_SUP;
vcs.volume_setting = data[2];
volume_flags = 0x01; /* mark as user-set */
break;
case 0x05: /* Unmute */
vcs.mute = 0;
break;
case 0x06: /* Mute */
vcs.mute = 1;
break;
default:
return VCS_ATT_ERR_OPCODE_NOT_SUP;
}
/* Only notify if something actually changed */
if (vcs.volume_setting != prev_vol || vcs.mute != prev_mute) {
vcs_increment_counter_and_notify();
}
return 0; /* ATT_ERR_SUCCESS */
}
Use these UUID values when discovering VCS characteristics on a connected BLE device:
| Characteristic | 16-bit UUID | 128-bit UUID |
|---|---|---|
| Volume Control Service | 0x1844 | 00001844-0000-1000-8000-00805f9b34fb |
| Volume State | 0x2B7D | 00002b7d-0000-1000-8000-00805f9b34fb |
| Volume Control Point | 0x2B7E | 00002b7e-0000-1000-8000-00805f9b34fb |
| Volume Flags | 0x2B7F | 00002b7f-0000-1000-8000-00805f9b34fb |
# Scan for VCS on a connected device using bluetoothctl
$ bluetoothctl
[bluetooth]# connect AA:BB:CC:DD:EE:FF
[AA:BB:CC:DD:EE:FF]# menu gatt
[AA:BB:CC:DD:EE:FF]# list-attributes AA:BB:CC:DD:EE:FF
# You should see entries like:
# Service - 00001844-0000-1000-8000-00805f9b34fb (Volume Control Service)
# Char - 00002b7d-0000-1000-8000-00805f9b34fb (Volume State)
# Char - 00002b7e-0000-1000-8000-00805f9b34fb (Volume Control Point)
# Char - 00002b7f-0000-1000-8000-00805f9b34fb (Volume Flags)
# Desc - 00002902-0000-1000-8000-00805f9b34fb (CCCD for Volume State notify)
# Select Volume State and enable notifications:
[AA:BB:CC:DD:EE:FF]# select-attribute /org/bluez/.../char_VolumeState
[AA:BB:CC:DD:EE:FF]# notify on
Summary
The Volume Control Service is a clean, well-structured GATT service for remote audio volume control. The design around the Change_Counter prevents race conditions elegantly at the ATT layer without needing any additional synchronization mechanism at the application layer.
Key takeaways for an implementation engineer:
Explore More BLE GATT Services
EmbeddedPathashala covers the full BLE protocol stack — from HCI and L2CAP internals to GATT profile implementation on Linux with BlueZ.
