bluetooth le audio tutorial – Bluetooth Hearing Access Service (HAS)

Bluetooth Hearing Access Service (HAS)
Complete Deep-Dive Tutorial — Architecture, Packet Formats, All Opcodes & BlueZ Code
3
GATT Characteristics
10
Control Point Opcodes
5
ATT Error Codes
BLE 4.2+
Compatible

What You Will Learn

HAS Architecture Preset Records GATT Characteristics Control Point Opcodes Binaural Sync Preset Changed Notifications ATT Error Codes BlueZ GATT Server Active Preset Index EATT Bearer isLast Flag PrevIndex Field

1. What Is the Hearing Access Service?

The Hearing Access Service (HAS) is a Bluetooth Low Energy (BLE) GATT service standardised by the Bluetooth SIG. It gives a smartphone, tablet, or remote control — called a client — a uniform way to communicate with a digital hearing aid — called a server.

Before HAS existed, every hearing-aid brand shipped its own proprietary BLE protocol. This meant a Samsung phone might work fine with Brand A hearing aids but fail entirely with Brand B. HAS fixes that by defining exactly how preset data is encoded, which GATT characteristics carry it, and which opcodes a client must support.

A preset is a saved configuration of the hearing aid’s audio processing — think microphone directionality, noise-reduction strength, and amplification curves — tuned for a specific listening environment. Typical preset names are “Universal”, “Noisy Environment”, “Outdoor”, and “Reverberant Room”. The actual DSP parameters behind each name are manufacturer-specific; HAS only exposes the name and a small set of flags.

HAS sits inside the broader Hearing Access Profile (HAP). HAP defines how the BLE connection is established and secured; HAS defines the GATT service that runs on top of that connection.

What a client can do through HAS:

  • Fetch the full ordered list of preset names from the hearing aid
  • Switch the currently active preset by Index or by navigating Next/Previous
  • Rename a preset (if the hearing aid’s firmware allows it)
  • Receive real-time notifications when the preset list or active preset changes
  • Send a single command that switches presets on both hearing aids of a binaural pair simultaneously

2. Key Terminology You Must Know

The Bluetooth SIG spec uses precise language. Before touching any packet format you need to know these terms cold.

HAS Terminology Reference
Term Precise Definition
Server The hearing aid device. It hosts the GATT service, holds the preset list, and responds to client requests.
Client The phone, tablet, or remote control that connects to the hearing aid and reads/writes its characteristics.
Active Preset The preset the hearing aid is currently using for audio processing right now. There is exactly one active preset at any moment (or none).
Preset Record The data structure that describes one preset: a 1-byte Index, a 1-byte Properties bitfield, and a UTF-8 Name string.
Monaural Hearing Aid A single hearing aid for one ear only. Used when only one ear has hearing loss (unilateral). It is its own standalone BLE device.
Binaural Hearing Aid Set A matched left-ear and right-ear pair that form a Coordinated Set. Each aid is a separate BLE peripheral, but they operate as a team.
Banded Hearing Aid Two hearing aids that are physically wired together but present only a single BLE radio interface to the phone.
Preset Synchronization A feature where one hearing aid (the one the phone is connected to) automatically relays a preset-change command to the other aid in a Binaural Set.
CCCD Client Characteristic Configuration Descriptor. The client writes to this descriptor to opt in to notifications (0x0001) or indications (0x0002) from the server.

Notifications vs Indications: In GATT, a notification is a fire-and-forget push from server to client — fast but unreliable. An indication requires the client to send back an ATT_HANDLE_VALUE_CONFIRM — slower but reliable. HAS uses indications for the Control Point characteristic to guarantee delivery of preset data.

3. Where HAS Fits in the BLE Protocol Stack

HAS is a GATT profile — it rides on top of ATT, which rides on top of L2CAP, which sits on the BLE Link Layer. Here is the full picture:

BLE Protocol Stack — HAS Position
HAP — Hearing Access Profile (roles, security, connection rules)
HAS — Hearing Access Service ← You Are Here
GATT — Generic Attribute Profile (service/characteristic model)
ATT — Attribute Protocol (PDU format, handles, permissions)
L2CAP (channels, segmentation, EATT multiplexing)
BLE Link Layer / Physical Layer (2.4 GHz radio)

Critical transport constraints you must implement correctly:

  • HAS runs on BLE (LE) transport only — not Bluetooth Classic.
  • All multi-byte values use little-endian byte order (LSO first).
  • All three characteristics require an encrypted, authenticated connection — a bonded link. A plain unencrypted ATT read will be rejected.
  • The server must support a minimum ATT_MTU of 49 octets. This is larger than the default 23-octet MTU so an MTU exchange must happen before preset records with long names can be transferred.
  • Standard ATT notifications are unreliable under buffer pressure. HAS recommends using an EATT (Enhanced ATT) bearer for reliable delivery, especially when sending many preset responses back-to-back.

4. The Three GATT Characteristics at a Glance

HAS exposes exactly three GATT characteristics. Think of them as three hardware registers inside the hearing aid that the phone can read, write, or subscribe to.

HAS Characteristics — Full Summary
# Characteristic UUID Requirement Properties Purpose
1 Hearing Aid Features 0x2BDA Mandatory Read (+ optional Notify) Advertises device capabilities as a 1-byte bitfield
2 Hearing Aid Preset Control Point 0x2BDB Optional Write + Indicate (+ Notify on EATT) Main control channel — all read/write/set operations
3 Active Preset Index 0x2BDC Mandatory if CP supported Read + Notify 1-byte register showing which preset is active right now

A minimal valid HAS server only needs Characteristic 1. Once you add the Control Point (Characteristic 2), Characteristic 3 becomes mandatory — because the client needs to know which preset is active after each operation.

The service itself is registered as a Primary Service with UUID 0x1854.

5. HAS-Specific ATT Application Error Codes

When the server rejects a client write, it sends an ATT_ERROR_RSP PDU. Besides the standard ATT errors (like Out of Range, Insufficient Encryption), HAS defines five application-level codes in the range 0x80–0x84.

