Skip to content

BLE ATT

Bluetooth Low Energy (BLE) uses the Attribute Protocol (ATT) to exchange data between a GATT client and server. ATT PDUs are sent over fixed L2CAP channel 4 (CID 0x0004) and are always little-endian — the opposite of most internet protocols.

This page shows the complete wirespec model for ATT and explains the features it exercises: @endian little, type aliases, enum as a field type, tagged frame unions, and bytes[remaining].

Protocol Background

ATT defines a small set of PDU types, identified by a 1-byte opcode. Common operations include reading and writing attribute values by handle, and server-initiated notifications. Error responses carry a structured body. All handles and MTU values are 16-bit little-endian integers.

Complete wirespec Definition

wire
# BLE ATT Protocol (RFC / Bluetooth Core Spec Vol 3, Part F)
@endian little
module ble.att

type AttHandle = u16le
type Uuid16 = u16le

enum AttErrorCode: u8 {
    InvalidHandle    = 0x01,
    ReadNotPermitted = 0x02,
    WriteNotPermitted = 0x03,
    AttributeNotFound = 0x0a,
}

frame AttPdu = match opcode: u8 {
    0x01 => ErrorRsp {
        request_opcode: u8,
        handle: AttHandle,
        error_code: AttErrorCode,
    },
    0x02 => ExchangeMtuReq { client_rx_mtu: u16le },
    0x03 => ExchangeMtuRsp { server_rx_mtu: u16le },
    0x0a => ReadReq  { handle: AttHandle },
    0x0b => ReadRsp  { value: bytes[remaining] },
    0x12 => WriteReq { handle: AttHandle, value: bytes[remaining] },
    0x13 => WriteRsp {},
    0x1b => Notification { handle: AttHandle, value: bytes[remaining] },
    0x52 => WriteCmd { handle: AttHandle, value: bytes[remaining] },
    _    => Unknown  { data: bytes[remaining] },
}

Features Demonstrated

FeatureWhere
Little-endian module default@endian little
Type aliastype AttHandle = u16le
enum definitionenum AttErrorCode: u8 { ... }
enum as field typeerror_code: AttErrorCode
Tagged frame unionframe AttPdu = match opcode: u8 { ... }
Fixed-width scalarclient_rx_mtu: u16le
Zero-copy byte spanvalue: bytes[remaining]
Empty branchWriteRsp {}
Wildcard branch_ => Unknown { ... }

Feature Notes

@endian little

Placed at the top of the module, @endian little sets the default byte order for all multi-byte integer fields. A u16 field in this module is read and written in little-endian order without any extra annotation. This matches the Bluetooth Core Specification, which mandates little-endian for all ATT fields.

Individual fields can still override with explicit suffixed types (u16be, u32be) if a mixed-endian structure is needed.

Type Aliases

wire
type AttHandle = u16le
type Uuid16 = u16le

These are pure naming aliases — no new wire layout is introduced. AttHandle encodes identically to u16le on the wire; the alias exists to make field declarations more readable and self-documenting. The alias appears transparently in generated C: both the struct field type and the parse/serialize logic use the underlying uint16_t.

Enum as a Field Type

wire
enum AttErrorCode: u8 {
    InvalidHandle    = 0x01,
    ReadNotPermitted = 0x02,
    WriteNotPermitted = 0x03,
    AttributeNotFound = 0x0a,
}

AttErrorCode has an underlying wire type of u8. Using it as a field type (error_code: AttErrorCode) tells wirespec to read one byte and interpret it as an AttErrorCode value. In generated C, the field becomes a uint8_t with an associated typedef enum.

The enum does not perform exhaustiveness checking on the wire value — unrecognised opcode values are still parsed without error. The application layer decides how to handle unknown codes.

frame with Tagged Union

wire
frame AttPdu = match opcode: u8 { ... }

frame defines a tagged union. The compiler reads the tag field (opcode: u8) first, then dispatches to the matching branch. If the tag does not match any explicit value and a wildcard _ branch exists, the wildcard is taken. Without a wildcard, an unknown tag returns WIRESPEC_ERR_INVALID_TAG.

In C, AttPdu becomes a struct with a kind discriminant and a union of branch structs:

c
typedef enum {
    ATT_PDU_ERROR_RSP     = 0x01,
    ATT_PDU_EXCHANGE_MTU_REQ = 0x02,
    /* ... */
    ATT_PDU_UNKNOWN,
} ble_att_att_pdu_kind_t;

typedef struct {
    ble_att_att_pdu_kind_t kind;
    union {
        ble_att_error_rsp_t      error_rsp;
        ble_att_exchange_mtu_req_t exchange_mtu_req;
        /* ... */
        ble_att_unknown_t        unknown;
    };
} ble_att_att_pdu_t;

bytes[remaining]

wire
0x0b => ReadRsp { value: bytes[remaining] },

bytes[remaining] consumes all bytes left in the current scope. For a top-level frame branch, the scope is the entire input buffer. The field must be the last wire field in its branch — the compiler enforces this statically.

In generated C, value becomes a wirespec_bytes_t — a { const uint8_t *ptr; size_t len; } pair pointing into the original input buffer. No copying occurs.

Empty Branch

wire
0x13 => WriteRsp {},

A branch with no fields is valid. WriteRsp is a zero-byte response acknowledgement. The generated C struct for it is empty, and parsing it consumes only the opcode byte (which was already consumed by the tag dispatch).

Compile

bash
wirespec compile examples/ble/att.wspec -o build/

This generates build/ble_att.h and build/ble_att.c.

Generated C API

c
wirespec_result_t ble_att_att_pdu_parse(
    const uint8_t *buf, size_t len,
    ble_att_att_pdu_t *out, size_t *consumed);

wirespec_result_t ble_att_att_pdu_serialize(
    const ble_att_att_pdu_t *in,
    uint8_t *buf, size_t cap, size_t *written);

Usage Example

c
#include "ble_att.h"

void handle_att_pdu(const uint8_t *buf, size_t len) {
    ble_att_att_pdu_t pdu;
    size_t consumed;

    wirespec_result_t r = ble_att_att_pdu_parse(buf, len, &pdu, &consumed);
    if (r != WIRESPEC_OK) return;

    switch (pdu.kind) {
    case ATT_PDU_READ_REQ:
        /* pdu.read_req.handle is a uint16_t attribute handle */
        respond_with_attribute(pdu.read_req.handle);
        break;
    case ATT_PDU_WRITE_REQ:
        /* pdu.write_req.value is a wirespec_bytes_t — zero-copy view */
        write_attribute(pdu.write_req.handle,
                        pdu.write_req.value.ptr,
                        pdu.write_req.value.len);
        break;
    case ATT_PDU_EXCHANGE_MTU_REQ:
        negotiate_mtu(pdu.exchange_mtu_req.client_rx_mtu);
        break;
    default:
        break;
    }
}

What Next

  • MQTT — continuation-bit VarInt, expression-based capsule tag, conditional fields
  • TLS 1.3enum tag types, u24 primitive, fill arrays with byte bounds
  • Classic Protocols — UDP, TCP, Ethernet, IPv4