Preset Control Procedures, Connection Establishment, Security & BlueZ Client Code
What This Part Covers
Parts 1 and 2 of this series focused on the Hearing Aid (HA) role — the peripheral GATT server that lives inside the hearing aid hardware. In this part we shift to the client side of HAP: the devices that connect to, discover services on, and control hearing aids.
HAP defines three client-side roles:
- HAUC (Hearing Aid Unicast Client) — manages LE Audio unicast (CIS-based) streaming to one or two hearing aids.
- HARC (Hearing Aid Remote Controller) — manages preset selection, naming, and synchronisation across both aids.
- IAC (Immediate Alert Client) — writes Immediate Alert level values to an HA to produce audible alerts (ringtones, proximity warnings).
We also cover how connection establishment and security work end-to-end, tying together CAP, GAP, and CSIP Set Coordinator roles. BlueZ Python examples are provided for all three client roles.
Prerequisites: Complete Part 1 (roles & profile stack) and Part 2 (HA role, HAS GATT server) before reading this part.
Topics Covered in Part 3
1. HAUC — Hearing Aid Unicast Client Role
1.1 What the HAUC Role Does
The HAUC role lives on the phone, tablet, or TV streaming device side of the connection. It is responsible for establishing the LE Audio isochronous path that carries the actual audio to one or two hearing aids. “Unicast” here means the audio flows through a Connection-Oriented Channel (CIS) rather than broadcast (BIS).
To implement the HAUC role a device must implement:
- BAP Unicast Client role — for CIS audio stream setup and teardown.
- CAP Initiator role — the top-level Common Audio Profile role that orchestrates multi-device procedures (binaural sets).
- CSIP Set Coordinator role — to detect that two HA devices belong to the same binaural set and coordinate operations on both simultaneously.
1.2 LE Transport Parameters for HAUC (Table 4.1 of HAP spec)
Table 4.1 in the HAP spec defines the framed ISOAL parameters that the HAUC must use when configuring CIS streams. These are slightly different from the HA-side parameters in Table 3.1 — the HAUC sets up the CIG (CIS Group) so it owns the parameter choice.
| Parameter | Value | Notes |
|---|---|---|
| Framing | Framed (0x01) | Mandatory for HA unicast paths |
| PHY | LE 2M (0x02) | Mandatory; 1M as fallback is not allowed |
| SDU Interval (Tx/Rx) | 10,000 µs (10 ms) | Matches LC3 10ms codec frame duration |
| Max SDU (Tx/Rx) | As per codec config | e.g. 40 bytes for LC3 @ 32kbps 10ms |
| Flush Timeout (FT) | 1 | Retransmission window = 1 interval |
| Burst Number (BN) | 1 | One PDU per flush timeout interval |
| Pre-Transmission Offset (PTO) | 0 | No pre-transmission offset |
| Max Transport Latency | ≤ 20 ms | End-to-end LE Audio latency budget |
| RTN (Retransmission number) | ≥ 2 | Reliability for audio over CIS |
Framed vs Unframed ISOAL — quick recap: Unframed packing skips the 5-byte ISOAL header per SDU (saving bandwidth) but requires exact timing alignment between the SDU boundary and the CIS subevents. Hearing aid audio uses framed packing so the receiver (HA) can handle presentation delay jitter without audio glitches. The 5-byte overhead is a small price to pay.
1.3 Service Discovery on the HA
When the HAUC connects to a hearing aid it must discover the GATT services it will use. The HAP spec permits two discovery methods:
- ATT_DISCOVER_ALL_PRIMARY_SERVICES — enumerate all services, then filter for known UUIDs. Simple but slightly wasteful if the HA exposes many services.
- ATT_FIND_BY_TYPE_VALUE — discover by specific service UUID. Faster for targeted discovery.
The HAUC must discover and cache handles for at minimum:
|
Mandatory Services
HAS — 0x1854
BASS — 0x184F
PACS — 0x1850
ASCS — 0x184E
|
Conditional Services
CSIS — 0x1846 (if binaural)
VCS — 0x1844
MICS — 0x184D
CCS — 0x184B
|
Optional Services
BAS — 0x180F
IAS — 0x1802
AICS / VOCS — included
|
1.4 BAP Unicast Client — Two CISes for Binaural Audio
When the user has a binaural set (left + right hearing aids), the HAUC must set up two simultaneous CIS streams — one to each HA. Both CISes should belong to the same CIG (CIS Group) so they share a synchronized transmission schedule. This is critical for binaural audio: even a few milliseconds of inter-aural delay is perceptible.
|
📱 HAUC
(Phone/Tablet) |
CIG — CIS Group
CIS_0
← Left HA →
2M PHY / framed
CIS_1
← Right HA →
2M PHY / framed
|
🦻 Left HA
Front Left = 1 🦻 Right HA
Front Right = 1 |
| Both CISes belong to the same CIG → synchronized subevent timing → no inter-aural delay | ||
For phone call scenarios, the CIS must be configured as bidirectional (Central-to-Peripheral for speaker, Peripheral-to-Central for microphone). The HAUC sets Max_SDU_C_to_P > 0 AND Max_SDU_P_to_C > 0 in the HCI_LE_Set_CIG_Parameters command.
1.5 ATT MTU Requirement
The HAUC must negotiate an ATT MTU of at least 49 bytes if it intends to use the Read Presets Request procedure (described in Section 2). The minimum 23-byte default MTU is too small for the multi-preset response payload. The HAUC initiates an ATT_EXCHANGE_MTU_REQ during connection setup requesting MTU ≥ 49.
#!/usr/bin/env python3
"""
HAUC (Hearing Aid Unicast Client) — BlueZ D-Bus client
Demonstrates: scan for HA devices, connect, discover HAS service,
read Hearing Aid Features characteristic, enable Active Preset Index notifications
"""
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
import sys
BLUEZ_SERVICE = "org.bluez"
ADAPTER_PATH = "/org/bluez/hci0"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
DEVICE_IFACE = "org.bluez.Device1"
GATT_SVC_IFACE = "org.bluez.GattService1"
GATT_CHR_IFACE = "org.bluez.GattCharacteristic1"
# HAS UUIDs (16-bit assigned numbers, full 128-bit form used by BlueZ)
HAS_SERVICE_UUID = "00001854-0000-1000-8000-00805f9b34fb"
HAS_FEATURES_UUID = "00002bda-0000-1000-8000-00805f9b34fb"
HAS_CONTROL_POINT_UUID = "00002bdb-0000-1000-8000-00805f9b34fb"
HAS_ACTIVE_PRESET_IDX_UUID = "00002bdc-0000-1000-8000-00805f9b34fb"
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
loop = GLib.MainLoop()
def get_managed_objects():
om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OM_IFACE)
return om.GetManagedObjects()
def find_ha_devices():
"""Return list of (path, props) for devices that advertise HAS UUID."""
objects = get_managed_objects()
ha_list = []
for path, ifaces in objects.items():
if DEVICE_IFACE not in ifaces:
continue
props = ifaces[DEVICE_IFACE]
uuids = props.get("UUIDs", [])
if HAS_SERVICE_UUID in uuids:
ha_list.append((path, props))
return ha_list
def find_characteristic(device_path, char_uuid):
"""Find a GATT characteristic object path under a device."""
objects = get_managed_objects()
for path, ifaces in objects.items():
if GATT_CHR_IFACE not in ifaces:
continue
if not path.startswith(device_path):
continue
props = ifaces[GATT_CHR_IFACE]
if props.get("UUID") == char_uuid:
return path
return None
def read_ha_features(device_path):
"""Read Hearing Aid Features characteristic (0x2BDA)."""
char_path = find_characteristic(device_path, HAS_FEATURES_UUID)
if not char_path:
print(" [!] HAS Features characteristic not found")
return
char_obj = bus.get_object(BLUEZ_SERVICE, char_path)
char_iface = dbus.Interface(char_obj, GATT_CHR_IFACE)
raw = bytes(char_iface.ReadValue({})) # returns dbus.Array of dbus.Byte
features_byte = raw[0]
ha_type_map = {0: "Monaural", 1: "Binaural", 2: "Banded"}
ha_type = ha_type_map.get(features_byte & 0x03, "Reserved")
preset_sync = bool(features_byte & 0x04)
independent = bool(features_byte & 0x08)
dynamic = bool(features_byte & 0x10)
writable = bool(features_byte & 0x20)
print(f" HA Type : {ha_type} (bits[1:0] = {features_byte & 0x03})")
print(f" Preset Synchronised : {preset_sync}")
print(f" Independent Presets : {independent}")
print(f" Dynamic Presets : {dynamic}")
print(f" Writable Presets : {writable}")
def subscribe_active_preset_index(device_path):
"""Enable notifications on Active Preset Index characteristic (0x2BDC)."""
char_path = find_characteristic(device_path, HAS_ACTIVE_PRESET_IDX_UUID)
if not char_path:
print(" [!] Active Preset Index characteristic not found")
return
char_obj = bus.get_object(BLUEZ_SERVICE, char_path)
char_iface = dbus.Interface(char_obj, GATT_CHR_IFACE)
char_props = dbus.Interface(char_obj, "org.freedesktop.DBus.Properties")
def on_properties_changed(iface, changed, invalidated):
if "Value" in changed:
val = bytes(changed["Value"])
if val:
print(f" [NOTIFICATION] Active Preset Index changed → {val[0]}")
char_obj.connect_to_signal("PropertiesChanged", on_properties_changed,
dbus_interface="org.freedesktop.DBus.Properties")
char_iface.StartNotify()
print(" [+] Subscribed to Active Preset Index notifications")
def connect_to_ha(device_path):
"""Connect to HA device and perform HAUC service discovery."""
device_obj = bus.get_object(BLUEZ_SERVICE, device_path)
device_iface = dbus.Interface(device_obj, DEVICE_IFACE)
print(f"\n[HAUC] Connecting to {device_path} ...")
device_iface.Connect()
print("[HAUC] Connected. Discovering GATT services ...")
# BlueZ auto-discovers GATT after connection; give it a moment
import time; time.sleep(2)
print("[HAUC] Reading Hearing Aid Features ...")
read_ha_features(device_path)
print("[HAUC] Subscribing to Active Preset Index ...")
subscribe_active_preset_index(device_path)
print("[HAUC] Ready. Waiting for preset notifications ...\n")
loop.run()
# ---------- Main ----------
if __name__ == "__main__":
devices = find_ha_devices()
if not devices:
print("[HAUC] No Hearing Aid devices found. Ensure HA is advertising with HAS UUID 0x1854.")
sys.exit(1)
print(f"[HAUC] Found {len(devices)} Hearing Aid device(s):")
for i, (path, props) in enumerate(devices):
name = props.get("Name", "Unknown")
addr = props.get("Address", "??:??:??:??:??:??")
conn = props.get("Connected", False)
print(f" [{i}] {name} ({addr}) {'[connected]' if conn else '[not connected]'}")
# Connect to first available HA
chosen_path, chosen_props = devices[0]
if not chosen_props.get("Connected", False):
connect_to_ha(chosen_path)
else:
print(f"[HAUC] Already connected to {chosen_path}")
read_ha_features(chosen_path)
subscribe_active_preset_index(chosen_path)
loop.run()
2. HARC — Hearing Aid Remote Controller Role
2.1 What the HARC Role Does
The Hearing Aid Remote Controller (HARC) role is a pure GATT client role focused entirely on preset management. Where the HAUC manages the audio stream, the HARC manages the listening programs stored in the hearing aid — presets like “Quiet Room”, “Restaurant”, “Music”, “Outdoor” and so on.
The HARC talks to the HA using two HAS characteristics:
- HA Preset Control Point (0x2BDB) — HARC writes procedure op-codes here to request preset operations.
- Active Preset Index (0x2BDC) — HA notifies this when the active preset changes; HARC subscribes to it.
The spec defines 9 preset control procedures. Each one maps to an op-code written to the Control Point characteristic, and most trigger an indication response back from the HA. Let’s go through all 9.
2.2 The 9 HAP Preset Control Procedures
Synchronized Locally procedures (⑧, ⑨): When a HA supports Preset Synchronisation (bit 2 of HA Features = 1), it can propagate the active preset change to the other aid over an HA-to-HA link without the HARC needing to write to both. The HARC sends one write to either aid and both switch.
2.3 Control Point Op-Codes and Packet Format
| Op-Code Value | Procedure Name | Parameters Written | HA Response |
|---|---|---|---|
| 0x01 | Read Presets Request | Start_Index (1B), Max_Count (1B) | Read Preset Response indication(s) |
| 0x02 | Read Preset by Index | Index (1B) | Read Preset Response indication |
| 0x03 | Preset Changed (server-initiated) | N/A — HA sends this | Indication to HARC |
| 0x04 | Write Preset Name | Index (1B), Name (1–40B UTF-8) | Preset Changed indication (index, new name) |
| 0x05 | Set Active Preset | Index (1B) | Active Preset Index notification |
| 0x06 | Set Next Preset | None | Active Preset Index notification |
| 0x07 | Set Previous Preset | None | Active Preset Index notification |
| 0x08 | Set Active Preset — Sync Locally | Index (1B) | Active Preset Index notification (both aids) |
| 0x09 | Set Next Preset — Sync Locally | None | Active Preset Index notification (both aids) |
| 0x0A | Set Previous Preset — Sync Locally | None | Active Preset Index notification (both aids) |
2.4 Read Presets Request — Procedure Flow
| HARC (Client) | HA (Server) | |
|
Write CP: op=0x01, start=1, max=0xFF
|
ATT Write / Write Without Response
|
|
|
Indication: op=0x03 (Read Preset Response), Index=1, IsLast=0, Name=”Quiet Room”
|
Read Preset Response #1
|
|
|
ATT Handle Value Confirmation
|
|
|
|
Indication: Index=2, IsLast=0, Name=”Restaurant”
|
Read Preset Response #2
|
|
|
Indication: Index=3, IsLast=1, Name=”Music”
|
Last Preset Response (IsLast=1)
|
Why ATT MTU ≥ 49? Each Read Preset Response indication carries: 1B op-code + 1B change counter + 1B is-last + 1B index + 1B properties + up to 40B UTF-8 name = 45 bytes of payload. Adding ATT header overhead, 49 bytes is the minimum MTU that fits a maximum-length preset name in a single indication without fragmentation.
2.5 Preset Changed — HA-Initiated Notification
The HA autonomously sends a Preset Changed indication (op-code 0x03) to the HARC when:
- A preset is added (Dynamic Presets feature)
- A preset is deleted
- A preset is renamed (after a Write Preset Name from HARC)
- The availability of a preset changes (e.g. device went to sleep)
The HARC handles this by re-reading the affected presets and refreshing its local preset list. The HARC must keep a local copy of all preset records so it can update only the changed entries rather than re-reading everything.
2.6 Procedure Failure Handling
If the HA cannot execute a requested procedure it responds with an ATT Error Response using these application-level error codes defined in HAS:
| Error Code | Meaning |
|---|---|
| 0x80 | Invalid Opcode — op-code value in the write is not recognised |
| 0x81 | Write Name Not Allowed — preset is read-only (Properties bit 0 = 0) |
| 0x82 | Synchronization to Preset Not Supported — HA does not have Preset Sync feature |
| 0x83 | Preset Operation Not Possible — HA cannot perform the procedure right now (e.g. busy) |
| 0x84 | Preset Index Out of Range — requested index does not exist |
2.7 BlueZ HARC — Python Client for All 9 Preset Procedures
#!/usr/bin/env python3
"""
HARC (Hearing Aid Remote Controller) — BlueZ D-Bus client
Implements all 9 HAP preset control procedures via HAS Control Point writes.
Prerequisites:
- HA device already paired and bonded (LE Security Mode 1 Level 2)
- HAS service discovered and characteristic handles cached
"""
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
import sys, time
BLUEZ_SERVICE = "org.bluez"
GATT_CHR_IFACE = "org.bluez.GattCharacteristic1"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
HAS_CONTROL_POINT_UUID = "00002bdb-0000-1000-8000-00805f9b34fb"
HAS_ACTIVE_PRESET_IDX_UUID = "00002bdc-0000-1000-8000-00805f9b34fb"
# HAP Control Point op-codes
OP_READ_PRESETS_REQ = 0x01
OP_READ_PRESET_BY_INDEX = 0x02
# 0x03 = Preset Changed (server-initiated; client only receives this)
OP_WRITE_PRESET_NAME = 0x04
OP_SET_ACTIVE_PRESET = 0x05
OP_SET_NEXT_PRESET = 0x06
OP_SET_PREVIOUS_PRESET = 0x07
OP_SET_ACTIVE_SYNC = 0x08 # Synchronized Locally
OP_SET_NEXT_SYNC = 0x09 # Synchronized Locally
OP_SET_PREVIOUS_SYNC = 0x0A # Synchronized Locally
# HAS ATT Error codes
HAS_ERR = {
0x80: "Invalid Opcode",
0x81: "Write Name Not Allowed (read-only preset)",
0x82: "Sync to Preset Not Supported",
0x83: "Preset Operation Not Possible",
0x84: "Preset Index Out of Range",
}
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
loop = GLib.MainLoop()
# ---- Utility: find characteristic path under a device ----
def find_char(device_path, uuid):
om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OM_IFACE)
for path, ifaces in om.GetManagedObjects().items():
if GATT_CHR_IFACE not in ifaces:
continue
if not path.startswith(device_path):
continue
if ifaces[GATT_CHR_IFACE].get("UUID") == uuid:
return path
return None
def get_char_iface(device_path, uuid):
path = find_char(device_path, uuid)
if not path:
raise RuntimeError(f"Characteristic {uuid} not found under {device_path}")
obj = bus.get_object(BLUEZ_SERVICE, path)
return dbus.Interface(obj, GATT_CHR_IFACE)
# ---- HARC class ----
class HARCClient:
def __init__(self, device_path):
self.device_path = device_path
self.cp_iface = get_char_iface(device_path, HAS_CONTROL_POINT_UUID)
self.api_iface = get_char_iface(device_path, HAS_ACTIVE_PRESET_IDX_UUID)
self.preset_cache = {} # index -> {"name": str, "is_available": bool, "is_writable": bool}
self._subscribe_active_preset_index()
print(f"[HARC] Initialised for device: {device_path}")
def _write_cp(self, payload: list):
"""Write a list of bytes to the Control Point characteristic."""
try:
self.cp_iface.WriteValue(
dbus.Array([dbus.Byte(b) for b in payload], signature='y'),
dbus.Dictionary({}, signature='sv')
)
except dbus.DBusException as e:
code = int(str(e).split("0x")[-1][:2], 16) if "0x" in str(e) else 0
print(f" [!] Control Point write failed: {HAS_ERR.get(code, str(e))}")
def _subscribe_active_preset_index(self):
api_path = find_char(self.device_path, HAS_ACTIVE_PRESET_IDX_UUID)
api_obj = bus.get_object(BLUEZ_SERVICE, api_path)
def on_change(iface, changed, invalidated):
if "Value" in changed:
idx = int(bytes(changed["Value"])[0])
name = self.preset_cache.get(idx, {}).get("name", "?")
print(f" [NOTIFY] Active Preset Index → {idx} ({name})")
api_obj.connect_to_signal("PropertiesChanged", on_change,
dbus_interface="org.freedesktop.DBus.Properties")
self.api_iface.StartNotify()
print("[HARC] Subscribed to Active Preset Index notifications")
# ---- Procedure 1: Read Presets Request ----
def read_presets(self, start_index=1, max_count=0xFF):
"""Op-code 0x01: Read all presets starting at start_index."""
print(f"\n[HARC] Procedure 1 — Read Presets Request (start={start_index}, max={max_count})")
self._write_cp([OP_READ_PRESETS_REQ, start_index, max_count])
# Responses arrive as indications on the Control Point → handled via PropertiesChanged
# ---- Procedure 2: Read Preset by Index ----
def read_preset_by_index(self, index: int):
"""Op-code 0x02: Read one specific preset."""
print(f"\n[HARC] Procedure 2 — Read Preset by Index (index={index})")
self._write_cp([OP_READ_PRESET_BY_INDEX, index])
# ---- Procedure 4: Write Preset Name ----
def write_preset_name(self, index: int, name: str):
"""Op-code 0x04: Rename a writable preset. Name max 40 bytes UTF-8."""
name_bytes = name.encode("utf-8")[:40]
if len(name_bytes) == 0:
print("[HARC] Name cannot be empty")
return
print(f"\n[HARC] Procedure 4 — Write Preset Name (index={index}, name='{name}')")
payload = [OP_WRITE_PRESET_NAME, index] + list(name_bytes)
self._write_cp(payload)
# ---- Procedure 5: Set Active Preset ----
def set_active_preset(self, index: int):
"""Op-code 0x05: Activate a specific preset by index."""
print(f"\n[HARC] Procedure 5 — Set Active Preset (index={index})")
self._write_cp([OP_SET_ACTIVE_PRESET, index])
# ---- Procedure 6: Set Next Preset ----
def set_next_preset(self):
"""Op-code 0x06: Move to the next available preset."""
print(f"\n[HARC] Procedure 6 — Set Next Preset")
self._write_cp([OP_SET_NEXT_PRESET])
# ---- Procedure 7: Set Previous Preset ----
def set_previous_preset(self):
"""Op-code 0x07: Move to the previous available preset."""
print(f"\n[HARC] Procedure 7 — Set Previous Preset")
self._write_cp([OP_SET_PREVIOUS_PRESET])
# ---- Procedure 8: Set Active Preset — Synchronized Locally ----
def set_active_preset_sync(self, index: int):
"""Op-code 0x08: Activate preset on BOTH aids in binaural set via HA-to-HA sync."""
print(f"\n[HARC] Procedure 8 — Set Active Preset (Sync Locally, index={index})")
self._write_cp([OP_SET_ACTIVE_SYNC, index])
# ---- Procedure 9a: Set Next Preset — Synchronized Locally ----
def set_next_preset_sync(self):
"""Op-code 0x09: Advance to next preset on BOTH aids simultaneously."""
print(f"\n[HARC] Procedure 9a — Set Next Preset (Sync Locally)")
self._write_cp([OP_SET_NEXT_SYNC])
# ---- Procedure 9b: Set Previous Preset — Synchronized Locally ----
def set_previous_preset_sync(self):
"""Op-code 0x0A: Step back to previous preset on BOTH aids simultaneously."""
print(f"\n[HARC] Procedure 9b — Set Previous Preset (Sync Locally)")
self._write_cp([OP_SET_PREVIOUS_SYNC])
# ---------- Demo run ----------
if __name__ == "__main__":
# Replace with your HA device path (find via bluetoothctl: devices)
HA_DEVICE_PATH = "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"
harc = HARCClient(HA_DEVICE_PATH)
# Read all presets first
harc.read_presets()
time.sleep(1)
# Set active preset to index 2
harc.set_active_preset(2)
time.sleep(0.5)
# Advance to next preset
harc.set_next_preset()
time.sleep(0.5)
# For a binaural set with Preset Sync supported:
harc.set_active_preset_sync(1) # Both aids switch to preset 1
# Keep running to receive Active Preset Index notifications
print("\n[HARC] Running. Press Ctrl-C to exit.")
loop.run()
3. IAC — Immediate Alert Client Role
3.1 What the IAC Role Does
The Immediate Alert Client (IAC) is the simplest of the three client roles. Its sole responsibility is writing an Alert Level value to the HA’s Immediate Alert Service (IAS) characteristic to trigger an audible beep or tone from the hearing aid speaker. This is used for:
- Incoming call notification — phone rings → IAC writes High Alert → HA beeps to inform the user.
- Find my hearing aid — IAC writes High Alert → HA beeps so user can locate a misplaced aid.
- Audio stream unavailable alert — source device going out of range triggers Mild Alert tone.
- Alert cancellation — IAC writes No Alert (0x00) to stop the beeping.
3.2 IAS Alert Level Values
| Alert Level Value | Name | Typical Use in HAP |
|---|---|---|
| 0x00 | No Alert | Stop any ongoing alert; used when call is answered or dismissed |
| 0x01 | Mild Alert | Low-priority notification (e.g. message received, connection lost) |
| 0x02 | High Alert | Urgent notification — incoming call, find my HA, proximity alarm |
IAC Bluetooth version requirement: IAC only requires Bluetooth 4.2 (not 5.2). This means even older phones without LE Audio capability can act as IAC and trigger alerts on a modern HA. This is intentional — the spec wants maximum compatibility for the simple alert-only use case.
3.3 IAS Characteristic Write — Write Without Response
The Alert Level characteristic (UUID 0x2A06) under IAS (UUID 0x1802) uses Write Without Response (ATT_WRITE_CMD, not ATT_WRITE_REQ). There is no acknowledgment from the HA. The IAC fires and forgets. This keeps latency low — important for ringtone responsiveness.
| 📱 Phone (IAC) | 🦻 HA (IAS Server) | |
|
Incoming call detected
|
||
|
Write Alert Level = 0x02 (High)
|
ATT Write Without Response (no ACK)
|
|
|
🔔 HA plays ringtone
|
||
|
Call answered by user
|
||
|
Write Alert Level = 0x00 (None)
|
|
|
|
🔇 HA stops ringtone
|
3.4 BlueZ IAC — Python Client
#!/usr/bin/env python3
"""
IAC (Immediate Alert Client) — BlueZ D-Bus client
Writes Alert Level values (0x00/0x01/0x02) to the HA's IAS Alert Level characteristic.
Uses Write Without Response (ATT_WRITE_CMD) — no ACK expected.
"""
import dbus
import dbus.mainloop.glib
import sys, time
BLUEZ_SERVICE = "org.bluez"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
GATT_CHR_IFACE = "org.bluez.GattCharacteristic1"
IAS_SERVICE_UUID = "00001802-0000-1000-8000-00805f9b34fb"
ALERT_LEVEL_UUID = "00002a06-0000-1000-8000-00805f9b34fb"
ALERT_NO = 0x00
ALERT_MILD = 0x01
ALERT_HIGH = 0x02
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
def find_alert_level_char(device_path):
om = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OM_IFACE)
for path, ifaces in om.GetManagedObjects().items():
if GATT_CHR_IFACE not in ifaces:
continue
if not path.startswith(device_path):
continue
if ifaces[GATT_CHR_IFACE].get("UUID") == ALERT_LEVEL_UUID:
return path
return None
def write_alert_level(device_path, level: int):
"""Write Alert Level value using Write Without Response."""
if level not in (ALERT_NO, ALERT_MILD, ALERT_HIGH):
raise ValueError(f"Invalid alert level: {level}. Must be 0, 1, or 2.")
char_path = find_alert_level_char(device_path)
if not char_path:
print("[IAC] Alert Level characteristic not found. Is IAS present?")
return
char_obj = bus.get_object(BLUEZ_SERVICE, char_path)
char_iface = dbus.Interface(char_obj, GATT_CHR_IFACE)
# type="command" tells BlueZ to use Write Without Response (ATT_WRITE_CMD)
char_iface.WriteValue(
dbus.Array([dbus.Byte(level)], signature='y'),
dbus.Dictionary({"type": dbus.String("command")}, signature='sv')
)
level_name = {ALERT_NO: "No Alert", ALERT_MILD: "Mild Alert", ALERT_HIGH: "High Alert"}[level]
print(f"[IAC] Alert Level written: {level_name} (0x{level:02X})")
# ---------- Demo: simulate incoming call ----------
if __name__ == "__main__":
HA_DEVICE_PATH = "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"
print("[IAC] Simulating incoming call ...")
write_alert_level(HA_DEVICE_PATH, ALERT_HIGH) # HA rings
print("[IAC] Waiting 5 seconds (ring duration) ...")
time.sleep(5)
write_alert_level(HA_DEVICE_PATH, ALERT_NO) # Call dismissed / answered
print("[IAC] Alert cleared.")
4. Connection Establishment
4.1 How Client Roles (HAUC / HARC / IAC) Connect to an HA
Connection procedures differ slightly depending on which client role is connecting. The key distinction is whether the connecting device must use CAP procedures (HAUC and HARC) or plain GAP procedures (IAC).
|
HAUC / HARC Role
Must use CAP Initiator procedures
1. Scan for HA advertising HAS UUID (0x1854)
2. If binaural: discover CSIS Set Identity Resolving Key (SIRK) to confirm both aids are in the same coordinated set
3. Use CSIP Set Coordinator to connect and manage both aids together
4. Connect to both, discover GATT, exchange MTU (≥ 49), enable notifications
|
IAC Role
GAP Central + GATT Client procedures
1. Scan for HA device (may use HA Appearance AD Type for faster filter)
2. Connect using
LE_Create_Connection3. Discover IAS service (UUID 0x1802)
4. Write Alert Level characteristic when alert is needed
|
4.2 CSIP Set Coordinator — Finding and Connecting a Binaural Pair
When the HAUC/HARC connects to a hearing aid that reports itself as a Set Member (CSIS present, bit 0 of HA Features not “Monaural”), it must use the CSIP Set Coordinator role to find the partner aid. The procedure is:
- Connect to the first HA and read its SIRK from the CSIS Set Identity Resolving Key characteristic.
- Scan for other advertising devices that resolve to the same SIRK (or share the same plaintext SIRK if SIRK type = 0x01).
- Connect to the partner HA.
- Confirm both HAs have the same SIRK value — they are in the same coordinated set.
- Proceed with coordinated preset control and audio stream setup across both.
Encrypted SIRK (type 0x02): The SIRK can be encrypted using the Link Key or LTK to prevent eavesdroppers from identifying the binaural set. The Set Coordinator resolves the encrypted SIRK using the key it obtained during pairing. This is why pairing (and thus security) must happen before CSIP Set discovery is meaningful.
5. HAP Security Requirements
5.1 LE Security Mode 1 Level 2 — The Baseline Requirement
Every HAS characteristic — including Hearing Aid Features, Control Point, and Active Preset Index — must be protected with LE Security Mode 1 Level 2 (authenticated LE pairing with encryption). This means:
- All ATT read/write/notify operations on HAS characteristics require the link to be encrypted.
- If the Central attempts to access HAS before pairing, the HA must respond with ATT error Insufficient Authentication (0x05) or Insufficient Encryption (0x0F).
- The IAS Alert Level characteristic also requires LE Security Mode 1 Level 2 in HAP contexts.
| Mode | Level | Requirements | HAP Use? |
|---|---|---|---|
| Mode 1 | Level 1 | No security (unencrypted, unauthenticated) | ❌ Not used |
| Mode 1 | Level 2 | Unauthenticated pairing with encryption (Just Works) | ✅ Minimum required |
| Mode 1 | Level 3 | Authenticated pairing with encryption (MITM protection) | ✅ Recommended |
| Mode 1 | Level 4 | Authenticated LE Secure Connections with 128-bit key | ✅ Highest security |
5.2 The 128-bit Encryption Key Requirement
HAP requires that the encryption key protecting HAS characteristics must be a 128-bit key. There are exactly three ways to generate this key:
|
Path A
LE Secure Connections
(BT 4.2+ pairing with ECDH) Generates a 128-bit LTK directly from the ECDH key agreement. The strongest path. No cross-transport needed. |
Path B
BR/EDR Secure Connections + CTKD
(Cross-Transport Key Derivation) Classic Bluetooth pairing generates a 128-bit Link Key → CTKD derives the LE LTK from it. Useful when the device also supports BR/EDR. |
Path C
OOB (Out of Band)
(NFC, QR code, etc.) 128-bit key exchanged via a separate secure channel. Useful for headless hearing aids without display or keyboard. |
Why not Just Works alone? Just Works pairing (LE Security Mode 1 Level 2) provides encryption but no MITM protection. An attacker in range could still forge the pairing and inject malicious preset writes. HAP’s 128-bit key requirement pushes implementations toward LE Secure Connections (Level 4) or at minimum properly authenticated Level 3 pairing.
5.3 IAC-Specific Security Requirements
The IAC role has its own additional security requirements because it can connect without the full LE Audio stack:
- IAC must support Bondable mode — pairing that creates a persistent bond so the HA recognises the IAC device on reconnection without re-pairing.
- IAC should initiate the Bonding procedure on first connection.
- If the HA uses Privacy mode (Resolvable Private Addresses), the IAC should distribute its own IRK (Identity Resolving Key) during pairing. This allows the HA to resolve the IAC’s RPA and recognise it as a trusted device on reconnection.
5.4 BlueZ — Pairing and Security Setup
In BlueZ, security is handled at the adapter level and via the org.bluez.Agent1 interface. The following shows how to set up a BlueZ agent that accepts Just Works pairing (minimum) and how to check that a connected device is encrypted before performing GATT operations.
#!/usr/bin/env python3
"""
HAP Security Setup — BlueZ pairing agent + encryption check
Registers a Just Works agent (no display/keyboard) for HA devices that do not
have input/output capabilities. Verifies link encryption before GATT access.
"""
import dbus
import dbus.service
import dbus.mainloop.glib
from gi.repository import GLib
BLUEZ_SERVICE = "org.bluez"
AGENT_IFACE = "org.bluez.Agent1"
AGENT_MGR_IFACE = "org.bluez.AgentManager1"
DEVICE_IFACE = "org.bluez.Device1"
DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager"
AGENT_PATH = "/org/embeddedpathashala/hauc_agent"
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
loop = GLib.MainLoop()
# ---- Pairing Agent ----
class HaNoInputNoOutputAgent(dbus.service.Object):
"""
Just Works agent — no passkey, no confirmation required.
Suitable for hearing aids that have no display or keyboard.
Capability string: "NoInputNoOutput"
"""
@dbus.service.method(AGENT_IFACE, in_signature="", out_signature="")
def Release(self):
print("[Agent] Released")
@dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
def AuthorizeService(self, device, uuid):
print(f"[Agent] AuthorizeService {device}: UUID={uuid} → Accepting")
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="")
def RequestAuthorization(self, device):
print(f"[Agent] RequestAuthorization from {device} → Accepting")
@dbus.service.method(AGENT_IFACE, in_signature="ou", out_signature="")
def RequestConfirmation(self, device, passkey):
# Just Works: accept any passkey without user confirmation
print(f"[Agent] RequestConfirmation {device} passkey={passkey:06d} → Auto-accepting")
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="u")
def RequestPasskey(self, device):
print(f"[Agent] RequestPasskey from {device} → 000000")
return dbus.UInt32(0)
@dbus.service.method(AGENT_IFACE, in_signature="ouq", out_signature="")
def DisplayPasskey(self, device, passkey, entered):
print(f"[Agent] DisplayPasskey {device}: {passkey:06d} (entered={entered})")
@dbus.service.method(AGENT_IFACE, in_signature="os", out_signature="")
def DisplayPinCode(self, device, pincode):
print(f"[Agent] DisplayPinCode {device}: {pincode}")
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="s")
def RequestPinCode(self, device):
return "0000"
@dbus.service.method(AGENT_IFACE, in_signature="o", out_signature="")
def Cancel(self):
print("[Agent] Pairing cancelled")
def register_agent():
"""Register the Just Works agent as the default agent."""
agent = HaNoInputNoOutputAgent(bus, AGENT_PATH)
mgr_obj = bus.get_object(BLUEZ_SERVICE, "/org/bluez")
mgr_iface = dbus.Interface(mgr_obj, AGENT_MGR_IFACE)
mgr_iface.RegisterAgent(AGENT_PATH, "NoInputNoOutput")
mgr_iface.RequestDefaultAgent(AGENT_PATH)
print("[Agent] Just Works agent registered as default")
return agent
def is_link_encrypted(device_path) -> bool:
"""Check whether the current connection to an HA is encrypted."""
dev_obj = bus.get_object(BLUEZ_SERVICE, device_path)
dev_props = dbus.Interface(dev_obj, "org.freedesktop.DBus.Properties")
try:
# BluezZ exposes "Trusted", "Paired", "Connected" but not explicit encryption state.
# A bonded + connected device with ServicesResolved=True has negotiated encryption.
paired = dev_props.Get(DEVICE_IFACE, "Paired")
bonded = dev_props.Get(DEVICE_IFACE, "Bonded")
connected = dev_props.Get(DEVICE_IFACE, "Connected")
resolved = dev_props.Get(DEVICE_IFACE, "ServicesResolved")
encrypted = paired and bonded and connected and resolved
print(f"[Security] Paired={paired}, Bonded={bonded}, "
f"Connected={connected}, ServicesResolved={resolved}")
print(f"[Security] Link considered {'ENCRYPTED ✅' if encrypted else 'NOT ENCRYPTED ❌'}")
return bool(encrypted)
except dbus.DBusException as e:
print(f"[Security] Could not check encryption status: {e}")
return False
def pair_and_bond(device_path):
"""Initiate pairing with HA device (creates bond for reconnection)."""
dev_obj = bus.get_object(BLUEZ_SERVICE, device_path)
dev_iface = dbus.Interface(dev_obj, DEVICE_IFACE)
dev_props = dbus.Interface(dev_obj, "org.freedesktop.DBus.Properties")
paired = dev_props.Get(DEVICE_IFACE, "Paired")
if paired:
print(f"[Security] Device already paired: {device_path}")
return
print(f"[Security] Initiating pairing with {device_path} ...")
try:
dev_iface.Pair()
print("[Security] Pairing successful — bond created")
# Set Trusted so BlueZ auto-accepts reconnection without re-pairing
dev_props.Set(DEVICE_IFACE, "Trusted", dbus.Boolean(True))
except dbus.DBusException as e:
print(f"[Security] Pairing failed: {e}")
# ---------- Main ----------
if __name__ == "__main__":
HA_DEVICE_PATH = "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"
agent = register_agent()
# Connect to the HA (it must already be in the BlueZ device list from scanning)
dev_obj = bus.get_object(BLUEZ_SERVICE, HA_DEVICE_PATH)
dev_iface = dbus.Interface(dev_obj, DEVICE_IFACE)
print(f"[Security] Connecting to {HA_DEVICE_PATH} ...")
dev_iface.Connect()
# Pair if not already done
pair_and_bond(HA_DEVICE_PATH)
# Check encryption before any GATT operation
if is_link_encrypted(HA_DEVICE_PATH):
print("[Security] Safe to access HAS characteristics")
# → proceed with HARC preset reads / HAUC CIS setup
else:
print("[Security] Aborting GATT access — link not encrypted")
loop.run()
6. Putting It All Together — Full HAP Stack on BlueZ
6.1 Role Combination in Real Devices
A real smartphone implementing HAP will typically combine multiple client roles at once:
| Device Type | Roles Implemented | Key Responsibilities |
|---|---|---|
| Smartphone / Tablet | HAUC + HARC + IAC | Full audio streaming, preset control, call alerts |
| Smart TV / Streaming Stick | HAUC only | Audio streaming (no preset UI, no phone calls) |
| Hearing Aid Remote | HARC only | Preset buttons on a physical remote control device |
| Smart Doorbell / Proximity Sensor | IAC only | Alert the hearing aid when doorbell rings or user is near |
| Hearing Aid (HA) | HA role (server side) | HAS GATT server, BAP Sink/Source, CSIP, VCS, MICP |
6.2 BlueZ systemd Setup for Persistent HAP Daemon
If you are building a Linux-based HAUC/HARC device (e.g. a Raspberry Pi hearing loop repeater), you will want to run the BlueZ HAP client as a systemd service. Here is the minimal setup:
# 1. Enable LE Audio in BlueZ (requires BlueZ 5.66+) sudo nano /etc/bluetooth/main.conf # In [General] section, ensure these are set: # ControllerMode = le # ExperimentalFeatures = true # In [Policy] section: # AutoEnable = true # 2. Restart BlueZ sudo systemctl restart bluetooth # 3. Verify LE Audio support btmgmt info | grep -i "le audio\|iso\|2m phy" # Expected: "LE transport" and "2M PHY" in supported settings # 4. Create systemd service file cat > /etc/systemd/system/hap-client.service << 'EOF' [Unit] Description=HAP Client Daemon (HAUC + HARC) After=bluetooth.target Requires=bluetooth.target [Service] Type=simple User=root ExecStart=/usr/bin/python3 /opt/hap_client/harc_client.py Restart=on-failure RestartSec=5s Environment=DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket [Install] WantedBy=multi-user.target EOF # 5. Enable and start sudo systemctl daemon-reload sudo systemctl enable hap-client.service sudo systemctl start hap-client.service sudo journalctl -fu hap-client.service # Follow logs
7. HAP Quick Reference Cheat Sheet
| Item | UUID / Value | Notes |
|---|---|---|
| HAS Service Characteristics | ||
| Hearing Access Service | 0x1854 |
Primary GATT service |
| HA Features | 0x2BDA |
Read; bits[1:0]=type, bit2=PresetSync |
| HA Preset Control Point | 0x2BDB |
Write / Indicate; 9 op-codes |
| Active Preset Index | 0x2BDC |
Read + Notify; 1 byte = active index |
| IAS (Alert) Characteristic | ||
| Immediate Alert Service | 0x1802 |
Service UUID |
| Alert Level | 0x2A06 |
Write Without Response only |
| HARC Control Point Op-Codes | ||
| Read Presets Request | 0x01 |
Params: start_idx, max_count |
| Read Preset by Index | 0x02 |
Params: index |
| Write Preset Name | 0x04 |
Params: index, UTF-8 name (≤40B) |
| Set Active Preset | 0x05 |
Params: index |
| Set Next Preset | 0x06 |
No params |
| Set Previous Preset | 0x07 |
No params |
| Set Active Preset (Sync) | 0x08 |
Params: index; requires PresetSync=1 |
| Set Next Preset (Sync) | 0x09 |
No params; requires PresetSync=1 |
| Set Prev Preset (Sync) | 0x0A |
No params; requires PresetSync=1 |
| Security Parameters | ||
| Required security mode | Mode 1 Level 2 | All HAS + IAS characteristics |
| Key length | 128 bits | From LE SC, CTKD, or OOB |
| IAC bonding | Must support Bondable mode | Should distribute IRK if using Privacy |
| LE Transport (HAUC CIS) | ||
| PHY | LE 2M mandatory | No 1M fallback permitted |
| Framing | Framed ISOAL (0x01) | 5-byte ISOAL header per SDU |
| SDU interval | 10,000 µs (10 ms) | LC3 codec frame duration |
| Max transport latency | ≤ 20 ms | Includes Presentation Delay |
| ATT MTU | ≥ 49 bytes | Required for Read Presets procedure |
Summary
In this part we completed the HAP client-side picture. The HAUC role handles LE Audio unicast streaming — setting up CIS streams on LE 2M PHY with framed ISOAL, managing two CISes for binaural audio, and going bidirectional for phone calls. The HARC role manages the nine preset control procedures via HAS Control Point writes, tracks the active preset via notifications, and handles binaural preset synchronization using the Sync Locally op-codes. The IAC role is the simplest — a single Write Without Response to the IAS Alert Level characteristic — but it requires Bondable mode and IRK distribution for reliable reconnection.
Security underpins all of this: every HAS and IAS access must happen over an encrypted link established with a 128-bit key from LE Secure Connections, CTKD, or OOB. The BlueZ pairing agent handles this transparently once registered as the default agent.
With Parts 1, 2, and 3 complete you now have a full picture of every HAP role and its implementation on BlueZ. Part 4 will bring it all together with an end-to-end demo on a Raspberry Pi — a software HA (Part 2 code) and a software HAUC/HARC client (Part 3 code) communicating over real BLE on the same machine using two Bluetooth adapters.