HAS ATT Application Error Codes
Hex Code Error Name When the Server Sends This
0x80 Invalid Opcode The client wrote an opcode that is marked RFU in Table 3.3, or wrote a server-only opcode (e.g., 0x02 or 0x03)
0x81 Write Name Not Allowed The client tried to rename a preset but that preset’s Writable bit (bit 0 of Properties) is 0 — it is a factory read-only preset
0x82 Preset Synchronization Not Supported The client sent a “Synchronized Locally” opcode (0x08, 0x09, or 0x0A) but the server does not have Preset Sync Support enabled
0x83 Preset Operation Not Possible The preset exists but cannot be activated right now — either its isAvailable bit is 0, or it is incompatible with the hearing aid’s current state
0x84 Invalid Parameters Length A valid opcode was written but the total write length is wrong (e.g., Set Active Preset with 0 or 3 bytes instead of exactly 2)

6. The Preset Record — Complete Breakdown

Everything in HAS revolves around preset records. A preset record is the fundamental unit of data. The server maintains an ordered list of them. Let us break down the wire encoding completely.

6.1 Wire Format

Preset Record — Byte Layout on the Wire
Byte 0 Byte 1 Bytes 2 to (N+1) — variable length
Index
uint8
0x01–0xFF
Properties
bitfield
see §6.3
Name
UTF-8 string, 1–40 octets
Length = ATT Value Size − 2

6.2 Index Field Rules

  • Valid range: 0x01 to 0xFF
  • 0x00 is reserved — used only in the Active Preset Index characteristic to mean “no preset is active”. It must never appear as a preset record’s own Index.
  • The server maintains the list in ascending Index order at all times.
  • Index values do not have to be consecutive. A valid list could have Index values 1, 5, 22.
  • When a preset is deleted, the remaining Indexes are not renumbered. If you delete Index 5 from {1, 5, 22}, the result is {1, 22} — not {1, 2}.
  • When adding a new preset, the server may reuse an Index that was previously deleted.

6.3 Properties Field — Bit Layout

Properties Byte — Bit Assignment
Bits 7–2 Bit 1 Bit 0
RFU — always 0, ignored on receive isAvailable Writable

Bit 0 — Writable:

  • 0 → Name is read-only. Set at manufacturing time. Client cannot rename it. Trying to do so returns error 0x81.
  • 1 → Client may rename this preset using Write Preset Name (opcode 0x04).

Bit 1 — isAvailable:

  • 0 → Preset is temporarily unavailable — it cannot be selected as the active preset. Think of it as greyed out in the phone’s UI.
  • 1 → Preset is available and can be activated.

Important rule: The active preset (the one currently in use) must always have isAvailable = 1. The server must never mark the currently active preset as unavailable.

6.4 Name Field

The Name is a UTF-8 string from 1 to 40 octets. Since each ATT PDU carries at most one preset record, there is no explicit length prefix for the Name. The receiver calculates Name length as: ATT_Value_Length − 2 (subtracting the fixed 2-byte header).

Complete wire example — Index 1, read-write, available, Name “Universal”:

Byte 0:  0x01              Index = 1
Byte 1:  0x03              Properties: bit0=1 (Writable), bit1=1 (Available) → 0b00000011
Bytes 2–10:                Name = "Universal" in UTF-8
         55 6E 69 76 65 72 73 61 6C

Full record bytes: 01 03 55 6E 69 76 65 72 73 61 6C   (11 bytes total)

Another example — Index 5, factory preset, currently unavailable, Name “Outdoor”:

Byte 0:  0x05              Index = 5
Byte 1:  0x00              Properties: bit0=0 (ReadOnly), bit1=0 (Unavailable) → 0b00000000
Bytes 2–8:                 Name = "Outdoor"
         4F 75 74 64 6F 6F 72

Full record bytes: 05 00 4F 75 74 64 6F 6F 72   (9 bytes total)

7. Hearing Aid Features Characteristic (UUID 0x2BDA)

This is the only mandatory characteristic. A client reads it first, immediately after connecting, to discover the device’s capabilities. It is a single byte that acts as a capability register.

7.1 Features Byte — Complete Bit Map

Hearing Aid Features — 1-Byte Bitfield Layout
Bits 7–6 Bit 5 Bit 4 Bit 3 Bit 2 Bits 1–0
RFU Writable
Presets
Dynamic
Presets
Independent
Presets
Preset Sync
Support
Hearing Aid Type
(2-bit)

7.2 Field Explanations

Bits 1–0: Hearing Aid Type

Hearing Aid Type — Bit Encoding
Bits 1:0 Type Description
0b00 Binaural Hearing Aid Part of a left/right pair. Each aid is a separate BLE peripheral but they share a coordinated preset list.
0b01 Monaural Hearing Aid Single device for one ear. Independent — no partner device.
0b10 Banded Hearing Aid Two physically joined aids presenting a single BLE radio to the phone.
0b11 RFU Reserved — do not use.

Bit 2 — Preset Synchronization Support: Set to 1 only on a Binaural aid that can relay preset changes to its partner. Monaural and Banded aids must set this to 0. If Independent Presets is 1, this must also be 0 (you cannot sync a pair that has different preset lists).

Bit 3 — Independent Presets: 0 means both aids in a Binaural Set have the same preset list. 1 means they may have different lists. Monaural and Banded aids always set this to 0.

Bit 4 — Dynamic Presets: 1 means the preset list can change at runtime — presets can be added, deleted, renamed, or toggled available/unavailable. 0 means the list is fixed forever after manufacturing.

Bit 5 — Writable Presets Support: 1 if at least one preset in the list has its Writable flag set. This is the device-level capability flag corresponding to the per-preset Writable bit in each preset record’s Properties byte.

7.3 Decoding Example: 0x31

The spec uses 0x31 as a concrete example. Here is the full decode:

0x31 = 0b 0011 0001

Bit 0 = 1 ─┐
Bit 1 = 0 ─┘  Hearing Aid Type = 0b01 = Monaural Hearing Aid

Bit 2 = 0     Preset Synchronization Support = No (correct for Monaural)
Bit 3 = 0     Independent Presets = No (N/A for Monaural — must be 0)
Bit 4 = 1     Dynamic Presets = YES (list can change at runtime)
Bit 5 = 1     Writable Presets Support = YES (client can rename some presets)
Bits 6–7 = 0  RFU

Conclusion: Monaural hearing aid, preset list can grow/shrink,
            and at least one preset is user-renameable.

7.4 Characteristic Behavior

  • The server exposes the same Features byte to every connected client.
  • If the Features byte changes while a client is connected with Notify enabled, the server sends a notification immediately.
  • If it changes while the bonded client is disconnected, the server sends the notification upon reconnection.

8. Hearing Aid Preset Control Point — All Operations

The Preset Control Point (CP) characteristic is the main interaction channel. The client writes an opcode (optionally followed by parameters) to request an operation. The server immediately responds with an ATT_WRITE_RSP, then sends one or more indications/notifications carrying the result.

Prerequisite: Before sending Read Presets Request, Write Preset Name, Set Active Preset, or Set Active Preset – Synchronized Locally, the client must first configure the CP characteristic’s CCCD for indications. If not done, the server returns CCCD Improperly Configured.

8.1 Complete Opcode Reference

Preset Control Point Opcode Table
Opcode Operation Name Direction Parameters Support
0x01 Read Presets Request Client → Server StartIndex (1B), NumPresets (1B) Mandatory
0x02 Read Preset Response Server → Client isLast (1B), PresetRecord (var) Mandatory
0x03 Preset Changed Server → Client ChangeId (1B), isLast (1B), … Cond: if Dynamic
0x04 Write Preset Name Client → Server Index (1B), Name (1–40B) Cond: if Writable
0x05 Set Active Preset Client → Server Index (1B) Mandatory
0x06 Set Next Preset Client → Server (none) Mandatory
0x07 Set Previous Preset Client → Server (none) Mandatory
0x08 Set Active Preset – Sync Locally Client → Server Index (1B) Cond: if Sync
0x09 Set Next Preset – Sync Locally Client → Server (none) Cond: if Sync
0x0A Set Previous Preset – Sync Locally Client → Server (none) Cond: if Sync

8.2 Read Presets Request (0x01) and Read Preset Response (0x02)

This is the mechanism for fetching the preset list. The client sends a request specifying the starting Index and maximum number of presets. The server sends one indication/notification per preset found.

Read Presets Request — Client Writes This to CP
Byte 0 — Opcode Byte 1 — StartIndex Byte 2 — NumPresets
0x01 0x01–0xFF 0x01–0xFF
Read Presets Request opcode Return presets starting at this Index (or next higher) Max number of presets to return (0xFF = return all)
Read Preset Response — Server Sends This per Preset (via Indication)
Byte 0 Byte 1 Bytes 2+
0x02 isLast
(0x00 or 0x01)
PresetRecord (Index + Properties + Name)
Response opcode 0x01 = this is the last preset. Stop listening. Full preset record as encoded in Section 6

Message flow — reading all presets from a hearing aid with three presets (Index 1, 5, 22):

