BLE GATT Characteristic Value Read & Write

 

GATT Characteristic Value Read & Write

Chapter 13 · Part 4 of 5 · Sections 13.6.6–13.6.7 · 4 Read Procedures · 5 Write Procedures · Long Values · Reliable Writes

4Read sub-procedures
5Write sub-procedures
Prepare+ExecuteLong value writes
Echo verifyReliable writes
SEO Keywords

BLE GATT Read Characteristic Value ATT_MTU-1 limit BLE Read Long Characteristic Blob Offset pagination BLE Read Using Characteristic UUID no handle needed BLE Read Multiple Characteristic Values concatenated BLE Write Without Response ATT Write Command fire forget BLE Write Long Characteristic Prepare Execute Write queue BLE Reliable Writes echo verify atomic multiple handles BlueZ gatttool char-read char-write-req C code

13.6.6 — Characteristic Value Read

13.6.6.1 — Read Characteristic Value

The direct, single-shot read. The client already knows the Characteristic Value Handle (discovered in section 13.6.4) and fires a Read Request. The server replies with the current value. This is the most common GATT operation — it is how you read a battery level, a device name, a sensor reading, or any other characteristic that fits in one ATT PDU.

ATT opcode:Read Request (0x0A)
Attribute Handle:Characteristic Value Handle from discovery
Max response:ATT_MTU − 1 bytes (22 bytes at default MTU=23)
Figure 13.21 — Read Characteristic Value
ClientServer
Read Request (Attribute Handle = 0x000C)
Read Response (Attribute Value — up to ATT_MTU-1 bytes)
If value > ATT_MTU-1 bytes → only first ATT_MTU-1 returned → use Read Blob for rest

The server can return an Error Response instead if: the attribute is not readable, authentication is required and the link is unauthenticated, authorization is required and the client has not been authorized, or the encryption key size is insufficient. These permissions are set by the profile that defines the characteristic.

/* Read single characteristic by handle:                      */
gatttool -b AA:BB:CC:DD:EE:FF --char-read --handle=0x000c

/* Read Device Name (handle 0x0003 in GAP service):           */
gatttool -b AA:BB:CC:DD:EE:FF --char-read --handle=0x0003
/* Output: Characteristic value/descriptor: 4d 79 44 65 76.. */
/* (hex bytes of "MyDevice")                                  */

/* bluetoothctl read:                                         */
[AA:BB:CC:DD:EE:FF]# select-attribute /org/bluez/hci0/.../char000b
[AA:BB:CC:DD:EE:FF]# read
/* Attempting to read /org/bluez/hci0/.../char000b            */
/* [CHG] Attribute .../char000b Value:                        */
/*   48 00 00 ff 00 01                                        */

/* Python / BlueZ D-Bus:                                      */
value = char_iface.ReadValue({})
# Returns dbus.Array of dbus.Byte objects                     

13.6.6.2 — Read Long Characteristic Value

When the characteristic value is larger than ATT_MTU−1 bytes, a single Read Request cannot carry it all. Read Long is the solution — it reads the value in offset-based chunks using Read Blob Request. The client first reads bytes 0 through ATT_MTU-2 using a regular Read Request, then continues from offset ATT_MTU-1 using Read Blob Requests, each time advancing the offset by ATT_MTU-1 until the response returns fewer bytes than the maximum, which signals the end of the value.

ATT opcode:Read Blob Request (0x0C)
Attribute Handle:Characteristic Value Handle
Value Offset:0 = first byte; increments by ATT_MTU-1 each request
Figure 13.22 — Read Long Value in Chunks
ClientServer (value = 68 bytes, MTU = 23)
Read Blob Request (Handle=0x000F, Offset=0)
Read Blob Response (bytes 0–21, 22 bytes)
Read Blob Request (Handle=0x000F, Offset=22)
Read Blob Response (bytes 22–43, 22 bytes)
Read Blob Request (Handle=0x000F, Offset=44)
Read Blob Response (bytes 44–67, 24 bytes) ← shorter = end of value
Client reassembles: 22 + 22 + 24 = 68 bytes total

