What You Will Learn
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
The Bluetooth SIG spec uses precise language. Before touching any packet format you need to know these terms cold.
| 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.
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:
| 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.
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.
| # | 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.
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.
| 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) |
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
| 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:
0x01to0xFF 0x00is 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
| 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 error0x81.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)
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
| 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
| 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.
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
| 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.
| 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) |
| 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):
| 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.
| 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:
- Reply with
ATT_WRITE_RSP - Update the preset’s Name field internally
- 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)
| 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.
| 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 hasisLast = 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.
| 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.
This is the simplest characteristic in HAS. It is a single read/notify byte that always holds the Index of the currently active preset.
| 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.
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:
- Scan → connect to your device
- Navigate to Service UUID
1854 - Read characteristic
2BDA→ should return31 - Read characteristic
2BDC→ should return01 - On characteristic
2BDB, tap the CCCD descriptor → enable Indicate - Write hex
01 01 FFto2BDB→ three Read Preset Response indications arrive - Write
05 05→ switches to preset 5 (“Outdoor”) - Write
06→ moves to next available preset
Let us trace a realistic scenario. The hearing aid currently has this 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.
| 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.
