What are we learning today?
Imagine you have fifty smart bulbs spread across a large building, all running on a Bluetooth Mesh network. How do you know if a bulb at the far end of the corridor is still alive? How do you figure out how many relay nodes a message has to cross to reach it? That is exactly the problem the Heartbeat feature solves. And once a message does reach a node, the Access Layer decides how that data gets handed off to your actual application. This post covers both, with real BlueZ examples so you can see what happens at the code level.
Key Terms
In a mesh network, nodes do not talk to a central controller. They relay messages peer-to-peer. That means there is no automatic way to know whether a distant node has lost power or gone out of range.
The naive solution — “just send a ping to every node every few seconds” — would drain batteries and clog the air with traffic. Instead, Bluetooth Mesh lets each node broadcast one small Heartbeat message at a configurable interval. Any other node that is listening can count those messages, measure hops, and build a picture of network health.
Two things Heartbeat tells you:
- Node liveness — is it still awake and transmitting?
- Network distance — how many relay hops does it take to reach this node?
Every Bluetooth Mesh PDU carries a TTL (Time To Live) field. Each relay node that forwards the message decrements TTL by 1. When TTL hits 0, the message is dropped and not forwarded further.
The clever part: a Heartbeat message carries the original TTL it started with (called InitTTL) inside its payload. The receiver can see both:
- InitTTL — what TTL the sender set at the start
- RxTTL — what TTL value is left when the message arrives
The hop count formula is simply:
Let’s walk through two examples:
| Scenario | InitTTL | RxTTL | Hops |
|---|---|---|---|
| Direct neighbor (no relay) | 5 | 5 | 1 |
| 3 relays in between | 5 | 2 | 4 |
| Longest possible path (127 relays) | 0x7F | 0x01 | 127 (0x7F) |
The receiver updates two running values: Min Hops (shortest path ever seen) and Max Hops (longest). Over time these give you a realistic range of how variable the routing is. If MinHops and MaxHops diverge wildly, your mesh has inconsistent relay coverage.
Here is a simple 4-node chain. Node A sends a Heartbeat with InitTTL = 3. Watch TTL drop at each hop:
|
Node A
Sender InitTTL = 3
|
TTL=3 →
|
Relay 1
−1 hop TTL → 2
|
TTL=2 →
|
Relay 2
−1 hop TTL → 1
|
TTL=1 →
|
Node D
Receiver RxTTL = 1
|
A node does not just start sending Heartbeats on its own. The Configuration Server model on that node must be programmed with a publication state. Think of it like a scheduler inside the node. Three things need to be set:
| State Field | What it controls | Stop condition |
|---|---|---|
| Destination | Address Heartbeats are sent to. Unassigned = disabled. | Set to unassigned address |
| Count | How many Heartbeats to send. Decrements by 1 after each send. 0xFFFF = forever. | Count reaches 0x0000 |
| Period | Interval in seconds between consecutive Heartbeats. | — |
| TTL | The initial TTL to put in the Heartbeat PDU. This also becomes InitTTL inside the payload. | — |
The first Heartbeat goes out as soon as the period is configured. After that, one message per Period seconds. Using a group address as destination is recommended so that all interested nodes can receive it without individual unicast overhead.
Triggered Heartbeats — Event-Driven Publishing
Besides periodic sending, a Heartbeat can also be triggered when a node’s feature changes. The Heartbeat Publication Features state has four bits:
This is useful for diagnostics. If you set the Relay bit, any node in the network that subscribes to the Heartbeat group address will immediately know when this node stops relaying — perhaps because of a firmware update or configuration change.
On the receiver side, a Heartbeat Subscription state controls which messages get processed. There are two filters:
- Source — only accept Heartbeats from this unicast address
- Destination — only accept Heartbeats addressed to this address (usually your group)
A Subscription Period countdown timer limits how long the node listens. When it hits zero, the node stops counting. This prevents stale counters from building up indefinitely.
Every valid Heartbeat received increments the Subscription Count register. It maxes out at 0xFFFF and does not wrap. Combined with MinHops and MaxHops, you get a compact health snapshot:
BlueZ exposes Bluetooth Mesh configuration through the mesh-cfgclient tool and the D-Bus API (org.bluez.mesh.Node1). Below are practical examples for setting up Heartbeat publication and subscription from userspace.
6.1 Start the BlueZ mesh daemon and attach a node
# Start bluetooth and mesh daemon
sudo systemctl start bluetooth
sudo bluetooth-meshd --debug &
# Attach your pre-provisioned node token
mesh-cfgclient
# Inside the interactive shell:
attach <app-path> <node-token>
6.2 Configure Heartbeat Publication on a target node
# Inside mesh-cfgclient interactive shell
# Syntax: hb-pub-set <node-addr> <dst> <ttl> <period-log> <count-log> <net-idx> [features]
#
# node-addr : unicast address of the node you are configuring
# dst : group address where Heartbeats will be published (e.g. 0xC000)
# ttl : initial TTL value (e.g. 0x05)
# period-log : log2 of seconds between messages (e.g. 0x02 = every 4 seconds)
# count-log : log2 of how many to send (0xFF = indefinitely)
# net-idx : network key index (usually 0x0000)
hb-pub-set 0x0005 0xC000 0x05 0x02 0xFF 0x0000
After this command, node 0x0005 will send a Heartbeat to group address 0xC000 every ~4 seconds, indefinitely, with TTL 5.
6.3 Configure Heartbeat Subscription on a listener node
# Syntax: hb-sub-set <node-addr> <src> <dst> <period-log>
#
# node-addr : the node that will listen (e.g. your gateway at 0x0001)
# src : unicast address of the node sending Heartbeats (e.g. 0x0005)
# dst : group address to listen on (must match publication dst)
# period-log : log2 of how many seconds to listen (0x05 = 32 seconds)
hb-sub-set 0x0001 0x0005 0xC000 0x05
6.4 Read back the Heartbeat Subscription status
# After the subscription period, read the counters
# This fetches src, dst, period remaining, count, min-hops, max-hops
hb-sub-get 0x0001
A typical response looks like:
Heartbeat Subscription Status:
Status: Success
Src: 0x0005
Dst: 0xC000
Period: 0x00 (expired)
Count: 0x001E (30 Heartbeats received)
MinHops: 0x02
MaxHops: 0x04
This tells you node 0x0005 is reachable in as few as 2 hops but sometimes takes 4. A good TTL to use when talking to it would be somewhere around 5–6, giving enough headroom.
6.5 Monitoring Heartbeats via D-Bus (Python snippet)
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
def heartbeat_received(source, destination, ttl, hops, features):
print(f"[HB] from={hex(source)} dst={hex(destination)} "
f"ttl={ttl} hops={hops} features={bin(features)}")
# Connect to BlueZ mesh node D-Bus interface
mesh_node = bus.get_object("org.bluez", "/org/bluez/mesh/node0005")
iface = dbus.Interface(mesh_node, "org.bluez.mesh.Node1")
# Subscribe to the HeartbeatReceived signal
bus.add_signal_receiver(
heartbeat_received,
signal_name="HeartbeatReceived",
dbus_interface="org.bluez.mesh.Node1"
)
loop = GLib.MainLoop()
loop.run()
Every time a Heartbeat arrives, your callback fires with the computed hops value already calculated by BlueZ. You do not need to do the InitTTL − RxTTL + 1 math yourself.
Below the Heartbeat machinery sits a question that every protocol stack has to answer: after a message survives all the bearer, network, and transport layers — how does it reach the right application code?
The Access Layer is that final bridge. It sits between the Upper Transport Layer and your application models (like the Generic OnOff Model or a custom sensor model). It does three concrete things:
Application / Model Layer
⬆ Access Layer ⬆
Upper Transport Layer
What exactly does the Access Layer do?
| Responsibility | Detail |
|---|---|
| Data format | Defines the structure of application payload bytes. The first 1–3 bytes are the opcode (which model operation this is), followed by parameters. |
| Encryption control | Tells the Upper Transport Layer which Application Key (AppKey) to use for encrypting or decrypting. Network keys protect routing; AppKeys protect the payload content. |
| Key validation | Before passing data up to the model, checks that the incoming message arrived with a NetKey and AppKey that are actually bound to this node and this model. Mismatched keys are silently dropped. |
Practically speaking, if you write a custom vendor model in BlueZ, the Access Layer handles the crypto for you. You receive a decrypted buffer in your model’s recv_msg callback and you never touch the raw cipher.
8.1 How BlueZ exposes the Access Layer to your application
In BlueZ mesh, your application registers element and model callbacks via D-Bus. When the Access Layer has decrypted and validated a message, it calls MessageReceived on your element path. You just handle the opcode and parameters.
/* Simplified BlueZ mesh application element handler (C) */
/* Opcode for Generic OnOff Get (SIG model) */
#define GENERIC_ONOFF_GET 0x8201
#define GENERIC_ONOFF_SET 0x8202
#define GENERIC_ONOFF_STATUS 0x8204
static bool element_recv_msg(uint16_t src, uint16_t dst,
uint16_t app_idx, uint8_t *data,
uint16_t len, void *user_data)
{
/* Access Layer has already decrypted `data` for us.
* First bytes = opcode, rest = parameters.
*/
uint32_t opcode;
uint8_t *params;
size_t params_len;
/* BlueZ mesh helper to extract opcode (1, 2, or 3 bytes) */
if (!mesh_opcode_get(data, len, &opcode, ¶ms_len))
return false;
params = data + (len - params_len);
switch (opcode) {
case GENERIC_ONOFF_GET:
/* Respond with current state */
send_onoff_status(src, app_idx, current_onoff_state);
break;
case GENERIC_ONOFF_SET:
/* params[0] is the target on/off value */
current_onoff_state = params[0];
send_onoff_status(src, app_idx, current_onoff_state);
break;
default:
return false; /* opcode not handled by this model */
}
return true;
}
8.2 Sending a message through the Access Layer
/* Sending a Generic OnOff Set via BlueZ D-Bus method SendMessage */
static void send_onoff_set(uint16_t dst, bool on_off, uint16_t app_idx)
{
uint8_t msg[4];
uint16_t opcode = GENERIC_ONOFF_SET;
int n = 0;
/* Build access payload: [opcode(2B)] [on_off(1B)] [TID(1B)] */
msg[n++] = (opcode >> 8) & 0xFF;
msg[n++] = opcode & 0xFF;
msg[n++] = on_off ? 0x01 : 0x00;
msg[n++] = tid++; /* transaction ID, increments each call */
/* BlueZ mesh_send_msg() passes buffer to Access Layer.
* Access Layer encrypts with app_idx AppKey, then
* hands to Upper Transport for possible segmentation.
*/
mesh_send_msg(node, element_idx, dst, app_idx,
DEFAULT_TTL, msg, n);
}
Notice that your code never calls any encryption function directly. The Access Layer intercepts mesh_send_msg(), looks up the AppKey bound to app_idx, encrypts the payload, and attaches the correct MIC (message integrity check). You hand it plain bytes; the receiver gets plain bytes. The crypto is transparent.
Here is a one-table summary of everything covered:
| Concept | One-line summary |
|---|---|
| Heartbeat message | A small periodic beacon a node sends to prove it is alive |
| InitTTL | TTL the sender put in the PDU, carried inside the Heartbeat payload |
| RxTTL | TTL remaining when the message arrives at the receiver |
| Hops = InitTTL − RxTTL + 1 | Formula for calculating number of relay hops |
| Publication state | Configures destination, count, period, and TTL for sending Heartbeats |
| Subscription state | Filters which Heartbeats to process and tracks count + min/max hops |
| Triggered Heartbeat | Sent on feature state change (Relay, Proxy, Friend, Low Power) |
| Access Layer | Defines payload format, manages AppKey encryption, validates keys before delivery to model |
What’s next on EmbeddedPathashala?
Next up: the Mesh Access Layer message format in depth — opcodes, opcode sizes, and how vendor models define their own payload structure.
