EmbeddedPathashala · BLE Mesh Series
How each layer processes data, what gets encrypted where, and how BlueZ implements the mesh stack via D-Bus.
Key Concepts in This Post
How Data Flows Through the Stack
When a light switch node wants to turn off a light bulb node across the building, the “turn off” command travels down the 8 layers of the sender’s stack, getting wrapped (encapsulated) at each layer, transmitted over BLE advertising, then unwrapped back up the layers of the receiver’s stack.
Think of it like posting a letter: your message goes inside an envelope (Access layer), the envelope goes into a padded bag (Transport), the bag gets a shipping label (Network), and the postal service (Bearer) carries it. The receiving end reverses this process.
📦 PDU Encapsulation Diagram
Each layer wraps the data from the layer above it. The innermost box is the raw application data; the outermost is what travels over BLE radio.
How network messages physically travel between nodes
The bearer layer is the transport vehicle for mesh network messages. Two bearers are defined:
- Uses BLE advertising channels 37, 38, 39
- No connection required
- All mesh nodes implement this
- Enables broadcast/relay behaviour
- Uses AD Type:
0x2A(Mesh Message)
- Uses a standard BLE GATT connection
- For legacy devices (smartphones, tablets)
- Uses Mesh Proxy Service (UUID 0x1828)
- Allows phones without native mesh to participate
- Acts as a proxy to the advertising bearer
# Check if BlueZ mesh daemon is running
systemctl status bluetooth-meshd
# Verify BlueZ version (mesh support requires 5.50+)
bluetoothctl --version
# Scan for a Mesh Proxy node advertising GATT Bearer
# Mesh Proxy Service UUID: 0x1828
sudo hcitool lescan --duplicates &
sudo hcidump -i hci0 -X | grep -A5 "1828"
Addressing, relay decisions, network-layer encryption
The network layer wraps the transport PDU into a Network PDU. It adds source/destination addresses and a TTL (Time To Live) field, then encrypts and authenticates using the Network Key (NetKey).
| Field | Size | Purpose |
|---|---|---|
| IVI | 1 bit | IV Index LSB (replay protection) |
| NID | 7 bits | Network ID derived from NetKey |
| CTL | 1 bit | 0 = access message, 1 = control message |
| TTL | 7 bits | Time To Live — decremented by each relay |
| SEQ | 24 bits | Sequence number (replay attack protection) |
| SRC | 16 bits | Source unicast address |
| DST | 16 bits | Destination address (unicast/group/all) |
| TransportPDU | 1–16 B | Encrypted transport layer payload |
# BlueZ exposes the mesh network via D-Bus
# Object path: /org/bluez/mesh
# Interface: org.bluez.mesh.Network1
# Inspect available D-Bus mesh objects
dbus-send --system --dest=org.bluez.mesh --print-reply /org/bluez/mesh org.freedesktop.DBus.Introspectable.Introspect
# List mesh network D-Bus properties
gdbus introspect --system --dest org.bluez.mesh --object-path /org/bluez/mesh
Segmentation and reassembly for large messages
BLE advertising payloads are tiny — at most ~29 usable bytes in the mesh network PDU. The lower transport layer handles Segmentation and Reassembly (SAR) so larger messages can be sent reliably.
(up to 380 bytes)
Each segment fits in one BLE advertising PDU. Destination reassembles them in order.
Application data encryption and the Friend feature
This is where your application payload gets encrypted using the Application Key (AppKey). The result includes an AES-CCM Message Integrity Check (MIC) to authenticate the data. A relay node forwarding this message can never read the payload — it only has the NetKey, not the AppKey.
- Algorithm: AES-CCM-128
- Key: AppKey (64 bytes)
- Nonce: IV Index + SEQ + SRC
- MIC: 4 or 8 bytes
- Low Power Nodes (LPN) sleep most of the time
- Friend node buffers messages for LPN
- LPN polls Friend when it wakes up
- Control messages managed here
# BlueZ mesh node: org.bluez.mesh.Node1
# Method: Send(source, destination, key_index, data)
# Using Python + dbus to send a mesh message
import dbus
bus = dbus.SystemBus()
mesh_node = bus.get_object(
'org.bluez.mesh',
'/org/bluez/mesh/node/AABBCCDDEEFF0011'
)
iface = dbus.Interface(mesh_node, 'org.bluez.mesh.Node1')
# Send Generic OnOff SET message
# dst=0xC000 (group addr), app_key_idx=0, data=[opcode, value, tid]
iface.Send(
0x0001, # source element address
0xC000, # destination group address
0, # app key index
[0x82, 0x02, 0x01, 0x01] # Generic OnOff SET (ON)
)
Defines the format of application data
The access layer defines the structure of the application payload before encryption. Every message has an OpCode (1–3 bytes) that identifies the operation, followed by its parameters.
| OpCode (Hex) | Message | Payload |
|---|---|---|
| 0x8201 | Generic OnOff GET | None |
| 0x8202 | Generic OnOff SET | OnOff (1B) + TID (1B) + [Transition] |
| 0x8204 | Generic OnOff Status | PresentOnOff (1B) + [TargetOnOff] |
| 0x824B | Generic Level SET | Level (2B) + TID (1B) |
The access layer verifies the application key context before passing messages upward — ensuring a lighting AppKey cannot accidentally unlock a door lock message.
Where your application logic lives
Built-in models required for managing the mesh network. Every mesh node implements these.
- Configuration Server — accepts config from Config Client
- Configuration Client — configures other nodes
- Health Server/Client — fault reporting
Bluetooth SIG-defined models for common use cases. Vendor models also allowed.
- Generic OnOff Server/Client
- Light Lightness Server
- Sensor Server/Client
- Scene Server
# BlueZ mesh application exposes D-Bus objects:
# /app -> org.bluez.mesh.Application1
# /app/ele0 -> org.bluez.mesh.Element1
# (element has models listed as properties)
import dbus
import dbus.service
import dbus.mainloop.glib
from gi.repository import GLib
MESH_APP_IFACE = 'org.bluez.mesh.Application1'
MESH_ELEM_IFACE = 'org.bluez.mesh.Element1'
class MeshElement(dbus.service.Object):
def __init__(self, bus, path, index, models):
super().__init__(bus, path)
self._index = index
self._models = models
@dbus.service.method(dbus.PROPERTIES_IFACE,
in_signature='ss', out_signature='v')
def Get(self, iface, prop):
if iface != MESH_ELEM_IFACE:
raise dbus.exceptions.DBusException('InvalidInterface')
if prop == 'Index':
return dbus.Byte(self._index)
if prop == 'Models':
# List SIG model IDs for this element
# 0x1000 = Generic OnOff Server
return dbus.Array(
[dbus.UInt16(m) for m in self._models],
signature='q'
)
raise dbus.exceptions.DBusException('InvalidProperty')
# Create element 0 with Generic OnOff Server model
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
element0 = MeshElement(bus, '/app/ele0', 0, [0x1000])
📋 Layer Summary Reference
| # | Layer | Encryption | Key BlueZ Interface | Job |
|---|---|---|---|---|
| 8 | Model | None | Application code | User scenario logic |
| 7 | Foundation Model | DevKey | meshctl / cfgclient | Network config & management |
| 6 | Access | None (controls AppKey) | Node1.Send() | OpCode + parameter format |
| 5 | Upper Transport | AES-CCM (AppKey) | meshd internal | Encrypt payload + MIC |
| 4 | Lower Transport | None | meshd internal | Segmentation & reassembly |
| 3 | Network | AES-CCM (NetKey) | Network1 D-Bus | Address, TTL, relay |
| 2 | Bearer | None | bluetooth-meshd | Adv / GATT transport |
| 1 | BLE Core | Link-layer (optional) | hci0 / bluetoothd | Radio, packets, channels |
Up Next: How Mesh Messages Actually Travel
Part 3 covers the managed-flood relay mechanism, TTL countdown, network message cache, and subnets — with BlueZ meshd setup included.
Next: Part 3 → Mesh Network Operation ← Part 1: Introduction
