BLE Volume Control Service (VCS) — Part 2

BLE Volume Control Service (VCS) — Part 2
Volume Control Point opcodes, Volume Flags, and BlueZ implementation
7
Control Opcodes
2
Error Codes
BlueZ
Code Examples

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

Volume Control Point Opcode Step Size Relative Volume Up/Down Set Absolute Volume Unmute Mute Invalid Change Counter Opcode Not Supported Volume Flags Volume_Setting_Persisted ATT Error Response

The Volume Control Point — How Commands Work

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

All 7 Volume Control Point Opcodes
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

Opcode Procedures Explained

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.

BlueZ: Writing Volume Commands from Python

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)
    

Volume Flags Characteristic

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 Application Error Codes

VCS defines two ATT application-layer error codes beyond the standard ATT errors:

0x80 — Invalid Change Counter
The Change_Counter operand in the write does not match the server’s current Change_Counter field in Volume State. The server’s state changed between the client’s last read and this write.
Recovery: Re-read Volume State, extract fresh Change_Counter, retry command.
0x81 — Opcode Not Supported
The opcode byte written to Volume Control Point is not in the range 0x00–0x06, or is a value the server does not recognize.
Recovery: This is a client programming error. Fix the opcode in your write.

Implementing a VCS GATT Server — Conceptual C Skeleton

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 */
}
    

Quick Reference: Discovering VCS Characteristics

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:

Always read Volume State before writing a VCP command to get a fresh Change_Counter Retry on ATT error 0x80 — it just means state changed between your read and write Mute never zeroes out Volume_Setting Only one VCS instance per device — use VOCS for per-output offsets All three characteristics require an encrypted connection Check Volume Flags on first connect to know if volume is factory-default

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.

← Back to Part 1 All BLE Tutorials

Leave a Reply

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