GATT Discovery Procedures
Chapter 13 · Part 3 of 5 · Sections 13.6.1–13.6.5 · Exchange MTU · Service Discovery · Characteristic Discovery · Descriptor Discovery
13.6.1 — Server Configuration: Exchange MTU
Setting the PDU Size Before Any GATT Work Begins
The very first GATT operation in a new connection should be MTU negotiation. Once agreed, this size applies to every ATT PDU exchanged during that connection — service discovery, characteristic reads, notification payloads, everything. The default of 23 bytes is tiny; negotiating a larger value dramatically improves throughput for any characteristic with multi-byte values.
The phone offered 300 bytes — it has plenty of RAM. The thermometer only supports 40 bytes — it is a constrained sensor. Both sides must use the smaller value. The phone gains nothing from its larger buffer in this session, but the protocol remains correct. Only the client initiates this exchange, and only once per connection.
/* BlueZ: set preferred MTU before connecting */
/* The kernel negotiates MTU during L2CAP connection setup */
/* Check what MTU was negotiated after connect: */
sudo btmon 2>&1 | grep -i "mtu\|att_mtu"
/* [ 12.345] ATT: Exchange MTU Request (0x02) len 2 */
/* Client RX MTU: 517 */
/* [ 12.347] ATT: Exchange MTU Response (0x03) len 2 */
/* Server RX MTU: 40 */
/* In BlueZ source — att_get_mtu() returns negotiated MTU: */
/* src/shared/att.c */
uint16_t bt_att_get_mtu(struct bt_att *att)
{
if (!att)
return 0;
return att->mtu; /* set to min(client_mtu, server_mtu) */
}
13.6.2.1 — Discover All Primary Services
Walking the Entire Handle Space for Services
The client has no idea what services the server has when the connection first starts. This procedure maps them all out. It keeps firing Read By Group Type Requests, each time starting just above the last handle received, until the server has nothing left to return. A single Error Response with “Attribute Not Found” is the signal that discovery is complete.
0x2800Figure 13.13 — Air Capture Analysis
The sniffer capture shows exactly this pattern. The client starts at handle 1, receives three services in one response (the packet fits three entries at 6 bytes each within the 23-byte default MTU: 1 + 3×6 = 19 bytes overhead leaves room for three). Then two more in the second pass. The third request returns nothing:
/* BlueZ gatttool discovers all primary services: */
gatttool -b AA:BB:CC:DD:EE:FF --primary
/* Watch the actual ATT frames with btmon: */
sudo btmon
/* Expected output: */
/* ATT: Read By Group Type Request (0x10) */
/* Handle range: 0x0001-0xffff */
/* Attribute group type: Primary Service (0x2800) */
/* ATT: Read By Group Type Response (0x11) */
/* Attribute data length: 6 */
/* Attribute group list: 3 entries */
/* handle: 0x0001, end: 0x0007, uuid: 0x1800 [Generic Access] */
/* handle: 0x0010, end: 0x0013, uuid: 0x1801 [Generic Attribute] */
/* handle: 0x0050, end: 0x0052, uuid: 0x1803 [Link Loss] */
/* BlueZ C: programmatic service discovery via D-Bus */
GDBusProxy *proxy = g_dbus_proxy_new_sync(...);
GVariant *result = g_dbus_proxy_call_sync(
proxy, "GetManagedObjects", NULL, ...);
/* Parse result for org.bluez.GattService1 interface entries */
13.6.2.2 — Discover Primary Service By Service UUID
Find One Specific Service Without Scanning Everything
When the client already knows what service it needs — say, it knows it wants to talk to the Heart Rate Service (UUID 0x180D) — there is no reason to walk the entire attribute database. Find By Type Value Request targets only attributes of type Primary Service whose value matches the requested UUID. This is faster and uses fewer packets.
0x2800The response contains Handle Information List entries — each entry is a pair of (Starting Handle, Ending Handle) for one matching service. After all instances are found the server replies with Error Response.
/* Discover only Heart Rate Service (UUID 0x180D): */
gatttool -b AA:BB:CC:DD:EE:FF --primary --uuid=0x180D
/* Output: */
/* attr handle: 0x000a, end grp handle: 0x0013 uuid: 0x180d */
/* With bluetoothctl — filter by UUID: */
[AA:BB:CC:DD:EE:FF]# list-attributes
/* Look for entries labelled "Heart Rate" (0x180d) */
13.6.3 — Relationship Discovery: Find Included Services
Discovering Which Other Services a Service References
A service can embed references to other services inside itself using Include Definitions. This procedure reveals those cross-references. Once the client has a service’s start and end handles (from primary service discovery), it scans the attributes in that range looking for Include Declaration attributes (UUID 0x2802). Each one found points to another service the current service depends on.
0x2802Figure 13.16 — Air Log: No Included Services Found
The air log shows the client checking all five previously discovered services one by one. Every single Read By Type Request for included services comes back with an Error Response. This is the normal case for simple devices — included services are relatively rare and used mainly in complex device profiles.
/* Check included services for a specific service range: */
gatttool -b AA:BB:CC:DD:EE:FF \
--char-read --uuid=0x2802 \
--start=0x0001 --end=0x0007
/* If nothing returned — no included services in that range */
/* Expected: Error: Attribute Not Found */
/* BlueZ C code — scan for Include declarations: */
/* src/shared/gatt-client.c: discover_included() */
/* Uses bt_gatt_discover_included() internally which sends */
/* ATT_OP_READ_BY_TYPE_REQ with type = BT_ATT_UUID_INCLUDE */
#define BT_ATT_UUID_INCLUDE 0x2802
13.6.4.1 — Discover All Characteristics of a Service
Finding Every Characteristic in a Given Service
Now that the client knows which services exist and their handle ranges, it needs to find the characteristics inside each service. Characteristic Declarations (UUID 0x2803) are the markers — each one announces a new characteristic with its Properties byte, the handle where the actual value lives, and the characteristic’s UUID. The client reads all these declarations iteratively within the service’s handle range.
0x2803Figure 13.18 Air Log — Decoding the First Response
The left panel of the sniffer capture shows the first response decoded. The Device Name characteristic sits at Attribute Handle 2. Its actual value (the device name string) is stored at Value Handle 3. The Properties byte says Read=Yes, all other operations (Write, Notify, Indicate) are not permitted on this characteristic.
/* Discover all characteristics in GAP service (handles 1-7): */
gatttool -b AA:BB:CC:DD:EE:FF \
--characteristics --start=0x0001 --end=0x0007
/* Output: */
/* handle: 0x0002, char properties: 0x02, char value handle: */
/* 0x0003, uuid: 00002a00-0000-1000-8000-00805f9b34fb */
/* handle: 0x0004, char properties: 0x02, char value handle: */
/* 0x0005, uuid: 00002a01-0000-1000-8000-00805f9b34fb */
/* Properties 0x02 = bit 1 = Read permitted */
/* Properties 0x0a = bits 1+3 = Read + Write */
/* Properties 0x1a = bits 1+3+4 = Read + Write + Notify */
/* BlueZ source: discover_chrcs() in src/shared/gatt-client.c */
/* Uses bt_gatt_discover_characteristics() which sends */
/* ATT_OP_READ_BY_TYPE_REQ with BT_ATT_UUID_CHARACTERISTIC */
#define BT_ATT_UUID_CHARACTERISTIC 0x2803
13.6.4.2 — Discover Characteristics By UUID
Targeted Characteristic Search When UUID Is Known
Same Read By Type sequence as Discover All Characteristics, but this time the client compares each returned characteristic’s UUID against the target UUID. The protocol does not filter at the ATT level — the server still returns all characteristics in the range. The client performs the UUID matching itself on the received data. The search always continues to the end of the service range even after a match is found, because the same UUID could theoretically appear multiple times in one service.
/* Find Heart Rate Measurement characteristic (UUID 0x2A37): */
gatttool -b AA:BB:CC:DD:EE:FF \
--characteristics --uuid=0x2A37
/* Returns only the matching characteristic: */
/* handle: 0x000b, char properties: 0x10, char value handle: */
/* 0x000c, uuid: 00002a37-0000-1000-8000-00805f9b34fb */
/* Properties 0x10 = bit 4 = Notify only */
13.6.5 — Characteristic Descriptor Discovery
Finding CCCD and Other Descriptors Attached to a Characteristic
Descriptors live in the attribute space after the characteristic’s value declaration and before the next characteristic declaration. To discover them, the client uses Find Information Request starting from (value handle + 1) through the last handle of the service. The response gives handle-UUID pairs for every attribute in that slot — each is a descriptor.
The most important descriptor is the Client Characteristic Configuration Descriptor (CCCD) with UUID 0x2902. Without writing to the CCCD, the client will never receive notifications or indications from the server, no matter how the characteristic’s Properties byte is set.
Write 0x0001 to enable notifications
Write 0x0002 to enable indications
Write 0x0000 to disable both
Must be discovered before subscribing
UTF-8 string — human-readable label
Example: “Room Temperature”
Read-only on most devices
Format (uint8/int16/float…) + unit
Exponent for SI scaling
Allows generic value parsers
/* Discover all descriptors for a characteristic: */
gatttool -b AA:BB:CC:DD:EE:FF --char-desc \
--start=0x000c --end=0x0013
/* Typical output for HR Measurement characteristic: */
/* handle: 0x000c, uuid: 0x2a37 (Heart Rate Measurement) */
/* handle: 0x000d, uuid: 0x2902 (CCCD ← enable notify here) */
/* BlueZ: Find Information uses ATT opcode 0x04 */
/* Response opcode 0x05 */
/* src/shared/gatt-client.c: discover_descs() */
/* bt_gatt_discover_descriptors(client->att, */
/* chrc->value_handle + 1, end_handle, */
/* discover_desc_cb, op, NULL) */
Next — GATT Read & Write Operations
Part 4 covers all four Characteristic Value Read sub-procedures and all five Characteristic Value Write sub-procedures with complete MSC diagrams.
