61 – 64
Transport
BlueZ Mesh
Intermediate
What Are We Learning Today?
In Bluetooth Mesh, every message passes through several layers before the application ever sees it. Two of those layers — the Lower Transport Layer and the Upper Transport Layer — do the heavy lifting around splitting large messages into smaller pieces, confirming delivery, buffering messages for sleeping devices, and encrypting application data before sending.
This post breaks down exactly what each of these layers does, with diagrams and real BlueZ code to make it stick.
Key Terms in This Post
Imagine you want to courier a large package, but the courier company only accepts boxes up to a fixed size. So you split the package into numbered smaller boxes, send them all, and the courier confirms which ones arrived. That is exactly what the lower transport layer does with mesh messages.
How Segmentation Works
When a message is larger than a single PDU can carry, the lower transport layer cuts it into segments. Each segment is numbered starting from zero. On the receiving side, every arrived segment is ticked off in a Block Acknowledgment (Block ACK) value — one bit per segment. When all bits are set, reassembly is complete and the full message is handed to the upper transport layer.
| Sender | Network | Receiver |
|---|---|---|
| Large message (e.g. 40 bytes) Too big for one PDU |
↓ | |
| Split into segments: Seg 0 | Seg 1 | Seg 2 |
→→→ | Receives segments Marks Block ACK bits: ✓ Seg0 ✓ Seg1 ✓ Seg2 |
| ✓ All delivered — done! | ← Block ACK ← | All bits set → Reassemble ↑ Pass to upper transport |
Two Flavours of Segmented Messages
Segmented Access Message
Carries real application data — sensor readings, light control commands, etc. Once reassembled, it goes to the upper transport access path.
Segmented Control Message
Carries internal mesh control info — Friend management, heartbeats. Once reassembled, processed by the transport control path.
Receiving a Lower Transport PDU — Decision Tree
| PDU Type Received | Action Taken |
|---|---|
| Segmented message or Segment Acknowledgment | Goes into the segmentation/reassembly engine |
| Unsegmented message | Sent directly to the upper transport access handler |
| Transport Control PDU | Processed based on the 7-bit Opcode value |
BlueZ — Watching Segmentation in Action
Start a BlueZ mesh session and use btmon to see transport PDUs on the wire:
# Terminal 1 — capture Bluetooth traffic
sudo btmon
# Terminal 2 — run the mesh daemon
sudo bluetooth-meshd --nodetach --debug
# Terminal 3 — launch config client
mesh-cfgclient
# Inside mesh-cfgclient, send a Composition Data Get request.
# This triggers an upper-transport access message. If the config
# response is larger than ~11 bytes, the stack segments it
# and you will see multiple HCI_ACL packets in btmon, one per segment.
target 0x0100
get-composition 0x00
In the btmon output, each segmented Lower Transport PDU has the SEG bit set to 1, along with SegO (segment offset) and SegN (last segment number) fields in the header.
Bluetooth Mesh supports battery-operated devices called Low Power Nodes (LPN). These nodes sleep most of the time to save energy. But while they sleep, other nodes may send them messages. Someone needs to hold those messages until the LPN wakes up. That someone is the Friend node.
Every Friend node keeps a per-LPN buffer called the Friend Queue. Think of it as a private mailbox for each LPN.
| Any mesh node
Sends a message destined for the LPN |
→ | Friend Node
Intercepts message |
⇄ | Low Power Node
Sleeping… 😴 |
Friend Queue Rules You Must Know
The Friend Poll / Deliver Cycle
| Low Power Node | Friend Node |
|---|---|
| Wakes up, sends Friend Poll → | Checks its Friend Queue for this LPN |
| Queue empty? → Generates and enqueues a fresh Friend Update | |
| ← Receives oldest queued entry | Sends oldest entry from Friend Queue |
| Sends ACK → | Receives ACK → discards delivered entry |
| Goes back to sleep 😴 | Waits for the next Friend Poll |
BlueZ — Enabling Friend Feature on a Node
# Provision the node first, then target it
mesh-cfgclient
target 0x0100 # address of the node you want to be a Friend
# Read current features (bit 0 = Relay, bit 1 = Proxy,
# bit 2 = Friend, bit 3 = Low Power)
get-node-comp 0x00
# Enable Friend feature — set bit 2 in the feature bitmask
# (use the Set Config Model Publication or mesh-cfgclient commands
# depending on your BlueZ version)
set-features 0x0004 # bit 2 = Friend feature ON
# Once enabled, bluetooth-meshd will automatically manage
# the Friend Queue for any LPN that establishes a friendship
# with this node. You can watch pairings in the daemon log:
# sudo bluetooth-meshd --nodetach --debug 2>&1 | grep -i friend
BlueZ Source — Friend Queue Logic (Conceptual)
/*
* BlueZ: mesh/friend.c
* The friend queue is a linked list of Lower Transport PDUs
* per LPN. Relevant constants and flow (simplified):
*/
#define FRND_QUEUE_MAX 32 /* max entries per LPN */
struct frnd_queue_entry {
uint8_t ctl; /* CTL bit from Network PDU */
uint8_t ttl; /* TTL already decremented by 1 */
uint32_t seq; /* SEQ from Network PDU */
uint16_t src; /* SRC address */
uint16_t dst; /* DST address */
uint8_t data[384]; /* Lower Transport PDU payload */
uint16_t data_len;
};
/*
* When a message arrives destined for an LPN:
* 1. Check TTL >= 2, then decrement TTL.
* 2. If segmented, wait until full reassembly + block-ack sent.
* 3. If queue full, pop oldest non-Friend-Update entry.
* 4. Push new entry to tail.
*
* When Friend Poll arrives from LPN:
* 1. If queue empty, create Friend Update and push it.
* 2. Send head entry to LPN.
* 3. On ACK from LPN, remove head entry.
*/
The upper transport layer sits above the lower transport layer. Its main job is to encrypt application data before sending and decrypt and verify it on the receiving side. It uses two types of keys for this.
Application Key (AppKey)
Used for normal application messages — sensor data, light commands, switch events. Shared across a group of nodes that belong to the same application.
Device Key (DevKey)
Used for configuration messages unique to a specific node. Only the provisioner and that node share the DevKey.
There are two formats for the Upper Transport PDU depending on whether it carries application data or control information:
Carries encrypted application payload + TransMIC
Carries internal control messages (no AppKey encryption)
PDU Structure
| Encrypted Access Payload 1 to 380 bytes (when TransMIC is 32-bit) 1 to 376 bytes (when TransMIC is 64-bit) |
TransMIC 4 bytes (32-bit) or 8 bytes (64-bit) |
| ▲ Encrypted using AppKey or DevKey. Opaque to transport — not inspected here. | ▲ Tamper seal |
What is TransMIC?
TransMIC (Transport Message Integrity Check) is a tamper seal on the encrypted payload. The sender computes a fingerprint over the encrypted data and appends it. The receiver recomputes the fingerprint independently — if the two values don’t match, the message is silently discarded.
32-bit TransMIC (4 bytes)
Used for unsegmented messages. Always 32-bit for data messages that fit in one packet.
64-bit TransMIC (8 bytes)
Used for segmented messages when the SZMIC field in the Lower Transport PDU is set to 1. Stronger integrity.
Control messages (CTL = 1) do not carry a TransMIC. Their integrity is covered by the 64-bit NetMIC at the network layer.
AppKey vs DevKey — AKF and AID Fields
The lower transport layer carries two small fields that tell the receiver which key was used for encryption:
| Key Type | AKF Field | AID Field | Typical Use |
|---|---|---|---|
| Application Key (AppKey) | 1 | Application key identifier (6 bits) | Sensor reports, light control, switch events |
| Device Key (DevKey) | 0 | 0b000000 (all zeros) | Configuration Model messages |
Transmitting — Key Rules
Receiving — What Happens on the Other Side
| PDU Type | Receive Action |
|---|---|
| Upper Transport Access PDU | Decrypt with matching AppKey/DevKey → verify TransMIC → check replay cache → deliver to access layer with source, destination, and key context. |
| Upper Transport Control PDU | Check destination unicast address → if it matches this node, process the control message. If Friend feature is active, also check if destination is in the Friend Subscription List and store in Friend Queue if needed. |
BlueZ — Sending an Access Message with AppKey
# In mesh-cfgclient, after provisioning a node at 0x0100
# and binding an AppKey to a model on that node:
# Bind AppKey index 0 to the Generic OnOff Server model (0x1000)
target 0x0100
bind 0 0 0x1000 # app_index=0, elem_addr=0, model_id=0x1000
# Now messages to that model use AppKey at upper transport layer:
# AKF = 1, AID = (derived from AppKey bytes)
# The stack encrypts the access payload with AppKey,
# appends TransMIC (32-bit for unsegmented, 64-bit for SZMIC=1)
# You can watch the debug output from the daemon:
# sudo bluetooth-meshd --nodetach --debug 2>&1 | grep -i "trans\|appkey\|encrypt"
BlueZ Source — Upper Transport TX Concept
/*
* BlueZ: mesh/transport.c (simplified concept)
* Transmitting an access payload at upper transport layer
*/
static bool upper_tx_access(struct mesh_net *net,
uint16_t src, uint16_t dst,
uint8_t key_aid, /* AID */
bool akf, /* AKF = 1 for AppKey */
uint8_t *payload,
uint16_t payload_len)
{
uint8_t enc_buf[380];
uint8_t trans_mic[8]; /* up to 64-bit TransMIC */
uint32_t seq;
/* Allocate a fresh sequence number */
seq = mesh_net_next_seq(net);
/* Encrypt access payload using AppKey / DevKey
* and compute TransMIC (AES-CCM under the hood) */
mesh_crypto_app_encrypt(net, akf, key_aid, seq, src, dst,
payload, payload_len,
enc_buf, trans_mic);
/* Set AKF=akf, AID=key_aid in Lower Transport header,
* then pass to lower transport layer for segmentation
* or direct delivery */
lower_tx_access(net, src, dst, enc_buf,
payload_len + sizeof(trans_mic));
return true;
}
When the CTL bit in the Network PDU is 1, there is no application data. Instead, the Upper Transport PDU carries a control message generated internally by the mesh stack. Examples include Friend Poll, Friend Update, Heartbeat, and path management messages.
These messages are not encrypted with an AppKey. They rely on the 64-bit NetMIC at the network layer for authentication only. Each control message type is identified by a 7-bit opcode in the Lower Transport header.
Payload Size Guide
Because segmenting control messages adds overhead, the spec recommends fitting them in the fewest packets possible. The table below shows the maximum useful payload for each packet count:
| Packets Used | Max Useful Payload (bytes) | Notes |
|---|---|---|
| 1 | 11 | Unsegmented — preferred when it fits |
| 1 | 8 | Segmented single packet |
| 2 | 16 | |
| 3 | 24 | |
| n | n × 8 | General formula |
| 32 (max) | 256 | Absolute upper limit |
BlueZ — Transport Control Messages in Practice
/*
* BlueZ generates Transport Control messages internally.
* You don't craft them manually — the stack handles it.
*
* Example: Friend Poll (opcode 0x01) is sent automatically
* by a Low Power Node to request buffered messages.
*
* You can observe control message opcodes in debug output:
*/
sudo bluetooth-meshd --nodetach --debug 2>&1 | grep "opcode\|ctl\|friend"
/*
* In BlueZ source (mesh/net.c / mesh/friend.c), control PDUs
* are built using mesh_net_send_ctl_msg() or similar helpers:
*
* Parameters:
* net_idx - which subnet to use (picks the NetKey)
* src - source unicast address
* dst - destination unicast address
* opcode - 7-bit control opcode (e.g. 0x01 = Friend Poll)
* params - opcode-specific parameters
* param_len- length of params in bytes
*
* The function:
* 1. Sets CTL = 1 in network header
* 2. Places opcode in lower transport header (no AKF/AID)
* 3. Encrypts + authenticates only at network layer (NetMIC 64-bit)
* 4. Passes to lower transport (segments if param_len > 11)
*/
Here is how an application message flows down through the transport layers on the sending side and back up on the receiving side:
| Sender (TX) | Receiver (RX) |
|---|---|
| Access Layer Provides application payload (plain bytes) |
Access Layer Receives decrypted, verified payload |
| ↓ | ↑ |
| Upper Transport Layer Encrypts with AppKey/DevKey Computes TransMIC Sets AKF, AID, SEQ |
Upper Transport Layer Decrypts payload Verifies TransMIC Checks replay cache |
| ↓ | ↑ |
| Lower Transport Layer Segments if too large Sends segments with SegO/SegN headers Waits for Block ACK |
Lower Transport Layer Receives segments Updates Block ACK bits Reassembles when all segments received |
| ↓ | ↑ |
| Network Layer Adds NetKey encryption + 32-bit NetMIC Adds TTL, SRC, DST to header |
Network Layer Verifies NetMIC Decrypts network header Checks TTL, forwards or delivers |
| ↓ | ↑ |
| Advertising Bearer Sends as BLE advertising packets |
Advertising Bearer Receives BLE advertising packets |
Lower Transport Layer
Splits big messages into numbered segments. Uses Block ACK to confirm delivery. Reassembles and passes complete messages up.
Friend Queue
Holds messages for sleeping Low Power Nodes. FIFO delivery on Friend Poll. Friend Update messages are always protected from eviction.
Upper Transport Access PDU
Encrypts payload with AppKey or DevKey. Adds TransMIC (32-bit or 64-bit) for integrity. Max payload 376–380 bytes.
Upper Transport Control PDU
No AppKey encryption. CTL = 1. Uses 64-bit NetMIC only. Max 256 bytes. Identified by 7-bit opcode in the lower transport header.
More Bluetooth Mesh on EmbeddedPathashala
We cover the full mesh protocol stack — for free. Check out our other posts on Network Layer, Provisioning, and the Access Layer.
