What is the Lower Transport Layer?
In a Bluetooth Mesh network, messages pass through several protocol layers before reaching their destination. The Lower Transport Layer sits between the Network Layer (below) and the Upper Transport Layer (above). It has two core responsibilities:
- Outgoing messages (TX): Take a payload from the Upper Transport Layer and, if it is too large for a single Network PDU, break it into numbered 12-byte chunks before handing them to the network layer one by one.
- Incoming messages (RX): Collect those chunks as they arrive from the network, track which ones are still missing, send acknowledgments back to the sender, and pass the fully reassembled payload up to the Upper Transport Layer.
If the payload is small enough to fit in one Network PDU, no splitting happens at all — it travels as an unsegmented message and the acknowledgment mechanism is skipped entirely, keeping things lightweight.
This tutorial walks through every PDU format defined in the Bluetooth Mesh Profile specification (§3.4.6.5 and §3.5), with BlueZ-style C code showing how each concept maps to a real implementation.
Key Terms You Will Encounter
Why duplicates happen
Bluetooth Mesh uses flooding to deliver messages: relay nodes forward packets they receive, and those relayed packets can arrive at the same destination node multiple times via different paths. Without any protection, a node would process and relay the same Network PDU dozens of times — wasting CPU time, battery, and air time.
How the cache works
Every mesh node maintains a Network Message Cache containing a record of recently seen Network PDUs. When a PDU arrives:
- If the PDU is already in the cache → discard it immediately without any further security checks or relaying.
- If the PDU is new → run security validation, and if it passes, store it in the cache and continue processing.
The specification does not require storing the entire PDU. Caching only the source address (SRC) paired with the sequence number (SEQ) is sufficient to detect re-arrivals, because each element in the mesh increments its sequence number for every new Network PDU it originates. Some implementations also cache the NetMIC value for an extra layer of uniqueness.
Cache size and eviction
The minimum cache size is two entries, though in a dense network you want significantly more. When the cache is full and a new entry needs to be stored, the oldest entry is replaced — a classic circular-buffer (FIFO) strategy. The exact size is left to the implementer because it depends on how many relay nodes and how much traffic the device expects to see.
BlueZ implementation
The BlueZ mesh daemon (mesh/net.c) checks incoming Network PDUs against a message cache before doing any security processing. The snippet below shows the core logic in a simplified form:
/*
* mesh/net.c — Network Message Cache (simplified)
*
* The mesh network layer calls net_pdu_received() for every
* incoming PDU. Duplicates are dropped before any security
* processing is attempted.
*/
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#define MSG_CACHE_SIZE 32 /* Adjust based on network density */
struct msg_cache_entry {
uint32_t seq; /* Sequence number from Network PDU header */
uint16_t src; /* Source unicast address */
bool valid; /* Slot is occupied */
};
static struct msg_cache_entry msg_cache[MSG_CACHE_SIZE];
static int cache_head = 0; /* Points to next slot to overwrite */
/* Returns true if (src, seq) is already in the cache */
static bool is_duplicate(uint16_t src, uint32_t seq)
{
int i;
for (i = 0; i < MSG_CACHE_SIZE; i++) {
if (msg_cache[i].valid &&
msg_cache[i].src == src &&
msg_cache[i].seq == seq) {
return true; /* Already seen — discard */
}
}
return false;
}
/* Store a new (src, seq) pair; overwrites the oldest entry */
static void cache_add(uint16_t src, uint32_t seq)
{
msg_cache[cache_head].src = src;
msg_cache[cache_head].seq = seq;
msg_cache[cache_head].valid = true;
cache_head = (cache_head + 1) % MSG_CACHE_SIZE;
}
/*
* Entry point called by the network layer for every incoming PDU.
* Spec §3.4.6.5: a PDU already in the cache shall not be processed.
*/
void net_pdu_received(uint16_t src, uint32_t seq,
bool ctl, uint8_t *pdu, size_t len)
{
if (is_duplicate(src, seq)) {
/* Drop silently — no relaying, no security check */
return;
}
/* New PDU: add to cache, then hand off to lower transport */
cache_add(src, seq);
lower_transport_receive(src, seq, ctl, pdu, len);
}
The key design point: only src and seq go into the cache — the actual PDU bytes are not stored. This keeps memory usage low while still satisfying the specification requirement of not processing the same PDU more than once.
The two-bit type selector
Every Lower Transport PDU starts with the SEG bit in the most significant position of its first byte (bit 7). This single bit tells the receiver whether the payload is a complete message or one chunk of a segmented message.
The CTL bit comes from the enclosing Network PDU header — it is not part of the Lower Transport PDU itself. CTL distinguishes access traffic (application data) from control traffic (mesh management messages).
Combining CTL and SEG gives four distinct Lower Transport PDU formats:
| CTL | SEG | Format | Typical use |
|---|---|---|---|
| 0 | 0 | Unsegmented Access | Small app messages that fit in one PDU |
| 0 | 1 | Segmented Access | Large app messages split across multiple PDUs |
| 1 | 0 | Unsegmented Control | Segment ACK or transport control opcode |
| 1 | 1 | Segmented Control | Large control messages split across PDUs |
All multi-byte numeric values inside the Lower Transport Layer are transmitted in big-endian (most significant byte first) order, consistent with the rest of the Bluetooth Mesh specification.
/*
* mesh/transport.c — Classify a Lower Transport PDU
*
* ctl : CTL bit read from the enclosing Network PDU header
* data : pointer to first byte of the Lower Transport PDU
*/
#include <stdint.h>
#include <stdbool.h>
/* SEG is always the MSB of the first Lower Transport PDU byte */
#define SEG_BIT 0x80
typedef enum {
MSG_UNSEG_ACCESS = 0, /* CTL=0, SEG=0 */
MSG_SEG_ACCESS = 1, /* CTL=0, SEG=1 */
MSG_UNSEG_CONTROL = 2, /* CTL=1, SEG=0 */
MSG_SEG_CONTROL = 3, /* CTL=1, SEG=1 */
} lt_msg_type_t;
static lt_msg_type_t classify(bool ctl, uint8_t first_byte)
{
bool seg = (first_byte & SEG_BIT) != 0;
if (!ctl && !seg) return MSG_UNSEG_ACCESS;
if (!ctl && seg) return MSG_SEG_ACCESS;
if ( ctl && !seg) return MSG_UNSEG_CONTROL;
return MSG_SEG_CONTROL;
}
void lower_transport_receive(uint16_t src, uint32_t seq,
bool ctl, uint8_t *data, size_t len)
{
switch (classify(ctl, data[0])) {
case MSG_UNSEG_ACCESS:
handle_unseg_access(src, data, len);
break;
case MSG_SEG_ACCESS:
handle_seg_access(src, seq, data, len);
break;
case MSG_UNSEG_CONTROL:
handle_unseg_control(src, data, len);
break;
case MSG_SEG_CONTROL:
handle_seg_control(src, seq, data, len);
break;
}
}
When is this format used?
If the encrypted Upper Transport Access PDU (including its 32-bit TransMIC) fits inside a single Network PDU — which allows up to 15 bytes of Lower Transport payload — the message is sent as an Unsegmented Access Message. No sequence tracking, no acknowledgment, no reassembly needed on the receiving end.
Field breakdown
The first byte of this PDU packs three fields together:
- SEG (bit 7) = 0 — Signals to the receiver that this is a complete message, not a segment.
- AKF (bit 6) — Application Key Flag. Set to
1when an Application Key encrypted the payload; set to0when a Device Key was used (for configuration messages). - AID (bits 5–0) — Application Key Identifier. A 6-bit value derived from the Application Key, used to select the correct key for decryption without transmitting the key itself.
The remaining bytes are the encrypted Upper Transport Access PDU. Because this format carries no SZMIC field, the TransMIC is always treated as a 32-bit (4-byte) value — exactly as if SZMIC were 0.
/*
* mesh/transport.c — Unsegmented Access Message handler
*
* Byte 0 layout: [SEG=0 | AKF | AID(6 bits)]
* Bytes 1..n: Encrypted Upper Transport Access PDU + 32-bit TransMIC
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#define AKF_MASK 0x40 /* Bit 6 of byte 0 */
#define AID_MASK 0x3F /* Bits 5-0 of byte 0 */
void handle_unseg_access(uint16_t src, uint8_t *data, size_t len)
{
bool akf = (data[0] & AKF_MASK) != 0;
uint8_t aid = data[0] & AID_MASK;
/*
* data[1..len-1] = encrypted Upper Transport PDU
* The last 4 bytes of that range are the 32-bit TransMIC.
* No SZMIC field exists here — TransMIC is always 32 bits.
*/
uint8_t *upper_pdu = &data[1];
size_t upper_len = len - 1;
if (akf) {
/*
* Payload was encrypted with an Application Key.
* Use 'aid' to look up the matching AppKey from the
* device's key database, then decrypt.
*/
int err = mesh_decrypt_appkey(aid, src, upper_pdu, upper_len);
if (err < 0) {
/* Wrong key or corrupted TransMIC — discard */
return;
}
} else {
/*
* Payload was encrypted with the Device Key.
* 'aid' is ignored in this case.
*/
int err = mesh_decrypt_devkey(src, upper_pdu, upper_len);
if (err < 0) {
return;
}
}
/* Hand decrypted application payload to the upper transport layer */
upper_transport_receive(src, upper_pdu, upper_len);
}
Why segmentation is necessary
A single Bluetooth Mesh Network PDU can carry at most 15 bytes of Lower Transport payload. But an upper-layer model message — say, a firmware update chunk or a long vendor model command — can be far larger. The lower transport layer handles this by slicing the Upper Transport PDU into 12-byte chunks and sending each one in a separate Network PDU.
Header fields in a Segmented Access PDU
Each segment PDU carries a 4-byte header followed by the data chunk:
- SEG (bit 7 of byte 0) = 1 — Marks this as a segment, not a complete message.
- AKF + AID — Same meaning as in the unsegmented format: identifies which key was used.
- SZMIC (1 bit) — Selects the TransMIC size of the final reassembled message: 0 → 32-bit TransMIC, 1 → 64-bit TransMIC. Individual segment PDUs do not carry a TransMIC themselves.
- SeqZero (13 bits) — The 13 least-significant bits of the sequence number assigned to the first segment. Every segment of the same message carries the same SeqZero, so the receiver can group them together.
- SegO (5 bits) — Segment Offset, zero-based. SegO = 0 is the first chunk, SegO = 1 is the second, and so on.
- SegN (5 bits) — The index of the final segment (zero-based). A value of 3 means there are 4 segments total (0, 1, 2, 3).
- Segment m (up to 12 bytes) — The actual data slice. All segments except the last carry exactly 12 bytes; the last segment carries the remaining bytes.
All segments of the same Upper Transport PDU share the same AKF, AID, SZMIC, SeqZero, and SegN values. Only SegO and the payload differ between segments.
/*
* mesh/transport.c — Segmented Access Message: parsing and reassembly
*
* Byte 0: [SEG=1 | AKF | AID(6)]
* Byte 1: [SZMIC | SeqZero(12:6)]
* Byte 2: [SeqZero(5:0) | SegO(4:3)]
* Byte 3: [SegO(2:0) | SegN(4:0)]
* Bytes 4+: Segment m data (up to 12 bytes)
*/
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#define MAX_SEG_SESSIONS 4
#define MAX_SEGMENTS 32
#define SEGMENT_SIZE 12 /* Each segment carries 12 bytes */
struct seg_rx_sess {
uint16_t src;
uint16_t seq_zero;
uint8_t seg_n; /* Index of last segment */
uint32_t block_ack; /* Bitmask: bit N set = seg N rcvd */
uint8_t buf[MAX_SEGMENTS * SEGMENT_SIZE];
bool active;
};
static struct seg_rx_sess rx_sessions[MAX_SEG_SESSIONS];
/* Locate an active session or claim a free slot */
static struct seg_rx_sess *get_session(uint16_t src, uint16_t seq_zero)
{
int i, free_slot = -1;
for (i = 0; i < MAX_SEG_SESSIONS; i++) {
if (rx_sessions[i].active &&
rx_sessions[i].src == src &&
rx_sessions[i].seq_zero == seq_zero) {
return &rx_sessions[i]; /* Existing session */
}
if (!rx_sessions[i].active && free_slot < 0)
free_slot = i;
}
if (free_slot < 0)
return NULL; /* All slots in use */
/* Initialise a new session */
rx_sessions[free_slot].src = src;
rx_sessions[free_slot].seq_zero = seq_zero;
rx_sessions[free_slot].block_ack = 0;
rx_sessions[free_slot].active = true;
return &rx_sessions[free_slot];
}
/* Extract the packed header fields from bytes 0-3 */
static void parse_seg_hdr(const uint8_t *data,
bool *akf, uint8_t *aid, bool *szmic,
uint16_t *seq_zero, uint8_t *seg_o, uint8_t *seg_n)
{
*akf = (data[0] & 0x40) != 0;
*aid = data[0] & 0x3F;
*szmic = (data[1] & 0x80) != 0;
*seq_zero = ((uint16_t)(data[1] & 0x7F) << 6) | (data[2] >> 2);
*seg_o = ((data[2] & 0x03) << 3) | (data[3] >> 5);
*seg_n = data[3] & 0x1F;
}
void handle_seg_access(uint16_t src, uint32_t seq,
uint8_t *data, size_t len)
{
bool akf, szmic;
uint8_t aid, seg_o, seg_n;
uint16_t seq_zero;
parse_seg_hdr(data, &akf, &aid, &szmic, &seq_zero, &seg_o, &seg_n);
/* Segment payload starts at byte 4 */
uint8_t *seg_data = &data[4];
size_t seg_len = len - 4; /* Up to 12 bytes for non-last segment */
struct seg_rx_sess *sess = get_session(src, seq_zero);
if (!sess) return; /* Resource exhaustion */
sess->seg_n = seg_n;
/* Copy segment into the correct position in the reassembly buffer */
memcpy(&sess->buf[seg_o * SEGMENT_SIZE], seg_data, seg_len);
/* Record this segment as received */
sess->block_ack |= (1u << seg_o);
/* All segments received when every bit up to seg_n is set */
uint32_t full_mask = (1u << (seg_n + 1)) - 1;
if (sess->block_ack == full_mask) {
/* Reassembly complete — hand up to the upper transport layer */
size_t total = (size_t)seg_n * SEGMENT_SIZE + seg_len;
upper_transport_receive(src, sess->buf, total);
sess->active = false;
}
/* Always ACK after each received segment */
send_seg_ack(src, seq_zero, sess->block_ack);
}
What is an Unsegmented Control Message?
An Unsegmented Control Message carries either a Segment Acknowledgment or a Transport Control message. The first byte holds SEG = 0 (in bit 7) followed by a 7-bit Opcode field that selects which of these it is:
- Opcode
0x00→ Segment Acknowledgment - Opcodes
0x01to0x7F→ Transport Control messages (heartbeat, friendship, etc.)
Segment Acknowledgment in detail
The Segment Acknowledgment is the mechanism that lets the sender know which segments have been received, so it can retransmit only the missing ones rather than all of them. Its 7-byte PDU structure is:
- Byte 0:
0x00— SEG=0 plus opcode 0x00. - OBO (1 bit): On Behalf Of flag. Set to
1by a Friend node sending the ACK on behalf of a Low Power node. Normally0. - SeqZero (13 bits): Identifies which segmented message this ACK belongs to — matches the SeqZero in the incoming Segmented Access PDUs.
- RFU (2 bits): Reserved, always zero.
- BlockAck (32 bits, big-endian): A bitmask where bit N being set means segment N was received successfully. The sender inspects this mask to find which bits are clear and retransmits those segments.
/*
* mesh/transport.c — Build and send a Segment Acknowledgment
*
* PDU layout (7 bytes, CTL=1 so network layer knows it is control traffic):
* Byte 0: [SEG=0 | Opcode = 0x00] → 0x00
* Byte 1: [OBO(1) | SeqZero(12:6)]
* Byte 2: [SeqZero(5:0) | RFU(1:0) = 0]
* Bytes 3-6: BlockAck, big-endian
*/
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#define SEG_ACK_OPCODE 0x00
#define SEG_ACK_LEN 7
static void build_seg_ack(uint8_t *buf, bool obo,
uint16_t seq_zero, uint32_t block_ack)
{
/* Byte 0: SEG=0, Opcode=0x00 */
buf[0] = SEG_ACK_OPCODE;
/* Bytes 1-2: OBO + SeqZero(13 bits) + 2 zero RFU bits */
buf[1] = (obo ? 0x80 : 0x00) | ((seq_zero >> 6) & 0x7F);
buf[2] = (uint8_t)((seq_zero & 0x3F) << 2); /* RFU bits are 0 */
/* Bytes 3-6: BlockAck in big-endian order */
buf[3] = (block_ack >> 24) & 0xFF;
buf[4] = (block_ack >> 16) & 0xFF;
buf[5] = (block_ack >> 8) & 0xFF;
buf[6] = (block_ack ) & 0xFF;
}
/*
* Called after each segment is received, or when a retransmission timer fires.
* dst : unicast address of the originating node that sent the segments.
*/
void send_seg_ack(uint16_t dst, uint16_t seq_zero, uint32_t block_ack)
{
uint8_t pdu[SEG_ACK_LEN];
build_seg_ack(pdu, /*obo=*/false, seq_zero, block_ack);
/* Transmit via network layer; CTL=1 marks this as a control PDU */
net_transmit(dst, /*ctl=*/true, pdu, SEG_ACK_LEN);
}
/* ------------------------------------------------------------------ */
/*
* On the TX side: process a received Segment Acknowledgment and
* retransmit any missing segments.
*/
void process_seg_ack(uint8_t *data, size_t len)
{
if (len < SEG_ACK_LEN)
return;
uint16_t seq_zero = ((uint16_t)(data[1] & 0x7F) << 6) |
(data[2] >> 2);
uint32_t block_ack = ((uint32_t)data[3] << 24) |
((uint32_t)data[4] << 16) |
((uint32_t)data[5] << 8) |
(uint32_t)data[6];
struct seg_tx_sess *sess = find_tx_session(seq_zero);
if (!sess) return;
/* Retransmit every segment whose bit is NOT yet set */
for (int i = 0; i <= sess->seg_n; i++) {
if (!(block_ack & (1u << i))) {
retransmit_segment(sess, i);
}
}
/* All segments confirmed — clean up TX session */
uint32_t full_mask = (1u << (sess->seg_n + 1)) - 1;
if (block_ack == full_mask) {
sess->active = false;
}
}
Sending a large message (TX path)
- The Upper Transport Layer passes a large PDU down to the Lower Transport Layer.
- The Lower Transport Layer determines how many 12-byte chunks are needed:
SegN = ceil(payload / 12) - 1. - It assigns a SeqZero value (taken from the current network sequence counter) and constructs a Segmented Access PDU for each chunk, filling in SegO = 0, 1, 2 … SegN.
- All segments are handed to the Network Layer for transmission.
- A retransmission timer starts. When a Segment Acknowledgment arrives, its BlockAck mask is inspected. Any segment whose bit is clear is retransmitted.
- Once the BlockAck shows all bits set, the TX session is closed.
Receiving segmented messages (RX path)
- Each arriving Network PDU is checked against the Network Message Cache — duplicates are silently dropped.
- The SEG and CTL bits are read. For a Segmented Access PDU, the receiver locates or creates a reassembly session keyed on
(src, seq_zero). - The segment data is copied into the reassembly buffer at offset
SegO × 12, and bit SegO is set in the BlockAck bitmask. - A Segment Acknowledgment carrying the current BlockAck is sent back to the originator after every received segment.
- When the BlockAck matches the full expected mask
(1 << (SegN+1)) - 1, all chunks are present and the reassembled PDU is passed to the Upper Transport Layer.
/*
* mesh/transport.c — TX path: segment an Upper Transport PDU
*
* pdu : Upper Transport Access PDU to be segmented
* pdu_len : Length of the PDU (must be > 15 bytes to require segmentation)
* dst : Destination unicast/group address
* akf : Application Key Flag
* aid : Application Key Identifier
* szmic : 0 = 32-bit TransMIC, 1 = 64-bit TransMIC
*/
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <math.h>
#define SEGMENT_SIZE 12
void lower_transport_send_segmented(const uint8_t *pdu, size_t pdu_len,
uint16_t dst, bool akf, uint8_t aid,
bool szmic, uint16_t seq_zero)
{
/* How many segments do we need? */
uint8_t seg_n = (uint8_t)((pdu_len + SEGMENT_SIZE - 1) / SEGMENT_SIZE) - 1;
for (uint8_t seg_o = 0; seg_o <= seg_n; seg_o++) {
uint8_t tx_buf[4 + SEGMENT_SIZE];
size_t offset = seg_o * SEGMENT_SIZE;
size_t seg_len = (offset + SEGMENT_SIZE <= pdu_len)
? SEGMENT_SIZE
: pdu_len - offset;
/* Byte 0: SEG=1, AKF, AID */
tx_buf[0] = 0x80 | (akf ? 0x40 : 0x00) | (aid & 0x3F);
/* Byte 1: SZMIC + SeqZero[12:6] */
tx_buf[1] = (szmic ? 0x80 : 0x00) | ((seq_zero >> 6) & 0x7F);
/* Byte 2: SeqZero[5:0] | SegO[4:3] */
tx_buf[2] = (uint8_t)((seq_zero & 0x3F) << 2) | (seg_o >> 3);
/* Byte 3: SegO[2:0] | SegN[4:0] */
tx_buf[3] = (uint8_t)((seg_o & 0x07) << 5) | (seg_n & 0x1F);
/* Bytes 4+: segment payload */
memcpy(&tx_buf[4], &pdu[offset], seg_len);
/* CTL=0 because this is access traffic */
net_transmit(dst, /*ctl=*/false, tx_buf, 4 + seg_len);
}
}
Quick reference: field sizes
| Field | Size (bits) | Present in | Purpose |
|---|---|---|---|
| SEG | 1 | All formats | 0 = complete, 1 = segment |
| AKF | 1 | Access formats | Which key type was used |
| AID | 6 | Access formats | Application key index |
| SZMIC | 1 | Segmented Access | 32-bit or 64-bit TransMIC |
| SeqZero | 13 | Segmented formats + ACK | Groups segments of one message |
| SegO | 5 | Segmented formats | This segment’s position (0-based) |
| SegN | 5 | Segmented formats | Last segment’s index |
| BlockAck | 32 | Segment ACK | Bitmask of received segments |
Ready to Go Deeper?
This tutorial covered the Lower Transport Layer of Bluetooth Mesh — the Network Message Cache, the four PDU formats, unsegmented and segmented access messages, and how the Segment Acknowledgment BlockAck mechanism drives selective retransmission. The natural next step is the Upper Transport Layer, which encrypts and authenticates the payloads that the lower transport layer carries.