Read Presets — Full Message Sequence (StartIndex=1, NumPresets=0xFF)
CLIENT (Phone / Linux Host) SERVER (Hearing Aid)
Write CP: [0x01, 0x01, 0xFF]
ATT_WRITE_RSP (accepted)
Indicate: [0x02, isLast=0x00, Preset#1 “Universal”]
ATT_HANDLE_VALUE_CONFIRM
Indicate: [0x02, isLast=0x00, Preset#5 “Outdoor”]
ATT_HANDLE_VALUE_CONFIRM
Indicate: [0x02, isLast=0x01, Preset#22 “Office”] ← DONE
ATT_HANDLE_VALUE_CONFIRM

Error conditions you must handle on the client side:

  • StartIndex = 0x00 → server returns Out of Range
  • NumPresets = 0x00 → server returns Out of Range
  • StartIndex beyond the highest preset’s Index → server returns Out of Range
  • Another Read Presets is already in progress for any client → server returns Procedure Already in Progress
  • If the BLE connection drops mid-transfer, the server treats the operation as aborted. It will not resume automatically when the client reconnects.

8.3 Write Preset Name (0x04)

Allows the client to rename a preset. Conditional — only supported if the server has Writable Presets Support (bit 5 of Features) set to 1.

Write Preset Name — Client Writes This to CP
Byte 0 Byte 1 Bytes 2+
0x04 Target Index New name as UTF-8 string (1–40 bytes)

After a successful write, the server must:

  1. Reply with ATT_WRITE_RSP
  2. Update the preset’s Name field internally
  3. Send a Preset Changed (Generic Update, ChangeId=0x00) indication/notification to every client that has the CP CCCD configured

Rejections: Preset is read-only → error 0x81. Name absent or longer than 40 bytes → error 0x84.

8.4 Set Active Preset (0x05)

Set Active Preset — Wire Format
Byte 0 — Opcode Byte 1 — Index
0x05 Target Preset Index

If the target preset is available, the server updates the Active Preset Index and notifies all subscribers of the new value. If the preset is unavailable, the server returns error 0x83. If the Index does not exist in the preset list, the server returns Out of Range.

8.5 Set Next Preset (0x06) and Set Previous Preset (0x07)

These navigation commands cycle through the list. They are the only way a simple display-less remote control (like a key-fob) can switch hearing aid modes — since it cannot show preset names, it just steps through the list one at a time.

  • Set Next (0x06): Move to the next available preset after the current one. If at the last, wrap to the first available preset.
  • Set Previous (0x07): Move to the previous available preset. If at the first, wrap to the last available preset.

Both skip presets with isAvailable = 0. Both have no parameters — just the single opcode byte. After either operation, the server sends a notification of the updated Active Preset Index.

8.6 Preset Changed Operation (0x03) — Server-Initiated

This operation is initiated autonomously by the server — not triggered by a client write. The server uses it to push updates to all connected and registered clients whenever the preset list changes. If a bonded client is disconnected, the server queues the notification and delivers it upon reconnection.

Preset Changed — ChangeId Values and Payload
ChangeId Hex Meaning Fields After isLast
Generic Update 0x00 New preset added, or name changed, or multiple changes at once PrevIndex (1B) + full PresetRecord
Preset Record Deleted 0x01 A preset was removed from the list Index (1B) of deleted preset
Preset Record Available 0x02 isAvailable bit changed from 0 to 1 Index (1B) of affected preset
Preset Record Unavailable 0x03 isAvailable bit changed from 1 to 0 Index (1B) of affected preset

What PrevIndex does in Generic Update: The PrevIndex byte tells the client which preset currently sits immediately before the new/changed preset in the ordered list. If PrevIndex is 1 and the new preset’s Index is 10, the client knows there is nothing between position 1 and 10 — any presets that were there (5, 8, etc.) are now gone. This lets the server communicate deletions and an addition in a single PDU instead of three.

isLast flag rules:

  • When only one preset changed → send one indication with isLast = 0x01.
  • When multiple presets changed → send indications in ascending Index order. All except the final one have isLast = 0x00. The last one has isLast = 0x01.
  • If the connection drops mid-sequence for a Preset Changed operation (unlike Read Presets), the server does resume from where it left off when the client reconnects. If the last indication sent was not confirmed, it is re-sent.

8.7 Synchronized Locally Operations (0x08, 0x09, 0x0A)

These three opcodes are the binaural synchronization extensions of 0x05, 0x06, and 0x07 respectively. The hearing aid behavior is identical to its non-sync counterpart, but with one addition: the server that receives the command must relay the preset change to its partner hearing aid in the Binaural Set.

The relay mechanism between the two hearing aids is not defined by HAS — it could be a proprietary BLE connection, a CSIP (Coordinated Set Identification Profile) mechanism, or a direct radio link. The spec only mandates the outcome.

Binaural Sync — Single Phone Command Switches Both Aids
PHONE (Client) RIGHT AID (Connected Server) LEFT AID (Partner Server)
Write CP [0x08, Index=3]
Sets own active preset to Index 3.
Sends ATT_WRITE_RSP.
Relays change to Left Aid.
Receives relay.
Sets own active preset to Index 3.
Receives Active Preset Index notification: 3 Notifies client of new Active Preset Index.

If the server does not support Preset Synchronization, it returns error 0x82 for any of these three opcodes.

9. Active Preset Index Characteristic (UUID 0x2BDC)

This is the simplest characteristic in HAS. It is a single read/notify byte that always holds the Index of the currently active preset.

Active Preset Index — Wire Format
Byte 0 — Active Preset Index Value
0x00 = no active preset
0x01–0xFF = Index of the active preset record

Rules the server must follow:

  • This value must match the Index of the preset the hearing aid is currently using.
  • If no preset is active, the value is 0x00.
  • The server must never set this to the Index of an unavailable preset (isAvailable = 0).
  • Whenever this value changes — whether due to a client command or autonomous user action on the hearing aid itself (e.g., user presses the button on the device) — the server must update this characteristic and, if notifications are enabled, push the new value to the client.
  • If the value changed while the bonded client was disconnected, the notification is delivered upon reconnection.

10. BlueZ Code — Discovery, Client, and Server

Now we move to practical code. All examples run on Linux with BlueZ 5.50+. The server code follows the pattern from BlueZ’s test/example-gatt-server. The client code follows test/example-gatt-client.

10.1 Initial Setup

# Install dependencies (Debian/Ubuntu)
sudo apt-get update
sudo apt-get install -y bluetooth bluez python3-dbus python3-gi

# Verify BlueZ version (need 5.50+)
bluetoothd --version

# Bring up adapter
sudo systemctl start bluetooth
bluetoothctl power on

10.2 Discovering a Hearing Aid with bluetoothctl

bluetoothctl

# Inside the bluetoothctl shell:
agent on
default-agent
scan on

# Expected output:
# [NEW] Device AA:BB:CC:DD:EE:FF HA-Left
# [NEW] Device 11:22:33:44:55:66 HA-Right

# Connect and pair (HAS requires bonding for encryption)
pair AA:BB:CC:DD:EE:FF
connect AA:BB:CC:DD:EE:FF

# Browse GATT services
menu gatt
list-attributes AA:BB:CC:DD:EE:FF

# Look for:
# Service: /org/bluez/.../service00XX
#   UUID: 00001854-0000-1000-8000-00805f9b34fb  ← HAS
# Characteristics under it:
#   00002bda-...  Hearing Aid Features
#   00002bdb-...  Preset Control Point
#   00002bdc-...  Active Preset Index

# Select and read Features
select-attribute /org/bluez/.../service00XX/char00YY
read
# Output: 0x31  → Monaural, Dynamic, Writable

10.3 Reading HAS Characteristics with gatttool

#!/bin/bash
# Replace with your hearing aid's address
HA_ADDR="AA:BB:CC:DD:EE:FF"

echo "=== Reading Hearing Aid Features (0x2BDA) ==="
gatttool -b "$HA_ADDR" --sec-level=high \
  --char-read --uuid=00002bda-0000-1000-8000-00805f9b34fb

# Sample output:  Characteristic value/descriptor: 31
# 0x31 decoded:   Monaural | Dynamic Presets | Writable Presets

echo ""
echo "=== Reading Active Preset Index (0x2BDC) ==="
gatttool -b "$HA_ADDR" --sec-level=high \
  --char-read --uuid=00002bdc-0000-1000-8000-00805f9b34fb

# Sample output:  Characteristic value/descriptor: 01
# Active preset is Index 1 ("Universal")

echo ""
echo "=== Sending Read Presets Request (opcode 0x01, start=1, num=255) ==="
# First find the handle of the Control Point:
# gatttool -b "$HA_ADDR" -I → connect → char-desc
# Then use the actual handle (e.g., 0x002b)

gatttool -b "$HA_ADDR" --sec-level=high \
  --char-write-req --handle=0x002b \
  --value=010101ff \
  --listen

# --listen waits for incoming indications
# Sample output:
# Characteristic value was written successfully
# Notification handle = 0x002b value: 02 00 01 03 55 6e 69 76 65 72 73 61 6c
#   ^opcode=0x02  ^isLast=0x00  ^Index=1 ^Props=0x03  ^"Universal"
# Notification handle = 0x002b value: 02 00 05 03 4f 75 74 64 6f 6f 72
#   Preset 5 "Outdoor", isLast=0x00
# Notification handle = 0x002b value: 02 01 16 03 4f 66 66 69 63 65
#   Preset 22 "Office", isLast=0x01  ← done

10.4 Python Client — Read and Decode All Presets via D-Bus

#!/usr/bin/env python3
"""
HAS Client — Connects to a HAS server and reads all preset records.
Based on BlueZ test/example-gatt-client D-Bus pattern.
Run: python3 has_client.py
Requirement: Device must already be paired and connected via bluetoothctl.
"""

import dbus

BLUEZ_SERVICE   = "org.bluez"
GATT_CHAR_IFACE = "org.bluez.GattCharacteristic1"
DBUS_OM_IFACE   = "org.freedesktop.DBus.ObjectManager"

# HAS Characteristic UUIDs
FEATURES_UUID    = "00002bda-0000-1000-8000-00805f9b34fb"
CTRL_PT_UUID     = "00002bdb-0000-1000-8000-00805f9b34fb"
ACTIVE_IDX_UUID  = "00002bdc-0000-1000-8000-00805f9b34fb"

# Replace with your device path from: busctl tree org.bluez
DEVICE_PATH = "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"


def find_char(bus, device_path, uuid):
    """Locate a GATT characteristic object by UUID under a device path."""
    om = dbus.Interface(
        bus.get_object(BLUEZ_SERVICE, "/"),
        DBUS_OM_IFACE
    )
    for path, ifaces in om.GetManagedObjects().items():
        if GATT_CHAR_IFACE not in ifaces:
            continue
        if (ifaces[GATT_CHAR_IFACE]["UUID"] == uuid
                and path.startswith(device_path)):
            return dbus.Interface(
                bus.get_object(BLUEZ_SERVICE, path),
                GATT_CHAR_IFACE
            )
    return None


def decode_features(raw):
    """Decode the single-byte Hearing Aid Features value."""
    b = raw[0]
    types = {0: "Binaural", 1: "Monaural", 2: "Banded", 3: "RFU"}
    print(f"  Raw hex        : 0x{b:02X}")
    print(f"  Hearing Aid Type       : {types[b & 0x03]}")
    print(f"  Preset Sync Support    : {'Yes' if (b >> 2) & 1 else 'No'}")
    print(f"  Independent Presets    : {'Yes' if (b >> 3) & 1 else 'No'}")
    print(f"  Dynamic Presets        : {'Yes' if (b >> 4) & 1 else 'No'}")
    print(f"  Writable Presets       : {'Yes' if (b >> 5) & 1 else 'No'}")


def decode_read_preset_response(raw):
    """
    Parse a Read Preset Response indication (opcode 0x02).
    raw: list of bytes including the opcode byte.
    """
    if raw[0] != 0x02:
        print(f"  Unexpected opcode: 0x{raw[0]:02X}")
        return

    is_last    = raw[1]
    index      = raw[2]
    properties = raw[3]
    writable   = bool(properties & 0x01)
    available  = bool((properties >> 1) & 0x01)
    name       = bytes(raw[4:]).decode("utf-8", errors="replace")

    print(f"  Index     : {index}")
    print(f"  Writable  : {writable}")
    print(f"  Available : {available}")
    print(f"  Name      : {name}")
    print(f"  isLast    : {bool(is_last)}")
    print()


def main():
    bus = dbus.SystemBus()

    # 1. Read Hearing Aid Features
    feat = find_char(bus, DEVICE_PATH, FEATURES_UUID)
    if feat:
        val = feat.ReadValue({})
        print("=== Hearing Aid Features ===")
        decode_features(list(val))
        print()

    # 2. Read Active Preset Index
    api = find_char(bus, DEVICE_PATH, ACTIVE_IDX_UUID)
    if api:
        val = api.ReadValue({})
        idx = val[0]
        print(f"=== Active Preset Index: {idx} ===\n")

    # 3. Enable indications and send Read Presets Request
    cp = find_char(bus, DEVICE_PATH, CTRL_PT_UUID)
    if not cp:
        print("Control Point not found. Is the device connected?")
        return

    cp.StartNotify()
    print("Indications enabled on Control Point.")

    # Send Read Presets Request: Opcode=0x01, StartIndex=0x01, NumPresets=0xFF
    request = [dbus.Byte(0x01), dbus.Byte(0x01), dbus.Byte(0xFF)]
    cp.WriteValue(request, {})
    print("Read Presets Request sent.\n")
    print("NOTE: Use a notification listener (e.g., dbus signal handler or")
    print("      gatttool --listen) to capture the Preset Response indications.")


if __name__ == "__main__":
    main()

10.5 Python BlueZ GATT Server — Minimal HAS Server

This implements a hearing aid simulator. It registers three presets and responds to Read Presets, Set Active Preset, Set Next, and Set Previous opcodes. Pattern is from BlueZ test/example-gatt-server.

#!/usr/bin/env python3
"""
Minimal HAS GATT Server — Hearing Aid Simulator
Based on BlueZ test/example-gatt-server D-Bus pattern.
Run with root: sudo python3 has_server.py
Install deps : sudo apt install python3-dbus python3-gi
"""

import dbus
import dbus.exceptions
import dbus.mainloop.glib
import dbus.service
from gi.repository import GLib

BLUEZ_SVC       = "org.bluez"
GATT_MGR_IFACE  = "org.bluez.GattManager1"
GATT_SVC_IFACE  = "org.bluez.GattService1"
GATT_CHR_IFACE  = "org.bluez.GattCharacteristic1"
DBUS_OM_IFACE   = "org.freedesktop.DBus.ObjectManager"
DBUS_PROP_IFACE = "org.freedesktop.DBus.Properties"
LE_ADV_MGR      = "org.bluez.LEAdvertisingManager1"
LE_ADV_IFACE    = "org.bluez.LEAdvertisement1"

HAS_SVC_UUID   = "00001854-0000-1000-8000-00805f9b34fb"
FEATURES_UUID  = "00002bda-0000-1000-8000-00805f9b34fb"
CTRL_PT_UUID   = "00002bdb-0000-1000-8000-00805f9b34fb"
ACTIVE_IDX_UUID = "00002bdc-0000-1000-8000-00805f9b34fb"

mainloop = None

# ── Preset database ───────────────────────────────────────────────────────────
# (index, writable, available, name)
PRESETS = [
    (0x01, False, True,  "Universal"),
    (0x05, False, True,  "Outdoor"),
    (0x0A, True,  True,  "My Custom"),
]
active_index = 0x01   # "Universal" is active at startup


def make_preset_record(idx, writable, available, name):
    """Return preset record as a list of int bytes."""
    props = (0x01 if writable else 0) | (0x02 if available else 0)
    return [idx, props] + list(name.encode("utf-8"))


def find_adapter(bus):
    om = dbus.Interface(bus.get_object(BLUEZ_SVC, "/"), DBUS_OM_IFACE)
    for path, ifaces in om.GetManagedObjects().items():
        if GATT_MGR_IFACE in ifaces:
            return path
    raise Exception("No GattManager1 found — is bluetoothd running?")


# ── Reusable GATT base ────────────────────────────────────────────────────────
class GattObj(dbus.service.Object):
    def __init__(self, bus, path):
        super().__init__(bus, path)
        self.bus = bus

    def get_path(self):
        return dbus.ObjectPath(self.path)


# ── HAS Primary Service ───────────────────────────────────────────────────────
class HASService(GattObj):
    def __init__(self, bus, index):
        self.path = f"/org/bluez/has/service{index}"
        self.chars = []
        super().__init__(bus, self.path)

    def add_char(self, c):
        self.chars.append(c)

    def get_props(self):
        return {
            GATT_SVC_IFACE: {
                "UUID":            HAS_SVC_UUID,
                "Primary":         dbus.Boolean(True),
                "Characteristics": dbus.Array(
                    [c.get_path() for c in self.chars], signature="o"),
            }
        }

    @dbus.service.method(DBUS_OM_IFACE, out_signature="a{oa{sa{sv}}}")
    def GetManagedObjects(self):
        r = {self.get_path(): self.get_props()}
        for c in self.chars:
            r[c.get_path()] = c.get_props()
        return r


# ── Hearing Aid Features Characteristic (0x2BDA) ─────────────────────────────
class FeaturesChar(GattObj):
    """
    Single byte: 0x31
      bits 1:0 = 0b01  Monaural
      bit  4   = 1     Dynamic Presets
      bit  5   = 1     Writable Presets
    """
    def __init__(self, bus, idx, svc):
        self.path = svc.path + f"/char{idx}"
        self.svc  = svc
        super().__init__(bus, self.path)

    def get_props(self):
        return {GATT_CHR_IFACE: {
            "Service": self.svc.get_path(),
            "UUID":    FEATURES_UUID,
            "Flags":   dbus.Array(["read", "notify"], signature="s"),
        }}

    @dbus.service.method(GATT_CHR_IFACE,
                         in_signature="a{sv}", out_signature="ay")
    def ReadValue(self, opts):
        return dbus.Array([dbus.Byte(0x31)], signature="y")

    @dbus.service.method(GATT_CHR_IFACE)
    def StartNotify(self): pass

    @dbus.service.method(GATT_CHR_IFACE)
    def StopNotify(self): pass


# ── Active Preset Index Characteristic (0x2BDC) ───────────────────────────────
class ActiveIdxChar(GattObj):
    def __init__(self, bus, idx, svc):
        self.path       = svc.path + f"/char{idx}"
        self.svc        = svc
        self.notifying  = False
        super().__init__(bus, self.path)

    def get_props(self):
        return {GATT_CHR_IFACE: {
            "Service": self.svc.get_path(),
            "UUID":    ACTIVE_IDX_UUID,
            "Flags":   dbus.Array(["read", "notify"], signature="s"),
        }}

    @dbus.service.method(GATT_CHR_IFACE,
                         in_signature="a{sv}", out_signature="ay")
    def ReadValue(self, opts):
        return dbus.Array([dbus.Byte(active_index)], signature="y")

    @dbus.service.method(GATT_CHR_IFACE)
    def StartNotify(self):
        self.notifying = True

    @dbus.service.method(GATT_CHR_IFACE)
    def StopNotify(self):
        self.notifying = False

    def notify_value(self, new_index):
        if self.notifying:
            self.PropertiesChanged(
                GATT_CHR_IFACE,
                {"Value": dbus.Array([dbus.Byte(new_index)], signature="y")},
                []
            )

    @dbus.service.signal(DBUS_PROP_IFACE, signature="sa{sv}as")
    def PropertiesChanged(self, iface, changed, inval): pass


# ── Preset Control Point Characteristic (0x2BDB) ─────────────────────────────
class CtrlPtChar(GattObj):
    def __init__(self, bus, idx, svc, active_char):
        self.path        = svc.path + f"/char{idx}"
        self.svc         = svc
        self.active_char = active_char
        self.notifying   = False
        super().__init__(bus, self.path)

    def get_props(self):
        return {GATT_CHR_IFACE: {
            "Service": self.svc.get_path(),
            "UUID":    CTRL_PT_UUID,
            "Flags":   dbus.Array(["write", "indicate"], signature="s"),
        }}

    def _indicate(self, data_ints):
        """Emit an indication by firing PropertiesChanged."""
        self.PropertiesChanged(
            GATT_CHR_IFACE,
            {"Value": dbus.Array(
                [dbus.Byte(b) for b in data_ints], signature="y")},
            []
        )

    def _do_read_presets(self, start_index, num_presets):
        """Send one indication per matching preset (ascending Index order)."""
        matches = [p for p in PRESETS if p[0] >= start_index][:num_presets]
        for i, (idx, wr, av, nm) in enumerate(matches):
            is_last = 0x01 if i == len(matches) - 1 else 0x00
            record  = make_preset_record(idx, wr, av, nm)
            # opcode=0x02 (Read Preset Response) | isLast | PresetRecord
            self._indicate([0x02, is_last] + record)
        return False   # GLib.idle_add one-shot

    @dbus.service.method(GATT_CHR_IFACE, in_signature="aya{sv}")
    def WriteValue(self, value, opts):
        global active_index
        data = list(value)
        if not data:
            return

        op = data[0]

        # ── 0x01  Read Presets Request ────────────────────────────────────
        if op == 0x01:
            if len(data) != 3:
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed",
                    "Invalid Parameters Length [0x84]")
            s_idx, n_pre = data[1], data[2]
            if s_idx == 0 or n_pre == 0:
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed", "Out of Range")
            GLib.idle_add(self._do_read_presets, s_idx, n_pre)

        # ── 0x04  Write Preset Name ───────────────────────────────────────
        elif op == 0x04:
            if len(data) < 3 or len(data) > 42:  # 1 opcode + 1 index + 1-40 name
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed",
                    "Invalid Parameters Length [0x84]")
            target_idx = data[1]
            preset     = next((p for p in PRESETS if p[0] == target_idx), None)
            if preset is None:
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed", "Out of Range")
            if not preset[1]:   # Writable bit is 0
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed",
                    "Write Name Not Allowed [0x81]")
            new_name = bytes(data[2:]).decode("utf-8", errors="replace")
            # Update preset list (replace tuple)
            idx_in_list = PRESETS.index(preset)
            PRESETS[idx_in_list] = (preset[0], preset[1], preset[2], new_name)
            print(f"Preset {target_idx} renamed to: {new_name}")
            # Send Preset Changed Generic Update to all clients
            record = make_preset_record(
                PRESETS[idx_in_list][0],
                PRESETS[idx_in_list][1],
                PRESETS[idx_in_list][2],
                PRESETS[idx_in_list][3])
            prev_idx = PRESETS[idx_in_list - 1][0] if idx_in_list > 0 else 0
            self._indicate([0x03, 0x00, 0x01, prev_idx] + record)
            # opcode=0x03, ChangeId=0x00, isLast=0x01, PrevIndex, record

        # ── 0x05  Set Active Preset ───────────────────────────────────────
        elif op == 0x05:
            if len(data) != 2:
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed",
                    "Invalid Parameters Length [0x84]")
            target = data[1]
            preset = next((p for p in PRESETS if p[0] == target), None)
            if preset is None:
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed", "Out of Range")
            if not preset[2]:   # isAvailable == False
                raise dbus.exceptions.DBusException(
                    "org.bluez.Error.Failed",
                    "Preset Operation Not Possible [0x83]")
            active_index = target
            print(f"Active preset → Index {target} ({preset[3]})")
            self.active_char.notify_value(active_index)

        # ── 0x06  Set Next Preset ─────────────────────────────────────────
        elif op == 0x06:
            avail = [p[0] for p in PRESETS if p[2]]
            if avail:
                pos = avail.index(active_index) if active_index in avail else -1
                active_index = avail[(pos + 1) % len(avail)]
                print(f"Set Next → Index {active_index}")
                self.active_char.notify_value(active_index)

        # ── 0x07  Set Previous Preset ─────────────────────────────────────
        elif op == 0x07:
            avail = [p[0] for p in PRESETS if p[2]]
            if avail:
                pos = avail.index(active_index) if active_index in avail else 0
                active_index = avail[(pos - 1) % len(avail)]
                print(f"Set Prev → Index {active_index}")
                self.active_char.notify_value(active_index)

        # ── 0x08–0x0A  Sync Locally (simplified: same as 0x05/06/07) ─────
        elif op in (0x08, 0x09, 0x0A):
            # This server is Monaural so sync is not supported
            raise dbus.exceptions.DBusException(
                "org.bluez.Error.Failed",
                "Preset Synchronization Not Supported [0x82]")

        else:
            raise dbus.exceptions.DBusException(
                "org.bluez.Error.Failed",
                "Invalid Opcode [0x80]")

    @dbus.service.method(GATT_CHR_IFACE)
    def StartNotify(self):
        self.notifying = True
        print("CP indications enabled by client.")

    @dbus.service.method(GATT_CHR_IFACE)
    def StopNotify(self):
        self.notifying = False

    @dbus.service.signal(DBUS_PROP_IFACE, signature="sa{sv}as")
    def PropertiesChanged(self, iface, changed, inval): pass


