14.7. GATT operations

A characteristic just sits in the GATT database as a named value. What makes it useful is the small, well-defined set of operations a client can run on it. Each characteristic declares which operations it supports as a property bitmask – a server that has nothing to expose can publish a read-only value, a control register might be write-only, a sensor that streams updates would set the notify bit. The client discovers the bitmask during discovery and respects it.

The five operations are read, write, write without response, notify, and indicate. They split into two groups – pull (the client asks) and push (the server sends).

14.7.1. Pull: read and write

These two are the simplest and look exactly like function calls.

  • Read. The client asks for the current value, the server sends it back. One round trip, the client gets whatever bytes the server has set for that characteristic, the server gets nothing about who read.

  • Write. The client sends new bytes, the server stores them (and optionally runs application logic on the new value). Two flavours exist:

    • Write with response – the server acknowledges, raising any application error on a non-zero status. Reliable, one round trip.

    • Write without response – the server stores the bytes silently; the client gets no acknowledgement at all. Faster (no round trip waiting on the ack) and useful for streaming, at the cost of finding out about errors only via side-channel readback.

In aioble, the client-side API hides the choice behind a single aioble.ClientCharacteristic.write() method with a response keyword (True / False / None to auto-select based on what the peer advertises).

14.7.2. Push: notify and indicate

The pull model is wrong for sensor data. A heart-rate strap that the phone has to poll every second would burn battery on a hundred unnecessary radio events; one that pushes a value only when it has a new reading is the point of BLE in the first place.

GATT solves this with server-initiated operations. The client subscribes to a characteristic; from that point on, every time the server updates the value, the new value is pushed across the link to the client. Two flavours:

  • Notify. Fire-and-forget. The server queues a notification, the link layer transmits it during the next connection event, the client receives it. There is no acknowledgement at the GATT level; the link layer’s normal retransmission handles loss on the radio side, but the application sees no confirmation that the value was processed.

  • Indicate. The server sends a notification and waits for the client’s GATT-level confirmation before sending the next one. One indication at a time. Used when the server needs to know the client actually saw the value – a critical-alarm characteristic, a configuration acknowledgement.

Two side-by-side diagrams of a server and a client. On the left, the client sends "read", the server replies with the value. Three reads in a row, each pair of arrows. On the right, the client sends a single "subscribe", then the server pushes three "notify" packets at the times it chooses, without any client request in between.

Pull (read) versus push (notify). With notifications, the client subscribes once and the server pushes new values whenever they change.

Subscribing happens by writing to a descriptor attached to the characteristic – the Client Characteristic Configuration Descriptor (CCCD, 0x2902). Writing 0x0001 enables notifications, 0x0002 enables indications, 0x0000 disables both. The aioble.ClientCharacteristic.subscribe() method performs the write for you, with notify=True and indicate=True keyword flags.

Once subscribed, the client waits for incoming pushes with notified() and indicated() – both async coroutines that suspend until the next push arrives.

14.7.3. The MTU governs payload size

Every operation is constrained by the negotiated MTU the connection settled on at link-up time. The default MTU is 23 bytes, which leaves 20 bytes for characteristic value bytes after the GATT header. Anything larger than that has to either fit into a larger MTU (negotiated up via aioble.DeviceConnection.exchange_mtu(), up to 512 bytes on the camera) or be split into multiple characteristics or multiple notifications.

Client-initiated reads and writes of values larger than the MTU are handled by GATT’s long procedures behind the scenes (Read Long / Prepare-Write + Execute-Write); aioble runs these transparently, so calling read() / write() with an oversized value just costs more round trips. Server- initiated notifications and indications are not fragmented – one push is bounded by the MTU, and the application splits anything larger into multiple notifications or steps off GATT entirely.

For genuinely large transfers – a captured frame, a batch of measurements, a firmware blob – the right answer is usually to step off GATT entirely and use an L2CAP channel instead (see L2CAP channels).

14.7.4. The two sides at a glance

The five operations expose differently on each side of the connection: