The Problem Models Alone Cannot Solve
Models describe behaviour, but mesh messages carry a single destination address. If a device has two light circuits, how do you address each one individually? You cannot put two instances of the same state inside one model — messages have no way to pick which instance to act on.
The solution is elements. Each independently addressable unit of a device is an element, and each element gets its own unicast address on the mesh network.
Key Terms
What is an Element?
An element is the smallest addressable unit inside a mesh node. Every element gets its own unicast address. A node always has at least one element — the primary element — and may have additional secondary elements.
Models live inside elements. An element can contain one or more models. The combination of models in an element defines exactly what that element can do.
/* ── BlueZ mesh/node.c — element data structure ── */
struct node_element {
struct l_queue *models; /* all models living in this element */
char *path; /* D-Bus object path */
uint16_t location; /* Bluetooth SIG location descriptor */
uint8_t idx; /* element index (0 = primary) */
};
/*
* idx == 0 → primary element (unicast address = node_addr)
* idx == 1 → secondary element (unicast address = node_addr + 1)
* idx == N → secondary element (unicast address = node_addr + N)
*/
States — What a Model Remembers
A state is a stored value inside a server model — it is what the model “remembers” about the physical world. The Generic OnOff Server keeps a boolean on/off state. The Generic Level Server keeps a signed 16-bit level value.
A model can have more than one state. The states in a model can be bound to each other — changing one automatically adjusts the other according to a defined rule. This linkage is called state binding.
/* ── BlueZ mesh/model.c — model + state bookkeeping ── */
struct mesh_model {
const struct mesh_model_ops *cbs; /* recv / bind / pub callbacks */
void *user_data; /* caller's private context */
struct l_queue *bindings; /* AppKey bindings */
struct l_queue *subs; /* subscription addresses */
uint32_t id; /* 16-bit SIG or 32-bit vendor */
bool pub_enabled; /* publication active? */
};
/*
* Application state is kept by the application layer (user_data),
* not by the BlueZ mesh daemon itself. The daemon handles routing;
* the app owns the state values.
*/
/*
* Example: Light Lightness Server has two bound states:
*
* Light Lightness Actual ←→ Light Lightness Linear
*
* Binding rule (spec-defined):
* Linear = (Actual²) / 65535
*
* Implemented in the application, not in the mesh daemon.
*/
static void update_lightness_binding(uint16_t actual, uint16_t *linear_out)
{
/*
* Spec formula: Linear = round( Actual^2 / 65535 )
* Use 32-bit intermediary to avoid overflow.
*/
uint32_t tmp = (uint32_t)actual * actual;
*linear_out = (uint16_t)(tmp / 65535);
}
The One-State-Per-Element Rule
Mesh messages carry a destination address and an opcode. They carry no “instance number.” This means: if you have two instances of the same state on the same element, an incoming message has no way to say which instance it targets.
The spec resolves this with a hard rule: at most one instance of any given state type may exist on a single element.
When you need a second instance of a state, you place it in a second element. That second element has its own unicast address, so messages to it are unambiguous.
/*
* Scenario: a two-circuit dimmer (two independent light outputs).
*
* Node unicast address: 0x0010
*
* Element #0 (primary) → address 0x0010
* Root Model (Light Lightness Server) → State X1 (circuit 1 level)
*
* Element #1 (secondary) → address 0x0011
* Extended Model → State X2 (circuit 2 level)
*
* Sending a Light Lightness Set to 0x0010 dims circuit 1.
* Sending a Light Lightness Set to 0x0011 dims circuit 2.
* No ambiguity.
*/
/* BlueZ: adding a secondary element to a node */
static struct node_element *add_element(struct mesh_node *node,
uint8_t ele_idx)
{
struct node_element *ele = l_new(struct node_element, 1);
if (!ele)
return NULL;
ele->idx = ele_idx;
ele->models = l_queue_new();
l_queue_push_tail(node->elements, ele);
return ele;
}
Model Association — Linking Root and Extended Models
When a model extends another, there is a model association between them. In Figure 2.4 of the spec, the root model lives in the primary element and the extended model lives in the secondary element — but they are logically linked via model association.
The extended model effectively “borrows” or “augments” the root model’s definition. Together they describe the full capability of the device.
/* ── BlueZ mesh/model.c — adding a model to an element ── */
bool mesh_model_add(struct mesh_node *node,
struct l_queue *mods,
uint32_t id,
struct l_queue **updated_mods)
{
struct mesh_model *mod;
/* Prevent duplicate model IDs on the same element */
mod = l_queue_find(mods, match_model_id,
L_UINT_TO_PTR(id));
if (mod)
return false; /* already present */
mod = l_new(struct mesh_model, 1);
mod->id = id;
mod->bindings = l_queue_new();
mod->subs = l_queue_new();
l_queue_push_tail(mods, mod);
if (updated_mods)
*updated_mods = mods;
return true;
}
Composite Devices — Scaling Elements
A composite device contains multiple instances of the same model group. The spec example (Figure 2.5) shows a single physical device with:
- Primary Element #0 — Root Model Instance A (State X1, State Y1 with state binding)
- Secondary Element #1 — Extended Model Instance A (State X2)
- Secondary Element #2 — Root Model Instance B (State X1, State Y1 with state binding)
- Secondary Element #3 — Extended Model Instance B (State X2)
Each pair (elements #0+#1, elements #2+#3) is a complete, independent functional unit within the same physical box.
/*
* BlueZ mesh/node.c — iterating over all elements on a node
*
* This is how the mesh daemon walks the full element-model tree
* when processing an incoming access message.
*/
static bool match_element_idx(const void *a, const void *b)
{
const struct node_element *ele = a;
return (ele->idx == L_PTR_TO_UINT(b));
}
struct node_element *node_get_element(struct mesh_node *node,
uint8_t ele_idx)
{
return l_queue_find(node->elements,
match_element_idx,
L_UINT_TO_PTR(ele_idx));
}
/* Unicast address of element N = node_primary_addr + N */
uint16_t node_element_unicast(struct mesh_node *node, uint8_t ele_idx)
{
return node_get_primary_net_idx(node) + ele_idx;
/*
* In practice: node->primary_net_idx gives the base address;
* secondary elements follow sequentially.
*/
}
Composition Data — How a Provisioner Learns the Layout
After a device is provisioned onto the mesh network, a Configuration Client reads its Composition Data. This is a structured binary blob that lists every element and every model (both SIG and vendor) inside the device.
Once the Configuration Client has this data it can bind AppKeys to models and set up subscriptions — effectively wiring the device into the network.
/* ── BlueZ mesh/node.c — building Composition Data Page 0 ── */
/*
* Composition Data Page 0 wire format (little-endian):
*
* [2] CID — Company Identifier
* [2] PID — Product Identifier
* [2] VID — Version Identifier
* [2] CRPL — Replay Protection List size
* [2] Features (Relay/Proxy/Friend/LPN bits)
*
* For each element:
* [2] LOC — location descriptor
* [1] NumS — number of SIG models
* [1] NumV — number of vendor models
* [NumS * 2] SIG model IDs
* [NumV * 4] Vendor model IDs (CID + ModelID)
*/
static bool build_composition_data(struct mesh_node *node,
uint8_t *buf,
uint16_t buf_len,
uint16_t *out_len)
{
uint8_t *ptr = buf;
/* Header */
put_le16(node->comp.cid, ptr); ptr += 2;
put_le16(node->comp.pid, ptr); ptr += 2;
put_le16(node->comp.vid, ptr); ptr += 2;
put_le16(node->comp.crpl, ptr); ptr += 2;
put_le16(node->comp.feat, ptr); ptr += 2;
/* Walk each element */
const struct l_queue_entry *e;
for (e = l_queue_get_entries(node->elements); e; e = e->next) {
struct node_element *ele = e->data;
uint8_t n_sig = 0, n_vendor = 0;
/* Count SIG vs vendor models */
const struct l_queue_entry *m;
for (m = l_queue_get_entries(ele->models); m; m = m->next) {
struct mesh_model *mod = m->data;
if (mod->id <= 0xffff) n_sig++;
else n_vendor++;
}
put_le16(ele->location, ptr); ptr += 2;
*ptr++ = n_sig;
*ptr++ = n_vendor;
/* Write SIG model IDs */
for (m = l_queue_get_entries(ele->models); m; m = m->next) {
struct mesh_model *mod = m->data;
if (mod->id <= 0xffff) {
put_le16((uint16_t)mod->id, ptr);
ptr += 2;
}
}
/* Write vendor model IDs */
for (m = l_queue_get_entries(ele->models); m; m = m->next) {
struct mesh_model *mod = m->data;
if (mod->id > 0xffff) {
put_le16((uint16_t)(mod->id >> 16), ptr); ptr += 2; /* CID */
put_le16((uint16_t)(mod->id), ptr); ptr += 2; /* MID */
}
}
}
*out_len = (uint16_t)(ptr - buf);
return true;
}
After provisioning from the Linux side with bluetooth-meshd, use the mesh-cfgclient tool to request Composition Data Page 0 from the newly provisioned node.
/* ── tools/mesh-cfgclient.c — requesting Composition Data ── */
/*
* After provisioning, the Configuration Client sends:
* Config Composition Data Get (opcode 0x8008)
* Page = 0x00
*
* The target node replies with:
* Config Composition Data Status (opcode 0x02)
* containing the full Page 0 payload.
*/
#define OP_DEV_COMP_GET 0x8008
#define OP_DEV_COMP_STATUS 0x0002
static void cfg_composition_get(uint16_t dest_unicast)
{
uint8_t msg[2];
uint16_t len;
len = mesh_model_opcode_set(OP_DEV_COMP_GET, msg);
msg[len++] = 0x00; /* Page 0 */
/* Send via Configuration Model using the DevKey */
mesh_model_send(node, 0, /* source element index */
dest_unicast, /* target unicast addr */
APP_IDX_DEV_LOCAL, /* uses Device Key */
USE_DEFAULT_TTL,
msg, len);
}
/* ── Parsing the response — reading element/model list ── */
static void parse_comp_data_page0(const uint8_t *data, uint16_t len)
{
const uint8_t *ptr = data;
uint16_t cid = get_le16(ptr); ptr += 2;
uint16_t pid = get_le16(ptr); ptr += 2;
uint16_t vid = get_le16(ptr); ptr += 2;
uint16_t crpl = get_le16(ptr); ptr += 2;
uint16_t feat = get_le16(ptr); ptr += 2;
printf("CID=0x%04x PID=0x%04x VID=0x%04x CRPL=%u feat=0x%04x\n",
cid, pid, vid, crpl, feat);
uint8_t ele_idx = 0;
while ((ptr - data) < len) {
uint16_t loc = get_le16(ptr); ptr += 2;
uint8_t n_sig = *ptr++;
uint8_t n_vnd = *ptr++;
printf("Element #%u loc=0x%04x SIG=%u Vendor=%u\n",
ele_idx++, loc, n_sig, n_vnd);
for (uint8_t i = 0; i < n_sig; i++) {
uint16_t mid = get_le16(ptr); ptr += 2;
printf(" SIG Model 0x%04x\n", mid);
}
for (uint8_t i = 0; i < n_vnd; i++) {
uint16_t cid_v = get_le16(ptr); ptr += 2;
uint16_t mid_v = get_le16(ptr); ptr += 2;
printf(" Vendor Model CID=0x%04x MID=0x%04x\n", cid_v, mid_v);
}
}
}
Quick Summary
- Element — smallest independently addressable unit inside a node
- Primary element — mandatory, has the node’s base unicast address
- Secondary elements — optional, addressed as base + N
- State — a persistent value held by a server model
- State binding — a spec-defined rule that links two states so changing one updates the other
- One state type per element — multiple instances spread across elements, not stacked on one
- Model association — the link between a root model and its extended model, possibly on different elements
- Composite device — one physical device with multiple element groups, each a full functional unit
- Composition Data — the binary manifest a Configuration Client reads after provisioning to learn the full element/model layout
What’s Next?
With models and elements covered, the next topic is Provisioning — how a new device joins the mesh network and gets its addresses, keys, and Composition Data read.