# ── Application root ──────────────────────────────────────────────────────────
class Application(dbus.service.Object):
    def __init__(self, bus):
        self.path     = "/"
        self.services = []
        super().__init__(bus, self.path)

    def get_path(self):
        return dbus.ObjectPath(self.path)

    def add_service(self, svc):
        self.services.append(svc)

    @dbus.service.method(DBUS_OM_IFACE, out_signature="a{oa{sa{sv}}}")
    def GetManagedObjects(self):
        r = {}
        for svc in self.services:
            r.update(svc.GetManagedObjects())
        return r


# ── Entry point ───────────────────────────────────────────────────────────────
def on_register_ok():
    print("HAS GATT application registered with BlueZ.")


def on_register_err(e):
    print(f"Registration failed: {e}")
    mainloop.quit()


def main():
    global mainloop
    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus     = dbus.SystemBus()
    adapter = find_adapter(bus)

    svc_mgr = dbus.Interface(
        bus.get_object(BLUEZ_SVC, adapter), GATT_MGR_IFACE)

    app         = Application(bus)
    has_svc     = HASService(bus, 0)
    feat_chr    = FeaturesChar(bus, 0, has_svc)
    active_chr  = ActiveIdxChar(bus, 1, has_svc)
    cp_chr      = CtrlPtChar(bus, 2, has_svc, active_chr)

    has_svc.add_char(feat_chr)
    has_svc.add_char(active_chr)
    has_svc.add_char(cp_chr)
    app.add_service(has_svc)

    svc_mgr.RegisterApplication(
        app.get_path(), {},
        reply_handler=on_register_ok,
        error_handler=on_register_err)

    print("HAS Server started.")
    print(f"Preset list: {[(p[0], p[3]) for p in PRESETS]}")
    print(f"Active Index: {active_index}")
    print("Waiting for connections... Press Ctrl+C to stop.")

    mainloop = GLib.MainLoop()
    mainloop.run()


