ble audio tutorial – HAP Part 3: HAUC, HARC & IAC Roles

 

 

ble audio tutorial – HAP Part 3: HAUC, HARC & IAC Roles

Preset Control Procedures, Connection Establishment, Security & BlueZ Client Code

9
Preset Procedures
49
Min ATT MTU (bytes)
128b
Encryption Key Length
0x2BDB
HAS Control Point UUID

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

HAUC LE transport params (framed ISOAL) GATT Discover All Primary Services BAP Unicast Client (2 CISes) Bidirectional CIS (phone call) ATT_MTU ≥ 49 requirement 9 HAP preset procedures Read Presets Request / Response Preset Changed notification Write Preset Name Set/Next/Previous Active Preset Preset Sync (binaural) IAC Alert Level write CSIP Set Coordinator discovery CAP Initiator connection procedures LE Security Mode 1 Level 2 128-bit key from LE Secure Connections CTKD cross-transport key derivation IRK and Privacy mode BlueZ GATT client Python

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:

HAUC Must Discover These GATT Services on the HA
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.

Python — BlueZ HAUC: Connect, discover HAS, exchange MTU BlueZ / D-Bus
#!/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

① Read Presets Request
Fetch all or a range of preset records from the HA.
② Read Preset by Index
Fetch one specific preset by its index number.
③ Preset Changed
HA-initiated notification; HARC subscribes passively.
④ Write Preset Name
Rename a user-writable preset (max 40 bytes, UTF-8).
⑤ Set Active Preset
Activate a specific preset by index.
⑥ Set Next Preset
Advance to the next available preset.
⑦ Set Previous Preset
Step back to the previous available preset.
⑧ Set Active Preset — Synchronized Locally
Activate a preset on both aids in a binaural set simultaneously.
⑨ Set Next/Prev Preset — Synchronized Locally
Step through presets on both aids simultaneously.

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

Read Presets Request / Response — Message Sequence

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)
The HA sends one indication per preset. IsLast=1 signals end of list. HARC must confirm each indication before the next is sent.

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

Python — BlueZ HARC: All 9 preset control procedures BlueZ / D-Bus
#!/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.

IAC Alert Level Write — Incoming Call Scenario
📱 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

Python — BlueZ IAC: Write IAS Alert Level to hearing aid BlueZ / D-Bus
#!/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).

Which Connection Procedure to Use?
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_Connection
3. 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:

  1. Connect to the first HA and read its SIRK from the CSIS Set Identity Resolving Key characteristic.
  2. Scan for other advertising devices that resolve to the same SIRK (or share the same plaintext SIRK if SIRK type = 0x01).
  3. Connect to the partner HA.
  4. Confirm both HAs have the same SIRK value — they are in the same coordinated set.
  5. 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.
LE Security Modes — Quick Reference
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:

128-bit Key Generation Paths (HAP Spec §4.2)
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.

Python — BlueZ security: pair, bond, verify encryption before GATT access BlueZ / D-Bus
#!/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:

Bash — Configure BlueZ for LE Audio + create systemd service Shell
# 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.

Leave a Reply

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