How battery-powered mesh nodes save power using the Friend feature — explained simply with BlueZ examples
What Problem Does the Friend Feature Solve?
In a Bluetooth Mesh network every node must be able to receive messages — but receiving radio constantly drains a battery very fast. A door sensor or a remote switch cannot afford to keep its radio on all day.
The Friend feature solves this. A permanently powered device nearby — called the Friend node — stays awake and collects incoming messages on behalf of the sleepy device. The sleepy device is called the Low Power Node (LPN). The LPN wakes up periodically, asks its Friend for any stored messages, and goes back to sleep.
Think of it like a post box outside your house. Your postman (the Friend) collects letters all day. You (the LPN) open the box only when you want to check — you do not have to stand at the door waiting.
Key Terms in This Post
The LPN and its Friend share a special relationship called a friendship. The Friend holds messages in a buffer called the Friend Queue until the LPN comes to collect them.
|
📱
Low Power Node
Sleeps most of the time.
Wakes briefly to fetch messages. e.g. Door sensor, Motion detector,
Battery switch 🔋 Long battery life
|
Friend Request (0x03)
Friend Offer
Friend Poll (0x01)
Friend Update (0x02) + Data
Friendship Security Credentials
TTL = 0 (direct, no relay) |
🗄️
Friend Node
Always awake. Stores mesh messages
in its Friend Queue for the LPN. e.g. Mains-powered relay,
Smart hub, Always-on light ⚡ Mains powered
|
Here is the step-by-step conversation between an LPN and its Friend — from initial discovery right through to fetching stored messages on every wake-up cycle.
|
Low Power Node (LPN)
|
Friend Node
|
|
|
① Wakes up. No friend yet.
|
Friend Request
|
Receives broadcast from LPN
|
|
Receives offer(s), picks the best Friend
|
Friend Offer
|
② Sends Friend Offer back
|
|
Friendship established ✓
|
||
|
③ Wakes up again — any messages?
|
Friend Poll
|
Checks Friend Queue…
|
|
Gets network status + knows if more messages are waiting
|
Friend Update + Data
|
④ Delivers queued messages
|
|
↩ LPN goes back to sleep
|
Steps ③ and ④ repeat on every LPN wake-up cycle for as long as the friendship lasts.
The LPN sends this every time it wakes up. It is essentially saying: “Hey Friend, send me the next stored message.”
This is the smallest control message in the mesh spec — just one byte, with only two fields.
Packet Layout (8 bits = 1 byte)
| 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
| Padding — 7 bits, always 0b0000000 | FSN | ||||||
| Reserved — no information here | 0 or 1 | ||||||
How FSN Works — The Ping-Pong Bit
| Step | LPN sends FSN | What it means |
|---|---|---|
| First Poll ever | FSN = 0 | Please send me the first message |
| After receiving message #1 | FSN = 1 | Got it! Send me the next one |
| After receiving message #2 | FSN = 0 | Got it! Send me the next one |
| If FSN does NOT change | Same FSN | Friend knows: last message was lost — re-send it |
Both Friend Poll and Friend Update carry TTL set to zero. This means the message travels only one hop — directly between the LPN and its Friend. It must never be relayed across the mesh network.
BlueZ — Monitoring Friend Poll with btmon
# Run btmon to capture all Bluetooth HCI traffic to a file
sudo btmon -w mesh_capture.btsnoop
# In a second terminal — start the BlueZ mesh daemon
sudo mesh-cfgd &
# Live view: filter for Transport Control messages (opcode 0x01 = Friend Poll)
sudo btmon | grep -A 8 "Transport Control"
BlueZ — Building a Friend Poll Message in C
/* friend_poll.c
*
* Friend Poll packet: 1 byte
* Bits [7:1] Padding — must be 0b0000000
* Bit [0] FSN — toggles between 0 and 1
*
* Opcode 0x01 lives in the Transport Control header,
* not inside this payload.
*/
#include <stdint.h>
#include <stdio.h>
#define FRIEND_POLL_OPCODE 0x01
/* Build a Friend Poll byte.
* fsn must be 0 or 1 only.
*/
static uint8_t build_friend_poll(uint8_t fsn)
{
return (uint8_t)(fsn & 0x01); /* upper 7 bits stay 0 */
}
int main(void)
{
uint8_t fsn = 0;
/* Simulate a few poll cycles */
for (int i = 0; i < 4; i++) {
uint8_t poll_byte = build_friend_poll(fsn);
printf("Poll #%d FSN=%u byte=0x%02X\n", i + 1, fsn, poll_byte);
fsn ^= 1; /* toggle FSN after each acknowledged message */
}
return 0;
}
# Compile with BlueZ headers available
gcc -o friend_poll friend_poll.c
# Expected output:
# Poll #1 FSN=0 byte=0x00
# Poll #2 FSN=1 byte=0x01
# Poll #3 FSN=0 byte=0x00
# Poll #4 FSN=1 byte=0x01
The Friend node sends this in response to a Friend Poll. It does two jobs in one message:
- Keeps the LPN in sync with the current network security state (IV Index, Key Refresh phase)
- Tells the LPN whether more messages are still waiting in the Friend Queue
Total size: 6 bytes.
Packet Layout
| Flags 1 byte |
IV Index 4 bytes |
MD 1 byte |
| Key Refresh Flag (bit 0) IV Update Flag (bit 1) Bits 2–7: Reserved |
Current IV Index used by this network |
0 = Queue empty 1 = More messages |
The Flags Byte — Bit by Bit
| 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
| Reserved for Future Use — always 0 | IV Update Flag | Key Refresh Flag | |||||
| Flag | Value = 0 | Value = 1 |
|---|---|---|
| Key Refresh Flag bit 0 |
No key refresh happening — use current Network Key | Key Refresh Phase 2 active — switch to the new Network Key now |
| IV Update Flag bit 1 |
Normal operation | IV Update in progress — network is bumping the IV Index counter |
| MD (More Data) separate byte |
Friend Queue is empty — LPN can go back to sleep | More messages waiting — LPN should send another Friend Poll |
Every mesh message is encrypted using a sequence number plus the IV Index. When sequence numbers are about to run out (after 2²⁴ messages), the whole network increments the IV Index together. The Friend Update keeps the LPN in sync with this counter even while it was sleeping.
BlueZ — Parsing a Friend Update in C
/* friend_update.c
*
* Friend Update payload: 6 bytes
* Byte 0 : Flags (bit0=KeyRefresh, bit1=IVUpdate, bits2-7=RFU)
* Bytes 1–4 : IV Index (big-endian, most significant byte first)
* Byte 5 : MD (0=queue empty, 1=more data)
*/
#include <stdint.h>
#include <stdio.h>
#include <arpa/inet.h> /* ntohl() — available via libc on Linux */
#define FRIEND_UPDATE_OPCODE 0x02
#define FLAG_KEY_REFRESH (1u << 0)
#define FLAG_IV_UPDATE (1u << 1)
void parse_friend_update(const uint8_t *buf, size_t len)
{
if (len < 6) {
fprintf(stderr, "Too short for Friend Update\n");
return;
}
uint8_t flags = buf[0];
/* IV Index is 4 bytes big-endian starting at buf[1] */
uint32_t iv_index = ((uint32_t)buf[1] << 24)
| ((uint32_t)buf[2] << 16)
| ((uint32_t)buf[3] << 8)
| (uint32_t)buf[4];
uint8_t md = buf[5];
printf("=== Friend Update ===\n");
printf("Key Refresh active : %s\n",
(flags & FLAG_KEY_REFRESH) ? "YES — switch to new key" : "NO");
printf("IV Update active : %s\n",
(flags & FLAG_IV_UPDATE) ? "YES — IV bump in progress" : "NO");
printf("IV Index : 0x%08X\n", iv_index);
printf("Friend Queue : %s\n",
md == 0 ? "EMPTY — go to sleep" : "MORE DATA — send another Poll");
}
int main(void)
{
/* Example: flags=0x00, IV Index=0x00000064, MD=0x01 */
uint8_t example[] = { 0x00, 0x00, 0x00, 0x00, 0x64, 0x01 };
parse_friend_update(example, sizeof(example));
return 0;
}
# Compile
gcc -o friend_update friend_update.c
# Expected output:
# === Friend Update ===
# Key Refresh active : NO
# IV Update active : NO
# IV Index : 0x00000064
# Friend Queue : MORE DATA — send another Poll
Before any polling can happen the LPN must first find a Friend. It does this by broadcasting a Friend Request to all nearby Friend-capable nodes at once. Each willing Friend calculates how suitable it is, waits a short delay, then replies with a Friend Offer. The LPN picks the best offer.
Total size: 10 bytes.
Packet Layout
| Criteria 1 byte |
ReceiveDelay 1 byte |
PollTimeout 3 bytes |
PreviousAddress 2 bytes |
NumElements 1 byte |
LPNCounter 2 bytes |
| What the LPN needs from a Friend |
Wait time after waking (ms) |
Max gap between polls (×100ms) |
Old Friend’s address or 0x0000 |
Number of LPN elements |
Count of Friend Requests sent |
The Criteria Byte — Zoomed In
This single byte tells potential Friend nodes what the LPN requires. A Friend that cannot meet these requirements should not reply.
| 7 RFU |
6 — 5 RSSIFactor |
4 — 3 ReceiveWindowFactor |
2 — 1 — 0 MinQueueSizeLog |
||||
| Reserved = 0 |
Weight for RSSI in Friend selection |
Weight for receive window in selection |
Min Friend Queue size = log₂(N) messages |
||||
RSSIFactor values
| 2-bit value | Factor |
|---|---|
| 0b00 | 1 |
| 0b01 | 1.5 |
| 0b10 | 2 |
| 0b11 | 2.5 |
MinQueueSizeLog values
| 3-bit value | Min slots (N) |
|---|---|
| 0b000 | Prohibited |
| 0b001 | 2 |
| 0b010 | 4 |
| 0b011 | 8 |
| 0b100 | 16 |
| 0b101 | 32 |
| 0b110 | 64 |
| 0b111 | 128 |
All Fields Explained Simply
| Field | Valid Range | What it does |
|---|---|---|
| RSSIFactor | 1, 1.5, 2, 2.5 | Controls how much the Friend’s signal strength affects Friend Offer delay. Higher = prefer closer Friends. |
| ReceiveWindowFactor | 1, 1.5, 2, 2.5 | Controls how much the Friend’s receive window size affects the delay. Higher = prefer Friends with longer listening windows. |
| MinQueueSizeLog | 0b001 to 0b111 | Minimum Friend Queue depth the LPN needs. Value 3 means at least 8 message slots (log₂(8)=3). |
| ReceiveDelay | 10–255 ms | How long the LPN waits after sending a Friend Poll before opening its radio to receive. Must be at least 10 ms. |
| PollTimeout | 1 sec – ~35 min | Maximum time between Friend Polls. If the LPN misses this deadline, the Friend terminates the friendship. |
| PreviousAddress | Any unicast or 0x0000 | Unicast address of the last Friend this LPN was paired with. The old Friend clears its queue for this LPN when it sees this. Use 0x0000 on first boot. |
| NumElements | 1–255 | Number of elements on this LPN. The Friend uses this to figure out the full range of unicast addresses the LPN occupies. |
| LPNCounter | 0–65535 | Running count of Friend Requests this LPN has ever sent. Lets Friend nodes distinguish a new request from a stale re-broadcast. |
BlueZ — Building a Friend Request in C
/* friend_request.c
*
* Friend Request payload: 10 bytes
* Criteria 1 byte (RFU[7], RSSIFactor[6:5], RWFactor[4:3], MinQueueSizeLog[2:0])
* ReceiveDelay 1 byte (min 0x0A = 10ms)
* PollTimeout 3 bytes (big-endian, unit = 100ms, max 0x34BBFF)
* PrevAddress 2 bytes (little-endian, 0x0000 if no previous friend)
* NumElements 1 byte
* LPNCounter 2 bytes (little-endian)
*/
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#define FRIEND_REQUEST_OPCODE 0x03
/* Criteria field bit packing */
#define CRITERIA_RSSI_FACTOR(x) (((x) & 0x03u) << 5)
#define CRITERIA_RW_FACTOR(x) (((x) & 0x03u) << 3)
#define CRITERIA_MIN_QUEUE_LOG(x) ( (x) & 0x07u)
typedef struct {
uint8_t criteria;
uint8_t receive_delay;
uint8_t poll_timeout[3]; /* 24-bit, big-endian */
uint8_t prev_address[2]; /* 16-bit, little-endian */
uint8_t num_elements;
uint8_t lpn_counter[2]; /* 16-bit, little-endian */
} __attribute__((packed)) friend_request_t;
void build_friend_request(friend_request_t *req,
uint16_t poll_timeout_100ms,
uint16_t prev_addr,
uint8_t num_elements,
uint16_t lpn_counter)
{
memset(req, 0, sizeof(*req));
/*
* Criteria:
* RSSIFactor = 0b01 (factor 1.5 — moderate RSSI weight)
* ReceiveWindowFactor= 0b01 (factor 1.5 — moderate window weight)
* MinQueueSizeLog = 0b011 (N=8 message slots minimum)
*/
req->criteria = CRITERIA_RSSI_FACTOR(1)
| CRITERIA_RW_FACTOR(1)
| CRITERIA_MIN_QUEUE_LOG(3);
req->receive_delay = 0x0A; /* 10ms — minimum allowed */
/* PollTimeout: big-endian 24-bit */
req->poll_timeout[0] = (poll_timeout_100ms >> 16) & 0xFF;
req->poll_timeout[1] = (poll_timeout_100ms >> 8) & 0xFF;
req->poll_timeout[2] = poll_timeout_100ms & 0xFF;
/* PreviousAddress: little-endian */
req->prev_address[0] = prev_addr & 0xFF;
req->prev_address[1] = (prev_addr >> 8) & 0xFF;
req->num_elements = num_elements;
/* LPNCounter: little-endian */
req->lpn_counter[0] = lpn_counter & 0xFF;
req->lpn_counter[1] = (lpn_counter >> 8) & 0xFF;
}
int main(void)
{
friend_request_t req;
/*
* Poll timeout = 100 units × 100ms = 10 seconds
* No previous friend (0x0000)
* 1 element
* First ever request (counter = 1)
*/
build_friend_request(&req, 100, 0x0000, 1, 1);
printf("Criteria byte : 0x%02X\n", req.criteria);
printf("Receive Delay : %d ms\n", req.receive_delay);
printf("Poll Timeout : %d00 ms\n", req.poll_timeout[2]);
printf("Prev Address : 0x%02X%02X\n",
req.prev_address[1], req.prev_address[0]);
printf("Num Elements : %d\n", req.num_elements);
printf("LPN Counter : %d\n",
req.lpn_counter[0] | (req.lpn_counter[1] << 8));
return 0;
}
# Install BlueZ development headers (Ubuntu / Debian)
sudo apt-get install libbluetooth-dev bluetooth bluez
# Compile
gcc -o friend_request friend_request.c
# Run
./friend_request
# Expected output:
# Criteria byte : 0x4B
# Receive Delay : 10 ms
# Poll Timeout : 10000 ms
# Prev Address : 0x0000
# Num Elements : 1
# LPN Counter : 1
| Message | Opcode | Size | Direction | One-line purpose |
|---|---|---|---|---|
| Friend Request | 0x03 | 10 bytes | LPN → All Friends (broadcast) | “I need a Friend — here are my requirements” |
| Friend Poll | 0x01 | 1 byte | LPN → Friend (unicast) | “Any messages for me? Acknowledge my last one with FSN” |
| Friend Update | 0x02 | 6 bytes | Friend → LPN (unicast) | “Here is the network state. MD tells you if the queue is empty” |
Key rules to remember:
- All three messages use friendship security credentials — they are not encrypted with the regular network key.
- Friend Poll and Friend Update always set TTL = 0 — they never relay through the mesh.
- The LPN keeps polling as long as MD = 1 (more data). It stops and sleeps only when MD = 0 (queue empty).
Next Up: Friend Offer & Friendship Establishment
Now that you know how the LPN asks for a Friend and how they exchange messages, the next post covers how Friend nodes calculate their Friend Offer Delay and how the complete establishment handshake works end to end.