An important edge case: if the server detects that the characteristic value is actually shorter than ATT_MTU-1 bytes and the client sends a Read Blob Request for it, the server returns an Error Response. That is why it is important to use a regular Read Request first and only switch to Read Blob if the response comes back fully filled.

13.6.6.3 — Read Using Characteristic UUID

Usually a client reads a characteristic by handle. But what if the client wants a value and knows the UUID but not the handle? This procedure skips the need for a prior discovery step. The client sends a Read By Type Request targeting the characteristic UUID within a handle range (typically the whole service range). The server returns the handle and current value directly in one response.

ATT opcode:Read By Type Request (0x08)
Attribute Type:Characteristic UUID (e.g. 0x2A37 for Heart Rate Measurement)
Handle range:Typically 0x0001–0xFFFF or within the specific service
Figure 13.23 — Read Using Characteristic UUID
ClientServer
Read By Type Request (Start=0x0000, End=0xFFFF, Type=0x2A37)
Read By Type Response (Length, Attribute Handle=0x000C, Attribute Value)
/* Read by UUID — no handle lookup needed:                    */
gatttool -b AA:BB:CC:DD:EE:FF --char-read --uuid=0x2A37

/* This fires a Read By Type Request directly                 */
/* Returns value AND handle so client can cache the handle    */

/* Useful for first-time reads where discovery was skipped    */
/* Less efficient than caching the handle for repeated reads  */

13.6.6.4 — Read Multiple Characteristic Values

For applications that need to poll several characteristics in one round trip — like reading temperature, humidity, and pressure from a multi-sensor device simultaneously — this sub-procedure sends all handles at once and receives all values concatenated in one response. The critical constraint: all requested values must have a known, fixed length, because the concatenated response has no length delimiters between values.

Figure 13.24 — Read Multiple Values at Once
ClientServer
Read Multiple Request (Set of Handles: [0x0025, 0x0027, 0x0029])
Read Multiple Response (value[0x0025] || value[0x0027] || value[0x0029])
Values are concatenated in handle order — client must know each value’s length to split them
/* BlueZ D-Bus: Read Multiple is not exposed directly via     */
/* bluetoothctl but is used internally by gatt-client.c       */
/* for performance optimizations                              */

/* C level — ATT Read Multiple Request opcode = 0x0E:         */
uint8_t handles[] = {
    0x25, 0x00,   /* handle 0x0025 little-endian */
    0x27, 0x00,   /* handle 0x0027               */
    0x29, 0x00    /* handle 0x0029               */
};
uint8_t pdu[1 + sizeof(handles)];
pdu[0] = 0x0E;  /* ATT_OP_READ_MULTI_REQ */
memcpy(pdu + 1, handles, sizeof(handles));
send(att_sock, pdu, sizeof(pdu), 0);
/* Receive 0x0F Read Multiple Response, parse concatenated values */

13.6.7 — Characteristic Value Write

13.6.7.1 — Write Without Response

The fastest possible write. The client sends a Write Command to the server and moves on immediately — no waiting, no acknowledgement, no error feedback. If the packet is lost or the server rejects it, the client will never know. Despite this unreliability, Write Without Response is widely used for streaming commands where latency matters more than guaranteed delivery, such as writing playback commands to a BLE audio device or sending UART data over BLE SPP-style profiles.

ATT opcode:Write Command (0x52) — CMD bit set in opcode byte
Max value size:ATT_MTU − 3 bytes (handles are 2 bytes, opcode 1 byte)
Server response:None — not even an error response
Figure 13.25 — Write Without Response (Write Command)
ClientServer
Write Command (Attribute Handle, Attribute Value) — opcode 0x52
No response ever — server silently discards on any error
/* Write Without Response (Write Command) with gatttool:       */
gatttool -b AA:BB:CC:DD:EE:FF \
         --char-write --handle=0x0025 --value=01
