Protocol Specification
bleRPC uses a two-layer protocol stack on top of BLE GATT. The Command Layer wraps Protocol Buffers payloads with RPC metadata, 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 --> 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–0x5(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 to the central.
Request (Central → Peripheral)
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x4 |
| payload_len | 0x00 |
Response (Peripheral → Central)
| Field | Value |
|---|---|
| type | 0b11 (control) |
| control_cmd | 0x4 |
| payload_len | 0x04 |
| payload[0:2] | max_request_payload_size (16-bit LE) |
| payload[2:4] | max_response_payload_size (16-bit LE) |
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
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)
Note over C,P: Ready for RPC calls