Protocol Specification
bleRPC uses a layered protocol stack on top of BLE GATT. The Command Layer wraps Protocol Buffers payloads with RPC metadata, the optional Encryption Layer encrypts the serialized command, and the Container Layer fragments them into MTU-sized packets for BLE transmission.
graph TD
A["Application"] --> B["Command Layer<br/>Protobuf encode/decode + command metadata"]
B --> E["Encryption Layer (optional)<br/>AES-128-GCM encrypt/decrypt"]
E --> C["Container Layer<br/>MTU-based split/reassemble + sequencing"]
C --> D["BLE GATT<br/>(single characteristic)"]
BLE Transport
All communication uses a single GATT Characteristic:
- Requests: Central writes via Write Without Response
- Responses: Peripheral sends via Notify
MTU is negotiated automatically via the BLE ATT MTU Exchange procedure. The application uses the negotiated MTU (minus 3 bytes ATT overhead) to determine the maximum container payload size.
Default timeout is 100ms, configurable via the timeout control container.
Container Layer
The container layer splits large payloads into MTU-sized packets and reassembles them on the receiving side. Each container carries a transaction ID and sequence number for tracking and ordering.
Container Format
All multi-byte fields are little-endian.
First Container (type=0b00)
The first container in a transaction includes the total payload length:
| Field | Bits | Description |
|---|---|---|
transaction_id | 8 | Unique ID for this request/response pair. Resets per transaction. |
sequence_number | 8 | Increments with each container sent. Resets per transaction. |
type | 2 | 0b00 = first |
control_cmd | 4 | 0x0 (unused for data containers) |
reserved | 2 | Zero-filled |
total_length | 16 | Total payload size across all containers (bytes, little-endian) |
payload_len | 8 | Payload size in this container (bytes) |
payload | variable | Fragment of the command data |
Header size: 6 bytes (transaction_id + sequence_number + flags + total_length + payload_len)
Subsequent Container (type=0b01)
Continuation containers omit total_length:
Header size: 4 bytes (no total_length field)
Byte 2 Bitfield Detail
The third byte of every container encodes the type, control command, and reserved bits:
type:00= first,01= subsequent,11= controlcontrol_cmd:0x0–0x6(only valid when type=11)reserved: zero-filled
Container Splitting Example
A 500-byte command payload with MTU=247 (244 usable after ATT overhead):
| Container | Type | Header | Payload | Total |
|---|---|---|---|---|
| 1 | FIRST | 6 bytes | 238 bytes | 244 bytes |
| 2 | SUBSEQUENT | 4 bytes | 240 bytes | 244 bytes |
| 3 | SUBSEQUENT | 4 bytes | 22 bytes | 26 bytes |
Total payload: 500 bytes (238 + 240 + 22)
Maximum Payload
The sequence_number is 8 bits, limiting a single transaction to ~255 containers. With a typical MTU of 247 (240 bytes per subsequent container), the practical maximum payload per transaction is approximately 60 KB. For larger transfers, use multiple request/response cycles.
Control Containers
Control containers (type=0b11) carry protocol-level commands instead of application data.
Timeout Sharing (control_cmd=0x1)
Allows the peripheral to communicate its processing timeout to the central.
Request (Central → Peripheral)
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x1 |
| payload_len | 0x00 |
Response (Peripheral → Central)
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x1 |
| payload_len | 0x02 |
| payload | timeout_ms (16-bit LE, milliseconds) |
Capability Sharing (control_cmd=0x4)
Allows the peripheral to communicate its buffer constraints and feature flags to the central.
Request (Central → Peripheral)
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x4 |
| payload_len | 0x06 |
| payload[0:2] | max_request_payload_size (16-bit LE, typically 0) |
| payload[2:4] | max_response_payload_size (16-bit LE, typically 0) |
| payload[4:6] | flags (16-bit LE, typically 0) |
Response (Peripheral → Central)
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x4 |
| payload_len | 0x06 |
| payload[0:2] | max_request_payload_size (16-bit LE) |
| payload[2:4] | max_response_payload_size (16-bit LE) |
| payload[4:6] | flags (16-bit LE) — bit 0: encryption supported |
When flags is absent (4-byte payload for backward compatibility), no optional features are available.
Stream End (control_cmd=0x2, 0x3)
Signals the end of a streaming sequence. Either side can terminate a stream.
| control_cmd | Direction | Description |
|---|---|---|
0x2 | Central → Peripheral | Central ends the upload stream |
0x3 | Peripheral → Central | Peripheral ends the download stream |
Both have payload_len=0x00 (no payload).
Error Notification (control_cmd=0x5)
Sent by the peripheral when an error occurs (e.g., response exceeds buffer limits).
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x5 |
| payload_len | 0x01 |
| payload[0] | Error code |
Error codes:
0x01— RESPONSE_TOO_LARGE: Response payload exceedsmax_response_payload_size
Key Exchange (control_cmd=0x6)
Carries key exchange handshake payloads for establishing an encrypted session. Used when the peripheral advertises encryption support via the capabilities flags field.
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x6 |
| payload_len | variable |
| payload | Key exchange step data (see Encryption) |
Four containers are exchanged (Step 1–4) to complete the handshake. After completion, all subsequent data containers carry encrypted payloads.
Encrypted Data Container
When encryption is active, the payload of data containers (FIRST / SUBSEQUENT) is replaced by the encrypted form of the serialized command:
| Field | Size | Description |
|---|---|---|
counter | 4 bytes | Monotonically increasing counter (little-endian). Used as AES-GCM nonce and for replay detection. |
ciphertext | N bytes | AES-128-GCM encrypted serialized command |
tag | 16 bytes | AES-GCM authentication tag |
Total overhead: 20 bytes (4 counter + 16 tag). The ciphertext is then split into containers as usual.
Command Layer
The command layer wraps Protocol Buffers-encoded data with RPC metadata. Commands are identified by name (ASCII string), enabling flexible, human-readable dispatch.
Command Format
| Field | Bits | Description |
|---|---|---|
type | 1 | 0 = request, 1 = response |
reserved | 7 | Zero-filled |
cmd_name_len | 8 | Length of the command name in bytes |
cmd_name | variable | ASCII command name (e.g., echo, flash_read) |
data_len | 16 | Length of the protobuf data (bytes, little-endian) |
data | variable | Protocol Buffers encoded payload |
Command Discovery
Commands are automatically generated from .proto file definitions. Matching *Request / *Response message pairs produce a command whose name is the prefix converted to snake_case:
| Message Pair | Command Name |
|---|---|
EchoRequest + EchoResponse | echo |
FlashReadRequest + FlashReadResponse | flash_read |
DataWriteRequest + DataWriteResponse | data_write |
Request/Response Flow
A complete RPC call involves encoding at the command layer, splitting at the container layer, BLE transmission, and the reverse on the receiving side:
sequenceDiagram
participant C as Central
participant P as Peripheral
Note over C: 1. Protobuf encode request
Note over C: 2. Wrap in command header
Note over C: 3. Split into containers
C->>P: Write Without Response (FIRST)
C->>P: Write Without Response (SUBSEQUENT...)
Note over P: 4. Reassemble containers
Note over P: 5. Decode command + Protobuf
Note over P: 6. Execute handler
Note over P: 7. Encode response + split
P->>C: Notify (FIRST)
P->>C: Notify (SUBSEQUENT...)
Note over C: 8. Reassemble + decode response
Streaming
bleRPC supports two streaming patterns beyond simple request/response:
Peripheral → Central Stream (Server Streaming)
One request triggers multiple responses. The stream ends with a STREAM_END_P2C control container.
sequenceDiagram
participant C as Central
participant P as Peripheral
C->>P: Request
P->>C: Response 1 (Notify)
P->>C: Response 2 (Notify)
P->>C: Response 3 (Notify)
P-->>C: STREAM_END_P2C (control)
Central → Peripheral Stream (Client Streaming)
Multiple requests are sent, followed by a STREAM_END_C2P control container. The peripheral responds with a final message.
sequenceDiagram
participant C as Central
participant P as Peripheral
C->>P: Request 1
C->>P: Request 2
C->>P: Request 3
C-->>P: STREAM_END_C2P (control)
P->>C: Final Response (Notify)
Connection Setup Sequence
When a central connects to a peripheral, it performs these initialization steps:
sequenceDiagram
participant C as Central
participant P as Peripheral
C->>P: BLE Connect
C->>P: MTU Exchange
C->>P: Discover Services
C->>P: Enable Notifications
C->>P: Timeout Request (ctrl)
P->>C: Timeout Response (e.g. 100ms)
C->>P: Capability Request (ctrl)
P->>C: Capability Response (max_req, max_resp, flags)
opt Encryption (flags & 0x01)
C->>P: Key Exchange Step 1 (ctrl 0x6)
P->>C: Key Exchange Step 2 (ctrl 0x6)
C->>P: Key Exchange Step 3 (ctrl 0x6)
P->>C: Key Exchange Step 4 (ctrl 0x6)
Note over C,P: AES-128-GCM session established
end
Note over C,P: Ready for RPC calls