/* --char-write sends Write Command (opcode 0x52)              */

/* Compare: --char-write-req sends Write Request (opcode 0x12) */
/* which waits for Write Response                             */

/* C — Write Command opcode 0x52:                             */
uint8_t pdu[] = {
    0x52,         /* Write Command opcode (bit6=CMD=1)        */
    0x25, 0x00,   /* Attribute Handle little-endian           */
    0x01          /* Value                                    */
};
/* No recv() needed — no response coming                      */
send(att_sock, pdu, sizeof(pdu), 0);

13.6.7.2 — Signed Write Without Response

Like Write Without Response, but with a 12-byte authentication signature appended. The signature is computed using the CSRK (Connection Signature Resolving Key), which was shared during SM Phase 3 pairing. This gives the server a way to verify the write came from the legitimate paired client, even without link-layer encryption. A replay counter prevents old signed writes from being reused.

This sub-procedure requires the devices to be bonded — the server needs the client’s CSRK to verify the signature, which was distributed during pairing. It cannot be used between non-bonded devices.

ATT opcode:Signed Write Command (0xD2) — CMD + AUTH bits set
Auth Signature:12 octets appended after the value — computed with CSRK
Prerequisite:Devices must be bonded (CSRK distributed in Phase 3)
Server response:None — signature mismatch is silently discarded
Figure 13.26 — Signed Write Without Response
ClientServer
Signed Write Command (Handle, Value, [12-byte CSRK signature + counter])
Server verifies signature — silently ignores if invalid (no error response)

13.6.7.3 — Write Characteristic Value (Acknowledged)

The reliable write for values that fit in one PDU. Unlike Write Without Response, the server sends back a Write Response once the value is saved. If anything goes wrong, the server sends an Error Response explaining why — insufficient permissions, authentication required, encryption needed, or the value is invalid. The client knows for certain whether the write succeeded.

ATT opcode:Write Request (0x12)
Max value size:ATT_MTU − 3 bytes
Server response:Write Response (0x13) on success, Error Response on failure
Figure 13.27 — Write Characteristic Value with Acknowledgement
ClientServer
Write Request (Attribute Handle, Attribute Value) — opcode 0x12
Write Response () — no parameters, just acknowledgement
Or instead of Write Response:
Error Response (Write Not Permitted / Insufficient Authentication / etc.)
/* Acknowledged write — waits for Write Response:             */
gatttool -b AA:BB:CC:DD:EE:FF \
         --char-write-req --handle=0x0025 --value=01

/* Enable notification by writing to CCCD:                    */
gatttool -b AA:BB:CC:DD:EE:FF \
         --char-write-req --handle=0x000d --value=0100
/* 0x0100 LE = 0x0001 = notifications enabled                 */

/* Python:                                                    */
char_iface.WriteValue([0x01], {})   # default = Write Request  */

13.6.7.4 — Write Long Characteristic Value

When the value to be written is too large to fit in one Write Request (ATT_MTU−3 bytes max), the client splits it into chunks and queues them on the server using Prepare Write Requests. The server holds the chunks in a write queue, echoing each one back to confirm receipt. Once all chunks are sent, the client sends Execute Write Request with Flags=0x01 to commit everything atomically.

Note the key difference from Reliable Writes: here, the client is not required to check the echoed values from Prepare Write Responses. The echo is there as a service but ignoring it is acceptable for this sub-procedure.

Phase 1 ATT opcode:Prepare Write Request (0x16) — repeated per chunk
Value Offset:0 for first chunk, +ATT_MTU-5 for each subsequent chunk
Phase 2 ATT opcode:Execute Write Request (0x18) with Flags = 0x01 (commit)
Cancel:Execute Write Request with Flags = 0x00 discards all queued data
Figure 13.28 — Write Long Value: Prepare + Execute Two-Phase Commit
ClientServer
Prepare Write Request (Handle, Offset=0, Chunk1)
→ Queue
Prepare Write Response (Handle, Offset=0, Chunk1) — echoed back
Prepare Write Request (Handle, Offset=N, Chunk2)
→ Queue
Prepare Write Response (Handle, Offset=N, Chunk2)
… repeat for remaining chunks …
Execute Write Request (Flags = 0x01) — commit all queued chunks
Execute Write Response — value fully written

13.6.7.5 — Reliable Writes

Reliable Writes adds a mandatory verification step on top of Write Long. After every Prepare Write Response, the client must compare the echoed data with what it sent. Any discrepancy means data was corrupted in transit — the client should cancel the entire operation with Execute Write (Flags=0x00) and retry from the start.

Reliable Writes also serves a second purpose: writing multiple characteristics atomically. By using different Attribute Handles in subsequent Prepare Write Requests, the client can write to Handle A, Handle B, and Handle C in one operation — the server commits all three simultaneously when Execute Write arrives, with no risk of another client interleaving writes between them.

Key difference:Client MUST verify echoed Prepare Write Response matches what was sent
Multi-handle:Subsequent Prepare Write Requests may use different Attribute Handles
Atomicity:All queued values committed simultaneously — no other client can interleave
Figure 13.29 — Reliable Writes with Echo Verification
ClientServer
Prepare Write Request (Handle1, Offset1, Value1)
→ Queue
Prepare Write Response (Handle1, Offset1, Value1)
Verify ✓
Prepare Write Request (Handle2, Offset2, Value2) — different handle!
→ Queue
Prepare Write Response (Handle2, Offset2, Value2)
Verify ✓
… more handles / chunks if needed …
Execute Write Request (Flags = 0x01) — commit all in order
Execute Write Response — Handle1 and Handle2 written atomically
If any echo mismatch → send Execute Write (Flags=0x00) to cancel and retry
When does the queue get full? The server may return an Error Response if its write queue is full. This is device-dependent — some BLE chips only have space for 3–4 queued prepare writes. If the Error Response has code “Prepare Queue Full”, the client must wait for the Execute Write Response before retrying.
/* Reliable Writes use the same Prepare+Execute pattern       */
/* but the client verifies each response                      */

/* BlueZ gatt-client.c implements Reliable Writes in           */
/* bt_gatt_client_write_long_value() with verify=true:         */

/* From src/shared/gatt-client.c:                             */
struct long_write_op {
    struct bt_gatt_client *client;
    bool reliable_writes;   /* true = verify each response    */
    uint16_t handle;
    size_t offset;
    uint8_t *value;
    size_t length;
    /* ... */
};

/* Pseudo-flow for each Prepare Write response:               */
static void prepare_write_cb(uint8_t opcode, const void *pdu,
                              uint16_t len, void *user_data) {
    struct long_write_op *op = user_data;
    if (op->reliable_writes) {
        /* Compare echoed value with sent value               */
        if (memcmp(pdu + 4, op->value + op->offset,
                   len - 4) != 0) {
            /* Mismatch — cancel with Execute Write 0x00      */
            cancel_long_write(op);
            return;
        }
    }
    /* All good — send next chunk or Execute Write 0x01       */
    send_next_chunk(op);
}

Write Sub-Procedures Comparison

Sub-Procedure ATT Opcode ACK? Long values? Auth Sig? Echo Verify?
Write Without Response 0x52 Write Cmd No No No No
Signed Write 0xD2 Signed Cmd No No Yes No
Write Char Value 0x12 Write Req Yes No No No
Write Long 0x16+0x18 Prep+Exec Yes Yes No Optional
Reliable Writes 0x16+0x18 Prep+Exec Yes Yes No Mandatory

Next — Notifications, Indications, Descriptor Ops & GATT Service

Part 5 covers Notifications, Indications, all four Descriptor operations, the 30-second timeout, and the Service Changed characteristic.

Part 5: Notify & GATT Service → ← Part 3

Leave a Reply

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