if __name__ == "__main__":
    main()

10.6 Verifying with nRF Connect

Once the server is running on your Linux machine, open nRF Connect on Android or iOS:

  1. Scan → connect to your device
  2. Navigate to Service UUID 1854
  3. Read characteristic 2BDA → should return 31
  4. Read characteristic 2BDC → should return 01
  5. On characteristic 2BDB, tap the CCCD descriptor → enable Indicate
  6. Write hex 01 01 FF to 2BDB → three Read Preset Response indications arrive
  7. Write 05 05 → switches to preset 5 (“Outdoor”)
  8. Write 06 → moves to next available preset

11. Worked Example — Preset Changed with Multiple Updates

Let us trace a realistic scenario. The hearing aid currently has this preset list:

Initial Preset List
Index Properties Name
1 0x03 Universal
5 0x03 Outdoor
8 0x03 Noisy Environment
22 0x03 Office

The firmware update removes presets 5 and 8 and adds a new one at Index 10 (“Reverberant Room”). The phone was connected during this change.

Option A — Three separate Preset Changed notifications (verbose, always valid):

Indication 1:
  Opcode=0x03  ChangeId=0x01 (Deleted)  isLast=0x00  Index=0x05
  → "Preset 5 deleted"

Indication 2:
  Opcode=0x03  ChangeId=0x01 (Deleted)  isLast=0x00  Index=0x08
  → "Preset 8 deleted"

Indication 3:
  Opcode=0x03  ChangeId=0x00 (Generic Update)  isLast=0x01
  PrevIndex=0x01  PresetRecord=[0x0A, 0x03, "Reverberant room"]
  → "New preset Index 10 added after Index 1"

Option B — Single Generic Update (bandwidth-efficient, spec-allowed):

Single Indication:
  Opcode    = 0x03 (Preset Changed)
  ChangeId  = 0x00 (Generic Update)
  isLast    = 0x01
  PrevIndex = 0x01   ← previous entry before Index 10 in the new list is Index 1
  PresetRecord = [0x0A, 0x03] + "Reverberant room"

Client receives this and reconstructs:
  Previous = Index 1,  New entry = Index 10
  Nothing between 1 and 10 → presets 5 and 8 are implicitly gone.

Resulting list: { Index 1 "Universal", Index 10 "Reverberant room", Index 22 "Office" }

Option B saves two ATT PDUs — a meaningful saving on a congested BLE channel, or when battery life of the hearing aid is critical.

12. Summary and Quick-Reference Table
HAS Complete Knowledge Map
Topic Key Points
Service Purpose Standardised BLE GATT interface for reading and controlling hearing aid presets. Part of HAP.
Transport BLE LE only. Min ATT_MTU 49 bytes. Encrypted link mandatory. EATT recommended for reliable delivery.
3 Characteristics 0x2BDA Features (M, Read), 0x2BDB Control Point (O, Write+Indicate), 0x2BDC Active Index (M if CP, Read+Notify)
Preset Record Index (1B, 0x01–0xFF) + Properties (1B bitfield: bit0=Writable, bit1=isAvailable) + Name (1–40B UTF-8). Sorted by Index ascending.
Features Byte bits bits 1:0 = HA Type, bit 2 = Preset Sync, bit 3 = Independent, bit 4 = Dynamic, bit 5 = Writable Support
CP Client Opcodes 0x01 Read Presets, 0x04 Write Name, 0x05 Set Active, 0x06 Set Next, 0x07 Set Prev, 0x08–0x0A Sync variants
CP Server Opcodes 0x02 Read Preset Response (carries preset record + isLast), 0x03 Preset Changed (carries ChangeId)
Preset Changed ChangeIds 0x00 Generic Update (+PrevIndex+Record), 0x01 Deleted (+Index), 0x02 Available (+Index), 0x03 Unavailable (+Index)
isLast flag 0x00 on all indications except the final one. 0x01 on the last indication signals the client that the operation is complete.
Binaural Sync Opcodes 0x08–0x0A relay the change to the partner aid. Phone connects to one device only. Relay mechanism is implementation-specific.
5 Custom Error Codes 0x80 Invalid Opcode, 0x81 Write Name Not Allowed, 0x82 Sync Not Supported, 0x83 Operation Not Possible, 0x84 Invalid Length
Read Presets abort rule If connection drops mid-read-presets, server treats operation as aborted. Preset Changed resumes from last unconfirmed indication on reconnect.

Continue Learning BLE on EmbeddedPathashala

This post is part of our Bluetooth BLE Deep-Dive Series. Next topics: PACS, ASCS, and the full LE Audio architecture.

Visit EmbeddedPathashala View Full BLE Series

Leave a Reply

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