Introduction
zigbee-rs is a complete Zigbee PRO R22 protocol stack written in Rust,
targeting embedded no_std environments. It runs on real hardware — ESP32,
nRF52, BL702, and more — yet the same code compiles and runs on your laptop
for rapid iteration without touching a single wire.
47,800+ lines of Rust · 161 source files · 9 crates · 33 ZCL clusters · 10 hardware backends
Why Rust for Zigbee?
Zigbee stacks have traditionally been written in C, shipping as opaque vendor blobs tied to a specific chipset. zigbee-rs takes a different approach:
- Memory safety without a runtime. Rust’s ownership model eliminates buffer overflows, use-after-free, and data races at compile time — exactly the classes of bugs that plague C-based embedded stacks.
#![no_std]and zero heap allocation. The entire stack builds without the standard library. Bounded collections fromheaplessreplaceVecandHashMap, so every buffer size is known at compile time.async/awaiton bare metal. The MAC layer is an async trait with 13 methods. Embassy,pollster, or any single-threaded executor can drive the stack — no RTOS threads, no mutexes.- Type-safe ZCL. Each cluster is a Rust struct with typed attributes.
You call
temp.set_temperature(2350)instead of stuffing bytes into an anonymous attribute table. The compiler catches mismatched types before your firmware ever runs. - One stack, many chips. The platform-specific code lives behind a single
MacDrivertrait. Swap animpland the same application logic runs on ESP32-C6, nRF52840, BL702, or a host mock.
Project Scope
zigbee-rs is structured as a Cargo workspace with 9 crates, each responsible for one protocol layer:
| Crate | Role |
|---|---|
zigbee-types | Core types — IeeeAddress, ShortAddress, PanId, ChannelMask |
zigbee-mac | IEEE 802.15.4 MAC layer + 10 hardware backends |
zigbee-nwk | Network layer — AODV + tree routing, NWK security, NIB |
zigbee-aps | Application Support — binding, groups, APS security |
zigbee-zdo | Zigbee Device Objects — discovery, binding, network management |
zigbee-bdb | Base Device Behavior — steering, formation, commissioning |
zigbee-zcl | Zigbee Cluster Library — 33 clusters, foundation frames, reporting |
zigbee-runtime | Device builder, power management, NV storage, device templates |
zigbee | Top-level crate — coordinator, router, re-exports |
The ZCL layer implements 33 clusters spanning General, Measurement & Sensing, Lighting, HVAC, Closures, Security, Smart Energy, and Touchlink.
The MAC layer provides 10 hardware backends:
| Backend | Target | Notes |
|---|---|---|
| MockMac | Host (macOS / Linux / Windows) | Full protocol simulation, no hardware |
| ESP32-C6 | riscv32imac-unknown-none-elf | Native 802.15.4 via esp-ieee802154 |
| ESP32-H2 | riscv32imac-unknown-none-elf | Native 802.15.4 via esp-ieee802154 |
| nRF52840 | thumbv7em-none-eabihf | 802.15.4 radio peripheral |
| nRF52833 | thumbv7em-none-eabihf | 802.15.4 radio peripheral |
| BL702 | riscv32imac-unknown-none-elf | Vendor lmac154 FFI |
| CC2340 | thumbv6m-none-eabi | TI SimpleLink SDK stubs |
| Telink B91 | riscv32imac-unknown-none-elf | Telink SDK stubs |
| TLSR8258 | riscv32-unknown-none-elf | Telink SDK stubs (tc32 ISA) |
| PHY6222 | thumbv6m-none-eabi | Pure Rust — zero vendor blobs! |
Current Status
zigbee-rs is functional for end devices (sensors, lights, sleepy devices). The mock examples exercise the full lifecycle — network scan, association, cluster creation, and attribute reporting — and every hardware target builds successfully in CI.
What works today:
- End device join flow (scan → associate → start)
- ZCL cluster creation and typed attribute read/write
- Device builder with templates for common sensor profiles
- MockMac for host-side development and testing
- ESP32-C6/H2, nRF52840/52833, and BL702 firmware that compiles and flashes
- AES-CCM* encryption (via RustCrypto,
no_std)
In development:
- Full coordinator and router operation
- OTA firmware upgrade flow
- Expanded test coverage
- Key management beyond the default Trust Center link key
What You Can Build
With zigbee-rs you can create standard Zigbee Home Automation devices that interoperate with coordinators like Zigbee2MQTT, ZHA, and deCONZ:
- Temperature & humidity sensors — the
mock-sensorexample is a complete starting point - Motion and occupancy detectors — using the IAS Zone and Occupancy clusters
- Smart switches and plugs — On/Off cluster with optional Metering
- Dimmable lights — On/Off + Level Control + Color Control
- Door and window sensors — IAS Zone with contact closure
- Thermostats — HVAC clusters for temperature setpoint control
How This Book Is Organized
This book is divided into six parts:
-
Getting Started — Install the toolchain, run the mock examples, and build your first device. No hardware required.
-
Core Concepts — Walk through each protocol layer: the Device Builder, the event loop, MAC, NWK, APS, ZDO, and BDB commissioning.
-
ZCL Clusters — Learn the ZCL foundation, then dive into each cluster category: General, Measurement, Lighting, HVAC, Closures, Security, and Smart Energy. Includes a guide to writing custom clusters.
-
Platform Guides — Hardware-specific instructions for ESP32, nRF52, BL702, CC2340, Telink, and PHY6222. Covers wiring, flashing, and debugging on each chip.
-
Advanced Topics — Power management for sleepy end devices, NV storage, security, OTA updates, and coordinator/router operation.
-
Reference — API quick reference, PIB attributes, ZCL cluster table, error types, and a glossary of Zigbee terminology.
Ready to get started? Head to the Quick Start to run your first zigbee-rs example in under five minutes.
Quick Start
This chapter gets you from zero to a running Zigbee sensor simulation in under five minutes. No hardware required — the mock MAC backend runs the full protocol stack on your laptop.
Prerequisites
You need a working Rust toolchain. zigbee-rs uses the 2024 edition, so a recent nightly or stable toolchain is required:
# Install Rust (if you haven't already)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Verify your installation
rustc --version
cargo --version
That’s it for the mock examples. No cross-compilation target, no probe, no
SDK — just rustc and cargo.
Hardware targets (ESP32, nRF52, etc.) need additional setup covered in the Platform Guides. The mock examples are the fastest way to explore the stack.
Clone the Repository
git clone https://github.com/faronov/zigbee-rs.git
cd zigbee-rs
Verify the workspace builds:
cargo build
This compiles all 9 library crates and the 4 mock examples. The first build downloads dependencies and takes about a minute; subsequent builds are incremental.
Run the Mock Sensor
The mock-sensor example simulates a Zigbee temperature and humidity sensor.
It exercises the full stack: MAC scanning, network association, ZCL cluster
creation, and attribute reporting.
cargo run -p mock-sensor
What You’ll See
The output walks through seven steps of the device lifecycle:
╔══════════════════════════════════════════════════════╗
║ zigbee-rs Mock Temperature + Humidity Sensor ║
╚══════════════════════════════════════════════════════╝
── Step 1: Configure Mock MAC Layer ──
Created MockMac with IEEE address: AA:BB:CC:DD:11:22:33:44
Added coordinator beacon: PAN 0x1A62, channel 15, LQI 220
Set association response: short addr 0x796F (Success)
── Step 2: Build Sensor Device ──
Built temperature + humidity sensor device
Device type: EndDevice
Profile: Home Automation (0x0104)
Endpoint 1 server clusters:
- Basic (0x0000)
- Power Configuration (0x0001)
- Identify (0x0003)
- Temperature Measurement (0x0402)
- Relative Humidity (0x0405)
Let’s break down what’s happening:
Step 1 — MockMac setup. A simulated MAC layer is created with a pre-configured coordinator beacon and association response. In real firmware these come over the air; here we inject them so the stack has something to find.
Step 2 — DeviceBuilder. The templates::temperature_humidity_sensor()
helper creates a fully configured end device with the right HA profile and
cluster set. Here’s the core of it:
#![allow(unused)]
fn main() {
use zigbee_runtime::templates;
use zigbee_types::*;
let device = templates::temperature_humidity_sensor(mac)
.manufacturer("zigbee-rs")
.model("MockTempHumid-01")
.sw_build("0.1.0")
.channels(ChannelMask::PREFERRED)
.build();
}
Step 3 — Network join. The stack performs the standard Zigbee join sequence using raw MAC primitives:
MLME-RESET— initialize the radioMLME-SCAN(Active)— discover nearby PANsMLME-ASSOCIATE— request a short address from the coordinatorMLME-START— begin operating on the assigned PAN
── Step 3: Network Join Sequence ──
[3a] MLME-RESET.request(setDefaultPIB=true) → OK
[3b] MLME-SCAN.request(Active, preferred channels) → found 1 PAN(s)
PAN[0]: channel 15, LQI 220, association_permit=true
[3c] MLME-ASSOCIATE.request → status=Success, short_addr=0x796F
[3d] MLME-START.request → joined PAN 0x1A62 on channel 15
Steps 4–6 — ZCL clusters. Temperature and humidity clusters are created, sensor readings are simulated, and attributes are read back through the typed cluster API:
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::temperature::TemperatureCluster;
use zigbee_zcl::clusters::humidity::HumidityCluster;
// Values are in hundredths: 2350 = 23.50°C, 6500 = 65.00%
let mut temp = TemperatureCluster::new(-4000, 12500);
let mut humid = HumidityCluster::new(0, 10000);
temp.set_temperature(2350);
humid.set_humidity(6500);
}
── Step 5: Simulate Sensor Readings ──
Reading #1: temperature=23.50°C, humidity=65.00%
Reading #2: temperature=24.10°C, humidity=63.80%
Reading #3: temperature=22.75°C, humidity=71.00%
Reading #4: temperature=18.90°C, humidity=82.50%
The example finishes with a summary confirming that MockMac configuration, MAC-level join, and ZCL attribute read/write all work correctly.
Run the Mock Coordinator
The mock-coordinator example shows the other side of the network — forming
a PAN and accepting joining devices:
cargo run -p mock-coordinator
This example:
- Performs an energy detection scan to pick the quietest channel
- Forms the network with
MLME-STARTas PAN coordinator - Sets up a Trust Center with the default link key
- Simulates three devices joining the network
- Builds a coordinator
ZigbeeDevicewith Basic and Identify clusters
── Step 2: Energy Detection Scan ──
MLME-SCAN.request(ED) → 4 measurements
Channel 11: energy level 180
Channel 15: energy level 45 ← best
Channel 20: energy level 90
Channel 25: energy level 60
Selected channel 15 (energy=45)
── Step 3: NLME-NETWORK-FORMATION ──
MLME-START.request → Network formed!
PAN ID: 0x1A62
Channel: 15
Short address: 0x0000 (coordinator)
Beacon order: 15 (non-beacon)
Association: PERMITTED
The coordinator allocates short addresses to joining devices and distributes the network key through the Trust Center — the same flow that happens on production Zigbee coordinators.
Other Mock Examples
Two more mock examples are available:
# Dimmable light (On/Off + Level Control clusters)
cargo run -p mock-light
# Sleepy end device (full SED lifecycle with polling)
cargo run -p mock-sleepy-sensor
Running Tests
The workspace includes integration tests that exercise protocol encoding, cluster behavior, and MAC primitives:
cargo test
You can also run the linter and formatter to match the project’s CI checks:
cargo clippy --workspace
cargo fmt --check
Next Steps
You’ve seen the stack in action without any hardware. From here:
- Your First Device — Build a custom sensor from
scratch using the
DeviceBuilderAPI, picking your own clusters and endpoint configuration. - Architecture Overview — Understand how the 9 crates fit together and how data flows from radio to application.
- ESP32-C6 / ESP32-H2 — Flash real firmware to hardware and join a live Zigbee network.
Your First Device
In this tutorial you will build a Zigbee temperature sensor from scratch — without using the built-in templates — so you understand every piece of the API. At the end you will have a working device that:
- Joins a Zigbee network
- Reports temperature readings
- Runs on your desktop using the mock MAC backend (no hardware needed)
Prerequisites: Rust 2024 edition toolchain (
rustup default nightly). The workspace already compiles withcargo build.
Step 1 — Create a New Cargo Project
From the repository root:
cargo init examples/my-temp-sensor
Edit examples/my-temp-sensor/Cargo.toml:
[package]
name = "my-temp-sensor"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "my-temp-sensor"
path = "src/main.rs"
[dependencies]
zigbee-types = { path = "../../zigbee-types" }
zigbee-mac = { path = "../../zigbee-mac", features = ["mock"] }
zigbee-nwk = { path = "../../zigbee-nwk" }
zigbee-zcl = { path = "../../zigbee-zcl" }
zigbee-runtime = { path = "../../zigbee-runtime" }
pollster = "0.4"
The mock feature on zigbee-mac enables MockMac — a simulated 802.15.4
radio that lets you test the full stack on your host machine.
Step 2 — Set Up the Mock MAC
Every Zigbee device needs a MAC layer to talk to the radio. MockMac simulates
one by letting you inject beacons and association responses:
use zigbee_mac::mock::MockMac;
use zigbee_mac::primitives::*;
use zigbee_types::*;
// Each device needs a unique IEEE address (8 bytes)
let ieee_addr: IeeeAddress = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
let mut mac = MockMac::new(ieee_addr);
Next, simulate a coordinator that the sensor will join:
// The coordinator's PAN and address
let coordinator_pan = PanId(0x1A62);
let coordinator_addr = ShortAddress(0x0000);
let extended_pan_id: IeeeAddress = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
// Inject a beacon so the sensor "sees" the coordinator during scan
mac.add_beacon(PanDescriptor {
channel: 15,
coord_address: MacAddress::Short(coordinator_pan, coordinator_addr),
superframe_spec: SuperframeSpec {
beacon_order: 15,
superframe_order: 15,
final_cap_slot: 15,
battery_life_ext: false,
pan_coordinator: true,
association_permit: true,
},
lqi: 220,
security_use: false,
zigbee_beacon: ZigbeeBeaconPayload {
protocol_id: 0x00,
stack_profile: 2, // ZigBee PRO
protocol_version: 2,
router_capacity: true,
device_depth: 0,
end_device_capacity: true,
extended_pan_id,
tx_offset: [0xFF, 0xFF, 0xFF],
update_id: 0,
},
});
// Pre-configure the association response the sensor will receive
let assigned_address = ShortAddress(0x1234);
mac.set_associate_response(MlmeAssociateConfirm {
short_address: assigned_address,
status: AssociationStatus::Success,
});
Step 3 — Define the Endpoint
A Zigbee endpoint groups related clusters under a profile and device ID. For a Home Automation temperature sensor:
| Field | Value | Meaning |
|---|---|---|
| Endpoint | 1 | Application endpoints are 1–240 |
| Profile ID | 0x0104 | Home Automation |
| Device ID | 0x0302 | Temperature Sensor |
We use DeviceBuilder to define this. The builder uses a fluent API where you
chain .endpoint() calls with a closure that configures clusters:
use zigbee_runtime::ZigbeeDevice;
use zigbee_nwk::DeviceType;
let device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("zigbee-rs-tutorial")
.model("MyTempSensor-01")
.sw_build("0.1.0")
.channels(ChannelMask::PREFERRED)
.endpoint(1, 0x0104, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0402) // Temperature Measurement
})
.build();
cluster_server(0x0000)— the Basic cluster is mandatory on every endpoint. It holds the manufacturer name, model, and software version.cluster_server(0x0402)— the Temperature Measurement cluster. It exposesMeasuredValue,MinMeasuredValue, andMaxMeasuredValueattributes.
The builder registers these with the ZDO layer so that discovery requests
(Active_EP_req, Simple_Desc_req) return the correct data to coordinators
and gateways.
Step 4 — Create Cluster Instances
The DeviceBuilder registers which cluster IDs exist on each endpoint, but
the actual attribute storage lives in cluster structs that you create
separately:
use zigbee_zcl::clusters::basic::BasicCluster;
use zigbee_zcl::clusters::temperature::TemperatureCluster;
// Basic cluster — holds device identity
let mut basic = BasicCluster::new(
b"zigbee-rs-tutorial", // manufacturer name
b"MyTempSensor-01", // model identifier
b"20250101", // date code
b"0.1.0", // SW build ID
);
basic.set_power_source(0x03); // Battery
// Temperature cluster — range -40.00°C to +125.00°C
// Values are in hundredths of a degree: -4000 = -40.00°C
let mut temp = TemperatureCluster::new(-4000, 12500);
Step 5 — Join the Network
Call device.start() to run BDB commissioning. This performs:
- MAC reset — initialize the radio
- Active scan — find nearby coordinators via beacons
- Association — join the best network and receive a short address
Since start() is async, we use pollster::block_on on the host:
pollster::block_on(async {
match device.start().await {
Ok(addr) => println!("Joined! Short address: 0x{:04X}", addr),
Err(e) => println!("Join failed: {:?}", e),
}
});
On real hardware with Embassy you would just .await directly inside an
#[embassy_executor::main] task.
Step 6 — Update Temperature and Tick
In a real sensor you would read the ADC or I²C sensor periodically. Here we simulate it:
use zigbee_runtime::ClusterRef;
// Update the temperature: 2350 = 23.50°C
temp.set_temperature(2350);
To drive the stack — send queued reports, handle incoming frames, manage power —
call device.tick():
pollster::block_on(async {
let mut clusters = [
ClusterRef { endpoint: 1, cluster: &mut basic },
ClusterRef { endpoint: 1, cluster: &mut temp },
];
let result = device.tick(10, &mut clusters).await;
println!("Tick result: {:?}", result);
});
tick(elapsed_secs, clusters) takes:
elapsed_secs— seconds since the last tick (drives the reporting timer)clusters— mutable references to your cluster instances so the runtime can read attributes for reports and dispatch incoming commands
Step 7 — Verify Attribute Values
You can read back attributes at any time through the Cluster trait:
use zigbee_zcl::clusters::Cluster;
use zigbee_zcl::clusters::temperature::ATTR_MEASURED_VALUE;
use zigbee_zcl::data_types::ZclValue;
let attrs = temp.attributes();
if let Some(ZclValue::I16(val)) = attrs.get(ATTR_MEASURED_VALUE) {
println!("Temperature: {:.2}°C", *val as f64 / 100.0);
}
Full Working Example
Here is the complete src/main.rs — paste this into
examples/my-temp-sensor/src/main.rs and run with cargo run -p my-temp-sensor:
use zigbee_mac::mock::MockMac;
use zigbee_mac::primitives::*;
use zigbee_nwk::DeviceType;
use zigbee_runtime::{ClusterRef, ZigbeeDevice};
use zigbee_types::*;
use zigbee_zcl::clusters::basic::BasicCluster;
use zigbee_zcl::clusters::temperature::{TemperatureCluster, ATTR_MEASURED_VALUE};
use zigbee_zcl::clusters::Cluster;
use zigbee_zcl::data_types::ZclValue;
fn main() {
// ── 1. Set up MockMac ──────────────────────────────────────────
let ieee_addr: IeeeAddress = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
let mut mac = MockMac::new(ieee_addr);
let coordinator_pan = PanId(0x1A62);
let coordinator_addr = ShortAddress(0x0000);
let extended_pan_id: IeeeAddress =
[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
mac.add_beacon(PanDescriptor {
channel: 15,
coord_address: MacAddress::Short(coordinator_pan, coordinator_addr),
superframe_spec: SuperframeSpec {
beacon_order: 15,
superframe_order: 15,
final_cap_slot: 15,
battery_life_ext: false,
pan_coordinator: true,
association_permit: true,
},
lqi: 220,
security_use: false,
zigbee_beacon: ZigbeeBeaconPayload {
protocol_id: 0x00,
stack_profile: 2,
protocol_version: 2,
router_capacity: true,
device_depth: 0,
end_device_capacity: true,
extended_pan_id,
tx_offset: [0xFF, 0xFF, 0xFF],
update_id: 0,
},
});
mac.set_associate_response(MlmeAssociateConfirm {
short_address: ShortAddress(0x1234),
status: AssociationStatus::Success,
});
// ── 2. Build the device (no template) ──────────────────────────
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("zigbee-rs-tutorial")
.model("MyTempSensor-01")
.sw_build("0.1.0")
.channels(ChannelMask::PREFERRED)
.endpoint(1, 0x0104, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0402) // Temperature Measurement
})
.build();
// ── 3. Create cluster instances ────────────────────────────────
let mut basic = BasicCluster::new(
b"zigbee-rs-tutorial",
b"MyTempSensor-01",
b"20250101",
b"0.1.0",
);
basic.set_power_source(0x03); // Battery
let mut temp = TemperatureCluster::new(-4000, 12500);
// ── 4. Join the network ────────────────────────────────────────
pollster::block_on(async {
match device.start().await {
Ok(addr) => {
println!("Joined network as 0x{:04X}", addr);
println!(" Channel: {}", device.channel());
println!(" PAN ID: 0x{:04X}", device.pan_id());
}
Err(e) => {
println!("Join failed: {:?}", e);
return;
}
}
// ── 5. Simulate sensor readings ────────────────────────────
let readings: &[i16] = &[2350, 2410, 2275, 1890];
for (i, &value) in readings.iter().enumerate() {
temp.set_temperature(value);
// Read back via the Cluster trait
if let Some(ZclValue::I16(v)) = temp.attributes().get(ATTR_MEASURED_VALUE) {
println!(
" Reading #{}: {:.2}°C",
i + 1,
*v as f64 / 100.0
);
}
// ── 6. Tick the stack ──────────────────────────────────
let mut clusters = [
ClusterRef { endpoint: 1, cluster: &mut basic },
ClusterRef { endpoint: 1, cluster: &mut temp },
];
let _result = device.tick(10, &mut clusters).await;
}
});
println!("Done!");
}
What Each Part Does
| Section | Purpose |
|---|---|
| MockMac setup | Creates a simulated radio with a fake coordinator beacon so the device can scan and associate without real hardware. |
ZigbeeDevice::builder(mac) | Constructs the full BDB→ZDO→APS→NWK→MAC layer stack. The .endpoint() call registers clusters with the ZDO so discovery works. |
BasicCluster::new(...) | Creates the attribute store for the Basic cluster. Every Zigbee endpoint must have one — it tells the coordinator your manufacturer and model. |
TemperatureCluster::new(-4000, 12500) | Creates the Temperature Measurement attribute store with a valid range of −40.00 °C to +125.00 °C. Values are in hundredths of a degree. |
device.start().await | Runs BDB commissioning: MAC reset → active scan → association → NWK join. Returns the assigned short address. |
temp.set_temperature(2350) | Updates the MeasuredValue attribute to 23.50 °C. On the next reporting interval, the runtime will send this to the coordinator. |
device.tick(10, &mut clusters) | Drives one iteration of the event loop: sends queued reports, processes pending user actions, and manages APS retransmissions. The 10 means “10 seconds since last tick”. |
Running the Example
cargo run -p my-temp-sensor
Expected output:
Joined network as 0x1234
Channel: 15
PAN ID: 0x1A62
Reading #1: 23.50°C
Reading #2: 24.10°C
Reading #3: 22.75°C
Reading #4: 18.90°C
Done!
Next Steps
- Add humidity — add
cluster_server(0x0405)to the endpoint and create aHumidityCluster. See themock-sensorexample for a complete temp+humidity device. - Use a template —
zigbee_runtime::templates::temperature_sensor(mac)gives you a pre-configuredDeviceBuilderwith Basic, Power Config, Identify, and Temperature clusters already set up. - Run on real hardware — swap
MockMacfor a platform-specific MAC backend (e.g.,Esp32Mac,NrfMac) and use Embassy as the async executor. See the Platform Guides. - Configure reporting — set up periodic attribute reports so Home Assistant / Zigbee2MQTT receives temperature updates automatically.
Architecture Overview
zigbee-rs is a complete Zigbee PRO R22 protocol stack written in Rust, split
across 9 crates that mirror the standard Zigbee layer model. Every crate is
#![no_std] and heap-free — suitable for the smallest microcontrollers.
Layer Diagram
┌─────────────────────────────────────┐
│ Application (your code) │
├─────────────────────────────────────┤
│ zigbee-runtime (ZigbeeDevice) │
├──────┬──────┬───────┬──────┬───────┤
│ BDB │ ZCL │ ZDO │ APS │ │
├──────┴──────┴───────┴──────┤ │
│ zigbee-nwk │ types │
├─────────────────────────────┤ │
│ zigbee-mac │ │
├─────────────────────────────┴───────┤
│ Hardware (radio) │
└─────────────────────────────────────┘
The top-level zigbee crate re-exports everything and adds
coordinator/router role support. Most applications interact with the
zigbee-runtime layer through ZigbeeDevice.
Crate Roles
| Crate | Role |
|---|---|
zigbee-types | Core types shared by all layers: IeeeAddress, ShortAddress, PanId, ChannelMask, MacAddress. No dependencies. |
zigbee-mac | IEEE 802.15.4 MAC layer. Defines the async MacDriver trait (13 methods) and ships 8+ backends: MockMac, ESP32-C6/H2, nRF52840/52833, BL702, CC2340, Telink B91/TLSR8258, PHY6222. |
zigbee-nwk | Network layer. Frame parsing, AODV + tree routing, NWK security (AES-CCM*), the NIB (Network Information Base), and the NwkLayer<M: MacDriver> wrapper. |
zigbee-aps | Application Support Sub-layer. APS frame encode/decode, binding table, group table, APS security, fragmentation, and duplicate detection. |
zigbee-zdo | Zigbee Device Objects (endpoint 0). Handles discovery (Active_EP_req, Simple_Desc_req, Match_Desc_req), binding, and network management requests. |
zigbee-bdb | Base Device Behavior. Implements BDB commissioning: network steering (end devices join), network formation (coordinators create), Finding & Binding, and Touchlink. |
zigbee-zcl | Zigbee Cluster Library. 33 clusters, foundation commands (Read/Write/Report/Discover Attributes), attribute storage engine, and reporting engine. |
zigbee-runtime | The integration layer your application uses. Provides DeviceBuilder, ZigbeeDevice, the event loop (tick() / process_incoming()), NV storage abstraction, power management, and pre-built device templates. |
zigbee | Top-level umbrella crate. Re-exports all sub-crates and adds coordinator/router role implementations. |
Data Flow
TX Path (Application → Radio)
When your application updates an attribute or sends a report, data flows down through the stack:
Application
│ set_temperature(2350)
▼
ZCL serialize attribute report frame
│
▼
APS wrap in APS Data Request, add APS header + security
│
▼
NWK add NWK header, route lookup, NWK encryption (AES-CCM*)
│
▼
MAC add MAC header, CRC, call MacDriver::mcps_data_request()
│
▼
Radio 802.15.4 RF transmission
In code, this is what happens when the runtime’s tick() method detects a due
attribute report:
// Inside tick() → check_and_send_cluster_reports() → send_report()
// builds ZCL frame → APS Data Request → NWK Data Request → MAC Data Request
RX Path (Radio → Application)
Incoming frames flow up. The application drives this by calling
device.receive() and then device.process_incoming():
Radio 802.15.4 frame received
│
▼
MAC MacDriver::mcps_data_indication() returns raw frame
│
▼
NWK parse NWK header, verify destination, decrypt if secured
│
▼
APS parse APS header, de-duplicate, reassemble fragments
│
▼
ZDO / ZCL endpoint 0 → ZDO handles automatically
endpoints 1-240 → ZCL dispatches to your clusters
│
▼
Application StackEvent returned to your code
Async Model
zigbee-rs is designed for single-threaded cooperative async runtimes, primarily Embassy:
no_stdthroughout — no heap allocation, nostd::thread, no OS.asyncwithoutSend/Sync— theMacDrivertrait usesasync fnmethods with noSendbounds, matching Embassy’s single-core executor model.stack_tick()polling — your main loop callsdevice.tick(elapsed_secs, clusters)periodically. Between ticks the executor can run other tasks (sensor reads, display updates, button debouncing). The runtime never blocks indefinitely.select!pattern — the idiomatic event loop usesembassy_futures::selectto racedevice.receive()against a timer, processing whichever fires first:
loop {
match select(device.receive(), Timer::after(Duration::from_secs(10))).await {
Either::First(Ok(frame)) => {
device.process_incoming(&frame, &mut clusters).await;
}
Either::First(Err(_)) => {} // MAC error, retry
Either::Second(_) => {
// Timer fired — run periodic maintenance
device.tick(10, &mut clusters).await;
}
}
}
On host machines (mock examples), pollster::block_on replaces Embassy as the
executor, so the same stack code compiles for both embedded and desktop.
Memory Model
Every buffer and collection in zigbee-rs has a compile-time upper bound:
heapless::Vec<T, N>— fixed-capacity vectors for endpoint lists, cluster lists, pending responses, and frame buffers. Noalloccrate needed.- Const generics — limits like
MAX_ENDPOINTS(8) andMAX_CLUSTERS_PER_ENDPOINT(16) areconstvalues, so the compiler knows the exact memory footprint at build time. - Static allocation —
ZigbeeDeviceand all its nested layers (BdbLayer<M>→ZdoLayer→ApsLayer→NwkLayer<M>→M) live on the stack or in astaticcell. There is noBox,Rc, orArc. - No
serde— frame serialization/deserialization uses manual bitfield parsing, keeping binary size small and avoiding trait-object overhead.
This means you can predict the exact RAM usage of a zigbee-rs device at compile time — critical for microcontrollers with 32–64 KB of SRAM.
Typical Memory Budget
| Component | Approximate Size |
|---|---|
ZigbeeDevice (full stack) | ~4–6 KB |
| Each ZCL cluster instance | 100–500 bytes |
| NWK routing table | ~200 bytes |
| APS binding + group tables | ~300 bytes |
| Frame buffers (TX + RX) | ~256 bytes each |
Layer Nesting
Each layer wraps the one below it using generics, not trait objects:
ZigbeeDevice<M: MacDriver>
└── BdbLayer<M>
└── ZdoLayer<M>
└── ApsLayer<M>
└── NwkLayer<M>
└── M // your MacDriver (MockMac, Esp32Mac, ...)
This means the concrete MAC type propagates all the way up. There is zero dynamic dispatch in the stack path — the compiler monomorphizes everything, producing tight, inlineable code for each target platform.
What’s Next?
- Your First Device — build a temperature sensor step by step
- The Device Builder — detailed builder API reference
- The Event Loop — how
tick()andprocess_incoming()work
The Device Builder
Every zigbee-rs application starts the same way: you describe what your device
is, and the builder assembles the full Zigbee stack for you. The
DeviceBuilder pattern lets you configure addresses, channels, endpoints,
clusters, power mode, and device metadata in a single fluent chain — then call
.build() to get a ready-to-run ZigbeeDevice.
Creating a Builder
The entry point is always ZigbeeDevice::builder(mac), where mac is your
platform’s MacDriver implementation:
use zigbee_runtime::ZigbeeDevice;
use zigbee_mac::esp::EspMac; // or nrf::NrfMac, mock::MockMac, …
let mac = EspMac::new();
let device = ZigbeeDevice::builder(mac)
// ... configuration ...
.build();
Under the hood this calls DeviceBuilder::new(mac), which sets sensible
defaults:
| Field | Default |
|---|---|
device_type | DeviceType::EndDevice |
channel_mask | ChannelMask::ALL_2_4GHZ |
power_mode | PowerMode::AlwaysOn |
manufacturer | "zigbee-rs" |
model | "Generic" |
sw_build | "0.1.0" |
date_code | "" |
You only override what you need.
Configuration Methods
Device Type
Set the Zigbee role — this affects how the stack joins and routes:
use zigbee_nwk::DeviceType;
// End Device — joins a network, does not route (default)
builder.device_type(DeviceType::EndDevice)
// Router — joins a network and relays frames for others
builder.device_type(DeviceType::Router)
// Coordinator — forms a new network (PAN coordinator)
builder.device_type(DeviceType::Coordinator)
Channel Mask
Control which 2.4 GHz channels (11–26) the device scans when joining:
use zigbee_types::ChannelMask;
// Scan all channels (default)
builder.channels(ChannelMask::ALL_2_4GHZ)
// Scan only channels 15, 20, and 25
builder.channels(ChannelMask::from_channels(&[15, 20, 25]))
// Single channel — useful for testing
builder.channels(ChannelMask::single(15))
Power Mode
Determines sleep behavior. This also sets rx_on_when_idle in the MAC
capability info sent during association:
use zigbee_runtime::power::PowerMode;
// Always on — router or mains-powered end device (default)
builder.power_mode(PowerMode::AlwaysOn)
// Sleepy End Device — wakes to poll periodically
builder.power_mode(PowerMode::Sleepy {
poll_interval_ms: 5_000, // poll parent every 5 s
wake_duration_ms: 500, // stay awake 500 ms after activity
})
// Deep sleep — wake only on timer (extreme battery savings)
builder.power_mode(PowerMode::DeepSleep {
wake_interval_s: 3600, // wake once per hour
})
When PowerMode::Sleepy or PowerMode::DeepSleep is set, the builder
automatically calls nwk.set_rx_on_when_idle(false) so the coordinator knows
this is a Sleepy End Device and will buffer frames for it.
Device Metadata
These values populate the Basic cluster (0x0000) attributes that Zigbee coordinators and tools like Zigbee2MQTT read during device interview:
builder
.manufacturer("Acme Corp") // ManufacturerName (attr 0x0004)
.model("TempSensor-v2") // ModelIdentifier (attr 0x0005)
.sw_build("1.3.0") // SWBuildID (attr 0x4000)
.date_code("20260101") // DateCode (attr 0x0006)
Adding Endpoints
Zigbee devices expose functionality through endpoints (1–240). Each endpoint has a profile ID, a device ID, and a set of server/client clusters.
Use the .endpoint() method with a closure that configures the endpoint’s
clusters:
builder.endpoint(
1, // endpoint number (1-240)
0x0104, // profile ID: Home Automation
0x0302, // device ID: Temperature Sensor
|ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0001) // Power Configuration
.cluster_server(0x0003) // Identify
.cluster_server(0x0402) // Temperature Measurement
},
)
EndpointBuilder Methods
The closure receives an EndpointBuilder with these methods:
| Method | Description |
|---|---|
cluster_server(id) | Add a server-side cluster (you implement it) |
cluster_client(id) | Add a client-side cluster (you send commands) |
device_version(v) | Set the device version (default: 1) |
Server clusters are clusters your device implements — other devices can read attributes and send commands to them. Client clusters are clusters your device sends commands to — for example, a light switch has On/Off as a client cluster.
You can add up to 16 clusters per endpoint and 8 endpoints per device.
Multiple Endpoints
Some devices expose multiple functions. For example, a multi-sensor:
builder
.endpoint(1, 0x0104, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0402) // Temperature
})
.endpoint(2, 0x0104, 0x0302, |ep| {
ep.cluster_server(0x0405) // Relative Humidity
})
.endpoint(3, 0x0104, 0x0402, |ep| {
ep.cluster_server(0x0500) // IAS Zone (contact)
})
Using Templates
For common device types, zigbee-rs provides pre-built templates in
zigbee_runtime::templates that set the correct device type, endpoint,
profile, device ID, and clusters for you:
use zigbee_runtime::templates;
// Temperature sensor (endpoint 1, device ID 0x0302)
// Clusters: Basic, Power Config, Identify, Temperature Measurement
let device = templates::temperature_sensor(mac)
.manufacturer("My Company")
.model("TH-Sensor-01")
.build();
Templates return a DeviceBuilder, so you can chain additional configuration
after them.
Available Templates
| Template | Device ID | Type | Key Clusters |
|---|---|---|---|
temperature_sensor | 0x0302 | EndDevice | Basic, PowerCfg, Identify, Temp |
temperature_humidity_sensor | 0x0302 | EndDevice | + Relative Humidity |
on_off_light | 0x0100 | Router | Basic, Identify, Groups, Scenes, On/Off |
dimmable_light | 0x0101 | Router | + Level Control |
color_temperature_light | 0x010C | Router | + Color Control |
contact_sensor | 0x0402 | EndDevice | Basic, PowerCfg, Identify, IAS Zone |
occupancy_sensor | 0x0107 | EndDevice | Basic, PowerCfg, Identify, Occupancy |
smart_plug | 0x0009 | Router | Basic, Identify, Groups, Scenes, On/Off, Electrical Meas |
thermostat | 0x0301 | Router | Basic, Identify, Groups, Thermostat, Temp |
Note: Templates set the device type for you. Lights and plugs default to
Router(they’re mains-powered and relay traffic). Sensors default toEndDevice.
Building the Device
Once configuration is complete, call .build() to construct the full stack:
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Acme Corp")
.model("TempSensor-v2")
.sw_build("1.3.0")
.channels(ChannelMask::from_channels(&[15, 20, 25]))
.power_mode(PowerMode::Sleepy {
poll_interval_ms: 5_000,
wake_duration_ms: 500,
})
.endpoint(1, 0x0104, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0001) // Power Configuration
.cluster_server(0x0003) // Identify
.cluster_server(0x0402) // Temperature Measurement
.cluster_server(0x0405) // Relative Humidity
})
.build();
What .build() Does
The builder constructs the entire layer stack:
- Creates the NWK layer with the MAC driver and device type
- Sets
rx_on_when_idlebased on power mode - Wraps NWK in the APS layer
- Wraps APS in the ZDO layer and registers all endpoint descriptors
- Sets the node descriptor (logical type, power descriptor)
- Wraps ZDO in the BDB layer for commissioning
- Creates the ReportingEngine for automatic attribute reporting
- Creates the PowerManager with the configured power mode
The result is a ZigbeeDevice<M> ready for start() and the event loop.
Complete Example
Here’s a full example of a battery-powered temperature + humidity sensor:
use zigbee_runtime::{ZigbeeDevice, ClusterRef, UserAction};
use zigbee_runtime::power::PowerMode;
use zigbee_mac::nrf::NrfMac;
use zigbee_nwk::DeviceType;
use zigbee_types::ChannelMask;
#[embassy_executor::main]
async fn main(spawner: embassy_executor::Spawner) {
let mac = NrfMac::new(/* peripherals */);
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Acme Corp")
.model("TH-Sensor-01")
.sw_build("1.3.0")
.date_code("20260325")
.channels(ChannelMask::ALL_2_4GHZ)
.power_mode(PowerMode::Sleepy {
poll_interval_ms: 7_500,
wake_duration_ms: 500,
})
.endpoint(1, 0x0104, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0001) // Power Configuration
.cluster_server(0x0003) // Identify
.cluster_server(0x0402) // Temperature Measurement
.cluster_server(0x0405) // Relative Humidity
})
.build();
// Join the network
device.user_action(UserAction::Join);
// ... enter event loop (see Event Loop chapter)
}
What’s Next
After building, you need to:
- Start the event loop — call
tick()andprocess_incoming()in a loop to drive the stack - Register cluster instances — pass
ClusterRefslices totick()so the runtime can handle attribute reads/writes and send reports - Persist state — call
save_state(nv)after joining so the device can rejoin quickly after reboot
The Event Loop
The event loop is the heartbeat of every zigbee-rs device. It drives all stack processing — scanning, joining, routing, ZCL command handling, and attribute reporting — by calling two functions in a loop:
device.tick(elapsed_secs, clusters)— periodic processingdevice.process_incoming(frame, clusters)— handle received frames
zigbee-rs uses cooperative async scheduling: you own the loop, the stack never blocks indefinitely, and you decide when to sleep or read sensors.
The Basic Pattern
use embassy_futures::select::{select, Either};
use embassy_time::{Duration, Timer};
loop {
match select(
device.receive(),
Timer::after(Duration::from_secs(10)),
).await {
// Incoming MAC frame — process through the stack
Either::First(Ok(frame)) => {
if let Some(event) = device.process_incoming(&frame, &mut clusters).await {
handle_event(event);
}
}
Either::First(Err(_)) => {} // MAC receive error, retry
// Timer fired — tick reporting engine and read sensors
Either::Second(_) => {
let result = device.tick(10, &mut clusters).await;
match result {
TickResult::Event(evt) => handle_event(evt),
TickResult::RunAgain(ms) => { /* schedule next tick sooner */ }
TickResult::Idle => {}
}
}
}
}
This select-based pattern is the recommended way to run zigbee-rs on any async
executor (Embassy, async-std, Tokio, etc.).
tick() — The Processing Pipeline
pub async fn tick(
&mut self,
elapsed_secs: u16,
clusters: &mut [ClusterRef<'_>],
) -> TickResult
Every call to tick() runs through these phases in order:
| Phase | What It Does |
|---|---|
| 1. User actions | Drains the pending_action queue — processes Join, Leave, Toggle, PermitJoin, FactoryReset |
| 2. ZCL responses | Sends any queued ZCL response frames (from sync process_incoming() handling) |
| 3. Join check | If not joined to a network, returns Idle early |
| 4. APS maintenance | Ages the APS ACK table, retransmits unacknowledged frames, ages duplicate-detection and fragment tables |
| 5. Reporting timers | Ticks the ZCL reporting engine by elapsed_secs seconds |
| 5b. Find & Bind | Handles Finding & Binding target requests (sets IdentifyTime) |
| 5c. F&B initiator | Ticks the F&B initiator response window |
| 6. Attribute reports | For each registered cluster, checks if any attribute reports are due and sends them |
The elapsed_secs parameter tells the reporting engine how much wall-clock time
has passed since the last tick. Use the actual interval of your timer.
ClusterRef — Connecting Clusters to the Runtime
The runtime needs access to your cluster instances to read attribute values for
reports and handle incoming commands. You pass them as a &mut [ClusterRef]:
use zigbee_runtime::ClusterRef;
let mut temp_cluster = TemperatureMeasurement::new();
let mut basic_cluster = BasicCluster::new();
let mut clusters = [
ClusterRef { endpoint: 1, cluster: &mut basic_cluster },
ClusterRef { endpoint: 1, cluster: &mut temp_cluster },
];
let result = device.tick(10, &mut clusters).await;
TickResult — What Tick Returns
pub enum TickResult {
/// Nothing happened — safe to sleep.
Idle,
/// A stack event occurred — handle it.
Event(StackEvent),
/// Stack needs to run again within this many milliseconds.
RunAgain(u32),
}
Idle— No pending work. Your loop can safely wait for the next frame or timer.Event(evt)— Something happened that your application should know about. See StackEvent below.RunAgain(ms)— The stack has pending work and needstick()called again withinmsmilliseconds. Schedule accordingly.
StackEvent — What the Stack Tells You
StackEvent is the primary way the stack communicates with your application.
Events are returned from both tick() and process_incoming().
Network Lifecycle Events
/// Device successfully joined a network.
StackEvent::Joined {
short_address: u16, // Our assigned NWK address
channel: u8, // Operating channel (11-26)
pan_id: u16, // PAN identifier
}
/// Device left the network.
StackEvent::Left
/// BDB commissioning completed.
StackEvent::CommissioningComplete {
success: bool, // true = joined, false = failed
}
/// Permit joining status changed (coordinator/router).
StackEvent::PermitJoinChanged {
open: bool, // true = accepting joins
}
ZCL Data Events
/// Attribute report received from another device.
StackEvent::AttributeReport {
src_addr: u16, // Source NWK address
endpoint: u8, // Source endpoint
cluster_id: u16, // Cluster the report belongs to
attr_id: u16, // Attribute that was reported
}
/// Cluster-specific command received.
StackEvent::CommandReceived {
src_addr: u16,
endpoint: u8,
cluster_id: u16,
command_id: u8,
seq_number: u8, // ZCL sequence (for responses)
payload: heapless::Vec<u8, 64>,
}
/// Default Response from a remote device.
StackEvent::DefaultResponse {
src_addr: u16,
endpoint: u8,
cluster_id: u16,
command_id: u8, // Command ID this responds to
status: u8, // ZCL status code
}
/// An attribute report was sent successfully.
StackEvent::ReportSent
OTA Events
/// OTA server has a new firmware image available.
StackEvent::OtaImageAvailable {
version: u32,
size: u32,
}
/// OTA download progress.
StackEvent::OtaProgress { percent: u8 }
/// OTA upgrade completed — reboot to apply.
StackEvent::OtaComplete
/// OTA upgrade failed.
StackEvent::OtaFailed
/// OTA server requested delayed activation.
StackEvent::OtaDelayedActivation { delay_secs: u32 }
Maintenance Events
/// Coordinator requested a factory reset via Basic cluster.
StackEvent::FactoryResetRequested
UserAction — What Your App Can Do
Queue actions from button presses, sensors, or application logic. The action is
consumed on the next tick():
pub enum UserAction {
/// Join a network via BDB commissioning.
Join,
/// Leave the current network.
Leave,
/// Toggle: leave if joined, join if not.
Toggle,
/// Open permit joining for N seconds (coordinator/router only).
PermitJoin(u8),
/// Factory reset — leave network and clear all state.
FactoryReset,
}
Use device.user_action(action) to queue:
// Button press handler
if button_pressed {
device.user_action(UserAction::Toggle);
}
Actions are processed at the start of the next tick() call. Only one action
can be pending at a time — if you queue a second action before tick runs, it
replaces the first.
Handling Incoming Frames
pub async fn process_incoming(
&mut self,
indication: &McpsDataIndication,
clusters: &mut [ClusterRef<'_>],
) -> Option<StackEvent>
When a MAC frame arrives (from device.receive() or device.poll()), pass it
to process_incoming(). The stack processes the frame through the full
pipeline:
- NWK layer — parses the NWK header, checks addressing, decrypts if NWK-secured
- APS layer — handles APS framing, duplicate detection, fragmentation reassembly
- ZDO (endpoint 0) — handles device interview commands
(
Node_Desc_req,Active_EP_req,Simple_Desc_req, etc.) and sends responses automatically - ZCL (app endpoints) — dispatches to your registered clusters for attribute read/write/report and command handling
Returns Some(StackEvent) if the frame produced an event your application
should handle, or None if the stack handled it internally.
Sending Attribute Reports
The reporting engine automatically sends reports when they’re due, but you can also send reports explicitly:
use zigbee_zcl::foundation::reporting::ReportAttributes;
let report = ReportAttributes::new()
.add(0x0000, ZclValue::I16(2350)); // MeasuredValue = 23.50°C
device.send_report(1, 0x0402, &report).await?;
Reports are sent to the coordinator (0x0000) using the APS data service with
NWK encryption enabled.
Receiving Frames: receive() and poll()
Two methods for getting incoming frames:
// For always-on devices (routers, mains-powered end devices):
// Blocks until a frame arrives from the radio.
let frame = device.receive().await?;
// For sleepy end devices:
// Sends a MAC Data Request to the parent and returns any queued frame.
if let Some(frame) = device.poll().await? {
device.process_incoming(&frame, &mut clusters).await;
}
Sleepy End Devices should call poll() periodically based on their poll
interval. The power manager can tell you when it’s time:
if device.power().should_poll(now_ms) {
if let Some(frame) = device.poll().await? {
device.process_incoming(&frame, &mut clusters).await;
}
device.power_mut().record_poll(now_ms);
}
Complete Event Loop Example
Here’s a complete event loop for a temperature sensor that reads every 60 seconds and reports automatically:
use embassy_futures::select::{select, Either};
use embassy_time::{Duration, Timer};
use zigbee_runtime::{ClusterRef, UserAction};
use zigbee_runtime::event_loop::{StackEvent, TickResult};
// After device.build()...
let mut temp = TemperatureMeasurement::new();
let mut basic = BasicCluster::new();
let mut clusters = [
ClusterRef { endpoint: 1, cluster: &mut basic },
ClusterRef { endpoint: 1, cluster: &mut temp },
];
// Start by requesting join
device.user_action(UserAction::Join);
loop {
match select(
device.receive(),
Timer::after(Duration::from_secs(60)),
).await {
Either::First(Ok(frame)) => {
if let Some(event) = device.process_incoming(&frame, &mut clusters).await {
match event {
StackEvent::Joined { short_address, channel, pan_id } => {
log::info!(
"Joined! addr=0x{:04X} ch={} pan=0x{:04X}",
short_address, channel, pan_id,
);
// Save state for fast rejoin after reboot
device.save_state(&mut nv_storage);
}
StackEvent::Left => {
log::info!("Left network — will retry...");
device.user_action(UserAction::Join);
}
StackEvent::CommandReceived { cluster_id, command_id, .. } => {
log::info!("Command 0x{:02X} on cluster 0x{:04X}", command_id, cluster_id);
}
StackEvent::FactoryResetRequested => {
device.user_action(UserAction::FactoryReset);
}
_ => {}
}
}
}
Either::First(Err(e)) => {
log::warn!("MAC error: {:?}", e);
}
Either::Second(_) => {
// Timer fired — read sensor and tick the stack
let temperature = read_temperature_sensor();
temp.set_measured_value(temperature);
let result = device.tick(60, &mut clusters).await;
if let TickResult::Event(event) = result {
// Handle events from tick (reports sent, etc.)
match event {
StackEvent::ReportSent => log::debug!("Report sent"),
_ => {}
}
}
}
}
}
Key Points
tick()is cheap — call it often. It returns quickly when there’s nothing to do.- One user action at a time — actions are queued, not stacked.
process_incoming()is async — it may send ZDO responses back through the MAC.- Pass the same
clustersslice to bothtick()andprocess_incoming()so the runtime can read attributes for reports and dispatch commands. - Save state after joining — call
device.save_state(&mut nv)so the device can rejoin quickly after power loss.
MAC Layer & Backends
The MAC (Medium Access Control) layer is the boundary between the
platform-independent Zigbee stack and the hardware-specific 802.15.4 radio.
In zigbee-rs, this boundary is defined by a single trait: MacDriver.
┌──────────────────────────────────────────┐
│ Zigbee Stack (NWK / APS / ZCL / BDB) │ platform-independent
└───────────────┬──────────────────────────┘
│ MacDriver trait
┌───────────────┴──────────────────────────┐
│ MAC backends: esp / nrf / bl702 / … │ platform-specific
└──────────────────────────────────────────┘
Each hardware platform implements MacDriver once (~500 lines of
platform-specific code). The entire upper stack is built against this trait and
never touches hardware directly.
The MacDriver Trait
MacDriver is an async trait covering the minimal complete set of MLME/MCPS
primitives needed for Zigbee PRO R22 operation. All methods are async to
support interrupt-driven radios with Embassy or other async executors.
pub trait MacDriver {
// Scanning
async fn mlme_scan(&mut self, req: MlmeScanRequest)
-> Result<MlmeScanConfirm, MacError>;
// Association
async fn mlme_associate(&mut self, req: MlmeAssociateRequest)
-> Result<MlmeAssociateConfirm, MacError>;
async fn mlme_associate_response(&mut self, rsp: MlmeAssociateResponse)
-> Result<(), MacError>;
async fn mlme_disassociate(&mut self, req: MlmeDisassociateRequest)
-> Result<(), MacError>;
// Management
async fn mlme_reset(&mut self, set_default_pib: bool)
-> Result<(), MacError>;
async fn mlme_start(&mut self, req: MlmeStartRequest)
-> Result<(), MacError>;
// PIB access
async fn mlme_get(&self, attr: PibAttribute)
-> Result<PibValue, MacError>;
async fn mlme_set(&mut self, attr: PibAttribute, value: PibValue)
-> Result<(), MacError>;
// Polling (sleepy devices)
async fn mlme_poll(&mut self)
-> Result<Option<MacFrame>, MacError>;
// Data service
async fn mcps_data(&mut self, req: McpsDataRequest<'_>)
-> Result<McpsDataConfirm, MacError>;
async fn mcps_data_indication(&mut self)
-> Result<McpsDataIndication, MacError>;
// Capability query
fn capabilities(&self) -> MacCapabilities;
}
Method Reference
Scanning
| Method | Description |
|---|---|
mlme_scan(req) | Perform an ED, Active, Passive, or Orphan scan. Scans channels in req.channel_mask for req.scan_duration. Returns discovered PAN descriptors or energy measurements. |
Association
| Method | Description |
|---|---|
mlme_associate(req) | Request association with a coordinator on req.channel. Returns the assigned short address on success. |
mlme_associate_response(rsp) | Coordinator/Router only. Respond to an association request with an assigned address or denial. |
mlme_disassociate(req) | Send a disassociation notification to leave the PAN. |
Management
| Method | Description |
|---|---|
mlme_reset(set_default_pib) | Reset the MAC to its default state. If true, all PIB attributes are reset to defaults. |
mlme_start(req) | Start a PAN (coordinator) or begin beacon transmission (router). End devices do not use this. |
PIB Access
| Method | Description |
|---|---|
mlme_get(attr) | Read a MAC PIB attribute (e.g., short address, PAN ID, channel). |
mlme_set(attr, value) | Write a MAC PIB attribute. |
Polling
| Method | Description |
|---|---|
mlme_poll() | Send a MAC Data Request to the coordinator and return any pending indirect frame. Used by Sleepy End Devices to retrieve queued data. |
Data Service
| Method | Description |
|---|---|
mcps_data(req) | Transmit a MAC frame to req.dst_address with the specified options (ACK, security, indirect). |
mcps_data_indication() | Block until an incoming MAC frame is received from the radio. The caller filters by frame type and addressing. |
Capability Query
| Method | Description |
|---|---|
capabilities() | Returns a MacCapabilities struct describing what this backend supports. |
MacCapabilities
pub struct MacCapabilities {
/// Can act as PAN coordinator
pub coordinator: bool,
/// Can act as router (relay frames)
pub router: bool,
/// Supports MAC-level hardware encryption
pub hardware_security: bool,
/// Maximum frame payload size (typically 102 bytes)
pub max_payload: u16,
/// Minimum supported TX power
pub tx_power_min: TxPower,
/// Maximum supported TX power
pub tx_power_max: TxPower,
}
Available Backends
zigbee-rs ships with MAC backends for a wide range of 802.15.4 radios. Each backend is behind a Cargo feature flag:
| Backend | Feature Flag | Chip(s) | Notes |
|---|---|---|---|
| ESP32 | esp32c6, esp32h2 | ESP32-C6, ESP32-H2 | Espressif IEEE 802.15.4 radio, esp-ieee802154 HAL |
| nRF | nrf52840, nrf52833 | nRF52840, nRF52833 | Nordic 802.15.4 radio, embassy-nrf peripherals |
| BL702 | bl702 | BL702, BL706 | Bouffalo Lab 802.15.4 radio |
| CC2340 | cc2340 | CC2340R5 | TI SimpleLink, Cortex-M0+ with 802.15.4 |
| Telink | telink | B91 (TLSR9518), TLSR8258 | Telink 802.15.4 radios — TLSR8258 is pure Rust (direct register access), B91 uses FFI |
| PHY6222 | phy6222 | PHY6222 | Phyplus BLE+802.15.4 combo SoC |
| EFR32MG1 | efr32 | EFR32MG1P | Silicon Labs Series 1, Cortex-M4F — pure Rust (direct register access) |
| EFR32MG21 | efr32s2 | EFR32MG21 | Silicon Labs Series 2, Cortex-M33 — pure Rust (direct register access) |
| Mock | mock | — | In-memory mock for unit tests and CI |
Choosing a Backend
Enable exactly one backend in your Cargo.toml:
[dependencies]
zigbee-mac = { path = "../zigbee-mac", features = ["esp32c6"] }
The rest of the stack is completely platform-independent — changing backends is a one-line Cargo feature change plus updating the MAC initialization code.
Decision guide:
- ESP32-C6 / ESP32-H2 — Best for Wi-Fi+Zigbee combo (C6) or pure Zigbee (H2). Great ESP-IDF ecosystem and tooling.
- nRF52840 — Best for ultra-low-power battery sensors. Excellent BLE+Zigbee combo. Mature Embassy async support.
- BL702 — Low cost, good for high-volume products.
- CC2340R5 — TI ecosystem, good for industrial applications.
- Telink B91 / TLSR8258 — Very low cost, widely used in commercial Zigbee products (IKEA TRÅDFRI, etc.). TLSR8258 has a pure-Rust radio driver (no vendor SDK needed); B91 requires Telink SDK.
- PHY6222 — Budget BLE+802.15.4 combo, pure-Rust radio driver, suitable for simple sensors.
- EFR32MG1 — Silicon Labs Series 1 (Cortex-M4F), used in IKEA TRÅDFRI modules. Pure-Rust radio driver — no GSDK/RAIL required. Great for repurposing existing hardware.
- EFR32MG21 — Silicon Labs Series 2 (Cortex-M33), used in Sonoff ZBDongle-E.
Pure-Rust radio driver with separate
efr32s2module (different register map from Series 1). - Mock — Use for testing your application logic without hardware.
The Mock Backend
The mock feature provides MockMac — a fully functional in-memory MAC
implementation for testing:
use zigbee_mac::mock::MockMac;
// Create with a specific IEEE address
let mac = MockMac::new([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]);
let device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.build();
MockMac simulates scan responses, association, and data transfer — useful for
integration tests and CI pipelines where no radio hardware is available.
Writing Your Own MacDriver
To port zigbee-rs to a new 802.15.4 radio, implement the MacDriver trait.
Here’s the skeleton:
use zigbee_mac::*;
pub struct MyRadioMac {
// Your radio peripheral handle, state, buffers, etc.
}
impl MacDriver for MyRadioMac {
async fn mlme_scan(&mut self, req: MlmeScanRequest) -> Result<MlmeScanConfirm, MacError> {
// 1. Configure radio for the requested scan type
// 2. For each channel in req.channel_mask:
// - Set radio to channel
// - Send beacon request (active scan) or listen (passive/ED)
// - Collect responses for scan_duration
// 3. Return collected PAN descriptors or ED values
todo!()
}
async fn mlme_associate(&mut self, req: MlmeAssociateRequest) -> Result<MlmeAssociateConfirm, MacError> {
// 1. Set radio to req.channel
// 2. Send Association Request command frame to req.coord_address
// 3. Wait for Association Response (with timeout)
// 4. Return assigned short address
todo!()
}
async fn mlme_associate_response(&mut self, rsp: MlmeAssociateResponse) -> Result<(), MacError> {
// Coordinator/Router: send Association Response frame
todo!()
}
async fn mlme_disassociate(&mut self, req: MlmeDisassociateRequest) -> Result<(), MacError> {
// Send Disassociation Notification frame
todo!()
}
async fn mlme_reset(&mut self, set_default_pib: bool) -> Result<(), MacError> {
// Reset radio hardware, optionally reset PIB to defaults
todo!()
}
async fn mlme_start(&mut self, req: MlmeStartRequest) -> Result<(), MacError> {
// Configure radio as PAN coordinator/router on the given channel
todo!()
}
async fn mlme_get(&self, attr: PibAttribute) -> Result<PibValue, MacError> {
// Read PIB attribute from your stored state or hardware registers
todo!()
}
async fn mlme_set(&mut self, attr: PibAttribute, value: PibValue) -> Result<(), MacError> {
// Write PIB attribute to your stored state and configure hardware
todo!()
}
async fn mlme_poll(&mut self) -> Result<Option<MacFrame>, MacError> {
// Send Data Request to coordinator, wait for response
todo!()
}
async fn mcps_data(&mut self, req: McpsDataRequest<'_>) -> Result<McpsDataConfirm, MacError> {
// Build 802.15.4 frame from req, transmit via radio with CSMA-CA
// If req.tx_options.ack_tx, wait for ACK
todo!()
}
async fn mcps_data_indication(&mut self) -> Result<McpsDataIndication, MacError> {
// Wait for incoming frame from radio (interrupt-driven)
// Parse 802.15.4 header, return payload + addressing
todo!()
}
fn capabilities(&self) -> MacCapabilities {
MacCapabilities {
coordinator: true,
router: true,
hardware_security: false,
max_payload: 102,
tx_power_min: TxPower(-20),
tx_power_max: TxPower(8),
}
}
}
Tip: Study the existing
espornrfbackends for reference — they handle all the edge cases (scan timing, CSMA-CA retry, ACK waiting, indirect TX for sleepy devices).
MAC Primitives Reference
The MAC layer uses structured request/confirm/indication types that map to IEEE 802.15.4 service primitives.
Scan Primitives
pub struct MlmeScanRequest {
pub scan_type: ScanType, // Ed, Active, Passive, Orphan
pub channel_mask: ChannelMask, // Which channels to scan
pub scan_duration: u8, // Duration exponent (0-14)
}
pub struct MlmeScanConfirm {
pub scan_type: ScanType,
pub pan_descriptors: PanDescriptorList, // Up to 27 discovered PANs
pub energy_list: EdList, // ED scan results
}
Scan types:
| Type | Description |
|---|---|
ScanType::Ed | Measure noise energy on each channel |
ScanType::Active | Send beacon requests, collect responses |
ScanType::Passive | Listen for beacons without transmitting |
ScanType::Orphan | Search for our coordinator after losing sync |
Scan duration: The time spent on each channel is
aBaseSuperframeDuration × (2^n + 1) symbols. Typical values:
| Exponent | Time per channel | Use case |
|---|---|---|
| 3 | ~138 ms | Fast scan |
| 5 | ~530 ms | Normal scan |
| 7 | ~2.1 s | Thorough scan |
Association Primitives
pub struct MlmeAssociateRequest {
pub channel: u8, // Channel to associate on
pub coord_address: MacAddress, // Coordinator address
pub capability_info: CapabilityInfo, // Our capabilities
}
pub struct CapabilityInfo {
pub device_type_ffd: bool, // Full Function Device
pub mains_powered: bool,
pub rx_on_when_idle: bool, // false = sleepy
pub security_capable: bool,
pub allocate_address: bool, // Request short address
}
pub struct MlmeAssociateConfirm {
pub short_address: ShortAddress, // Assigned address (or 0xFFFF on failure)
pub status: AssociationStatus, // Success, PanAtCapacity, PanAccessDenied
}
Data Primitives
pub struct McpsDataRequest<'a> {
pub src_addr_mode: AddressMode, // None, Short, Extended
pub dst_address: MacAddress,
pub payload: &'a [u8], // Up to MAX_MAC_PAYLOAD (127) bytes
pub msdu_handle: u8, // Handle for TX confirmation
pub tx_options: TxOptions,
}
pub struct TxOptions {
pub ack_tx: bool, // Request ACK from receiver
pub indirect: bool, // Indirect TX (for sleepy children)
pub security_enabled: bool, // MAC-level security
}
pub struct McpsDataIndication {
pub src_address: MacAddress,
pub dst_address: MacAddress,
pub lqi: u8, // Link Quality (0-255)
pub payload: MacFrame, // Received frame data
pub security_use: bool,
}
MacFrame — Zero-Allocation Frame Buffer
MacFrame is a fixed-size buffer (127 bytes) that holds received frame data
without heap allocation:
pub struct MacFrame {
buf: [u8; MAX_MAC_PAYLOAD], // 127 bytes
len: usize,
}
impl MacFrame {
pub fn from_slice(data: &[u8]) -> Option<Self>;
pub fn as_slice(&self) -> &[u8];
pub fn len(&self) -> usize;
pub fn is_empty(&self) -> bool;
}
MacError — Error Types
All MacDriver methods return Result<_, MacError>:
pub enum MacError {
NoBeacon, // No beacon received during scan
InvalidParameter, // Invalid parameters supplied
RadioError, // Hardware radio failure
ChannelAccessFailure, // CSMA-CA failed (channel busy)
NoAck, // No acknowledgement received
FrameTooLong, // Frame exceeds PHY maximum
Unsupported, // Operation not supported by backend
SecurityError, // MAC security processing failed
TransactionOverflow, // Indirect queue full
TransactionExpired, // Indirect frame expired before delivery
ScanInProgress, // Another scan is already running
TrackingOff, // Lost superframe tracking
AssociationDenied, // Coordinator denied association
PanAtCapacity, // PAN has no room for more devices
NoData, // No data frame within timeout (poll)
Other, // Unclassified error
}
PibAttribute & PibValue — The MAC Configuration Interface
The NWK layer configures the MAC through PIB (PAN Information Base) get/set operations. This is the standard IEEE 802.15.4 configuration mechanism.
PibAttribute Reference
Addressing (set during join)
| Attribute | ID | Description | Default |
|---|---|---|---|
MacShortAddress | 0x53 | Own 16-bit NWK address | 0xFFFF (unassigned) |
MacPanId | 0x50 | PAN ID of our network | 0xFFFF (not associated) |
MacExtendedAddress | 0x6F | Own 64-bit IEEE address | From hardware |
MacCoordShortAddress | 0x4B | Parent’s short address | — |
MacCoordExtendedAddress | 0x4A | Parent’s extended address | — |
Network Configuration
| Attribute | ID | Description |
|---|---|---|
MacAssociatedPanCoord | 0x56 | Is this the PAN coordinator? |
MacRxOnWhenIdle | 0x52 | Receive during idle (false = sleepy) |
MacAssociationPermit | 0x41 | Accepting join requests? |
Beacon (always 15/15 for Zigbee PRO non-beacon mode)
| Attribute | ID | Description |
|---|---|---|
MacBeaconOrder | 0x47 | Beacon interval (always 15) |
MacSuperframeOrder | 0x54 | Superframe duration (always 15) |
MacBeaconPayload | 0x45 | Beacon payload bytes |
MacBeaconPayloadLength | 0x46 | Length of beacon payload |
TX/RX Tuning
| Attribute | ID | Description | Default |
|---|---|---|---|
MacMaxCsmaBackoffs | 0x4E | Max CSMA-CA retries | 4 |
MacMinBe | 0x4F | Min backoff exponent | 3 |
MacMaxBe | 0x57 | Max backoff exponent | 5 |
MacMaxFrameRetries | 0x59 | Max ACK retries | 3 |
PHY Attributes (accessed via MAC GET/SET)
| Attribute | ID | Description |
|---|---|---|
PhyCurrentChannel | 0x00 | Operating channel (11-26) |
PhyChannelsSupported | 0x01 | Supported channels bitmask |
PhyTransmitPower | 0x02 | TX power in dBm |
PhyCcaMode | 0x03 | Clear Channel Assessment mode |
PhyCurrentPage | 0x04 | Channel page (always 0 for 2.4 GHz) |
PibValue
PibValue is a tagged union for PIB get/set operations:
pub enum PibValue {
Bool(bool),
U8(u8),
U16(u16),
U32(u32),
I8(i8),
ShortAddress(ShortAddress),
PanId(PanId),
ExtendedAddress(IeeeAddress),
Payload(PibPayload), // Variable-length beacon payload (max 52 bytes)
}
Convenience accessors (as_bool(), as_u8(), as_short_address(), etc.) are
provided for safe downcasting.
Network Layer
The NWK (Network) layer sits between the MAC and APS layers and is responsible for everything that makes Zigbee a mesh network: discovering PANs, joining, routing frames across multiple hops, managing neighbors, and encrypting all routed traffic.
┌──────────────────────────────────────┐
│ APS Layer (zigbee-aps) │
└──────────────┬───────────────────────┘
│ NLDE-DATA / NLME-*
┌──────────────┴───────────────────────┐
│ NWK Layer (zigbee-nwk) │
│ ├── nlme: management primitives │
│ ├── nlde: data service │
│ ├── nib: network information base │
│ ├── frames: NWK frame codec │
│ ├── neighbor: neighbor table │
│ ├── routing: tree + AODV routing │
│ └── security: NWK encryption │
└──────────────┬───────────────────────┘
│ MacDriver trait
┌──────────────┴───────────────────────┐
│ MAC Layer (zigbee-mac) │
└──────────────────────────────────────┘
In zigbee-rs the NWK layer is implemented as NwkLayer<M>, generic over the
MAC driver. You normally don’t interact with it directly — the
ZigbeeDevice runtime drives it through BDB → ZDO → APS. But understanding
how it works is essential for debugging and advanced use.
NwkLayer — The Core Struct
pub struct NwkLayer<M: MacDriver> {
mac: M, // The MAC driver
nib: Nib, // Network Information Base
neighbors: NeighborTable, // Known neighbors
routing: RoutingTable, // Routing + route discovery
security: NwkSecurity, // Encryption keys & frame counters
device_type: DeviceType, // Coordinator / Router / EndDevice
joined: bool, // Whether we're on a network
rx_on_when_idle: bool, // false = sleepy end device
}
Key accessors:
nwk.nib() // &Nib — read network state
nwk.nib_mut() // &mut Nib — modify network state
nwk.neighbor_table() // &NeighborTable
nwk.routing_table() // &RoutingTable
nwk.security() // &NwkSecurity — read keys
nwk.security_mut() // &mut NwkSecurity — install keys
nwk.is_joined() // bool
nwk.device_type() // DeviceType
nwk.mac() / mac_mut() // Access the underlying MAC driver
Network Discovery
Before joining, a device must find available networks. This is done with
nlme_network_discovery():
let networks = nwk.nlme_network_discovery(
ChannelMask::ALL_2_4GHZ, // Scan all 2.4 GHz channels
3, // Scan duration exponent
).await?;
What happens internally:
- Sets
macAutoRequest = false(don’t auto-request data during scan) - Sends an Active Scan via MAC — beacon requests on each channel
- Collects beacon responses as
PanDescriptorstructs - Filters for Zigbee PRO beacons (
protocol_id == 0,stack_profile == 2) - Converts to
NetworkDescriptorstructs - Sorts by LQI (best signal first)
- Restores
macAutoRequest = true
The returned NetworkDescriptor contains everything needed to join:
pub struct NetworkDescriptor {
pub extended_pan_id: IeeeAddress, // 64-bit network ID
pub pan_id: PanId, // 16-bit PAN ID
pub logical_channel: u8, // Channel (11-26)
pub stack_profile: u8, // 2 = Zigbee PRO
pub permit_joining: bool, // Network is open for joining
pub router_capacity: bool, // Can accept router children
pub end_device_capacity: bool, // Can accept end device children
pub lqi: u8, // Signal quality (0-255)
pub router_address: ShortAddress, // Beacon sender's address
pub depth: u8, // Sender's depth in tree
// ... more fields
}
Joining a Network
After discovery, the NWK layer joins the best network via MAC association:
nwk.nlme_join(&best_network).await?;
The join sequence:
- Select the best network (highest LQI, open for joining, has capacity)
- Configure MAC: set channel, PAN ID, coordinator address
- Send
MLME-ASSOCIATE.requestto the chosen router/coordinator - Receive
MLME-ASSOCIATE.confirmwith our assigned short address - Update NIB: PAN ID, channel, short address, parent address
- Add parent to neighbor table with
Relationship::Parent - Set
joined = true
Join Methods
pub enum JoinMethod {
/// Normal first join — MAC-level association
Association,
/// Rejoin using existing network key (after losing parent)
Rejoin,
/// Direct join — coordinator adds device without association
Direct,
}
- Association is the normal path for a fresh device.
- Rejoin is used after power loss when the device has saved network state (NV storage). It’s faster because it skips the full BDB commissioning.
- Direct is used by coordinators to pre-authorize devices.
Network Formation (Coordinator)
A coordinator creates a new network instead of joining one:
nwk.nlme_network_formation(
ChannelMask::ALL_2_4GHZ, // Channels to evaluate
3, // Scan duration
).await?;
What happens:
- ED Scan — measures energy (noise) on each channel
- Pick quietest channel — lowest energy = least interference
- Generate PAN ID — random 16-bit ID, avoiding 0xFFFF
- Configure MAC — set short address to 0x0000 (coordinator), set PAN ID
- Start PAN —
MLME-START.requestbegins beacon transmission - Update NIB — record channel, PAN ID, address, depth = 0
After formation, the coordinator opens permit joining so other devices can associate.
Routing
The NWK layer supports two routing algorithms:
AODV Mesh Routing
AODV (Ad-hoc On-demand Distance Vector) is the primary routing mechanism in Zigbee PRO. Routes are discovered on-demand when a frame needs to reach a destination with no known route.
Route discovery flow:
- Router needs to send to destination
Dbut has no route - Broadcasts a Route Request (RREQ) with destination
D - Each receiving router re-broadcasts the RREQ, recording path cost
- When RREQ reaches
D(or a router with a route toD), a Route Reply (RREP) is unicast back along the best path - Each router along the path installs a route entry
Tree Routing
Tree routing uses the hierarchical network address space to forward frames without a route table. It’s a fallback when mesh routing isn’t available:
// CSkip algorithm determines next hop based on address ranges
routing.tree_route(
our_addr, // Our NWK address
dst_addr, // Destination address
depth, // Our depth in the tree
max_routers, // nib.max_routers
max_depth, // nib.max_depth
) -> Option<ShortAddress>
If the destination is within our child address range, forward to the appropriate child. Otherwise, forward to our parent.
The Route Table
pub struct RoutingTable {
routes: [RouteEntry; MAX_ROUTES], // 32 entries
discoveries: [RouteDiscovery; MAX_ROUTE_DISCOVERIES], // 8 pending
}
Each RouteEntry tracks:
pub struct RouteEntry {
pub destination: ShortAddress, // Target NWK address
pub next_hop: ShortAddress, // Where to forward
pub status: RouteStatus, // Active, DiscoveryUnderway, etc.
pub many_to_one: bool, // Concentrator route
pub route_record_required: bool,
pub group_id: bool, // Multicast route
pub path_cost: u8, // Sum of link costs
pub age: u16, // Ticks since last use
pub active: bool,
}
Route status values:
| Status | Meaning |
|---|---|
Active | Route is valid and ready for forwarding |
DiscoveryUnderway | Route request broadcast, awaiting reply |
DiscoveryFailed | No route reply received within timeout |
Inactive | Route expired or was removed |
ValidationUnderway | Route is being validated |
Key operations:
routing.next_hop(destination) // Look up next hop
routing.update_route(destination, next_hop, cost) // Add/update route
routing.remove(destination) // Delete a route
routing.age_tick() // Age all entries
routing.mark_discovery(destination) // Mark as discovering
When the route table is full, the oldest inactive or highest-cost route is evicted.
Neighbor Table
The neighbor table tracks all known nearby devices:
pub struct NeighborTable {
entries: [NeighborEntry; MAX_NEIGHBORS], // 32 entries
count: usize,
}
Each NeighborEntry contains:
pub struct NeighborEntry {
pub ieee_address: IeeeAddress, // 64-bit address
pub network_address: ShortAddress, // 16-bit NWK address
pub device_type: NeighborDeviceType, // Coordinator/Router/EndDevice/Unknown
pub rx_on_when_idle: bool, // false = sleepy
pub relationship: Relationship, // Parent/Child/Sibling/etc.
pub lqi: u8, // Link Quality (rolling average)
pub outgoing_cost: u8, // 1-7, derived from LQI
pub depth: u8, // Network depth
pub permit_joining: bool, // For routers/coordinators
pub age: u16, // Ticks since last heard from
pub extended_pan_id: IeeeAddress,
pub active: bool,
}
Relationship Types
pub enum Relationship {
Parent, // Device we joined through
Child, // Device that joined through us
Sibling, // Same parent (used for routing)
PreviousChild, // Was our child, rejoined elsewhere
UnauthenticatedChild, // Joined but not yet authenticated
}
Link Cost Calculation
LQI (Link Quality Indicator, 0–255) is converted to an outgoing cost (1–7) used by the routing algorithm:
| LQI Range | Cost | Quality |
|---|---|---|
| 201–255 | 1 | Excellent |
| 151–200 | 2 | Good |
| 101–150 | 3 | Fair |
| 51–100 | 5 | Poor |
| 0–50 | 7 | Very poor |
Table Operations
neighbors.find_by_short(addr) // Look up by NWK address
neighbors.find_by_ieee(&ieee) // Look up by IEEE address
neighbors.parent() // Get our parent entry
neighbors.children() // Iterate over child entries
neighbors.add_or_update(entry) // Insert or update
neighbors.remove(addr) // Remove by NWK address
neighbors.age_tick() // Increment all age counters
neighbors.iter() // Iterate active entries
Eviction policy: When the table is full, the oldest non-parent, non-child entry is evicted. Parents and children are never evicted automatically — this ensures the device never loses track of its parent or its children.
NIB — Network Information Base
The NIB holds all NWK-layer configuration and state. It’s the NWK equivalent of the MAC PIB.
Key Fields
Network Identity
| Field | Type | Description | Default |
|---|---|---|---|
extended_pan_id | IeeeAddress | 64-bit network identifier | [0; 8] |
pan_id | PanId | 16-bit PAN ID | 0xFFFF |
network_address | ShortAddress | Our 16-bit address | 0xFFFF |
logical_channel | u8 | Operating channel (11-26) | 0 |
Network Parameters
| Field | Type | Description | Default |
|---|---|---|---|
stack_profile | u8 | 0x02 = Zigbee PRO | 0x02 |
depth | u8 | Our depth in network tree | 0 |
max_depth | u8 | Maximum network depth | 15 |
max_routers | u8 | Max child routers | 5 |
max_children | u8 | Max child end devices | 20 |
update_id | u8 | Network update counter | 0 |
Addressing
| Field | Type | Description | Default |
|---|---|---|---|
ieee_address | IeeeAddress | Our 64-bit IEEE address | [0; 8] |
parent_address | ShortAddress | Parent’s NWK address | 0xFFFF |
address_assign | AddressAssignMethod | TreeBased or Stochastic | Stochastic |
Routing
| Field | Type | Description | Default |
|---|---|---|---|
use_tree_routing | bool | Enable tree routing fallback | false |
source_routing | bool | Enable source routing | false |
route_discovery_retries | u8 | Max RREQ retries | 3 |
Security
| Field | Type | Description | Default |
|---|---|---|---|
security_level | u8 | 5 = ENC-MIC-32 | 5 |
security_enabled | bool | NWK encryption on/off | true |
active_key_seq_number | u8 | Active key index | 0 |
outgoing_frame_counter | u32 | Outgoing frame counter | 0 |
Permit Joining
| Field | Type | Description | Default |
|---|---|---|---|
permit_joining | bool | Accept new join requests | false |
permit_joining_duration | u8 | Time remaining (seconds) | 0 |
Helper Methods
nib.next_seq() // Get next NWK sequence number (wrapping)
nib.next_route_request_id() // Get next route request ID
nib.next_frame_counter() // Increment frame counter (returns None if exhausted)
Frame counter exhaustion: The outgoing frame counter is a 32-bit value. If it reaches
u32::MAX, the device cannot send any more secured frames and must perform a key update or factory reset. In practice this takes billions of frames and is unlikely, butnext_frame_counter()returnsNoneto protect against it.
NwkStatus — Error Codes
NWK operations return NwkStatus on failure:
pub enum NwkStatus {
Success = 0x00,
InvalidParameter = 0xC1,
InvalidRequest = 0xC2, // e.g., formation on non-coordinator
NotPermitted = 0xC3,
StartupFailure = 0xC4, // MAC start failed
AlreadyPresent = 0xC5,
SyncFailure = 0xC6,
NeighborTableFull = 0xC7,
UnknownDevice = 0xC8,
UnsupportedAttribute = 0xC9,
NoNetworks = 0xCA, // Scan found nothing
MaxFrmCounterReached = 0xCC, // Frame counter exhausted
NoKey = 0xCD, // No network key available
BadCcmOutput = 0xCE, // AES-CCM* decryption failed
RouteDiscoveryFailed = 0xD0, // No route found
RouteError = 0xD1, // Route broke during use
BtTableFull = 0xD2, // Broadcast transaction table full
FrameNotBuffered = 0xD3,
FrameTooLong = 0xD4, // NWK frame exceeds max size
}
Network Security
All NWK-layer frames in Zigbee 3.0 are encrypted. zigbee-rs implements standard Zigbee PRO NWK security:
How It Works
- Algorithm: AES-128-CCM* with a 4-byte Message Integrity Code (MIC)
- Security Level: 5 (ENC-MIC-32) — standard for Zigbee PRO
- Key type: A single network key shared by all devices on the network
- Frame counter: 32-bit counter for replay protection (each sender maintains their own)
- Key distribution: The coordinator distributes the network key during joining via the APS Transport Key command (itself protected by the well-known Trust Center Link Key)
NWK Security Header
Every secured NWK frame includes an auxiliary security header:
pub struct NwkSecurityHeader {
pub security_control: u8, // Security level + key identifier + flags
pub frame_counter: u32, // Replay protection
pub source_address: IeeeAddress, // 64-bit sender IEEE address
pub key_seq_number: u8, // Which network key was used
}
The security control field for standard Zigbee is always 0x2D:
- Security Level = 5 (ENC-MIC-32)
- Key Identifier = 1 (Network Key)
- Extended Nonce = 1 (source address present)
Key Management
// Install a network key
nwk.security_mut().set_network_key(key, seq_number);
// Read the active key
if let Some(key_entry) = nwk.security().active_key() {
// key_entry.key: [u8; 16]
// key_entry.seq_number: u8
}
// Look up key by sequence number (for key rotation)
let key = nwk.security().key_by_seq(1);
The security module stores up to 2 keys (current + previous) to support seamless key rotation.
Replay Protection
The NWK security module maintains a frame counter table that maps each sender’s IEEE address to the last seen frame counter. When a secured frame arrives:
check_frame_counter(source, counter)— verifies the counter is strictly greater than the last seen value- If the frame decrypts and verifies successfully:
commit_frame_counter(source, counter)— updates the table
This two-phase approach prevents attackers from advancing the counter with forged frames that fail MIC verification.
Summary
The NWK layer handles the “mesh” in Zigbee mesh networking:
| Capability | How |
|---|---|
| Find networks | Active scan + beacon parsing |
| Join | MAC association + short address assignment |
| Form (coordinator) | ED scan + PAN creation |
| Route (mesh) | AODV on-demand route discovery |
| Route (tree) | CSkip hierarchical forwarding |
| Track neighbors | Neighbor table with LQI-based costs |
| Encrypt | AES-128-CCM* with network key + frame counter |
| Prevent replay | Per-sender frame counter tracking |
Most of this happens transparently when you call device.start() and run the
event loop. The NWK layer’s internal state (NIB, neighbor table, routing table,
security keys) can be inspected for debugging and is automatically persisted
when you call device.save_state().
The APS Layer
The Application Support Sub-layer (APS) is the bridge between your application and the Zigbee network layer. Every time you send a ZCL command, report an attribute, or receive a message from another device, the data passes through the APS layer. Think of it as the postal service of a Zigbee network — it handles addresses, tracks deliveries, encrypts letters, and reassembles oversized packages.
┌──────────────────────────────────────┐
│ ZDO / ZCL / Application │
└──────────────┬───────────────────────┘
│ APSDE-DATA / APSME-*
┌──────────────┴───────────────────────┐
│ APS Layer (zigbee-aps) │
│ ├── apsde: data service │
│ ├── apsme: management entity │
│ ├── aib: APS information base │
│ ├── binding: binding table │
│ ├── group: group table │
│ ├── fragment: reassembly │
│ └── security: APS encryption │
└──────────────┬───────────────────────┘
│ NLDE-DATA / NLME-*
┌──────────────┴───────────────────────┐
│ NWK Layer (zigbee-nwk) │
└──────────────────────────────────────┘
The zigbee-aps crate is a #![no_std] library — it compiles for bare-metal
MCUs just like the rest of zigbee-rs.
The ApsLayer Struct
ApsLayer<M: MacDriver> is the central type. It owns the NWK layer and all
APS-level state: the binding table, the group table, security material,
duplicate-rejection, ACK tracking, and fragment reassembly.
#![allow(unused)]
fn main() {
use zigbee_aps::ApsLayer;
use zigbee_nwk::NwkLayer;
// Create the APS layer by wrapping an existing NWK layer.
let aps = ApsLayer::new(nwk_layer);
}
You rarely construct ApsLayer directly — the higher-level ZdoLayer and
BdbLayer wrap it for you. But you can always reach down:
#![allow(unused)]
fn main() {
// From a BdbLayer, reach the APS layer
let aps: &ApsLayer<_> = bdb.zdo().aps();
// Or get a mutable reference
let aps_mut = bdb.zdo_mut().aps_mut();
}
Important Accessors
| Method | Returns | Purpose |
|---|---|---|
aps.nwk() | &NwkLayer<M> | Read NWK state (NIB, neighbor table, …) |
aps.nwk_mut() | &mut NwkLayer<M> | Send NWK frames, join/leave |
aps.aib() | &Aib | Read APS Information Base attributes |
aps.aib_mut() | &mut Aib | Write AIB attributes |
aps.binding_table() | &BindingTable | Inspect binding entries |
aps.binding_table_mut() | &mut BindingTable | Add/remove bindings |
aps.group_table() | &GroupTable | Inspect group memberships |
aps.group_table_mut() | &mut GroupTable | Add/remove groups |
aps.security() | &ApsSecurity | Inspect link keys |
aps.security_mut() | &mut ApsSecurity | Add/remove link keys |
aps.fragment_rx() | &FragmentReassembly | Inspect reassembly state |
Addressing Modes
When you send data through the APS layer, you choose an addressing mode that
tells the layer how to find the destination. The ApsAddressMode enum
captures the four modes defined by the Zigbee specification:
#![allow(unused)]
fn main() {
#[repr(u8)]
pub enum ApsAddressMode {
/// Indirect — look up destinations in the binding table
Indirect = 0x00,
/// Group — deliver to all members of a 16-bit group
Group = 0x01,
/// Direct (short) — 16-bit NWK address + endpoint
Short = 0x02,
/// Direct (extended) — 64-bit IEEE address + endpoint
Extended = 0x03,
}
}
And ApsAddress carries the actual address value:
#![allow(unused)]
fn main() {
pub enum ApsAddress {
Short(ShortAddress), // e.g. ShortAddress(0x1A2B)
Extended(IeeeAddress), // e.g. [0x00, 0x12, …, 0xFF]
Group(u16), // e.g. 0x0001
}
}
Direct Addressing (Unicast)
The most common mode. You specify the recipient’s 16-bit short address (or 64-bit IEEE address) and endpoint number. The message is delivered to exactly one device, one endpoint.
#![allow(unused)]
fn main() {
use zigbee_aps::{ApsAddress, ApsAddressMode, ApsTxOptions};
use zigbee_aps::apsde::ApsdeDataRequest;
use zigbee_types::ShortAddress;
let payload = [0x01, 0x00]; // ZCL frame bytes
let req = ApsdeDataRequest {
dst_addr_mode: ApsAddressMode::Short,
dst_address: ApsAddress::Short(ShortAddress(0x1A2B)),
dst_endpoint: 1,
profile_id: 0x0104, // Home Automation
cluster_id: 0x0006, // On/Off cluster
src_endpoint: 1,
payload: &payload,
tx_options: ApsTxOptions {
ack_request: true, // request APS-level ACK
..ApsTxOptions::default()
},
radius: 0, // 0 = use default NWK radius
alias_src_addr: None,
alias_seq: None,
};
// Send — returns Ok(()) on success
aps.apsde_data_request(&req).await?;
}
Indirect Addressing (via Binding Table)
With indirect addressing you don’t specify a destination at all. Instead the APS layer looks up matching entries in the binding table and delivers the frame to every matching destination. This is the mode used by Finding & Binding (EZ-Mode).
#![allow(unused)]
fn main() {
let req = ApsdeDataRequest {
dst_addr_mode: ApsAddressMode::Indirect,
dst_address: ApsAddress::Short(ShortAddress(0x0000)), // ignored
dst_endpoint: 0, // ignored — determined by binding table
profile_id: 0x0104,
cluster_id: 0x0006,
src_endpoint: 1, // looked up in binding table
payload: &payload,
tx_options: ApsTxOptions::default(),
radius: 0,
alias_src_addr: None,
alias_seq: None,
};
}
When this request is processed, the APS layer calls
binding_table.find_by_source(our_ieee, src_endpoint, cluster_id) and sends
the frame to each destination returned by the iterator.
Group Addressing (Multicast)
Group addressing delivers the message to every device that has registered the given group address in its group table. This is how Zigbee “rooms” and “scenes” work — a single frame reaches all the lights in the living room.
#![allow(unused)]
fn main() {
let req = ApsdeDataRequest {
dst_addr_mode: ApsAddressMode::Group,
dst_address: ApsAddress::Group(0x0001), // group 1
dst_endpoint: 0xFF, // broadcast endpoint
// ...
tx_options: ApsTxOptions::default(), // no ACK for groups
..
};
}
Broadcast
Broadcast is not a separate ApsAddressMode variant — you use
ApsAddressMode::Short with one of the well-known broadcast NWK addresses:
| Address | Meaning |
|---|---|
0xFFFF | All devices |
0xFFFD | All rx-on-when-idle devices (routers + mains-powered EDs) |
0xFFFC | All routers (+ coordinator) |
Well-Known Endpoints and Profiles
The APS layer defines several constants you’ll encounter frequently:
#![allow(unused)]
fn main() {
pub const ZDO_ENDPOINT: u8 = 0x00; // Zigbee Device Object
pub const MIN_APP_ENDPOINT: u8 = 0x01; // First application endpoint
pub const MAX_APP_ENDPOINT: u8 = 0xF0; // Last application endpoint
pub const BROADCAST_ENDPOINT: u8 = 0xFF; // Deliver to all endpoints
pub const PROFILE_ZDP: u16 = 0x0000; // Zigbee Device Profile
pub const PROFILE_HOME_AUTOMATION: u16 = 0x0104; // HA profile
pub const PROFILE_SMART_ENERGY: u16 = 0x0109; // SE profile
pub const PROFILE_ZLL: u16 = 0xC05E; // Zigbee Light Link
pub const PROFILE_WILDCARD: u16 = 0xFFFF; // matches any profile
}
Endpoint 0 is always reserved for ZDO (the Zigbee Device Object that handles discovery, binding, and management). Your application clusters live on endpoints 1–240.
TX Options
The ApsTxOptions bitfield controls per-frame behavior:
#![allow(unused)]
fn main() {
pub struct ApsTxOptions {
pub security_enabled: bool, // APS link-key encryption
pub use_nwk_key: bool, // Use NWK key instead of link key
pub ack_request: bool, // Request an APS ACK
pub fragmentation_permitted: bool, // Allow automatic fragmentation
pub include_extended_nonce: bool, // Include IEEE addr in security header
}
}
If ack_request is true, the APS layer registers the frame in an internal
ACK-tracking table (up to 8 concurrent pending ACKs) and will retransmit up
to 3 times if the ACK doesn’t arrive.
The Binding Table
The binding table maps (source address, source endpoint, cluster) to one or
more destinations. It is the backbone of indirect addressing — when you
send a frame with ApsAddressMode::Indirect, the APS layer looks up matching
entries here.
Data Model
#![allow(unused)]
fn main() {
/// A single binding table entry.
pub struct BindingEntry {
pub src_addr: IeeeAddress, // our IEEE address
pub src_endpoint: u8, // our endpoint (1-240)
pub cluster_id: u16, // e.g. 0x0006 (On/Off)
pub dst_addr_mode: BindingDstMode,
pub dst: BindingDst, // where to send
}
/// Destination can be unicast or group.
pub enum BindingDst {
Group(u16),
Unicast { dst_addr: IeeeAddress, dst_endpoint: u8 },
}
}
The table holds up to MAX_BINDING_ENTRIES (32) entries in a fixed-capacity
heapless::Vec — no heap allocation.
Creating Bindings
There are two convenient constructors:
#![allow(unused)]
fn main() {
use zigbee_aps::binding::BindingEntry;
// Unicast binding: our ep 1, On/Off cluster → remote device ep 1
let entry = BindingEntry::unicast(
our_ieee, // source IEEE address
1, // source endpoint
0x0006, // On/Off cluster
remote_ieee, // destination IEEE address
1, // destination endpoint
);
// Group binding: our ep 1, On/Off cluster → group 0x0001
let entry = BindingEntry::group(
our_ieee,
1,
0x0006,
0x0001, // group address
);
}
Managing Bindings
Through the BindingTable:
#![allow(unused)]
fn main() {
let table = aps.binding_table_mut();
// Add — returns Err if table full or duplicate
table.add(entry)?;
// Remove — returns true if found
table.remove(&src_addr, src_endpoint, cluster_id, &dst);
// Query
for entry in table.find_by_source(&our_ieee, 1, 0x0006) {
// each matching destination for indirect sends
}
// Iterate all entries
for entry in table.entries() {
println!("{:?}", entry);
}
table.len(); // number of entries
table.is_full(); // true if 32 entries
table.clear(); // remove all
}
APSME Bind / Unbind
The formal Zigbee management primitives go through ApsLayer methods:
#![allow(unused)]
fn main() {
use zigbee_aps::apsme::{ApsmeBindRequest, ApsmeBindConfirm};
use zigbee_aps::binding::BindingDstMode;
let req = ApsmeBindRequest {
src_addr: our_ieee,
src_endpoint: 1,
cluster_id: 0x0006,
dst_addr_mode: BindingDstMode::Extended,
dst_addr: remote_ieee,
dst_endpoint: 1,
group_address: 0,
};
let confirm: ApsmeBindConfirm = aps.apsme_bind(&req);
assert_eq!(confirm.status, ApsStatus::Success);
// To unbind:
let confirm = aps.apsme_unbind(&req);
}
The Group Table
The group table maps 16-bit group addresses to local endpoints. When a frame arrives addressed to a group, the APS layer delivers it to each endpoint that is a member of that group.
#![allow(unused)]
fn main() {
let gt = aps.group_table_mut();
// Add endpoint 1 to group 0x0001
assert!(gt.add_group(0x0001, 1));
// Add endpoint 2 to the same group
assert!(gt.add_group(0x0001, 2));
// Check membership
assert!(gt.is_member(0x0001, 1)); // true
assert!(!gt.is_member(0x0001, 3)); // false
// Remove endpoint 1 from the group
gt.remove_group(0x0001, 1);
// Remove endpoint 2 from ALL groups at once
gt.remove_all_groups(2);
// Inspect
for group in gt.groups() {
println!("Group 0x{:04X}: endpoints {:?}",
group.group_address,
group.endpoint_list);
}
}
Capacity: up to MAX_GROUPS (16) groups, each with up to
MAX_ENDPOINTS_PER_GROUP (8) endpoints.
APSME Group Management
The formal management primitives:
#![allow(unused)]
fn main() {
use zigbee_aps::apsme::{ApsmeAddGroupRequest, ApsmeRemoveGroupRequest};
let confirm = aps.apsme_add_group(&ApsmeAddGroupRequest {
group_address: 0x0001,
endpoint: 1,
});
assert_eq!(confirm.status, ApsStatus::Success);
let confirm = aps.apsme_remove_group(&ApsmeRemoveGroupRequest {
group_address: 0x0001,
endpoint: 1,
});
}
Fragmentation
The APS layer automatically splits large payloads into fragments and
reassembles them at the receiver. The FragmentReassembly context manages
up to 4 concurrent reassembly sessions, each tracking up to 8 blocks via a
bitmask.
You don’t call fragmentation directly — it works behind the scenes:
-
Sender side: When
ApsTxOptions::fragmentation_permittedistrueand the payload exceeds the NWK maximum transfer unit, the APS layer splits it into numbered blocks. -
Receiver side: The
FragmentReassemblymodule collects blocks identified by(src_addr, aps_counter). The first fragment (block_num == 0) creates a reassembly slot; subsequent fragments fill in the bitmask. When all blocks arrive, the complete payload is returned. -
Timeout: Call
fragment_rx_mut().age_entries()once per second from your event loop. Incomplete reassemblies are expired after 10 seconds of inactivity.
#![allow(unused)]
fn main() {
// In your 1-second tick handler:
aps.fragment_rx_mut().age_entries();
aps.age_dup_table();
}
Fragment API (Advanced)
If you need to inspect reassembly state:
#![allow(unused)]
fn main() {
let frag = aps.fragment_rx_mut();
// Insert a fragment — returns Some(&[u8]) when complete
if let Some(full_payload) = frag.insert_fragment(
src_addr, // sender short addr
aps_counter, // APS counter
block_num, // 0 for first fragment
total_blocks, // total blocks (first frag only)
&fragment_data,
) {
// Process the reassembled payload
process(full_payload);
// Free the slot
frag.complete_entry(src_addr, aps_counter);
}
}
APS Security
APS security provides end-to-end encryption between two specific devices, on top of the network-wide NWK encryption. While every device on the network shares the NWK key, APS link keys are known only to the two communicating parties.
Key Types
#![allow(unused)]
fn main() {
pub enum ApsKeyType {
TrustCenterMasterKey = 0x00, // pre-installed
TrustCenterLinkKey = 0x01, // shared with TC
NetworkKey = 0x02, // shared by all devices
ApplicationLinkKey = 0x03, // between two app devices
DistributedGlobalLinkKey = 0x04, // for distributed TC networks
}
}
The well-known default TC link key is the ASCII string "ZigBeeAlliance09":
#![allow(unused)]
fn main() {
pub const DEFAULT_TC_LINK_KEY: [u8; 16] = [
0x5A, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6C,
0x6C, 0x69, 0x61, 0x6E, 0x63, 0x65, 0x30, 0x39,
];
}
Every Zigbee 3.0 device ships with this key pre-installed. During joining, the Trust Center encrypts the actual network key with this well-known key so it can be delivered securely over the air.
Link Key Table
The ApsSecurity struct maintains a table of up to
MAX_KEY_TABLE_ENTRIES (16) link keys:
#![allow(unused)]
fn main() {
pub struct ApsLinkKeyEntry {
pub partner_address: IeeeAddress,
pub key: [u8; 16],
pub key_type: ApsKeyType,
pub outgoing_frame_counter: u32,
pub incoming_frame_counter: u32,
}
}
Each entry pairs a partner’s IEEE address with a 128-bit AES key and independent frame counters for replay protection.
Key Management Primitives
#![allow(unused)]
fn main() {
// Distribute a key to another device
aps.apsme_transport_key(&ApsmeTransportKeyRequest {
dst_address: remote_ieee,
key_type: ApsKeyType::ApplicationLinkKey,
key: my_app_key,
}).await;
// Request a key from the Trust Center
aps.apsme_request_key(&ApsmeRequestKeyRequest {
dst_address: tc_ieee,
key_type: ApsKeyType::TrustCenterLinkKey,
partner_address: None,
}).await;
// Switch all devices to a new network key
aps.apsme_switch_key(&ApsmeSwitchKeyRequest {
dst_address: broadcast_ieee,
key_seq_number: 1,
}).await;
// Verify a TC link key
aps.apsme_verify_key(&ApsmeVerifyKeyRequest {
dst_address: tc_ieee,
key_type: ApsKeyType::TrustCenterLinkKey,
}).await;
}
APS Security Header
When APS security is enabled, an auxiliary header is prepended to the payload:
┌───────────────────────────────────────────────────────┐
│ Security Control (1 byte) │
│ ├── Security Level (bits 0-2) │
│ ├── Key Identifier (bits 3-4) │
│ └── Extended Nonce (bit 5) │
├───────────────────────────────────────────────────────┤
│ Frame Counter (4 bytes LE) │
│ Source Address (8 bytes) — if Extended Nonce bit set │
│ Key Sequence Number (1 byte) — if Key ID = Network Key │
└───────────────────────────────────────────────────────┘
The default security level is ENC-MIC-32 (AES-CCM encryption + 4-byte message integrity code).
The APS Information Base (AIB)
The Aib struct holds all APS-layer configuration, analogous to the MAC PIB
and NWK NIB:
#![allow(unused)]
fn main() {
pub struct Aib {
pub aps_designated_coordinator: bool, // true if this is the TC
pub aps_channel_mask: u32, // 2.4 GHz channel bitmask
pub aps_use_extended_pan_id: IeeeAddress,
pub aps_use_insecure_join: bool, // default: true
pub aps_interframe_delay: u8, // ms between frames (default: 10)
pub aps_max_window_size: u8, // fragmentation window (default: 8)
pub aps_max_frame_retries: u8, // fragment retries (default: 3)
pub aps_duplicate_rejection_timeout: u16, // ms (default: 3000)
pub aps_trust_center_address: IeeeAddress,
pub aps_security_enabled: bool, // default: true
pub aps_outgoing_frame_counter: u32,
// ... channel quality attributes
}
}
Read and write attributes through the APSME-GET / APSME-SET interface:
#![allow(unused)]
fn main() {
use zigbee_aps::aib::AibAttribute;
// Read
let is_tc = aps.apsme_get_bool(AibAttribute::ApsDesignatedCoordinator)?;
let window = aps.apsme_get_u8(AibAttribute::ApsMaxWindowSize)?;
let mask = aps.apsme_get_u32(AibAttribute::ApsChannelMaskList)?;
// Write
aps.apsme_set_bool(AibAttribute::ApsSecurityEnabled, true);
aps.apsme_set_u8(AibAttribute::ApsInterframeDelay, 20);
}
ApsStatus — All Variants
Every APS operation returns an ApsStatus code:
| Variant | Value | Meaning |
|---|---|---|
Success | 0x00 | Request executed successfully |
AsduTooLong | 0xA0 | Payload too large and fragmentation not supported |
DefragDeferred | 0xA1 | Received fragment could not be defragmented |
DefragUnsupported | 0xA2 | Device does not support fragmentation |
IllegalRequest | 0xA3 | A parameter value was out of range |
InvalidBinding | 0xA4 | Unbind failed — entry not found |
InvalidParameter | 0xA5 | Unknown AIB attribute identifier |
NoAck | 0xA6 | APS ACK not received (after retries) |
NoBoundDevice | 0xA7 | Indirect send but no matching binding entry |
NoShortAddress | 0xA8 | Group send but no matching group entry |
TableFull | 0xA9 | Binding or group table is full |
UnsecuredKey | 0xAA | Frame secured with link key but key not found |
UnsupportedAttribute | 0xAB | Unknown AIB attribute in GET/SET |
SecurityFail | 0xAD | Unsecured frame received |
DecryptionError | 0xAE | APS frame decryption or authentication failed |
InsufficientSpace | 0xAF | Not enough buffers for the operation |
NotFound | 0xB0 | No matching entry in binding table |
Duplicate Rejection
The APS layer maintains a duplicate rejection table (16 entries) that
remembers recently seen (src_addr, aps_counter) pairs. This prevents
delivering the same frame twice when NWK-level retransmission is active.
Call aps.age_dup_table() periodically (every ~1 second) to expire stale
entries. The timeout is controlled by aib.aps_duplicate_rejection_timeout
(default: 3000 ms).
ACK Tracking and Retransmission
When you send with ack_request: true, the APS layer:
- Registers the frame in the ACK table (up to 8 slots)
- Starts a retry counter (default: 3 retries)
- If no ACK arrives within one tick, retransmits the original frame
- After all retries, reports
ApsStatus::NoAck
#![allow(unused)]
fn main() {
// In your periodic tick handler:
let retransmissions = aps.age_ack_table();
for frame_bytes in retransmissions {
// The APS layer has already prepared these for retransmission
aps.nwk_mut().nlde_data_request(/* ... */).await;
}
}
Putting It Together
Here’s a complete example of an APS-layer interaction in a typical Zigbee application:
#![allow(unused)]
fn main() {
// 1. Set up security
aps.security_mut().install_default_tc_link_key();
// 2. Join a network (handled by BDB steering, but conceptually:)
// ... network steering happens ...
// 3. Create a binding for attribute reports
let entry = BindingEntry::unicast(
our_ieee, 1, 0x0402, // Temperature Measurement cluster
coordinator_ieee, 1,
);
aps.binding_table_mut().add(entry).unwrap();
// 4. Send a temperature report via indirect addressing
let report_payload = build_zcl_report(temperature);
let req = ApsdeDataRequest {
dst_addr_mode: ApsAddressMode::Indirect,
dst_address: ApsAddress::Short(ShortAddress(0)),
dst_endpoint: 0,
profile_id: 0x0104,
cluster_id: 0x0402,
src_endpoint: 1,
payload: &report_payload,
tx_options: ApsTxOptions {
ack_request: true,
..Default::default()
},
radius: 0,
alias_src_addr: None,
alias_seq: None,
};
aps.apsde_data_request(&req).await?;
// 5. Periodic maintenance (call every ~1 second)
aps.age_dup_table();
aps.fragment_rx_mut().age_entries();
let retx = aps.age_ack_table();
}
Summary
The APS layer is the workhorse of the Zigbee application stack:
- Four addressing modes let you send unicast, multicast, indirect, and broadcast messages.
- The binding table powers indirect addressing and Finding & Binding.
- The group table enables room-level multicast.
- Fragmentation transparently handles large payloads.
- APS security provides end-to-end link-key encryption.
- ACK tracking and duplicate rejection ensure reliable delivery.
In the next chapter, we’ll look at the ZDO layer, which sits on top of APS endpoint 0 and provides device discovery and network management.
The ZDO Layer (Zigbee Device Objects)
The Zigbee Device Object (ZDO) layer is your device’s “identity card” and “phone book” combined. It answers questions like “Who is at NWK address 0x1A2B?”, “What clusters does endpoint 3 support?”, and “Please create a binding between these two devices.” All of this traffic flows over APS endpoint 0 using the Zigbee Device Profile (ZDP, profile ID 0x0000).
┌─────────────────────────────────────────┐
│ Application / ZCL / BDB │
└───────────────┬─────────────────────────┘
┌───────────────┴─────────────────────────┐
│ ZDO Layer (zigbee-zdo) │
│ ├── descriptors — node/power/simple │
│ ├── discovery — addr/desc/EP/match │
│ ├── binding_mgmt — bind/unbind │
│ ├── network_mgmt — mgmt LQI/RTG/… │
│ ├── device_announce │
│ └── handler — ZDP dispatcher │
└───────────────┬─────────────────────────┘
│ APS endpoint 0
┌───────────────┴─────────────────────────┐
│ APS Layer (zigbee-aps) │
└─────────────────────────────────────────┘
The zigbee-zdo crate is #![no_std] and builds for bare-metal targets.
The ZdoLayer Struct
ZdoLayer<M: MacDriver> owns the APS layer and all ZDO-local state:
descriptors, the endpoint registry, address caches, and a pending
request-response table for correlating ZDP transactions.
#![allow(unused)]
fn main() {
use zigbee_zdo::ZdoLayer;
use zigbee_aps::ApsLayer;
let zdo = ZdoLayer::new(aps_layer);
}
In practice you access it through BdbLayer:
#![allow(unused)]
fn main() {
let zdo: &ZdoLayer<_> = bdb.zdo();
let zdo_mut = bdb.zdo_mut();
}
Key Accessors
| Method | Returns | Purpose |
|---|---|---|
zdo.aps() | &ApsLayer<M> | Read APS / NWK state |
zdo.aps_mut() | &mut ApsLayer<M> | Send frames, manage bindings |
zdo.nwk() | &NwkLayer<M> | Shortcut to NWK layer |
zdo.nwk_mut() | &mut NwkLayer<M> | NWK management |
zdo.node_descriptor() | &NodeDescriptor | This device’s node descriptor |
zdo.power_descriptor() | &PowerDescriptor | This device’s power descriptor |
zdo.get_local_descriptor(ep) | Option<&SimpleDescriptor> | Simple descriptor for an endpoint |
ZDP Cluster IDs
Every ZDP command has a request cluster ID and a response cluster ID. The
response ID is always request_id | 0x8000:
| Service | Request | Response |
|---|---|---|
| NWK_addr | 0x0000 | 0x8000 |
| IEEE_addr | 0x0001 | 0x8001 |
| Node_Desc | 0x0002 | 0x8002 |
| Power_Desc | 0x0003 | 0x8003 |
| Simple_Desc | 0x0004 | 0x8004 |
| Active_EP | 0x0005 | 0x8005 |
| Match_Desc | 0x0006 | 0x8006 |
| Device_annce | 0x0013 | — |
| Bind | 0x0021 | 0x8021 |
| Unbind | 0x0022 | 0x8022 |
| Mgmt_Lqi | 0x0031 | 0x8031 |
| Mgmt_Rtg | 0x0032 | 0x8032 |
| Mgmt_Bind | 0x0033 | 0x8033 |
| Mgmt_Leave | 0x0034 | 0x8034 |
| Mgmt_Permit_Joining | 0x0036 | 0x8036 |
| Mgmt_NWK_Update | 0x0038 | 0x8038 |
Device Discovery
Device discovery lets you translate between the two types of addresses in a Zigbee network: the 16-bit NWK short address (changes when a device rejoins) and the 64-bit IEEE extended address (permanent, factory-programmed).
NWK_addr_req — Find a Short Address
“I know this device’s IEEE address. What is its current NWK short address?”
#![allow(unused)]
fn main() {
use zigbee_zdo::discovery::{NwkAddrReq, NwkAddrRsp, RequestType};
use zigbee_types::ShortAddress;
let req = NwkAddrReq {
ieee_addr: target_ieee,
request_type: RequestType::Single,
start_index: 0,
};
// Send to the device itself (or broadcast to 0xFFFD)
let rsp: NwkAddrRsp = zdo.nwk_addr_req(
ShortAddress(0xFFFD), // broadcast
&req,
).await?;
println!("Device is at NWK 0x{:04X}", rsp.nwk_addr.0);
}
The response includes the status, the IEEE address echo, and the resolved NWK
address. If RequestType::Extended is used, it also lists associated devices
(children).
IEEE_addr_req — Find an IEEE Address
The inverse operation: “I see NWK address 0x1A2B on the network. What is its permanent IEEE address?”
#![allow(unused)]
fn main() {
use zigbee_zdo::discovery::{IeeeAddrReq, RequestType};
let req = IeeeAddrReq {
nwk_addr_of_interest: ShortAddress(0x1A2B),
request_type: RequestType::Single,
start_index: 0,
};
let rsp = zdo.ieee_addr_req(ShortAddress(0x1A2B), &req).await?;
println!("IEEE address: {:02X?}", rsp.ieee_addr);
}
The response type IeeeAddrRsp is a type alias for NwkAddrRsp — both
carry the same fields.
Service Discovery
Service discovery answers the question: “What does this device do?”
Node_Desc_req — What Kind of Device?
The Node Descriptor tells you the logical type (Coordinator, Router, or End Device), frequency band, MAC capabilities, manufacturer code, and buffer sizes.
#![allow(unused)]
fn main() {
use zigbee_zdo::discovery::{NodeDescReq, NodeDescRsp};
let req = NodeDescReq {
nwk_addr_of_interest: ShortAddress(0x1A2B),
};
let rsp: NodeDescRsp = zdo.node_desc_req(
ShortAddress(0x1A2B),
&req,
).await?;
if let Some(desc) = rsp.node_descriptor {
println!("Logical type: {:?}", desc.logical_type);
println!("Manufacturer: 0x{:04X}", desc.manufacturer_code);
println!("Max buffer: {} bytes", desc.max_buffer_size);
}
}
The NodeDescriptor struct (13 bytes on the wire):
#![allow(unused)]
fn main() {
pub struct NodeDescriptor {
pub logical_type: LogicalType, // Coordinator / Router / EndDevice
pub complex_desc_available: bool,
pub user_desc_available: bool,
pub aps_flags: u8,
pub frequency_band: u8, // bit 3 = 2.4 GHz
pub mac_capabilities: u8,
pub manufacturer_code: u16,
pub max_buffer_size: u8,
pub max_incoming_transfer: u16,
pub server_mask: u16,
pub max_outgoing_transfer: u16,
pub descriptor_capabilities: u8,
}
}
Simple_Desc_req — What Clusters on This Endpoint?
The Simple Descriptor is the most important descriptor for application interoperability. It tells you the profile, device type, and the exact list of input (server) and output (client) clusters on an endpoint.
#![allow(unused)]
fn main() {
use zigbee_zdo::discovery::{SimpleDescReq, SimpleDescRsp};
let req = SimpleDescReq {
nwk_addr_of_interest: ShortAddress(0x1A2B),
endpoint: 1,
};
let rsp: SimpleDescRsp = zdo.simple_desc_req(
ShortAddress(0x1A2B),
&req,
).await?;
if let Some(desc) = rsp.simple_descriptor {
println!("Profile: 0x{:04X}", desc.profile_id);
println!("Device ID: 0x{:04X}", desc.device_id);
println!("Input clusters: {:04X?}", desc.input_clusters);
println!("Output clusters: {:04X?}", desc.output_clusters);
}
}
The SimpleDescriptor struct:
#![allow(unused)]
fn main() {
pub struct SimpleDescriptor {
pub endpoint: u8,
pub profile_id: u16, // e.g. 0x0104 (HA)
pub device_id: u16, // e.g. 0x0302 (Temperature Sensor)
pub device_version: u8,
pub input_clusters: Vec<u16, MAX_CLUSTERS>, // server clusters
pub output_clusters: Vec<u16, MAX_CLUSTERS>, // client clusters
}
}
Up to MAX_CLUSTERS (16) input and 16 output clusters per descriptor.
Active_EP_req — Which Endpoints Are Active?
Before you can query simple descriptors, you need to know which endpoints exist:
#![allow(unused)]
fn main() {
use zigbee_zdo::discovery::ActiveEpRsp;
let rsp: ActiveEpRsp = zdo.active_ep_req(ShortAddress(0x1A2B)).await?;
for &ep in &rsp.active_ep_list {
println!("Endpoint {} is active", ep);
// Now query Simple_Desc for each endpoint
}
}
Match_Desc_req — Find Compatible Endpoints
“Who on this device (or the whole network) supports the On/Off cluster?”
#![allow(unused)]
fn main() {
use zigbee_zdo::discovery::{MatchDescReq, MatchDescRsp};
let req = MatchDescReq {
nwk_addr_of_interest: ShortAddress(0xFFFD), // broadcast
profile_id: 0x0104, // Home Automation
input_clusters: vec![0x0006].into(), // On/Off (server)
output_clusters: heapless::Vec::new(),
};
let rsp: MatchDescRsp = zdo.match_desc_req(
ShortAddress(0xFFFD),
&req,
).await?;
for &ep in &rsp.match_list {
println!("Matching endpoint: {}", ep);
}
}
Other Descriptors
PowerDescriptor
Reports the device’s power configuration (2 bytes on the wire):
#![allow(unused)]
fn main() {
pub struct PowerDescriptor {
pub current_power_mode: u8, // 0 = on, synced with receiver
pub available_power_sources: u8, // bitmask (mains, battery, …)
pub current_power_source: u8, // which source is active
pub current_power_level: u8, // 0x0C = 100%
}
}
ComplexDescriptor
A list of compressed XML tags describing additional device capabilities (rarely used in practice):
#![allow(unused)]
fn main() {
pub struct ComplexDescriptor {
pub data: Vec<u8, 64>, // raw bytes
}
}
UserDescriptor
Up to 16 characters of user-settable text (like a friendly name):
#![allow(unused)]
fn main() {
pub struct UserDescriptor {
pub data: Vec<u8, 16>, // ASCII text
}
}
Binding Management
ZDP provides over-the-air commands to create and remove bindings on remote
devices. These are different from the local APSME-BIND / APSME-UNBIND —
here you’re asking another device to update its binding table.
Bind_req
#![allow(unused)]
fn main() {
use zigbee_zdo::binding_mgmt::{BindReq, BindRsp, BindTarget};
let req = BindReq {
src_addr: sensor_ieee, // source device (the one creating the binding)
src_endpoint: 1,
cluster_id: 0x0402, // Temperature Measurement
dst: BindTarget::Unicast {
dst_addr: gateway_ieee,
dst_endpoint: 1,
},
};
// Send Bind_req to the sensor device
let rsp: BindRsp = zdo.bind_req(sensor_nwk_addr, &req).await?;
assert_eq!(rsp.status, ZdpStatus::Success);
}
The BindTarget enum mirrors APS binding destinations:
#![allow(unused)]
fn main() {
pub enum BindTarget {
Group(u16),
Unicast { dst_addr: IeeeAddress, dst_endpoint: u8 },
}
}
Unbind_req
Structurally identical to Bind_req — UnbindReq is a type alias for BindReq,
and UnbindRsp is a type alias for BindRsp:
#![allow(unused)]
fn main() {
let rsp = zdo.unbind_req(sensor_nwk_addr, &req).await?;
}
Network Management
Network management commands let you query and control the Zigbee mesh topology. These are essential tools for diagnostics and network administration.
Mgmt_Lqi_req — Neighbor Table
Query a device’s neighbor table to map the mesh topology. Each record includes the neighbor’s address, link quality (LQI), relationship, and device type.
#![allow(unused)]
fn main() {
use zigbee_zdo::network_mgmt::{MgmtLqiReq, MgmtLqiRsp, NeighborTableRecord};
let req = MgmtLqiReq { start_index: 0 };
let rsp: MgmtLqiRsp = zdo.mgmt_lqi_req(ShortAddress(0x0000), &req).await?;
for neighbor in &rsp.neighbor_table_list {
println!(
" 0x{:04X} LQI={} type={} rel={}",
neighbor.network_addr.0,
neighbor.lqi,
neighbor.device_type, // 0=Coord, 1=Router, 2=ED
neighbor.relationship, // 0=parent, 1=child, 2=sibling
);
}
}
The NeighborTableRecord (22 bytes each):
#![allow(unused)]
fn main() {
pub struct NeighborTableRecord {
pub extended_pan_id: [u8; 8],
pub extended_addr: IeeeAddress,
pub network_addr: ShortAddress,
pub device_type: u8, // 2-bit: 0=Coord, 1=Router, 2=EndDevice
pub rx_on_when_idle: u8, // 2-bit: 0=off, 1=on, 2=unknown
pub relationship: u8, // 3-bit: 0=parent, 1=child, 2=sibling
pub permit_joining: u8, // 2-bit: 0=no, 1=yes, 2=unknown
pub depth: u8,
pub lqi: u8,
}
}
Mgmt_Rtg_req — Routing Table
Query a router’s routing table to understand message paths:
#![allow(unused)]
fn main() {
use zigbee_zdo::network_mgmt::MgmtRtgReq;
let req = MgmtRtgReq { start_index: 0 };
let rsp = zdo.mgmt_rtg_req(ShortAddress(0x0000), &req).await?;
for route in &rsp.routing_table_list {
println!(
" dst=0x{:04X} → next_hop=0x{:04X} status={:?}",
route.dst_addr.0,
route.next_hop_addr.0,
route.status,
);
}
}
Mgmt_Bind_req — Remote Binding Table
Query another device’s binding table:
#![allow(unused)]
fn main() {
use zigbee_zdo::network_mgmt::MgmtBindReq;
let req = MgmtBindReq { start_index: 0 };
let rsp = zdo.mgmt_bind_req(ShortAddress(0x1A2B), &req).await?;
for entry in &rsp.binding_table_list {
println!(
" ep {} cluster 0x{:04X} → {:?}",
entry.src_endpoint,
entry.cluster_id,
entry.dst,
);
}
}
Mgmt_Leave_req — Ask a Device to Leave
Tell a device to leave the network (optionally removing its children too):
#![allow(unused)]
fn main() {
use zigbee_zdo::network_mgmt::MgmtLeaveReq;
let req = MgmtLeaveReq {
device_address: device_ieee,
remove_children: false,
rejoin: false,
};
let rsp = zdo.mgmt_leave_req(ShortAddress(0x1A2B), &req).await?;
}
Mgmt_Permit_Joining_req — Open/Close the Network
Control whether new devices can join through a particular router (or the whole network via broadcast):
#![allow(unused)]
fn main() {
// Open the whole network for 180 seconds
zdo.mgmt_permit_joining_req(
ShortAddress(0xFFFC), // broadcast to all routers
180, // duration in seconds
true, // TC significance
).await?;
// Close the network
zdo.mgmt_permit_joining_req(
ShortAddress(0xFFFC),
0, // 0 = close
true,
).await?;
}
ZdpStatus — All Variants
Every ZDP response carries a status code:
| Variant | Value | Meaning |
|---|---|---|
Success | 0x00 | Request completed successfully |
InvRequestType | 0x80 | Invalid request type field |
DeviceNotFound | 0x81 | No device with the requested address |
InvalidEp | 0x82 | Endpoint is not valid (0 or > 240) |
NotActive | 0x83 | Endpoint exists but is not active |
NotSupported | 0x84 | Request not supported on this device |
Timeout | 0x85 | Request timed out |
NoMatch | 0x86 | No matching descriptors found |
TableFull | 0x87 | Binding / neighbor / routing table is full |
NoEntry | 0x88 | No matching entry found (unbind, remove) |
NoDescriptor | 0x89 | Requested descriptor does not exist |
ZdoError
Operations that fail locally (before reaching the network) return ZdoError:
#![allow(unused)]
fn main() {
pub enum ZdoError {
BufferTooSmall, // serialization buffer too small
InvalidLength, // frame shorter than expected
InvalidData, // reserved / malformed field
ApsError(ApsStatus), // APS layer error
TableFull, // local table capacity exceeded
}
}
ZDP Transaction Sequence Numbers
Every ZDP exchange is correlated by a Transaction Sequence Number (TSN).
The ZDO layer manages this automatically — you don’t need to track TSNs
yourself. Internally, ZdoLayer maintains a pending-response table (up to
4 concurrent requests) that matches incoming response TSNs to outstanding
requests.
#![allow(unused)]
fn main() {
// TSN is allocated and tracked internally
let tsn = zdo.next_seq(); // wrapping u8 counter
}
Device Announce
When a device joins (or rejoins) a network, it broadcasts a Device_annce
(cluster 0x0013) to inform all routers of its presence:
#![allow(unused)]
fn main() {
// Automatically called by BDB steering on join, but available manually:
zdo.device_annce(my_nwk_addr, my_ieee_addr).await?;
}
This is a one-way broadcast — there is no response.
Complete Example: Discovering a New Device
Here’s how you’d discover everything about a device that just joined your network:
#![allow(unused)]
fn main() {
// 1. We received a Device_annce — we know the NWK addr and IEEE addr
let device_nwk = ShortAddress(0x5E3F);
let device_ieee = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
// 2. Get the node descriptor — what type of device is it?
let node_desc = zdo.node_desc_req(device_nwk, &NodeDescReq {
nwk_addr_of_interest: device_nwk,
}).await?;
if let Some(nd) = node_desc.node_descriptor {
match nd.logical_type {
LogicalType::EndDevice => println!("It's an end device"),
LogicalType::Router => println!("It's a router"),
LogicalType::Coordinator => println!("It's a coordinator"),
}
}
// 3. Enumerate active endpoints
let eps = zdo.active_ep_req(device_nwk).await?;
// 4. Get the simple descriptor for each endpoint
for &ep in &eps.active_ep_list {
let sd = zdo.simple_desc_req(device_nwk, &SimpleDescReq {
nwk_addr_of_interest: device_nwk,
endpoint: ep,
}).await?;
if let Some(desc) = sd.simple_descriptor {
println!("Endpoint {}: profile=0x{:04X} device=0x{:04X}",
ep, desc.profile_id, desc.device_id);
println!(" Server clusters: {:04X?}", desc.input_clusters);
println!(" Client clusters: {:04X?}", desc.output_clusters);
}
}
// 5. Create a binding if we find a matching cluster
// (BDB Finding & Binding does this automatically)
}
Summary
The ZDO layer provides the essential management infrastructure for every Zigbee network:
- Device discovery (
NWK_addr_req,IEEE_addr_req) translates between address types. - Service discovery (
Node_Desc,Simple_Desc,Active_EP,Match_Desc) reveals device capabilities. - Binding management (
Bind_req,Unbind_req) creates and removes over-the-air bindings. - Network management (
Mgmt_Lqi,Mgmt_Rtg,Mgmt_Bind,Mgmt_Leave,Mgmt_Permit_Joining) monitors and controls the mesh. - Descriptors (
NodeDescriptor,PowerDescriptor,SimpleDescriptor,ComplexDescriptor,UserDescriptor) describe each device’s identity and capabilities.
In the next chapter, we’ll look at BDB Commissioning — the standardized process of getting devices onto the network and binding them together.
BDB Commissioning
Base Device Behavior (BDB) is the Zigbee 3.0 specification that standardises how devices join networks, form networks, and find each other. Before BDB, every manufacturer invented its own commissioning process — some used button sequences, others relied on proprietary apps. BDB defines four universal methods so that any Zigbee 3.0 device can join any Zigbee 3.0 network.
┌──────────────────────────────────────┐
│ Application │
└──────────────┬───────────────────────┘
│ BDB commissioning API
┌──────────────┴───────────────────────┐
│ BDB Layer (zigbee-bdb) │
│ ├── state_machine: top-level FSM │
│ ├── steering: join network │
│ ├── formation: create network │
│ ├── finding_binding: EZ-Mode F&B │
│ ├── touchlink: proximity comm. │
│ └── attributes: BDB attributes │
└──────────────┬───────────────────────┘
│ ZDP services / NLME-*
┌──────────────┴───────────────────────┐
│ ZDO Layer (zigbee-zdo) │
└──────────────────────────────────────┘
The Four Commissioning Modes
| Mode | What it does | Who uses it |
|---|---|---|
| Network Steering | Join an existing network (or open it for others) | End Devices, Routers |
| Network Formation | Create a new PAN from scratch | Coordinators |
| Finding & Binding | Automatically create bindings between compatible endpoints | All device types |
| Touchlink | Join via physical proximity (Inter-PAN) | Lights, remotes |
Each mode can be enabled or disabled independently through a bitmask:
#![allow(unused)]
fn main() {
use zigbee_bdb::CommissioningMode;
// Enable only steering (most common for end devices)
let mode = CommissioningMode::STEERING;
// Enable steering + finding & binding
let mode = CommissioningMode::STEERING.or(CommissioningMode::FINDING_BINDING);
// Enable everything
let mode = CommissioningMode::ALL; // 0x0F
// Check what's enabled
if mode.contains(CommissioningMode::FORMATION) {
println!("Formation is enabled");
}
}
The four bits:
| Constant | Value | Method |
|---|---|---|
CommissioningMode::TOUCHLINK | 0x01 | Touchlink |
CommissioningMode::STEERING | 0x02 | Network Steering |
CommissioningMode::FORMATION | 0x04 | Network Formation |
CommissioningMode::FINDING_BINDING | 0x08 | Finding & Binding |
CommissioningMode::ALL | 0x0F | All of the above |
The BdbLayer Struct
BdbLayer<M: MacDriver> is the top-level type in zigbee-rs. It owns the
ZDO layer (which owns APS, which owns NWK, which owns MAC) and drives the
commissioning state machine.
#![allow(unused)]
fn main() {
use zigbee_bdb::BdbLayer;
use zigbee_zdo::ZdoLayer;
let bdb = BdbLayer::new(zdo_layer);
// Access lower layers
let zdo = bdb.zdo();
let aps = bdb.zdo().aps();
let nwk = bdb.zdo().aps().nwk();
// Check state
println!("On network: {}", bdb.is_on_network());
println!("State: {:?}", bdb.state());
}
Key Accessors
| Method | Returns | Purpose |
|---|---|---|
bdb.zdo() | &ZdoLayer<M> | Access ZDO and below |
bdb.zdo_mut() | &mut ZdoLayer<M> | Mutable ZDO access |
bdb.attributes() | &BdbAttributes | Read BDB attributes |
bdb.attributes_mut() | &mut BdbAttributes | Configure BDB behavior |
bdb.state() | &BdbState | Current state machine state |
bdb.is_on_network() | bool | Whether device has joined |
The State Machine
BDB commissioning follows a strict state machine. When you call
bdb.commission(), it runs each enabled method in order, skipping any that
aren’t available for the device type:
┌──────────┐
┌─────────►│ Idle │◄────────────────┐
│ └────┬─────┘ │
│ │ commission() │
│ ┌────▼──────────┐ │
│ │ Initializing │ │
│ └────┬──────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ TL? │ Touchlink │──► fail ──┐ │
│ └───────┬────────┘ │ │
│ │ skip/done │ │
│ ┌───────▼────────┐ │ │
│ NS? │ NetworkSteering│──► fail ──┤ │
│ └───────┬────────┘ │ │
│ │ skip/done │ │
│ ┌───────▼────────┐ │ │
│ NF? │NetworkFormation│──► fail ──┤ │
│ └───────┬────────┘ │ │
│ │ skip/done │ │
│ ┌───────▼────────┐ │ │
│ FB? │FindingBinding │──► fail ──┘ │
│ └───────┬────────┘ │
│ │ │
└───────────────┴────────────────────────┘
BdbState
#![allow(unused)]
fn main() {
pub enum BdbState {
Idle, // No commissioning in progress
Initializing, // Running BDB initialization
NetworkSteering, // Scanning / joining a network
NetworkFormation, // Creating a new PAN
FindingBinding, // EZ-Mode automatic binding
Touchlink, // Proximity commissioning
}
}
Device Type Capabilities
Not every device can use every mode. The initialize() method sets the
capability mask automatically:
| Device Type | Available Modes |
|---|---|
| Coordinator | Steering + Formation + Finding & Binding |
| Router | Steering + Finding & Binding + Touchlink |
| End Device | Steering + Finding & Binding + Touchlink |
The requested mode is intersected with the capability mask to produce the effective commissioning mode. If you request Formation on an End Device, it is silently skipped.
Initialization
Before any commissioning, you must call initialize() once after power-on:
#![allow(unused)]
fn main() {
// Initialize BDB — sets capabilities, syncs on-network state
bdb.initialize().await?;
}
This performs:
- Reset of lower layers (
NLME-RESET) - Detection of device type (Coordinator / Router / End Device)
- Setting
node_commissioning_capabilitybased on device type - Syncing
node_is_on_a_networkwith NWK layer state
Network Steering
Network Steering is the most common commissioning method. Its behavior depends on whether the device is already on a network.
Not on a Network — Join
When node_is_on_a_network is false, steering performs a full join sequence:
1. Scan primary channels (11, 15, 20, 25) for open networks
└── NLME-NETWORK-DISCOVERY
2. Filter by extended PAN ID (if configured)
3. Join the best-LQI network with permit-joining enabled
└── NLME-JOIN
4. Broadcast Device_annce
5. Wait for Transport-Key from Trust Center
└── Poll parent via MAC Data Request
6. Send APSME-REQUEST-KEY for unique TC link key
7. Re-broadcast Device_annce (now secured)
If primary channels yield no results, secondary channels (all other 2.4 GHz channels) are scanned.
#![allow(unused)]
fn main() {
// Configure and run steering
bdb.attributes_mut().commissioning_mode = CommissioningMode::STEERING;
bdb.attributes_mut().primary_channel_set = BDB_PRIMARY_CHANNEL_SET;
bdb.attributes_mut().secondary_channel_set = BDB_SECONDARY_CHANNEL_SET;
// Option A: Run the full state machine (recommended)
bdb.commission().await?;
// Option B: Call steering directly
bdb.network_steering().await?;
}
The steering retry budget is controlled by steering_attempts_remaining
(default: 5). Each call to steer_off_network decrements this counter.
Already on a Network — Open for Joining
When node_is_on_a_network is true, steering opens the network so other
devices can join:
1. Open local permit joining (180 seconds)
└── NLME-PERMIT-JOINING
2. Broadcast Mgmt_Permit_Joining_req to all routers
For End Devices (which can’t accept joins themselves), steering sends a
Mgmt_Permit_Joining_req to the coordinator.
Network Formation
Network Formation creates a brand-new Zigbee PAN. Only coordinators can form networks.
1. Verify this device is coordinator-capable
2. Form network on primary channels
└── NLME-NETWORK-FORMATION (energy scan + selection)
3. If primary channels fail, try secondary channels
4. Configure Trust Center policies
5. Install NWK key
6. Open permit joining for 180 seconds
#![allow(unused)]
fn main() {
// Configure as coordinator
bdb.attributes_mut().commissioning_mode = CommissioningMode::FORMATION;
// Form the network
bdb.network_formation().await?;
}
After formation:
aps.aib().aps_designated_coordinatoris set totrueaps.aib().aps_trust_center_addressis set to the coordinator’s IEEE address- The NWK key is installed by the NWK layer
- Permit joining is opened for
BDB_MIN_COMMISSIONING_TIME(180 seconds)
Security Modes
Formation supports two security models:
- Centralized (default): The coordinator acts as the Trust Center and distributes the NWK key to all joining devices.
- Distributed: Routers form their own trust domain with no central TC (used in some ZLL/Touchlink scenarios).
Finding & Binding (EZ-Mode)
Finding & Binding (F&B) automatically creates bindings between compatible endpoints on different devices. It uses the Identify cluster (0x0003) to discover targets.
There are two roles:
Initiator — The Device That Creates Bindings
The initiator broadcasts an Identify Query and waits for responses from devices that are currently in Identify mode (e.g., LED blinking after a button press).
1. Broadcast Identify Query to 0xFFFD
2. Collect responses for 180 seconds (bdbcMinCommissioningTime)
3. For each responding target:
a. Send Active_EP_req to get endpoint list
b. Send Simple_Desc_req for each endpoint
c. Match clusters:
• Our output clusters ↔ their input clusters
• Our input clusters ↔ their output clusters
d. Create local binding + send ZDP Bind_req to remote
#![allow(unused)]
fn main() {
// Start F&B initiator on endpoint 1
bdb.finding_binding_initiator(1).await?;
// In your event loop, tick every second:
loop {
// ... process incoming frames ...
let completed = bdb.tick_finding_binding(1).await;
if completed {
println!("F&B finished!");
break;
}
sleep(1_second).await;
}
}
The cluster matching algorithm:
- A binding is created when the initiator’s output cluster matches the target’s input cluster (client → server).
- And when the initiator’s input cluster matches the target’s output cluster (server → client).
- Both endpoints must share the same profile ID (or one must be the wildcard
0xFFFF). - If
commissioning_group_idis not0xFFFF, group bindings are also created.
Target — The Device That Gets Bound To
The target enters Identify mode and waits for an initiator to discover it:
#![allow(unused)]
fn main() {
// Enter F&B target mode on endpoint 1 (LED blinks for 180 seconds)
bdb.finding_binding_target(1).await?;
}
This sets fb_target_request to Some((endpoint, 180)). Your runtime reads
this and writes the IdentifyTime attribute on the Identify cluster, which
makes the device respond to Identify Query broadcasts.
The target’s normal APS/ZCL processing handles incoming Simple_Desc_req
and Bind_req from the initiator automatically.
Touchlink
Touchlink (formerly ZLL commissioning) is a proximity-based method. Devices must be brought physically close together (RSSI threshold: -40 dBm).
1. Initiator sends Scan Request via Inter-PAN on each primary channel
└── Channels: 11, 15, 20, 25
2. Target responds if RSSI > -40 dBm
3. Initiator sends Network Start/Join Request
4. Target applies network parameters and joins
Current Status
⚠️ Touchlink is currently a stub implementation.
Full Touchlink requires Inter-PAN frame support in the MAC layer, which is not yet implemented. Calling
touchlink_commissioning()returnsErr(BdbStatus::TouchlinkFailure).
Key types and constants are defined for future implementation:
#![allow(unused)]
fn main() {
use zigbee_bdb::touchlink::*;
// Touchlink primary channels
const TOUCHLINK_PRIMARY_CHANNELS: [u8; 4] = [11, 15, 20, 25];
// RSSI threshold for proximity detection
const TOUCHLINK_RSSI_THRESHOLD: i8 = -40; // dBm
// Pre-configured link key for key transport
const TOUCHLINK_PRECONFIGURED_LINK_KEY: [u8; 16] = [0xD0, ..., 0xDF];
// Command IDs (cluster 0x1000)
touchlink::command_id::SCAN_REQUEST // 0x00
touchlink::command_id::SCAN_RESPONSE // 0x01
touchlink::command_id::NETWORK_START_REQUEST // 0x10
touchlink::command_id::FACTORY_NEW_RESET // 0x07
// ... and more
}
BDB Attributes
The BdbAttributes struct controls all BDB behavior. You configure these
before calling commission():
#![allow(unused)]
fn main() {
pub struct BdbAttributes {
/// Group ID for F&B group bindings (0xFFFF = disabled)
pub commissioning_group_id: u16,
/// Which commissioning modes to run
pub commissioning_mode: CommissioningMode,
/// Result of the last commissioning attempt
pub commissioning_status: BdbCommissioningStatus,
/// Whether this node is currently on a network
pub node_is_on_a_network: bool,
/// How this node's link key was obtained
pub node_join_link_key_type: NodeJoinLinkKeyType,
/// Primary channels to scan first (default: 11, 15, 20, 25)
pub primary_channel_set: ChannelMask,
/// Secondary channels if primary fails (default: all others)
pub secondary_channel_set: ChannelMask,
/// TC join timeout in seconds (default: 10)
pub trust_center_node_join_timeout: u16,
/// Whether TC requires link key exchange (default: true)
pub trust_center_require_key_exchange: bool,
/// Steering retry budget (default: 5)
pub steering_attempts_remaining: u8,
// ... internal fields
}
}
Channel Sets
BDB defines two channel sets for scanning:
#![allow(unused)]
fn main() {
use zigbee_bdb::attributes::*;
// Primary: channels 11, 15, 20, 25 (fastest discovery)
BDB_PRIMARY_CHANNEL_SET // ChannelMask(0x0210_8800)
// Secondary: all other 2.4 GHz channels
BDB_SECONDARY_CHANNEL_SET // ChannelMask(0x05EF_7000)
// Minimum commissioning time for F&B (180 seconds)
BDB_MIN_COMMISSIONING_TIME // 180
}
Link Key Types
#![allow(unused)]
fn main() {
pub enum NodeJoinLinkKeyType {
DefaultGlobalTrustCenterLinkKey = 0x00, // "ZigBeeAlliance09"
IcDerivedTrustCenterLinkKey = 0x01, // install code
AppTrustCenterLinkKey = 0x02, // pre-configured
TouchlinkPreconfiguredLinkKey = 0x03, // ZLL key
}
}
BdbStatus — All Variants
| Variant | Value | Meaning |
|---|---|---|
Success | 0x00 | Commissioning completed successfully |
InProgress | 0x01 | Commissioning is currently running |
NotOnNetwork | 0x02 | Operation requires network membership |
NotPermitted | 0x03 | Not supported by this device type |
NoScanResponse | 0x04 | No beacons received during steering |
FormationFailure | 0x05 | Network formation failed |
SteeringFailure | 0x06 | Steering failed after all retries |
NoIdentifyResponse | 0x07 | No Identify Query response during F&B |
BindingTableFull | 0x08 | Binding table full during F&B |
TouchlinkFailure | 0x09 | Touchlink failed or not supported |
TargetFailure | 0x0A | Target not in identifying mode |
Timeout | 0x0B | Operation timed out |
BdbCommissioningStatus
The commissioning_status attribute records the outcome of the last
commissioning attempt with finer granularity:
#![allow(unused)]
fn main() {
pub enum BdbCommissioningStatus {
Success = 0x00,
InProgress = 0x01,
NoNetwork = 0x02,
TlTargetFailure = 0x03,
TlNotAddressAssignment = 0x04,
TlNoScanResponse = 0x05,
NotPermitted = 0x06,
SteeringFormationFailure = 0x07,
NoIdentifyQueryResponse = 0x08,
BindingTableFull = 0x09,
NoScanResponse = 0x0A,
}
}
Factory Reset
BDB provides a standardized factory reset procedure:
#![allow(unused)]
fn main() {
bdb.factory_reset().await?;
}
This performs:
- Leave the current network (if joined)
- Reset NWK + MAC layers (clears neighbor table, security, routing)
- Clear APS binding table and group table
- Reset all BDB attributes to defaults
After factory reset, the device is in a “fresh out of box” state and must be commissioned again.
Rejoin
When a device loses its parent or detects network problems, it can attempt a rejoin:
#![allow(unused)]
fn main() {
// Attempt rejoin using stored NWK key
bdb.rejoin().await?;
// Or leave and rejoin (clean restart)
bdb.leave_and_rejoin().await?;
}
The rejoin procedure:
- Scan the last-known channel for the previous network
- Attempt
NLME-JOINwith Rejoin method (uses stored NWK key) - Broadcast
Device_annce - If rejoin fails, fall back to full Network Steering
Complete Example: End Device Commissioning
Here’s how a typical temperature sensor joins a network:
#![allow(unused)]
fn main() {
use zigbee_bdb::{BdbLayer, BdbStatus, CommissioningMode};
use zigbee_bdb::attributes::BDB_PRIMARY_CHANNEL_SET;
// 1. Create the stack (MAC → NWK → APS → ZDO → BDB)
let mac = MyMacDriver::new();
let nwk = NwkLayer::new(mac, DeviceType::EndDevice);
let aps = ApsLayer::new(nwk);
let zdo = ZdoLayer::new(aps);
let mut bdb = BdbLayer::new(zdo);
// 2. Register our endpoint (temperature sensor)
bdb.zdo_mut().register_endpoint(SimpleDescriptor {
endpoint: 1,
profile_id: 0x0104, // Home Automation
device_id: 0x0302, // Temperature Sensor
device_version: 1,
input_clusters: vec![
0x0000, // Basic
0x0003, // Identify
0x0402, // Temperature Measurement
].into(),
output_clusters: heapless::Vec::new(),
});
// 3. Initialize BDB
bdb.initialize().await?;
// 4. Configure commissioning
bdb.attributes_mut().commissioning_mode =
CommissioningMode::STEERING.or(CommissioningMode::FINDING_BINDING);
// 5. Commission! This will:
// - Skip Touchlink (not requested)
// - Run Network Steering (scan, join, announce, key exchange)
// - Run Finding & Binding (Identify Query, match clusters, bind)
// - Skip Formation (we're an End Device, not supported)
bdb.commission().await?;
// 6. We're on the network!
assert!(bdb.is_on_network());
// 7. Now send periodic temperature reports via indirect addressing
// (bindings were created by F&B)
loop {
let temp = read_temperature_sensor();
send_zcl_report(bdb.zdo_mut().aps_mut(), 0x0402, temp).await;
sleep(60_seconds).await;
}
}
Complete Example: Coordinator Formation
And here’s how a coordinator creates and manages a network:
#![allow(unused)]
fn main() {
// 1. Create the stack as Coordinator
let nwk = NwkLayer::new(mac, DeviceType::Coordinator);
let aps = ApsLayer::new(nwk);
let zdo = ZdoLayer::new(aps);
let mut bdb = BdbLayer::new(zdo);
// 2. Initialize
bdb.initialize().await?;
// 3. Form the network
bdb.attributes_mut().commissioning_mode = CommissioningMode::FORMATION;
bdb.commission().await?;
// The network is now active:
// - NWK key is installed
// - Permit joining is open for 180 seconds
// - We are the Trust Center
// 4. Later, open joining for more devices
bdb.attributes_mut().commissioning_mode = CommissioningMode::STEERING;
bdb.commission().await?;
// This calls steer_on_network() which broadcasts Mgmt_Permit_Joining_req
}
Summary
BDB commissioning provides a standardized, interoperable way to get Zigbee 3.0 devices onto the network:
- Network Steering handles the common case of joining an existing network (scanning channels, joining the best PAN, TC key exchange).
- Network Formation lets coordinators create new PANs with proper Trust Center configuration.
- Finding & Binding automates the tedious process of creating bindings between compatible devices using the Identify cluster.
- Touchlink enables proximity-based commissioning for consumer-friendly experiences (stub implementation — Inter-PAN MAC support needed).
- The state machine runs methods in priority order and handles fallbacks.
- Factory reset and rejoin provide recovery paths when things go wrong.
All of this is driven by the BdbLayer struct and its BdbAttributes
configuration. Set the attributes, call commission(), and zigbee-rs handles
the rest.
ZCL Foundation Commands
Foundation commands are the global command set shared by every ZCL cluster. They handle attribute reading, writing, reporting, discovery, and default responses. In zigbee-rs, the runtime processes these automatically — your cluster code rarely needs to touch them directly.
All foundation types live in zigbee_zcl::foundation.
Command Overview
| ID | Command | Direction | Response ID |
|---|---|---|---|
0x00 | Read Attributes | Client → Server | 0x01 |
0x01 | Read Attributes Response | Server → Client | — |
0x02 | Write Attributes | Client → Server | 0x04 |
0x03 | Write Attributes Undivided | Client → Server | 0x04 |
0x04 | Write Attributes Response | Server → Client | — |
0x05 | Write Attributes No Response | Client → Server | — |
0x06 | Configure Reporting | Client → Server | 0x07 |
0x07 | Configure Reporting Response | Server → Client | — |
0x08 | Read Reporting Configuration | Client → Server | 0x09 |
0x09 | Read Reporting Configuration Response | Server → Client | — |
0x0A | Report Attributes | Server → Client | — |
0x0B | Default Response | Either | — |
0x0C | Discover Attributes | Client → Server | 0x0D |
0x0D | Discover Attributes Response | Server → Client | — |
0x11 | Discover Commands Received | Client → Server | 0x12 |
0x13 | Discover Commands Generated | Client → Server | 0x14 |
0x15 | Discover Attributes Extended | Client → Server | 0x16 |
These are defined as a Rust enum:
#![allow(unused)]
fn main() {
pub enum FoundationCommandId {
ReadAttributes = 0x00,
ReadAttributesResponse = 0x01,
WriteAttributes = 0x02,
WriteAttributesUndivided = 0x03,
WriteAttributesResponse = 0x04,
WriteAttributesNoResponse = 0x05,
ConfigureReporting = 0x06,
ConfigureReportingResponse = 0x07,
ReadReportingConfig = 0x08,
ReadReportingConfigResponse = 0x09,
ReportAttributes = 0x0A,
DefaultResponse = 0x0B,
DiscoverAttributes = 0x0C,
DiscoverAttributesResponse = 0x0D,
DiscoverCommandsReceived = 0x11,
DiscoverCommandsReceivedResponse = 0x12,
DiscoverCommandsGenerated = 0x13,
DiscoverCommandsGeneratedResponse = 0x14,
DiscoverAttributesExtended = 0x15,
DiscoverAttributesExtendedResponse = 0x16,
}
}
Read Attributes (0x00 / 0x01)
The most common foundation command. A coordinator or binding partner reads attribute values from a cluster.
Request — a list of AttributeIds:
#![allow(unused)]
fn main() {
use zigbee_zcl::foundation::read_attributes::*;
let req = ReadAttributesRequest::parse(&payload)?;
// req.attributes: Vec<AttributeId, 16>
}
Processing — the runtime calls process_read_dyn() automatically:
#![allow(unused)]
fn main() {
use zigbee_zcl::foundation::read_attributes::process_read_dyn;
let response = process_read_dyn(cluster.attributes(), &request);
// Each record: { id, status, data_type, value }
}
Each ReadAttributeRecord in the response contains:
id— the attribute ID that was requestedstatus—Success,UnsupportedAttribute, orWriteOnlydata_type/value— present only whenstatus == Success
Write Attributes (0x02 / 0x04)
Writes one or more attributes. The runtime enforces access control (read-only attributes are rejected) and type checking (mismatched data types are rejected).
#![allow(unused)]
fn main() {
use zigbee_zcl::foundation::write_attributes::*;
let req = WriteAttributesRequest::parse(&payload)?;
let resp = process_write_dyn(cluster.attributes_mut(), &req);
// resp.records: Vec<WriteAttributeStatusRecord, 16>
}
Write Attributes Undivided (0x03) provides all-or-nothing semantics — if any single attribute write would fail, none are applied:
#![allow(unused)]
fn main() {
let resp = process_write_undivided_dyn(cluster.attributes_mut(), &req);
}
Write Attributes No Response (0x05) silently writes without sending a response frame:
#![allow(unused)]
fn main() {
process_write_no_response_dyn(cluster.attributes_mut(), &req);
}
Per the ZCL spec, if all writes succeed the response is a single byte 0x00 (Success). Only failed attributes appear individually in the response.
Configure Reporting (0x06 / 0x07)
Configures periodic and change-triggered attribute reports. The ReportingEngine stores these configurations and decides when to generate ReportAttributes (0x0A) frames.
#![allow(unused)]
fn main() {
use zigbee_zcl::foundation::reporting::*;
let req = ConfigureReportingRequest::parse(&payload)?;
for config in &req.configs {
engine.configure_for_cluster(endpoint, cluster_id, config.clone())?;
}
}
Each ReportingConfig contains:
| Field | Description |
|---|---|
direction | Send (0x00) or Receive (0x01) |
attribute_id | Which attribute to report |
data_type | ZCL data type of the attribute |
min_interval | Minimum seconds between reports |
max_interval | Maximum seconds between reports (0xFFFF = disable periodic) |
reportable_change | Minimum value change to trigger report (analog types only) |
Report Attributes (0x0A)
Sent by the server when a configured report triggers. The ReportingEngine handles this:
#![allow(unused)]
fn main() {
// In the main loop, advance timers:
engine.tick(elapsed_seconds);
// Then check each cluster for due reports:
let mut reports = heapless::Vec::new();
engine.check_and_collect_dyn(
endpoint, cluster_id, cluster.attributes(), &mut reports,
);
if !reports.is_empty() {
let payload = ReportAttributes { reports };
// Send payload as ZCL frame with command ID 0x0A
}
}
The engine tracks per-attribute state:
- Elapsed time since last report
- Last reported value for change detection
- For analog types: checks if change exceeds the configured threshold
- For discrete types: any value change triggers a report
Default Response (0x0B)
Sent in reply to any command that lacks a cluster-specific response, unless the sender set the “disable default response” flag.
#![allow(unused)]
fn main() {
use zigbee_zcl::foundation::default_response::DefaultResponse;
let dr = DefaultResponse {
command_id: 0x00, // The command this responds to
status: ZclStatus::Success, // Result
};
let mut buf = [0u8; 2];
dr.serialize(&mut buf);
}
The runtime generates Default Responses automatically when handle_command() returns Ok(empty_vec) or Err(status).
Discover Attributes (0x0C / 0x0D)
Lets a client enumerate which attributes a cluster supports, starting from a given attribute ID.
#![allow(unused)]
fn main() {
use zigbee_zcl::foundation::discover::*;
let req = DiscoverAttributesRequest::parse(&payload)?;
let resp = process_discover_dyn(cluster.attributes(), &req);
// resp.complete: bool (true = all attributes returned)
// resp.attributes: Vec<DiscoverAttributeInfo, 16>
// each: { id: AttributeId, data_type: ZclDataType }
}
Discover Attributes Extended (0x15 / 0x16)
Like Discover Attributes, but also returns access control flags per attribute:
#![allow(unused)]
fn main() {
let resp = process_discover_extended_dyn(cluster.attributes(), &req);
// resp.attributes: Vec<DiscoverAttributeExtendedInfo, 16>
// each: { id, data_type, access_control }
// access_control bits: 0x01=readable, 0x02=writable, 0x04=reportable
}
Discover Commands (0x11–0x14)
Enumerates which cluster-specific commands a cluster supports. Each cluster implements received_commands() and generated_commands():
#![allow(unused)]
fn main() {
// The Cluster trait provides:
fn received_commands(&self) -> heapless::Vec<u8, 32>;
fn generated_commands(&self) -> heapless::Vec<u8, 32>;
// Processing:
let resp = process_discover_commands(
&cluster.received_commands(), req.start_command_id, req.max_results,
);
}
How the Runtime Handles Foundation Commands
You almost never handle foundation commands yourself. The zigbee-rs runtime:
- Parses the incoming ZCL frame and checks
frame_control.frame_type - Foundation frames (frame_type = 0b00) are dispatched to the appropriate handler:
- Read Attributes →
process_read_dyn() - Write Attributes →
process_write_dyn()orprocess_write_undivided_dyn() - Configure Reporting →
ReportingEngine::configure_for_cluster() - Discover →
process_discover_dyn()/process_discover_extended_dyn()
- Read Attributes →
- Cluster-specific frames (frame_type = 0b01) are dispatched to your cluster’s
handle_command() - Reporting is driven by the event loop calling
engine.tick()+check_and_collect_dyn()periodically
This means your Cluster implementation only needs to:
- Register attributes with correct
AttributeAccessmodes - Implement
handle_command()for cluster-specific commands - Implement
received_commands()/generated_commands()for discovery
General Clusters
General clusters provide core functionality required by most Zigbee devices. This chapter covers every general cluster implemented in zigbee-rs.
Basic (0x0000)
Mandatory for all Zigbee devices. Exposes identity and version information.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| ZCLVersion | 0x0000 | U8 | Read | ZCL revision (8) |
| ApplicationVersion | 0x0001 | U8 | Read | Application version |
| StackVersion | 0x0002 | U8 | Read | Stack version |
| HWVersion | 0x0003 | U8 | Read | Hardware version |
| ManufacturerName | 0x0004 | String | Read | Manufacturer name |
| ModelIdentifier | 0x0005 | String | Read | Model identifier |
| DateCode | 0x0006 | String | Read | Date code |
| PowerSource | 0x0007 | Enum8 | Read | Power source (0x01=mains, 0x03=battery) |
| LocationDescription | 0x0010 | String | R/W | User-settable location |
| SWBuildID | 0x4000 | String | Read | Software build identifier |
Commands: ResetToFactoryDefaults (0x00)
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::basic::BasicCluster;
let basic = BasicCluster::new(
b"zigbee-rs", // manufacturer
b"MyDevice", // model
b"20250101", // date code
b"1.0.0", // sw build
);
// basic.set_power_source(0x03); // Battery
}
Power Configuration (0x0001)
Battery monitoring and alarm thresholds.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| BatteryVoltage | 0x0020 | U8 | Report | Voltage in 100 mV units |
| BatteryPercentageRemaining | 0x0021 | U8 | Report | Percentage in 0.5% units |
| BatterySize | 0x0031 | Enum8 | R/W | Battery size (3=AA, 4=AAA, …) |
| BatteryQuantity | 0x0033 | U8 | R/W | Number of battery cells |
| BatteryRatedVoltage | 0x0034 | U8 | R/W | Rated voltage (100 mV units) |
| BatteryAlarmMask | 0x0035 | Bitmap8 | R/W | Alarm enable bits |
| BatteryVoltageMinThreshold | 0x0036 | U8 | R/W | Low-voltage threshold |
| BatteryAlarmState | 0x003E | Bitmap32 | Read | Active alarm bits |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::power_config::PowerConfigCluster;
let mut pwr = PowerConfigCluster::new();
pwr.set_battery_voltage(33); // 3.3V
pwr.set_battery_percentage(200); // 100%
pwr.set_battery_size(3); // AA
pwr.set_battery_quantity(2);
pwr.set_battery_voltage_min_threshold(24); // 2.4V alarm threshold
pwr.update_alarm_state(); // Recalculate alarms
}
Identify (0x0003)
Allows a coordinator to make a device identify itself (e.g. blink an LED).
| Attribute | ID | Type | Access |
|---|---|---|---|
| IdentifyTime | 0x0000 | U16 | R/W |
Commands: Identify (0x00), IdentifyQuery (0x01), TriggerEffect (0x40)
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::identify::IdentifyCluster;
let mut identify = IdentifyCluster::new();
// In your timer callback:
identify.tick(1); // decrement by 1 second
if identify.is_identifying() {
toggle_led();
}
// Check for trigger effects:
if let Some((effect_id, variant)) = identify.take_pending_effect() {
play_effect(effect_id, variant);
}
}
Groups (0x0004)
Manages group membership for multicast addressing.
| Attribute | ID | Type | Access |
|---|---|---|---|
| NameSupport | 0x0000 | U8 | Read |
Commands: AddGroup (0x00), ViewGroup (0x01), GetGroupMembership (0x02), RemoveGroup (0x03), RemoveAllGroups (0x04), AddGroupIfIdentifying (0x05)
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::groups::{GroupsCluster, GroupAction};
let mut groups = GroupsCluster::new();
// After handling a command, check for APS table actions:
match groups.take_action() {
GroupAction::Added(id) => aps_add_group(endpoint, id),
GroupAction::Removed(id) => aps_remove_group(endpoint, id),
GroupAction::RemovedAll => aps_remove_all_groups(endpoint),
GroupAction::None => {},
}
}
Scenes (0x0005)
Stores and recalls attribute snapshots (scenes) associated with groups.
| Attribute | ID | Type | Access |
|---|---|---|---|
| SceneCount | 0x0000 | U8 | Read |
| CurrentScene | 0x0001 | U8 | Read |
| CurrentGroup | 0x0002 | U16 | Read |
| SceneValid | 0x0003 | Bool | Read |
| NameSupport | 0x0004 | U8 | Read |
| LastConfiguredBy | 0x0005 | IEEE | Read |
Commands: AddScene (0x00), ViewScene (0x01), RemoveScene (0x02), RemoveAllScenes (0x03), StoreScene (0x04), RecallScene (0x05), GetSceneMembership (0x06)
The cluster maintains a fixed-capacity scene table (16 entries) with extension data for attribute snapshots.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::scenes::ScenesCluster;
let scenes = ScenesCluster::new();
// scene_count() returns number of active scenes
}
On/Off (0x0006)
The most common actuator cluster — controls a boolean on/off state.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| OnOff | 0x0000 | Bool | Report | Current state |
| GlobalSceneControl | 0x4000 | Bool | Read | Global scene recall flag |
| OnTime | 0x4001 | U16 | R/W | Timed-on remaining (1/10s) |
| OffWaitTime | 0x4002 | U16 | R/W | Off-wait remaining (1/10s) |
| StartUpOnOff | 0x4003 | Enum8 | R/W | Startup behavior |
Commands: Off (0x00), On (0x01), Toggle (0x02), OffWithEffect (0x40), OnWithRecallGlobalScene (0x41), OnWithTimedOff (0x42)
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::on_off::OnOffCluster;
let mut on_off = OnOffCluster::new();
assert!(!on_off.is_on());
// In the 100ms timer callback:
on_off.tick(); // manages OnTime/OffWaitTime timers
// On startup:
on_off.apply_startup(previous_state);
}
Level Control (0x0008)
Smooth dimming transitions with TransitionManager.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| CurrentLevel | 0x0000 | U8 | Report | Current brightness (0–254) |
| RemainingTime | 0x0001 | U16 | Read | Transition time left (1/10s) |
| MinLevel | 0x0002 | U8 | Read | Minimum level |
| MaxLevel | 0x0003 | U8 | Read | Maximum level |
| OnOffTransitionTime | 0x0010 | U16 | R/W | Default transition time |
| OnLevel | 0x0011 | U8 | R/W | Level when turned on |
| StartUpCurrentLevel | 0x4000 | U8 | R/W | Level on power-up |
Commands: MoveToLevel (0x00), Move (0x01), Step (0x02), Stop (0x03), MoveToLevelWithOnOff (0x04), MoveWithOnOff (0x05), StepWithOnOff (0x06), StopWithOnOff (0x07)
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::level_control::LevelControlCluster;
let mut level = LevelControlCluster::new();
// In the 100ms timer callback:
level.tick(1); // 1 decisecond
let brightness = level.current_level(); // 0–254
set_pwm(brightness);
}
Time (0x000A)
Provides UTC time and timezone information. Attributes are writable so a coordinator can set the time.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::time; // TimeCluster
}
Alarms (0x0009)
Maintains an alarm table and handles alarm acknowledgement.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::alarms; // AlarmsCluster
}
OTA Upgrade (0x0019)
Client cluster for over-the-air firmware updates. Tracks image version, file offset, and download status.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::ota; // OtaCluster
// See also: zigbee_zcl::clusters::ota_image for image header parsing
}
Poll Control (0x0020)
Manages polling intervals for sleepy end devices.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::poll_control; // PollControlCluster
}
Green Power (0x0021)
Proxy/sink for Green Power devices (battery-less sensors).
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::green_power; // GreenPowerCluster
}
Diagnostics (0x0B05)
Network and hardware diagnostic counters.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::diagnostics; // DiagnosticsCluster
}
Common Pattern
All general clusters follow the same pattern:
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::Cluster;
// 1. Create the cluster
let mut cluster = OnOffCluster::new();
// 2. The runtime dispatches foundation commands automatically
// (read attributes, write attributes, reporting, discovery)
// 3. Cluster-specific commands go through handle_command():
let result = cluster.handle_command(CommandId(0x02), &[]); // Toggle
// 4. Read attributes from application code:
let val = cluster.attributes().get(AttributeId(0x0000));
// 5. If the cluster has a tick(), call it from your timer:
cluster.tick();
}
Measurement & Sensing Clusters
Measurement clusters are server-side, read-only clusters used by sensors. They share a common pattern: a MeasuredValue attribute (reportable) plus MinMeasuredValue and MaxMeasuredValue bounds. The application updates the measured value via a setter method; the runtime handles attribute reads and reporting.
None of these clusters define cluster-specific commands — handle_command() always returns UnsupClusterCommand.
Temperature Measurement (0x0402)
Values in 0.01°C units (e.g. 2250 = 22.50°C).
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| MeasuredValue | 0x0000 | I16 | Report | Current temperature × 100 |
| MinMeasuredValue | 0x0001 | I16 | Read | Minimum measurable |
| MaxMeasuredValue | 0x0002 | I16 | Read | Maximum measurable |
| Tolerance | 0x0003 | U16 | Read | Measurement tolerance |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::temperature::TemperatureCluster;
let mut temp = TemperatureCluster::new(-4000, 8500); // -40°C to 85°C
temp.set_temperature(2250); // 22.50°C
}
Relative Humidity (0x0405)
Values in 0.01% RH units (e.g. 5000 = 50.00% RH).
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| MeasuredValue | 0x0000 | U16 | Report | Current humidity × 100 |
| MinMeasuredValue | 0x0001 | U16 | Read | Minimum measurable |
| MaxMeasuredValue | 0x0002 | U16 | Read | Maximum measurable |
| Tolerance | 0x0003 | U16 | Read | Measurement tolerance |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::humidity::HumidityCluster;
let mut hum = HumidityCluster::new(0, 10000); // 0–100%
hum.set_humidity(5000); // 50.00% RH
}
Pressure Measurement (0x0403)
Values in 0.1 kPa units (e.g. 10132 = 1013.2 hPa). Also supports extended precision with scaled attributes.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| MeasuredValue | 0x0000 | I16 | Report | Pressure in 0.1 kPa |
| MinMeasuredValue | 0x0001 | I16 | Read | Minimum measurable |
| MaxMeasuredValue | 0x0002 | I16 | Read | Maximum measurable |
| Tolerance | 0x0003 | U16 | Read | Measurement tolerance |
| ScaledValue | 0x0010 | I16 | Report | High-precision pressure |
| MinScaledValue | 0x0011 | I16 | Read | Minimum scaled |
| MaxScaledValue | 0x0012 | I16 | Read | Maximum scaled |
| ScaledTolerance | 0x0013 | U16 | Read | Scaled tolerance |
| Scale | 0x0014 | I8 | Read | 10^Scale multiplier |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::pressure::PressureCluster;
let mut press = PressureCluster::new(3000, 11000); // 300–1100 hPa
press.set_pressure(10132); // 1013.2 hPa
}
Illuminance Measurement (0x0400)
Measures ambient light level.
| Attribute | ID | Type | Access |
|---|---|---|---|
| MeasuredValue | 0x0000 | U16 | Report |
| MinMeasuredValue | 0x0001 | U16 | Read |
| MaxMeasuredValue | 0x0002 | U16 | Read |
| Tolerance | 0x0003 | U16 | Read |
Values use a logarithmic formula: MeasuredValue = 10,000 × log10(lux) + 1.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::illuminance::IlluminanceCluster;
}
Flow Measurement (0x0404)
Measures flow rate in 0.1 m³/h units.
| Attribute | ID | Type | Access |
|---|---|---|---|
| MeasuredValue | 0x0000 | U16 | Report |
| MinMeasuredValue | 0x0001 | U16 | Read |
| MaxMeasuredValue | 0x0002 | U16 | Read |
| Tolerance | 0x0003 | U16 | Read |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::flow_measurement::FlowMeasurementCluster;
}
Occupancy Sensing (0x0406)
Binary occupancy detection with configurable sensor type.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| Occupancy | 0x0000 | Bitmap8 | Report | Bit 0 = occupied |
| OccupancySensorType | 0x0001 | Enum8 | Read | 0=PIR, 1=Ultrasonic, 2=PIR+US |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::occupancy::OccupancyCluster;
}
Electrical Measurement (0x0B04)
Real-time electrical measurements (voltage, current, power, power factor).
| Attribute | ID | Type | Access |
|---|---|---|---|
| MeasurementType | 0x0000 | Bitmap32 | Read |
| RmsVoltage | 0x0505 | U16 | Report |
| RmsCurrent | 0x0508 | U16 | Report |
| ActivePower | 0x050B | I16 | Report |
| PowerFactor | 0x0510 | I8 | Read |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::electrical::ElectricalMeasurementCluster;
}
PM2.5 Measurement (0x042A)
Particulate matter (PM2.5) concentration.
| Attribute | ID | Type | Access |
|---|---|---|---|
| MeasuredValue | 0x0000 | U16 | Report |
| MinMeasuredValue | 0x0001 | U16 | Read |
| MaxMeasuredValue | 0x0002 | U16 | Read |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::pm25::Pm25Cluster;
}
Carbon Dioxide (0x040D)
CO₂ concentration measurement in PPM.
| Attribute | ID | Type | Access |
|---|---|---|---|
| MeasuredValue | 0x0000 | U16 | Report |
| MinMeasuredValue | 0x0001 | U16 | Read |
| MaxMeasuredValue | 0x0002 | U16 | Read |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::carbon_dioxide::CarbonDioxideCluster;
}
Soil Moisture (0x0408)
Soil moisture level in 0.01% units.
| Attribute | ID | Type | Access |
|---|---|---|---|
| MeasuredValue | 0x0000 | U16 | Report |
| MinMeasuredValue | 0x0001 | U16 | Read |
| MaxMeasuredValue | 0x0002 | U16 | Read |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::soil_moisture::SoilMoistureCluster;
}
Common Sensor Pattern
All measurement clusters follow the same usage pattern:
#![allow(unused)]
fn main() {
// 1. Create with min/max bounds
let mut sensor = TemperatureCluster::new(-4000, 8500);
// 2. Register on an endpoint via the builder
builder.add_cluster(ClusterId::TEMPERATURE, sensor);
// 3. In your sensor read callback, update the value:
sensor.set_temperature(read_adc_temperature());
// 4. The runtime handles:
// - Read Attributes responses
// - Attribute reporting (when configured)
// - Discover Attributes responses
}
Reporting Configuration Example
A coordinator typically configures measurement clusters to report on change:
Configure Reporting for TemperatureMeasurement (0x0402):
Attribute: MeasuredValue (0x0000)
Type: I16
Min Interval: 30 seconds
Max Interval: 300 seconds
Reportable Change: 50 (= 0.50°C)
The ReportingEngine tracks the last reported value and sends a new report when:
- The value changes by more than 0.50°C and at least 30 seconds have passed, or
- 300 seconds have passed regardless of change
Lighting Clusters
Lighting clusters control color and ballast configuration for smart lights.
Color Control (0x0300)
The most complex cluster in zigbee-rs. Supports three color models (Hue/Saturation, CIE XY, and Color Temperature) plus enhanced hue and color loop functionality. All color transitions are managed by a built-in TransitionManager.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| CurrentHue | 0x0000 | U8 | Report | Hue (0–254) |
| CurrentSaturation | 0x0001 | U8 | Report | Saturation (0–254) |
| RemainingTime | 0x0002 | U16 | Read | Transition time remaining (1/10s) |
| CurrentX | 0x0003 | U16 | Report | CIE x chromaticity (0–65279) |
| CurrentY | 0x0004 | U16 | Report | CIE y chromaticity (0–65279) |
| ColorTemperatureMireds | 0x0007 | U16 | Report | Color temp in mireds |
| ColorMode | 0x0008 | Enum8 | Read | Active mode (0=Hue/Sat, 1=XY, 2=Temp) |
| Options | 0x000F | Bitmap8 | R/W | Processing flags |
| EnhancedCurrentHue | 0x4000 | U16 | Read | 16-bit enhanced hue |
| EnhancedColorMode | 0x4001 | Enum8 | Read | Enhanced mode indicator |
| ColorLoopActive | 0x4002 | U8 | Read | Color loop running (0/1) |
| ColorLoopDirection | 0x4003 | U8 | Read | Loop direction (0=decrement, 1=increment) |
| ColorLoopTime | 0x4004 | U16 | Read | Loop period in seconds |
| ColorCapabilities | 0x400A | Bitmap16 | Read | Supported features bitmask |
| ColorTempPhysicalMin | 0x400B | U16 | Read | Min supported mireds (e.g. 153 = 6500K) |
| ColorTempPhysicalMax | 0x400C | U16 | Read | Max supported mireds (e.g. 500 = 2000K) |
Color Modes
#![allow(unused)]
fn main() {
pub const COLOR_MODE_HUE_SAT: u8 = 0x00;
pub const COLOR_MODE_XY: u8 = 0x01;
pub const COLOR_MODE_TEMPERATURE: u8 = 0x02;
}
Commands
| ID | Command | Description |
|---|---|---|
0x00 | MoveToHue | Transition to target hue |
0x01 | MoveHue | Continuous hue movement |
0x02 | StepHue | Step hue by increment |
0x03 | MoveToSaturation | Transition to target saturation |
0x04 | MoveSaturation | Continuous saturation movement |
0x05 | StepSaturation | Step saturation by increment |
0x06 | MoveToHueAndSaturation | Transition both |
0x07 | MoveToColor | Transition to XY color |
0x08 | MoveColor | Continuous XY movement |
0x09 | StepColor | Step XY by increments |
0x0A | MoveToColorTemperature | Transition to color temp |
0x40 | EnhancedMoveToHue | 16-bit hue transition |
0x41 | EnhancedMoveHue | 16-bit continuous hue |
0x42 | EnhancedStepHue | 16-bit hue step |
0x43 | EnhancedMoveToHueAndSaturation | 16-bit hue + sat |
0x44 | ColorLoopSet | Start/stop color loop |
0x47 | StopMoveStep | Stop all transitions |
0x4B | MoveColorTemperature | Continuous temp movement |
0x4C | StepColorTemperature | Step color temp |
Usage
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::color_control::ColorControlCluster;
let mut color = ColorControlCluster::new();
// Default: XY mode, color temp 250 mireds (~4000K), all capabilities enabled
// In your timer callback (call every 100ms):
color.tick(1); // advance transitions by 1 decisecond
// Read current state for LED driver:
let hue = color.attributes().get(AttributeId(0x0000)); // CurrentHue
let sat = color.attributes().get(AttributeId(0x0001)); // CurrentSaturation
let temp = color.attributes().get(AttributeId(0x0007)); // ColorTemperature
}
The Transition Engine
Color Control uses a TransitionManager<4> supporting 4 concurrent transitions (hue, saturation, X, Y or color temperature). When a command like MoveToColor arrives:
- The cluster calculates start value, target value, and transition time
- Starts a
Transitionin theTransitionManager - Each
tick()call interpolates the current value linearly - Attribute store is updated with the interpolated value
RemainingTimeattribute reflects time left
Color Loop
The Color Loop engine (ColorLoopSet command 0x44) continuously cycles the hue:
- Active: ColorLoopActive attribute (0 = off, 1 = running)
- Direction: 0 = decrement hue, 1 = increment hue
- Time: Full cycle period in seconds
tick()advances the hue based on elapsed time and loop parameters
Ballast Configuration (0x0301)
Configuration attributes for fluorescent lamp ballasts.
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| PhysicalMinLevel | 0x0000 | U8 | Read | Minimum light output |
| PhysicalMaxLevel | 0x0001 | U8 | Read | Maximum light output |
| BallastStatus | 0x0002 | Bitmap8 | Read | Status flags |
| MinLevel | 0x0010 | U8 | R/W | Configured minimum |
| MaxLevel | 0x0011 | U8 | R/W | Configured maximum |
No cluster-specific commands — configuration is done via Write Attributes.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::ballast_config::BallastConfigCluster;
let ballast = BallastConfigCluster::new();
}
Putting It Together: Dimmable Color Light
A typical color light uses On/Off + Level Control + Color Control together:
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::on_off::OnOffCluster;
use zigbee_zcl::clusters::level_control::LevelControlCluster;
use zigbee_zcl::clusters::color_control::ColorControlCluster;
let mut on_off = OnOffCluster::new();
let mut level = LevelControlCluster::new();
let mut color = ColorControlCluster::new();
// In the 100ms timer:
on_off.tick();
level.tick(1);
color.tick(1);
// Drive the LED:
if on_off.is_on() {
let brightness = level.current_level();
// Read color temp from attribute store
// Apply to LED driver
}
}
HVAC Clusters
Heating, Ventilation, and Air Conditioning clusters for climate control devices.
Thermostat (0x0201)
A full-featured thermostat with heating/cooling setpoints, weekly scheduling, and automatic mode switching. Temperature values are in 0.01°C units throughout.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| LocalTemperature | 0x0000 | I16 | Report | Current sensor reading |
| OutdoorTemperature | 0x0001 | I16 | Read | Outdoor temp (optional) |
| Occupancy | 0x0002 | U8 | Read | Occupancy bitmap |
| AbsMinHeatSetpointLimit | 0x0003 | I16 | Read | Absolute minimum heat SP (700 = 7°C) |
| AbsMaxHeatSetpointLimit | 0x0004 | I16 | Read | Absolute maximum heat SP (3000 = 30°C) |
| AbsMinCoolSetpointLimit | 0x0005 | I16 | Read | Absolute minimum cool SP (1600 = 16°C) |
| AbsMaxCoolSetpointLimit | 0x0006 | I16 | Read | Absolute maximum cool SP (3200 = 32°C) |
| OccupiedCoolingSetpoint | 0x0011 | I16 | R/W | Active cooling setpoint (2600 = 26°C) |
| OccupiedHeatingSetpoint | 0x0012 | I16 | R/W | Active heating setpoint (2000 = 20°C) |
| MinHeatSetpointLimit | 0x0015 | I16 | R/W | Configurable heat SP minimum |
| MaxHeatSetpointLimit | 0x0016 | I16 | R/W | Configurable heat SP maximum |
| MinCoolSetpointLimit | 0x0017 | I16 | R/W | Configurable cool SP minimum |
| MaxCoolSetpointLimit | 0x0018 | I16 | R/W | Configurable cool SP maximum |
| ControlSequenceOfOperation | 0x001B | Enum8 | R/W | 0x04 = Cooling and Heating |
| SystemMode | 0x001C | Enum8 | R/W | Current operating mode |
| ThermostatRunningMode | 0x001E | Enum8 | Read | Computed running mode |
System Modes
| Value | Mode | Description |
|---|---|---|
0x00 | Off | System disabled |
0x01 | Auto | Automatic heat/cool switching |
0x03 | Cool | Cooling only |
0x04 | Heat | Heating only |
0x05 | Emergency Heat | Emergency/auxiliary heating |
0x07 | Fan Only | Fan without heating/cooling |
Commands
| ID | Direction | Command |
|---|---|---|
0x00 | Client→Server | SetpointRaiseLower |
0x01 | Client→Server | SetWeeklySchedule |
0x02 | Client→Server | GetWeeklySchedule |
0x03 | Client→Server | ClearWeeklySchedule |
0x00 | Server→Client | GetWeeklyScheduleResponse |
Usage
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::thermostat::ThermostatCluster;
let mut therm = ThermostatCluster::new();
// Update temperature from sensor (22.50°C):
therm.set_local_temperature(2250);
// In the periodic callback — advance schedule and compute running mode:
// day_of_week: bitmask (bit 0 = Sunday .. bit 6 = Saturday)
// minutes_since_midnight: current time of day
therm.tick(0b0000010, 480); // Monday, 08:00
// SystemMode and setpoints can be written remotely via Write Attributes
// RunningMode is computed automatically by tick():
// - Auto mode: heats if temp < heat SP, cools if temp > cool SP
// - Heat mode: heats if temp < heat SP
// - Cool mode: cools if temp > cool SP
}
Weekly Schedule
The thermostat supports a weekly schedule with up to 16 entries, each containing multiple transitions:
#![allow(unused)]
fn main() {
// A coordinator sends SetWeeklySchedule (0x01):
// num_transitions: 3
// days_of_week: 0b0111110 (Monday–Friday)
// mode: 0x01 (heat only)
// transitions:
// 06:00 → heat SP = 21.00°C
// 09:00 → heat SP = 18.00°C (away)
// 17:00 → heat SP = 21.00°C (home)
// tick() finds the latest passed transition and applies its setpoints
}
Fan Control (0x0202)
Simple fan speed control with mode enumeration.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| FanMode | 0x0000 | Enum8 | R/W | Current fan mode |
| FanModeSequence | 0x0001 | Enum8 | R/W | Available mode sequence |
Fan Modes
| Value | Mode |
|---|---|
0x00 | Off |
0x01 | Low |
0x02 | Medium |
0x03 | High |
0x04 | On |
0x05 | Auto |
0x06 | Smart |
Fan Mode Sequences
| Value | Sequence |
|---|---|
0x00 | Low/Med/High |
0x01 | Low/High |
0x02 | Low/Med/High/Auto |
0x03 | Low/High/Auto |
0x04 | On/Auto |
No cluster-specific commands — fan mode is set via Write Attributes.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::fan_control::FanControlCluster;
let mut fan = FanControlCluster::new();
assert_eq!(fan.fan_mode(), 0x05); // Auto by default
fan.set_fan_mode(0x03); // High
}
Thermostat User Interface Configuration (0x0204)
Controls how the thermostat’s local UI behaves.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| TemperatureDisplayMode | 0x0000 | Enum8 | R/W | 0=Celsius, 1=Fahrenheit |
| KeypadLockout | 0x0001 | Enum8 | R/W | 0=No lockout, 1–5=lockout levels |
| ScheduleProgrammingVisibility | 0x0002 | Enum8 | R/W | 0=enabled, 1=disabled |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::thermostat_ui::ThermostatUiCluster;
let mut ui = ThermostatUiCluster::new();
ui.set_display_mode(0x01); // Fahrenheit
ui.set_keypad_lockout(0x01); // Level 1 lockout
}
Putting It Together: Smart Thermostat
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::thermostat::ThermostatCluster;
use zigbee_zcl::clusters::fan_control::FanControlCluster;
use zigbee_zcl::clusters::thermostat_ui::ThermostatUiCluster;
use zigbee_zcl::clusters::temperature::TemperatureCluster;
let mut therm = ThermostatCluster::new();
let mut fan = FanControlCluster::new();
let mut ui = ThermostatUiCluster::new();
let mut temp_sensor = TemperatureCluster::new(-1000, 5000);
// Periodic callback (every minute):
let reading = read_temperature_sensor(); // 0.01°C units
temp_sensor.set_temperature(reading);
therm.set_local_temperature(reading);
therm.tick(get_day_of_week(), get_minutes_since_midnight());
// The thermostat running mode drives the HVAC relays
}
Closures & Security Clusters
Clusters for physical access control (door locks, window coverings) and intrusion detection (IAS zones).
Door Lock (0x0101)
Full-featured door lock with PIN code management, auto-relock, and operating modes.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| LockState | 0x0000 | Enum8 | Report | 0=NotFullyLocked, 1=Locked, 2=Unlocked |
| LockType | 0x0001 | Enum8 | Read | DeadBolt(0), Magnetic(1), Other(2), etc. |
| ActuatorEnabled | 0x0002 | Bool | Read | Actuator operational |
| DoorState | 0x0003 | Enum8 | Report | Open(0), Closed(1), Jammed(2), etc. |
| DoorOpenEvents | 0x0004 | U32 | R/W | Door-open counter |
| DoorClosedEvents | 0x0005 | U32 | R/W | Door-close counter |
| OpenPeriod | 0x0006 | U16 | R/W | Auto-close period |
| NumPINUsersSupported | 0x0012 | U16 | Read | Max PIN users |
| MaxPINCodeLength | 0x0017 | U8 | Read | Max PIN length (default 8) |
| MinPINCodeLength | 0x0018 | U8 | Read | Min PIN length (default 4) |
| Language | 0x0021 | String | R/W | Display language |
| AutoRelockTime | 0x0023 | U32 | R/W | Auto-relock delay in seconds |
| OperatingMode | 0x0025 | Enum8 | R/W | Normal(0), Vacation(1), Privacy(2), etc. |
Commands (Client → Server)
| ID | Command | Description |
|---|---|---|
0x00 | LockDoor | Lock the door |
0x01 | UnlockDoor | Unlock (starts auto-relock timer) |
0x02 | Toggle | Toggle lock/unlock |
0x03 | UnlockWithTimeout | Unlock with auto-relock |
0x05 | SetPINCode | Set user PIN |
0x06 | GetPINCode | Retrieve user PIN |
0x07 | ClearPINCode | Delete a user’s PIN |
0x08 | ClearAllPINCodes | Delete all PINs |
0x09 | SetUserStatus | Enable/disable a user |
0x0A | GetUserStatus | Query user status |
Auto-Relock
When AutoRelockTime is non-zero, unlocking the door starts a countdown. The tick() method (call every second) decrements the timer and automatically locks when it expires:
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::door_lock::DoorLockCluster;
let mut lock = DoorLockCluster::new(0x00); // DeadBolt type
lock.set_lock_state(0x01); // Locked
// When UnlockDoor command arrives, the cluster:
// 1. Sets LockState = Unlocked
// 2. Reads AutoRelockTime attribute
// 3. Starts countdown in auto_relock_remaining
// In your 1-second timer:
lock.tick(); // When countdown reaches 0 → auto-locks
}
PIN Code Management
PINs are stored in a fixed-capacity table (8 entries):
#![allow(unused)]
fn main() {
// SetPINCode payload: user_id(2) + status(1) + type(1) + pin_len(1) + pin[]
// The cluster stores: { status, user_type, pin: Vec<u8, 8> }
// PinEntry fields:
// status: 0=available, 1=occupied_enabled, 3=occupied_disabled
// user_type: 0=unrestricted, 1=year_day, 2=week_day, 3=master
}
Window Covering (0x0102)
Controls roller shades, blinds, awnings, and other window treatments.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| WindowCoveringType | 0x0000 | Enum8 | Read | Covering type |
| ConfigStatus | 0x0007 | Bitmap8 | Read | Configuration flags |
| CurrentPositionLiftPercentage | 0x0008 | U8 | Report | Lift position (0–100%) |
| CurrentPositionTiltPercentage | 0x0009 | U8 | Report | Tilt position (0–100%) |
| InstalledOpenLimitLift | 0x0010 | U16 | Read | Open limit |
| InstalledClosedLimitLift | 0x0011 | U16 | Read | Closed limit |
| Mode | 0x0017 | Bitmap8 | R/W | Operating mode flags |
Covering Types
| Value | Type |
|---|---|
0x00 | Roller Shade |
0x04 | Drapery |
0x05 | Awning |
0x06 | Shutter |
0x07 | Tilt Blind (tilt only) |
0x08 | Tilt Blind (lift + tilt) |
0x09 | Projector Screen |
Commands
| ID | Command |
|---|---|
0x00 | UpOpen |
0x01 | DownClose |
0x02 | Stop |
0x05 | GoToLiftPercentage |
0x08 | GoToTiltPercentage |
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::window_covering::WindowCoveringCluster;
let covering = WindowCoveringCluster::new(0x00); // Roller shade
}
IAS Zone (0x0500)
The primary security cluster for intrusion/alarm sensors. Implements a state machine for zone enrollment with a CIE (Control and Indicating Equipment).
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| ZoneState | 0x0000 | Enum8 | Read | NotEnrolled(0) / Enrolled(1) |
| ZoneType | 0x0001 | Enum16 | Read | Sensor type code |
| ZoneStatus | 0x0002 | Bitmap16 | Read | Alarm and tamper bits |
| IAS_CIE_Address | 0x0010 | IEEE | R/W | CIE’s IEEE address |
| ZoneID | 0x0011 | U8 | Read | Assigned zone ID |
| NumZoneSensitivityLevels | 0x0012 | U8 | Read | Supported sensitivity levels |
| CurrentZoneSensitivityLevel | 0x0013 | U8 | R/W | Active sensitivity |
Zone Types
| Value | Type |
|---|---|
0x0000 | Standard CIE |
0x000D | Motion Sensor |
0x0015 | Contact Switch |
0x0028 | Fire Sensor |
0x002A | Water Sensor |
0x002B | CO Sensor |
0x002D | Personal Emergency |
0x010F | Remote Control |
0x0115 | Key Fob |
0x021D | Keypad |
0x0225 | Standard Warning |
Zone Status Bits
| Bit | Meaning |
|---|---|
| 0 | Alarm1 (zone-type specific) |
| 1 | Alarm2 (zone-type specific) |
| 2 | Tamper |
| 3 | Battery low |
| 4 | Supervision reports |
| 5 | Restore reports |
| 6 | Trouble |
| 7 | AC (mains) fault |
Enrollment Flow
Device CIE
│ │
│ Write IAS_CIE_Address ◄──────┤
│ │
├──── Zone Enroll Request ─────►│
│ (zone_type + mfr_code) │
│ │
│ Zone Enroll Response ◄───────┤
│ (status=0x00, zone_id) │
│ │
│ [ZoneState → Enrolled] │
│ │
├── Zone Status Change Notif ──►│
│ (alarm bits + zone_id) │
Usage
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::ias_zone::*;
// Motion sensor
let mut zone = IasZoneCluster::new(ZONE_TYPE_MOTION_SENSOR);
// CIE writes its address during setup:
zone.set_cie_address(0x00124B0012345678);
// Build enrollment request to send to CIE:
let enroll_payload = zone.build_zone_enroll_request(0x1234); // mfr code
// After CIE responds with ZoneEnrollResponse(success, zone_id=5):
// handle_command() sets ZoneState=Enrolled, ZoneID=5
// When motion detected:
zone.set_zone_status(0x0001); // Alarm1
let notif = zone.build_zone_status_change_notification();
// Send as cluster-specific command 0x00 (server→client)
// Check enrollment:
if zone.is_enrolled() {
// Device is enrolled and can send notifications
}
}
IAS ACE (0x0501)
Ancillary Control Equipment — keypads and panic buttons.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::ias_ace; // IasAceCluster
}
IAS WD (0x0502)
Warning Device — sirens and strobes.
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::ias_wd; // IasWdCluster
}
Smart Energy Clusters
Smart Energy clusters provide utility metering for electricity, gas, and water consumption.
Simple Metering (0x0702)
Tracks cumulative energy consumption and instantaneous demand. This is a read/report-only cluster with no cluster-specific commands — all data is published via attribute reads and reporting.
Attributes
| Attribute | ID | Type | Access | Description |
|---|---|---|---|---|
| CurrentSummationDelivered | 0x0000 | U48 | Report | Total energy delivered to premises |
| CurrentSummationReceived | 0x0001 | U48 | Report | Total energy exported (solar, etc.) |
| UnitOfMeasure | 0x0300 | Enum8 | Read | Measurement unit |
| Multiplier | 0x0301 | U24 | Read | Value multiplier |
| Divisor | 0x0302 | U24 | Read | Value divisor |
| SummationFormatting | 0x0303 | Bitmap8 | Read | Display format |
| DemandFormatting | 0x0304 | Bitmap8 | Read | Demand display format |
| MeteringDeviceType | 0x0308 | Bitmap8 | Read | Device type |
| InstantaneousDemand | 0x0400 | I32 | Report | Current power draw (signed) |
| PowerFactor | 0x0510 | I8 | Read | Power factor (-100 to +100) |
Unit of Measure Values
| Value | Unit | Description |
|---|---|---|
0x00 | kWh | Kilowatt hours |
0x01 | m³ | Cubic meters |
0x02 | ft³ | Cubic feet |
0x03 | CCF | Centum cubic feet |
0x04 | US gal | US gallons |
0x05 | IMP gal | Imperial gallons |
0x06 | BTU | British thermal units |
0x07 | L | Liters |
0x08 | kPa | Kilopascals (gas pressure) |
Metering Device Types
| Value | Type |
|---|---|
0x00 | Electric metering |
0x01 | Gas metering |
0x02 | Water metering |
Value Conversion
To convert raw attribute values to engineering units:
Actual Value = RawValue × Multiplier ÷ Divisor
For example, with Multiplier=1 and Divisor=1000:
CurrentSummationDelivered = 123456→ 123.456 kWhInstantaneousDemand = 1500→ 1.500 kW
Usage
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::metering::*;
// Electric meter: kWh, multiplier=1, divisor=1000
let mut meter = MeteringCluster::new(UNIT_KWH, 1, 1000);
// In your metering ISR / periodic callback:
meter.add_energy_delivered(100); // Add 100 Wh
meter.set_instantaneous_demand(1500); // 1.5 kW draw
// Read cumulative total:
let total_wh = meter.get_total_delivered(); // returns u64
}
Reporting Example
A typical energy monitor reports:
- InstantaneousDemand: every 10 seconds, or on 100W change
- CurrentSummationDelivered: every 5 minutes, or on 100 Wh change
Configure Reporting for Metering (0x0702):
Attribute: InstantaneousDemand (0x0400), Type: I32
Min: 10s, Max: 60s, Change: 100
Attribute: CurrentSummationDelivered (0x0000), Type: U48
Min: 60s, Max: 300s, Change: 100
Electrical Measurement (0x0B04)
While technically a “Measurement & Sensing” cluster, Electrical Measurement is closely related to Smart Energy metering. It provides real-time electrical parameters:
- RMS Voltage (0x0505)
- RMS Current (0x0508)
- Active Power (0x050B)
- Power Factor (0x0510)
See the Measurement & Sensing chapter for details.
Writing Custom Clusters
When the standard ZCL clusters don’t cover your needs, you can implement custom clusters using the same traits the built-in clusters use. This chapter walks through creating a custom sensor cluster from scratch.
The Cluster Trait
Every cluster in zigbee-rs implements the Cluster trait:
#![allow(unused)]
fn main() {
pub trait Cluster {
/// The cluster identifier.
fn cluster_id(&self) -> ClusterId;
/// Handle a cluster-specific command.
/// Returns response payload on success, or a ZCL status on failure.
fn handle_command(
&mut self,
cmd_id: CommandId,
payload: &[u8],
) -> Result<heapless::Vec<u8, 64>, ZclStatus>;
/// Immutable access to the attribute store.
fn attributes(&self) -> &dyn AttributeStoreAccess;
/// Mutable access to the attribute store.
fn attributes_mut(&mut self) -> &mut dyn AttributeStoreMutAccess;
/// Command IDs this cluster can receive (client→server).
fn received_commands(&self) -> heapless::Vec<u8, 32> {
heapless::Vec::new()
}
/// Command IDs this cluster can generate (server→client).
fn generated_commands(&self) -> heapless::Vec<u8, 32> {
heapless::Vec::new()
}
}
}
The Attribute Store
AttributeStore<N> is a fixed-capacity, #![no_std]-friendly container for attribute values. The const generic N determines how many attributes the cluster can hold.
Attribute Definition
Each attribute needs a definition with metadata:
#![allow(unused)]
fn main() {
use zigbee_zcl::attribute::{AttributeAccess, AttributeDefinition};
use zigbee_zcl::data_types::{ZclDataType, ZclValue};
use zigbee_zcl::AttributeId;
let def = AttributeDefinition {
id: AttributeId(0x0000),
data_type: ZclDataType::U16,
access: AttributeAccess::Reportable,
name: "MyMeasuredValue",
};
}
Access Modes
| Mode | Reads | Writes | Reporting |
|---|---|---|---|
ReadOnly | ✓ | ✗ | ✓ |
WriteOnly | ✗ | ✓ | ✗ |
ReadWrite | ✓ | ✓ | ✓ |
Reportable | ✓ | ✗ | ✓ |
Store Operations
#![allow(unused)]
fn main() {
use zigbee_zcl::attribute::AttributeStore;
let mut store = AttributeStore::<8>::new();
// Register attribute with initial value:
store.register(def, ZclValue::U16(0))?;
// Read (returns Option<&ZclValue>):
let val = store.get(AttributeId(0x0000));
// Write (respects access control + type checking):
store.set(AttributeId(0x0000), ZclValue::U16(42))?;
// Write bypassing access control (for server-side updates):
store.set_raw(AttributeId(0x0000), ZclValue::U16(42))?;
}
The runtime calls set() for remote Write Attributes commands (which checks is_writable()). Your application code should use set_raw() to update values that are read-only to the network but set by the firmware.
Type-Erased Access Traits
The Cluster trait returns attribute stores through two type-erased traits:
#![allow(unused)]
fn main() {
/// Read access
pub trait AttributeStoreAccess {
fn get(&self, id: AttributeId) -> Option<&ZclValue>;
fn find(&self, id: AttributeId) -> Option<&AttributeDefinition>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool;
fn all_ids(&self) -> heapless::Vec<AttributeId, 32>;
}
/// Write access
pub trait AttributeStoreMutAccess {
fn set(&mut self, id: AttributeId, value: ZclValue) -> Result<(), ZclStatus>;
fn set_raw(&mut self, id: AttributeId, value: ZclValue) -> Result<(), ZclStatus>;
fn find(&self, id: AttributeId) -> Option<&AttributeDefinition>;
}
}
Both traits are automatically implemented for any AttributeStore<N>, so you just return &self.store and &mut self.store from your cluster.
Example: Custom UV Index Sensor
Here’s a complete custom cluster for a UV index sensor:
#![allow(unused)]
fn main() {
use zigbee_zcl::attribute::{AttributeAccess, AttributeDefinition, AttributeStore};
use zigbee_zcl::clusters::{
AttributeStoreAccess, AttributeStoreMutAccess, Cluster,
};
use zigbee_zcl::data_types::{ZclDataType, ZclValue};
use zigbee_zcl::{AttributeId, ClusterId, CommandId, ZclStatus};
// Cluster ID — use manufacturer-specific range (0xFC00–0xFCFF)
pub const CLUSTER_UV_INDEX: ClusterId = ClusterId(0xFC01);
// Attribute IDs
pub const ATTR_UV_INDEX: AttributeId = AttributeId(0x0000);
pub const ATTR_UV_INDEX_MIN: AttributeId = AttributeId(0x0001);
pub const ATTR_UV_INDEX_MAX: AttributeId = AttributeId(0x0002);
// Command IDs
pub const CMD_RESET_MAX: CommandId = CommandId(0x00);
pub struct UvIndexCluster {
store: AttributeStore<4>,
}
impl UvIndexCluster {
pub fn new() -> Self {
let mut store = AttributeStore::new();
let _ = store.register(
AttributeDefinition {
id: ATTR_UV_INDEX,
data_type: ZclDataType::U8,
access: AttributeAccess::Reportable,
name: "UVIndex",
},
ZclValue::U8(0),
);
let _ = store.register(
AttributeDefinition {
id: ATTR_UV_INDEX_MIN,
data_type: ZclDataType::U8,
access: AttributeAccess::ReadOnly,
name: "MinUVIndex",
},
ZclValue::U8(0),
);
let _ = store.register(
AttributeDefinition {
id: ATTR_UV_INDEX_MAX,
data_type: ZclDataType::U8,
access: AttributeAccess::ReadOnly,
name: "MaxUVIndex",
},
ZclValue::U8(0),
);
Self { store }
}
/// Update the UV index reading.
pub fn set_uv_index(&mut self, index: u8) {
let _ = self.store.set_raw(ATTR_UV_INDEX, ZclValue::U8(index));
// Track maximum
if let Some(ZclValue::U8(max)) = self.store.get(ATTR_UV_INDEX_MAX) {
if index > *max {
let _ = self
.store
.set_raw(ATTR_UV_INDEX_MAX, ZclValue::U8(index));
}
}
}
}
impl Cluster for UvIndexCluster {
fn cluster_id(&self) -> ClusterId {
CLUSTER_UV_INDEX
}
fn handle_command(
&mut self,
cmd_id: CommandId,
_payload: &[u8],
) -> Result<heapless::Vec<u8, 64>, ZclStatus> {
match cmd_id {
CMD_RESET_MAX => {
let _ = self
.store
.set_raw(ATTR_UV_INDEX_MAX, ZclValue::U8(0));
Ok(heapless::Vec::new())
}
_ => Err(ZclStatus::UnsupClusterCommand),
}
}
fn attributes(&self) -> &dyn AttributeStoreAccess {
&self.store
}
fn attributes_mut(&mut self) -> &mut dyn AttributeStoreMutAccess {
&mut self.store
}
fn received_commands(&self) -> heapless::Vec<u8, 32> {
heapless::Vec::from_slice(&[CMD_RESET_MAX.0]).unwrap_or_default()
}
}
}
Registering with the Device Builder
Once your cluster struct implements Cluster, register it on an endpoint:
#![allow(unused)]
fn main() {
let uv_sensor = UvIndexCluster::new();
builder
.endpoint(1)
.device_id(0x0302) // or your custom device ID
.add_cluster(CLUSTER_UV_INDEX, uv_sensor);
}
Handling Commands
The handle_command method receives:
cmd_id— the cluster-specific command ID (0x00, 0x01, etc.)payload— raw bytes after the ZCL header
Return values:
Ok(Vec::new())— success, runtime sends a Default ResponseOk(vec_with_data)— success, runtime sends a cluster-specific responseErr(ZclStatus)— failure, runtime sends a Default Response with that status
Parsing Payloads
Parse command payloads manually from the &[u8] slice:
#![allow(unused)]
fn main() {
fn handle_command(
&mut self,
cmd_id: CommandId,
payload: &[u8],
) -> Result<heapless::Vec<u8, 64>, ZclStatus> {
match cmd_id {
CommandId(0x00) => {
if payload.len() < 3 {
return Err(ZclStatus::MalformedCommand);
}
let param1 = payload[0];
let param2 = u16::from_le_bytes([payload[1], payload[2]]);
// Process...
Ok(heapless::Vec::new())
}
_ => Err(ZclStatus::UnsupClusterCommand),
}
}
}
What the Runtime Handles Automatically
When you implement Cluster, the runtime provides these features for free:
| Feature | How It Works |
|---|---|
| Read Attributes | Calls attributes().get() for each requested ID |
| Write Attributes | Calls attributes_mut().set() with access control |
| Write Undivided | Validates all writes first, then applies atomically |
| Configure Reporting | Stores config in ReportingEngine |
| Report Attributes | Checks values via attributes() on each tick |
| Discover Attributes | Enumerates from attributes().all_ids() |
| Discover Commands | Calls received_commands() / generated_commands() |
| Default Response | Generated for commands without a specific response |
You only need to implement handle_command() for cluster-specific commands. Everything else is automatic.
ESP32-C6 / ESP32-H2
Espressif’s ESP32-C6 and ESP32-H2 are RISC-V SoCs with native IEEE 802.15.4 radio support, making them a great fit for zigbee-rs. Both chips share the same MAC driver code — only the HAL feature flag differs.
✅ Hardware Verified: The ESP32-C6 has been tested end-to-end on an ESP32-C6-DevKitC-1 board with Home Assistant + ZHA. It appears as “Zigbee-RS ESP32-C6-Sensor” with Temperature, Humidity, and Battery entities. Network state is persisted to flash — the device survives reboots without re-pairing.
Hardware Overview
| ESP32-C6 | ESP32-H2 | |
|---|---|---|
| Core | RISC-V (single, 160 MHz) | RISC-V (single, 96 MHz) |
| Flash | 4 MB (external SPI) | 4 MB (external SPI) |
| SRAM | 512 KB | 320 KB |
| Radio | WiFi 6 + BLE 5 + 802.15.4 | BLE 5 + 802.15.4 |
| Target | riscv32imac-unknown-none-elf | riscv32imac-unknown-none-elf |
Both chips have a built-in IEEE 802.15.4 radio driven by the esp-radio
crate’s ieee802154 module. The radio supports hardware CRC, configurable
TX power, RSSI/LQI measurement, and software address filtering.
Common Development Boards
- ESP32-C6-DevKitC-1 — USB-C, BOOT button on GPIO9
- ESP32-H2-DevKitM-1 — USB-C, BOOT button on GPIO9
- Seeed XIAO ESP32-C6 — compact, castellated pads
- Ai-Thinker ESP-C6-12F — module with PCB antenna
Prerequisites
Rust Toolchain
# Install nightly (required for no_std async + build-std)
rustup default nightly
rustup update nightly
# Add the RISC-V target
rustup target add riscv32imac-unknown-none-elf
# Ensure rust-src is available (needed for -Z build-std)
rustup component add rust-src
Flash Tool
cargo install espflash
espflash handles flashing and serial monitoring in one command. Alternatively,
use the web flasher — no tools needed,
just a browser with Web Serial API support (Chrome/Edge).
Building
ESP32-C6
cd examples/esp32c6-sensor
cargo build --release -Z build-std=core,alloc
ESP32-H2
cd examples/esp32h2-sensor
cargo build --release -Z build-std=core,alloc
Note: The
-Z build-std=core,allocflag is configured in each example’s.cargo/config.tomlunder[unstable], so a plaincargo build --releasealso works from within the example directory.
What .cargo/config.toml Sets
[build]
target = "riscv32imac-unknown-none-elf"
[target.riscv32imac-unknown-none-elf]
runner = "espflash flash --monitor"
rustflags = ["-C", "link-arg=-Tlinkall.x"]
[unstable]
build-std = ["core", "alloc"]
[env]
ESP_LOG = "info"
The linkall.x linker script is provided by esp-hal and sets up the ESP32
memory layout, interrupt vectors, and boot sequence.
CI Build Command
From .github/workflows/ci.yml:
# Exact command used in CI (ubuntu-latest, nightly toolchain)
cd examples/esp32c6-sensor
cargo build --release -Z build-std=core,alloc
# Firmware artifact extraction
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary target/riscv32imac-unknown-none-elf/release/esp32c6-sensor \
target/riscv32imac-unknown-none-elf/release/esp32c6-sensor.bin
Release Profile
Both examples use an optimized release profile:
[profile.release]
opt-level = "s" # Optimize for size
lto = true # Link-Time Optimization
Flashing
espflash (recommended)
cd examples/esp32c6-sensor
# Flash and open serial monitor
espflash flash --monitor target/riscv32imac-unknown-none-elf/release/esp32c6-sensor
# Or use cargo run (runner configured in .cargo/config.toml)
cargo run --release
Web Flasher (no tools needed)
Visit https://faronov.github.io/zigbee-rs/ in Chrome or Edge:
- Select your chip (ESP32-C6 or ESP32-H2)
- Click Connect and choose the serial port
- Click Flash — firmware is downloaded from the latest CI build
The web flasher uses the ESP Web Tools
library and the Web Serial API. The firmware .bin artifacts are published to
GitHub Pages on every push to main.
espflash Troubleshooting
If espflash times out:
- Hold the BOOT button
- Press and release RESET (while holding BOOT)
- Release BOOT
- Retry the flash command
MAC Backend Notes
The ESP32 MAC backend lives in zigbee-mac/src/esp/:
zigbee-mac/src/esp/
├── mod.rs # EspMac struct, MacDriver trait impl, PIB management
└── driver.rs # Ieee802154Driver — low-level radio wrapper
Feature Flags
| Feature | Chip | Cargo.toml dependency |
|---|---|---|
esp32c6 | ESP32-C6 | zigbee-mac = { features = ["esp32c6"] } |
esp32h2 | ESP32-H2 | zigbee-mac = { features = ["esp32h2"] } |
Key Dependencies
esp-hal = { version = "1.0.0", features = ["esp32c6", "unstable"] }
esp-radio = { version = "0.17.0", features = ["esp32c6", "ieee802154", "unstable"] }
How It Works
EspMacwrapsIeee802154Driverand implements theMacDrivertraitIeee802154Driverwrapsesp_radio::ieee802154::Ieee802154for synchronous TX and polling-based RX- The EUI-64 address is read from the chip’s eFuse factory MAC
- Scanning uses real beacon parsing — the radio enters RX mode and collects beacon frames across channels 11–26
- CSMA-CA is implemented in software with configurable backoff parameters
Switching Chips
To switch between ESP32-C6 and ESP32-H2, replace all feature flags:
- zigbee-mac = { path = "../../zigbee-mac", features = ["esp32c6"] }
+ zigbee-mac = { path = "../../zigbee-mac", features = ["esp32h2"] }
- esp-hal = { version = "1.0.0", features = ["esp32c6", "unstable"] }
+ esp-hal = { version = "1.0.0", features = ["esp32h2", "unstable"] }
- esp-radio = { version = "0.17.0", features = ["esp32c6", "ieee802154", "unstable"] }
+ esp-radio = { version = "0.17.0", features = ["esp32h2", "ieee802154", "unstable"] }
The MAC driver code is shared — only the HAL feature gate changes.
Example Walkthrough
The esp32c6-sensor example implements a Zigbee 3.0 temperature & humidity
end device with:
- On-chip temperature sensor (via
esp_hal::tsens::TemperatureSensor) - Flash NV storage — network state persists across power cycles (no re-pairing)
- NWK Leave handler — auto-erases NV and rejoins when coordinator sends Leave
- Default reporting — configures report intervals at boot so data flows before ZHA interview
- Identify cluster (0x0003) — supports Identify, IdentifyQuery, TriggerEffect
- Battery percentage reporting via Power Configuration cluster
- Join/leave button (BOOT / GPIO9)
Initialization
#[esp_hal::main]
fn main() -> ! {
let peripherals = esp_hal::init(esp_hal::Config::default());
// BOOT button (GPIO9, active low with pull-up)
let button = Input::new(
peripherals.GPIO9,
InputConfig::default().with_pull(Pull::Up),
);
// IEEE 802.15.4 MAC driver
let ieee802154 = esp_radio::ieee802154::Ieee802154::new(peripherals.IEEE802154);
let config = esp_radio::ieee802154::Config::default();
let mac = zigbee_mac::esp::EspMac::new(ieee802154, config);
Device Setup
#![allow(unused)]
fn main() {
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Zigbee-RS")
.model("ESP32-C6-Sensor")
.sw_build("0.1.0")
.channels(zigbee_types::ChannelMask::ALL_2_4GHZ)
.endpoint(1, PROFILE_HOME_AUTOMATION, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0001) // Power Configuration
.cluster_server(0x0003) // Identify
.cluster_server(0x0402) // Temperature Measurement
.cluster_server(0x0405) // Relative Humidity
})
.build();
}
Main Loop
The main loop handles button presses (join/leave), updates simulated sensor values every 30 seconds, and ticks the Zigbee stack.
Adding a Real Sensor
To add an external SHTC3 I²C sensor (SDA→GPIO6, SCL→GPIO7):
#![allow(unused)]
fn main() {
use esp_hal::i2c::master::I2c;
let i2c = I2c::new(peripherals.I2C0, /* config */)
.with_sda(peripherals.GPIO6)
.with_scl(peripherals.GPIO7);
// Use any embedded-hal 1.0 compatible sensor driver
}
Flash NV Storage (ESP32-C6)
The esp32c6-sensor example persists Zigbee network state to the last two
4 KB sectors of the on-chip flash (addresses 0x3FE000–0x3FFFFF, 8 KB total).
This uses the esp-storage crate’s low-level SPI flash API directly:
- Read:
esp_storage::ll::spiflash_read - Write:
esp_storage::ll::spiflash_write - Erase:
esp_storage::ll::spiflash_erase_sector
The storage is wrapped in LogStructuredNv<EspFlashDriver> — a log-structured
format that appends writes and only erases on compaction, minimizing flash wear.
On boot, the device checks for saved network state and automatically rejoins the previous network. If the coordinator sends a NWK Leave command, the device erases NV storage and starts fresh commissioning.
Note: The ESP32-H2 example does not yet have NV flash storage. Network state is lost on reboot and the device must re-pair.
ESP32-C6-DevKitC-1 LED Note
The ESP32-C6-DevKitC-1 has a WS2812 addressable RGB LED (on GPIO8), not
a simple GPIO LED. The Identify cluster blink feature in the ESP32-C6 example
does not drive this LED. If you want LED feedback during Identify, you would
need to add a WS2812 driver (e.g., smart-leds + esp-hal-smartled). The
ESP32-H2 example does implement LED blinking during Identify.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
espflash can’t find device | Not in download mode | Hold BOOT → press RESET → release BOOT |
espflash timeout | USB-UART bridge issue | Try a different USB cable/port |
Build error: rust-src not found | Missing component | rustup component add rust-src |
Linker error: linkall.x not found | esp-hal version mismatch | Check esp-hal version matches esp-radio |
| Serial output garbled | Wrong baud rate | Default is 115200 — check monitor settings |
| Device doesn’t join network | Coordinator not in permit-join mode | Enable permit joining on your coordinator |
| No beacon found | Wrong channel | Ensure coordinator and device scan the same channels |
Serial Monitor
# Standalone monitor (without flashing)
espflash monitor
# Or any serial terminal at 115200 baud
screen /dev/ttyUSB0 115200
Expected output:
[init] ESP32-C6 Zigbee sensor starting
[init] Radio ready
[init] NV: restored network state from flash
[init] Default reporting configured (temp: 60-300s, hum: 60-300s, battery: 300-3600s)
[init] Device ready — press BOOT button to join/leave
[btn] Joining network…
[scan] Found network on channel 15, PAN 0x1AAA
[join] Association successful, short addr = 0x1234
[sensor] T=22.50°C H=50.00% Battery=100%
[nv] State saved to flash
nRF52840 / nRF52833
Nordic’s nRF52840 and nRF52833 are ARM Cortex-M4F SoCs with a built-in IEEE 802.15.4 radio. The zigbee-rs nRF backend uses Embassy’s radio driver for interrupt-driven, DMA-based TX/RX — no SoftDevice required.
✅ Hardware Verified: The nRF52840-DK has been tested end-to-end with Home Assistant + ZHA. Features include flash NV storage (survives reboots), NWK Leave handling (auto-erase + rejoin), default reporting configuration, Identify cluster with LED blink, and optional BME280/SHT31 I2C sensors.
Hardware Overview
| nRF52840 | nRF52833 | |
|---|---|---|
| Core | ARM Cortex-M4F, 64 MHz | ARM Cortex-M4F, 64 MHz |
| Flash | 1024 KB | 512 KB |
| RAM | 256 KB | 128 KB |
| Radio | BLE 5.3 + 802.15.4 + NFC | BLE 5.3 + 802.15.4 + NFC |
| Target | thumbv7em-none-eabihf | thumbv7em-none-eabihf |
Hardware Radio Features
- Auto-CRC generation and checking
- Hardware address filtering (PAN ID + short address)
- Auto-ACK for frames with ACK request bit set
- Energy Detection (ED) via EDREQ task
- RSSI measurement per packet
- DMA-driven TX/RX buffers
- Factory-programmed IEEE address in FICR registers
Common Development Boards
- nRF52840-DK (PCA10056) — J-Link debugger, 4 buttons, 4 LEDs
- nRF52840 USB Dongle (PCA10059) — USB bootloader, compact form
- nice!nano v2 — Pro Micro form factor, UF2 bootloader
- Seeed XIAO nRF52840 — compact, USB-C
- Makerdiary nRF52840 MDK USB Dongle — UF2 bootloader
- nRF52833-DK (PCA10100) — J-Link debugger, 4 buttons, 4 LEDs
No SoftDevice Needed
Unlike BLE-only projects, zigbee-rs accesses the 802.15.4 radio peripheral
directly through Embassy’s embassy-nrf radio driver. There is no dependency
on Nordic’s SoftDevice. This gives full control over the radio and avoids the
SoftDevice’s RAM/Flash overhead.
UF2 variant note: If your board has a SoftDevice-based UF2 bootloader (e.g., nice!nano with Adafruit bootloader), the
nrf52840-sensor-uf2example disables the SoftDevice at startup via an SVC call. See the UF2 section below.
Prerequisites
Rust Toolchain
rustup default nightly
rustup update nightly
# Add the ARM Cortex-M4F target
rustup target add thumbv7em-none-eabihf
Debug Probe (for DK boards)
# probe-rs handles flashing + defmt log viewing
cargo install probe-rs-tools
Supported probes:
- On-board J-Link (nRF52840-DK, nRF52833-DK)
- Any CMSIS-DAP probe
- Segger J-Link (external)
For UF2 boards (no probe needed)
pip install intelhex # for uf2conv.py
Building
nRF52840-DK (probe-rs)
cd examples/nrf52840-sensor
cargo build --release
nRF52833-DK (probe-rs)
cd examples/nrf52833-sensor
cargo build --release
nRF52840 UF2 (nice!nano / ProMicro / MDK Dongle)
cd examples/nrf52840-sensor-uf2
cargo build --release # ProMicro (default)
cargo build --release --no-default-features --features board-mdk # MDK Dongle
cargo build --release --no-default-features --features board-nrf-dongle # PCA10059
cargo build --release --no-default-features --features board-nrf-dk # DK (J-Link)
nRF52840 Router
cd examples/nrf52840-router
cargo build --release
nRF52840 Bridge (coordinator)
cd examples/nrf52840-bridge
cargo build --release
What .cargo/config.toml Sets
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52840_xxAA"
[env]
DEFMT_LOG = "info"
CI Build Commands
From .github/workflows/ci.yml:
# nRF52840 sensor
cd examples/nrf52840-sensor
cargo build --release
# nRF52840 router
cd examples/nrf52840-router
cargo build --release
# nRF52833 sensor
cd examples/nrf52833-sensor
cargo build --release
# UF2 variant (includes .uf2 conversion)
cd examples/nrf52840-sensor-uf2
cargo build --release
# Firmware artifact extraction
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
# UF2 conversion (CI uses uf2conv.py from Microsoft's UF2 repo)
python uf2conv.py -c -f 0xADA52840 ${ELF}.hex -o ${ELF}.uf2
Memory Layout
The memory.x linker script defines the memory regions:
nRF52840 (full chip, no bootloader):
FLASH : ORIGIN = 0x00000000, LENGTH = 1024K
RAM : ORIGIN = 0x20000000, LENGTH = 256K
nRF52833:
FLASH : ORIGIN = 0x00000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
nRF52840 UF2 (with SoftDevice S140 bootloader):
FLASH : ORIGIN = 0x00026000, LENGTH = 808K ← app starts after SoftDevice
RAM : ORIGIN = 0x20002000, LENGTH = 248K
The UF2 example’s build.rs selects the memory layout based on the board feature.
Flashing
probe-rs (DK boards)
cd examples/nrf52840-sensor
# Flash + live defmt log output
cargo run --release
# Or flash only
probe-rs run --chip nRF52840_xxAA target/thumbv7em-none-eabihf/release/nrf52840-sensor
Tip: Plug in the DK before running
cargo run. probe-rs auto-detects the probe. Check withprobe-rs listif detection fails.
UF2 Drag-and-Drop Flash
For boards with UF2 bootloaders (nice!nano, ProMicro, MDK Dongle):
-
Build the firmware:
cd examples/nrf52840-sensor-uf2 cargo build --release -
Convert to UF2:
# Extract binary OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1) $OBJCOPY -O ihex target/thumbv7em-none-eabihf/release/nrf52840-sensor-uf2 fw.hex # Convert to UF2 (download uf2conv.py from Microsoft's UF2 repo) python uf2conv.py -c -f 0xADA52840 fw.hex -o fw.uf2 -
Enter bootloader mode: Double-tap the RESET button on the board. A USB mass storage device appears (e.g.,
NICENANO). -
Copy the
.uf2file to the USB drive. The board flashes automatically and reboots into your firmware.
J-Link Commander (alternative)
nrfjprog --program target/thumbv7em-none-eabihf/release/nrf52840-sensor.hex --chiperase --verify
nrfjprog --reset
MAC Backend Notes
The nRF MAC backend lives in zigbee-mac/src/nrf/mod.rs (single file — no
separate driver module needed since Embassy provides the radio abstraction).
Feature Flags
| Feature | Chip | Cargo.toml dependency |
|---|---|---|
nrf52840 | nRF52840 | zigbee-mac = { features = ["nrf52840"] } |
nrf52833 | nRF52833 | zigbee-mac = { features = ["nrf52833"] } |
Key Dependencies
embassy-nrf = { version = "0.3", features = ["nrf52840", "time-driver-rtc1", "gpiote"] }
embassy-executor = { version = "0.7", features = ["arch-cortex-m", "executor-thread"] }
How It Works
NrfMac<T: Instance>wraps Embassy’sRadio<T>and implementsMacDriver- Radio TX/RX is fully interrupt-driven with DMA — no polling needed
- Hardware auto-ACK is enabled for frames with the ACK request bit
- Hardware address filtering is configured through the radio peripheral
- The factory-programmed IEEE address is read from FICR registers
- Embassy’s
time-driver-rtc1provides async timers via RTC1
Embassy Integration
The nRF examples use Embassy’s cooperative async executor:
bind_interrupts!(struct Irqs {
RADIO => radio::InterruptHandler<peripherals::RADIO>;
TEMP => embassy_nrf::temp::InterruptHandler;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_nrf::init(Default::default());
let radio = radio::ieee802154::Radio::new(p.RADIO, Irqs);
let mac = zigbee_mac::nrf::NrfMac::new(radio);
// ...
}
The select3 combinator handles concurrent events:
#![allow(unused)]
fn main() {
match select3(
device.receive(), // Radio RX
button.wait_for_falling_edge(), // Button press
Timer::after(Duration::from_secs(REPORT_INTERVAL)), // Periodic report
).await {
Either3::First(event) => { /* handle stack event */ }
Either3::Second(_) => { /* handle button press */ }
Either3::Third(_) => { /* read sensor, update clusters */ }
}
}
Power Optimization
Sensor (End Device)
The nRF52840 sensor example includes several hardware-level power optimizations that bring the average current draw down to ~5 µA. See the Power Management chapter for full details.
DC-DC Converter
The nRF52840’s internal DC-DC converter replaces the default LDO regulators,
reducing current draw by ~40%. Both reg0 (main supply) and reg1 (radio
supply) are enabled at startup:
#![allow(unused)]
fn main() {
config.dcdc = embassy_nrf::config::DcdcConfig {
reg0: true,
reg0_voltage: None,
reg1: true,
};
}
TX Power
TX power is set to 0 dBm (down from the default +8 dBm), which cuts TX current roughly in half while maintaining adequate range for home environments:
#![allow(unused)]
fn main() {
mac.set_tx_power(0); // 0 dBm — saves ~50% TX current vs +8 dBm
}
HFCLK Source
The high-frequency clock source is set to the internal RC oscillator. The radio peripheral automatically requests the external crystal when it needs high accuracy (during TX/RX), saving ~250 µA during idle periods:
#![allow(unused)]
fn main() {
config.hfclk_source = embassy_nrf::config::HfclkSource::Internal;
}
Poll and Report Intervals
The sensor uses a two-phase polling scheme:
| Phase | Poll Interval | Duration | Current |
|---|---|---|---|
| Fast poll | 250 ms | 120 s after join/activity | Higher (responsive) |
| Slow poll | 30 s | Steady state | Very low (~5 µA avg) |
Reports are sent every 60 seconds, but only when sensor values change by more than the configured thresholds (±0.5 °C temperature, ±1% humidity, ±2% battery). This suppresses unnecessary transmissions in stable environments.
RAM Power-Down
Unused RAM banks are powered down at startup, saving ~190 KB of unpowered SRAM. This was already implemented in earlier versions.
Radio Sleep
Between polls, the radio is disabled via TASKS_DISABLE register write,
saving ~4-8 mA of radio RX/idle current. The radio_wake() method re-applies
the channel setting and re-enables the radio before the next TX/RX operation.
Router (Always-On)
The nRF52840 router uses PowerMode::AlwaysOn — the radio is always on since
routers must relay frames continuously. DC-DC converters are still enabled for
lower power, but no sleep logic is applied. Typical current draw with DC-DC
enabled is ~5-7 mA (radio RX idle).
Example Walkthrough
nrf52840-sensor
The flagship example: an Embassy-based Zigbee 3.0 end device that reads the on-chip temperature sensor and reports simulated humidity. Includes:
- Flash NV storage — network state persists across power cycles (last 8 KB of flash)
- NWK Leave handler — auto-erases NV and rejoins when coordinator sends Leave
- Default reporting — configures report intervals at boot (temp/hum: 60–300 s, battery: 300–3600 s)
- Identify cluster (0x0003) — LED blinks during Identify
- Battery monitoring via SAADC (VDD internal divider)
- Optional external sensors — BME280 (temp + humidity + pressure) or SHT31 (temp + humidity)
Initialization:
#![allow(unused)]
fn main() {
let p = embassy_nrf::init(Default::default());
// On-chip temperature sensor (real hardware reading)
let mut temp_sensor = Temp::new(p.TEMP, Irqs);
// Button 1 on nRF52840-DK (P0.11, active low)
let mut button = gpio::Input::new(p.P0_11, gpio::Pull::Up);
// IEEE 802.15.4 MAC driver (interrupt-driven, DMA-based)
let radio = radio::ieee802154::Radio::new(p.RADIO, Irqs);
let mac = zigbee_mac::nrf::NrfMac::new(radio);
}
Real temperature reading:
#![allow(unused)]
fn main() {
// Read actual die temperature (°C with 0.25° resolution)
let temp_c = temp_sensor.read().await;
let temp_hundredths = (temp_c.to_num::<f32>() * 100.0) as i16;
temp_cluster.set_temperature(temp_hundredths);
}
nrf52840-sensor-uf2
The UF2 variant supports multiple boards via cargo features:
| Feature | Board | LED | Flash Origin |
|---|---|---|---|
board-promicro | ProMicro / nice!nano | P0.15 (HIGH) | 0x26000 |
board-mdk | Makerdiary MDK Dongle | P0.22 (LOW) | 0x1000 |
board-nrf-dongle | Nordic PCA10059 | P0.06 (LOW) | 0x1000 |
board-nrf-dk | Nordic DK (PCA10056) | P0.13 (LOW) | 0x0000 |
This variant auto-joins on boot (no button press needed) and includes a
log → defmt bridge so internal stack log messages appear in RTT output.
nrf52840-bridge
A coordinator/bridge example that exposes the Zigbee network over USB serial.
nrf52840-router
A Zigbee 3.0 router that extends network range. Key differences from the sensor examples:
- Device type: Router (FFD) instead of End Device
- Power mode:
AlwaysOn— radio is never turned off - Frame relay: Relays unicast, broadcast, and indirect frames
- Child management: Accepts end device joins, buffers frames for sleepy children
- Link Status: Sends periodic broadcasts (every 15 seconds)
- RREQ rebroadcast: Participates in AODV route discovery
- LEDs: LED1 = joined status, LED2 = blink on frame relay
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
probe-rs can’t find device | Probe not connected | Check USB; run probe-rs list |
probe-rs permission denied | Missing udev rules (Linux) | See probe-rs setup |
292 / RAM overflow | Too many features enabled | Check Embassy feature flags, reduce arena size |
| defmt output garbled | Version mismatch | Ensure defmt, defmt-rtt, panic-probe versions match |
| UF2 board not appearing | Not in bootloader | Double-tap RESET quickly; look for USB drive |
| Device doesn’t join | Coordinator not permitting | Enable permit-join on coordinator |
| No temperature reading | TEMP interrupt not bound | Ensure bind_interrupts! includes TEMP handler |
Adjusting Log Level
# Via environment variable
DEFMT_LOG=trace cargo run --release
# Or set in .cargo/config.toml
[env]
DEFMT_LOG = "debug"
Expected Serial Output (via RTT)
INFO Zigbee-RS nRF52840 sensor starting…
INFO Radio ready
INFO NV: restored network state from flash
INFO Default reporting configured (temp: 60-300s, hum: 60-300s, battery: 300-3600s)
INFO Device ready — press Button 1 to join/leave
INFO [btn] Joining network…
INFO [scan] Scanning channels 11-26…
INFO [scan] Found network: ch=15, PAN=0x1AAA
INFO [join] Association successful, addr=0x1234
INFO [sensor] T=23.75°C H=52.30% Battery=100%
INFO [nv] State saved to flash
BL702
Bouffalo Lab’s BL702 is a RISC-V SoC with built-in IEEE 802.15.4 and BLE 5.0
radio. The zigbee-rs BL702 backend uses FFI bindings to Bouffalo’s lmac154
C library for radio access, combined with Embassy for async operation.
Hardware Overview
| Spec | Value |
|---|---|
| Core | RISC-V 32-bit (RV32IMAF), 144 MHz |
| Flash | 512 KB (XIP) |
| SRAM | 128 KB (112 KB usable after cache) |
| Radio | BLE 5.0 + IEEE 802.15.4 |
| Target | riscv32imac-unknown-none-elf |
| I/O | UART ×2, SPI, I2C, ADC, DAC, USB 2.0 FS |
Common Modules and Boards
- XT-ZB1 — Zigbee module based on BL702
- DT-BL10 — compact BL702 breakout
- Pine64 Pinenut — BL602/BL702 module
- BL706 IoT DevBoard (Sipeed) — devkit with USB and headers
Memory Map
FLASH : ORIGIN = 0x23000000, LENGTH = 512K
RAM : ORIGIN = 0x42014000, LENGTH = 112K
Prerequisites
Rust Toolchain
rustup default nightly
rustup update nightly
# Add the RISC-V target
rustup target add riscv32imac-unknown-none-elf
# rust-src for build-std
rustup component add rust-src
Vendor Libraries (for real radio operation)
The BL702 backend uses FFI bindings to two pre-compiled C libraries from Bouffalo’s BL IoT SDK:
liblmac154.a— 802.15.4 MAC/PHY librarylibbl702_rf.a— RF calibration and configuration
These libraries are compiled with rv32imfc/ilp32f (hardware float ABI),
while Rust targets riscv32imac/ilp32 (soft-float). The .a files must
have their ELF float-ABI flag stripped before linking.
Flash Tool
cargo install blflash # Community flash tool for BL702
Building
With Stubs (CI mode — no vendor SDK)
cd examples/bl702-sensor
cargo build --release --features stubs -Z build-std=core,alloc
The stubs feature provides no-op implementations of all FFI symbols, allowing
the full Rust code to compile and link without the vendor libraries. This is how
CI verifies the BL702 code compiles on every push.
With Real Vendor Libraries
Three options for linking vendor libraries (in priority order):
Option 1: Full SDK path
BL_IOT_SDK_DIR=/path/to/bl_iot_sdk cargo build --release -Z build-std=core,alloc
Option 2: Explicit library directories
LMAC154_LIB_DIR=/path/to/lib BL702_RF_LIB_DIR=/path/to/lib cargo build --release -Z build-std=core,alloc
Option 3: Local vendor_libs/ directory
Place ABI-patched copies of liblmac154.a and libbl702_rf.a in
examples/bl702-sensor/vendor_libs/:
cargo build --release -Z build-std=core,alloc
CI Build Command
From .github/workflows/ci.yml:
cd examples/bl702-sensor
cargo build --release --features stubs -Z build-std=core,alloc
# Firmware artifact extraction
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
Build Script (build.rs)
The build.rs handles vendor library discovery:
#![allow(unused)]
fn main() {
// Skip vendor libs when `stubs` feature is active (CI mode)
let use_stubs = env::var("CARGO_FEATURE_STUBS").is_ok();
if use_stubs { return; }
// Priority: BL_IOT_SDK_DIR → LMAC154_LIB_DIR → vendor_libs/
}
.cargo/config.toml
[build]
target = "riscv32imac-unknown-none-elf"
[unstable]
build-std = ["core", "alloc"]
Flashing
blflash
blflash flash target/riscv32imac-unknown-none-elf/release/bl702-sensor.bin \
--port /dev/ttyUSB0
Bouffalo Dev Cube (GUI)
- Download BLDevCube from Bouffalo
- Select BL702 chip
- Load the
.binfile - Connect via UART and flash
Entering Boot Mode
Hold the BOOT pin low during power-on or reset to enter the UART bootloader.
MAC Backend Notes
The BL702 MAC backend lives in zigbee-mac/src/bl702/:
zigbee-mac/src/bl702/
├── mod.rs # Bl702Mac struct, MacDriver trait impl, PIB management
└── driver.rs # Bl702Driver — FFI bindings to lmac154.a
Feature Flag
zigbee-mac = { features = ["bl702"] }
Architecture
MacDriver trait methods
│
▼
Bl702Mac (mod.rs)
├── PIB state (addresses, channel, config)
├── Frame construction (beacon req, assoc req, data)
└── Bl702Driver (driver.rs)
├── FFI → liblmac154.a (Bouffalo C library)
│ ├── lmac154_setChannel / setPanId / setShortAddr
│ ├── lmac154_triggerTx / enableRx / readRxData
│ └── lmac154_runCCA / getRSSI / getLQI
├── TX completion: lmac154_txDoneEvent callback → TX_SIGNAL
└── RX completion: lmac154_rxDoneEvent callback → RX_SIGNAL
FFI Callbacks
The vendor library calls back into Rust through these functions:
#![allow(unused)]
fn main() {
#[no_mangle]
extern "C" fn lmac154_txDoneEvent(/* ... */) {
TX_SIGNAL.signal(tx_status);
}
#[no_mangle]
extern "C" fn lmac154_rxDoneEvent(/* ... */) {
// Copy RX data to static buffer
RX_SIGNAL.signal(());
}
}
Interrupt Registration
After creating the MAC, register the M154 interrupt handler:
#![allow(unused)]
fn main() {
// bl_irq_register(M154_IRQn, lmac154_getInterruptHandler());
// bl_irq_enable(M154_IRQn);
}
Radio Features
- Hardware CRC generation and checking
- Configurable TX power: 0 dBm to +14 dBm
- RSSI / LQI measurement
- Hardware auto-ACK with configurable retransmission
- Hardware address filtering (PAN ID, short addr, long addr)
- CSMA-CA support
- AES-128 CCM hardware acceleration
Example Walkthrough
The bl702-sensor example implements a Zigbee 3.0 temperature & humidity
end device with button control and UART logging.
Custom Embassy Time Driver
Since there is no embassy-bl702 HAL yet, the example provides a minimal
Embassy time driver using the BL702 TIMER_CH0:
#![allow(unused)]
fn main() {
// 32-bit match-compare timer at 1 MHz (FCLK/32 from 32 MHz)
const TIMER_BASE: usize = 0x4000_A500;
pub fn init() {
// Prescaler: FCLK(32 MHz) / 32 = 1 MHz tick
write_volatile(TCCR, 0x1F);
// Free-running mode
write_volatile(TMR_0, 0xFFFF_FFFF);
// Enable counter
write_volatile(TCER, 0x01);
}
}
UART Logging
Debug output goes through the BL702 UART peripheral:
#![allow(unused)]
fn main() {
impl log::Log for Bl702Logger {
fn log(&self, record: &log::Record) {
let mut w = UartWriter;
write!(w, "[{}] {}\r\n", record.level(), record.args());
}
}
}
GPIO Button
Direct register-based GPIO for the join/leave button:
#![allow(unused)]
fn main() {
const BUTTON_PIN: u8 = 8; // GPIO8 on most BL702 modules
gpio::configure_input_pullup(BUTTON_PIN);
}
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Linker error: undefined lmac154_* | Vendor libraries not linked | Set BL_IOT_SDK_DIR or use --features stubs |
| Float ABI mismatch | Vendor .a uses hard-float | Strip ELF float-ABI flag from .a files |
blflash can’t connect | Not in boot mode | Hold BOOT pin low during reset |
| No UART output | Wrong UART pins or baud | Check board schematic; default 115200 baud |
| Timer not working | TIMER_CH0 not initialized | Ensure time_driver::init() runs before Embassy |
Build fails without stubs | Missing vendor lib env vars | Set LMAC154_LIB_DIR or BL_IOT_SDK_DIR |
CC2340
Texas Instruments’ CC2340R5 is an ARM Cortex-M0+ SoC with a dedicated 2.4 GHz IEEE 802.15.4 radio, controlled through TI’s Radio Control Layer (RCL). The zigbee-rs CC2340 backend uses FFI bindings to TI’s precompiled RCL and MAC platform libraries.
Hardware Overview
| Spec | Value |
|---|---|
| Core | ARM Cortex-M0+, 48 MHz |
| Flash | 512 KB |
| RAM | 36 KB SRAM |
| Radio | 2.4 GHz IEEE 802.15.4 + BLE 5.4 |
| Target | thumbv6m-none-eabi |
| I/O | UART, SPI, I2C, ADC, GPIO |
| Package | QFN 5×5 mm (40 pins) |
Common Development Boards
- LP-EM-CC2340R5 — TI LaunchPad evaluation module
- BTN1 (DIO13): Join/Leave
- BTN2 (DIO14): Identify
- LED1 (DIO7): Network status
- LED2 (DIO6): Activity
Memory Map
FLASH : ORIGIN = 0x00000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 36K
Note: The CC2340R5 has 36 KB SRAM (not 64 KB — that’s the CC2340R53 variant).
Current Status
⚡ Stubs mode — The CC2340 backend compiles with stub FFI symbols in CI. Full radio operation requires linking TI’s SimpleLink Low Power F3 SDK libraries.
The backend is architecturally complete:
- Full
MacDrivertrait implementation - FFI bindings to TI’s RCL (Radio Control Layer)
- Frame construction and PIB management
- Example firmware with GPIO, LED, and button handling
What’s needed for real RF operation:
- TI SimpleLink Low Power F3 SDK (
CC2340_SDK_DIR) - RCL library (
rcl_cc23x0r5.a) - RF firmware patches (
pbe_ieee,mce_ieee,rfe_ieee) - A proper Embassy time driver using RTC or SysTick
Prerequisites
Rust Toolchain
rustup default nightly
rustup update nightly
# Add the Cortex-M0+ target
rustup target add thumbv6m-none-eabi
TI SimpleLink SDK (for real RF)
Download the SimpleLink Low Power F3 SDK from TI. Set the environment variable:
export CC2340_SDK_DIR=/path/to/simplelink_lowpower_f3_sdk
Flash Tool
Use TI’s UniFlash or a J-Link/CMSIS-DAP probe with probe-rs:
cargo install probe-rs-tools
Building
With Stubs (CI mode — no TI SDK needed)
cd examples/cc2340-sensor
cargo build --release --features stubs
With TI SDK (real radio)
cd examples/cc2340-sensor
CC2340_SDK_DIR=/path/to/sdk cargo build --release
CI Build Command
From .github/workflows/ci.yml:
# Toolchain: nightly with thumbv6m-none-eabi + rust-src + llvm-tools
cd examples/cc2340-sensor
cargo build --release --features stubs
# Firmware artifact extraction
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
Build Script (build.rs)
The build.rs conditionally links TI libraries when CC2340_SDK_DIR is set:
#![allow(unused)]
fn main() {
if let Ok(sdk_dir) = env::var("CC2340_SDK_DIR") {
// RCL (Radio Control Layer) library
println!("cargo:rustc-link-search={sdk}/source/ti/drivers/rcl/lib/ticlang/m0p");
println!("cargo:rustc-link-lib=static=rcl_cc23x0r5");
// RF firmware patches for IEEE 802.15.4
println!("cargo:rustc-link-lib=static=pbe_ieee_cc23x0r5");
println!("cargo:rustc-link-lib=static=mce_ieee_cc23x0r5");
println!("cargo:rustc-link-lib=static=rfe_ieee_cc23x0r5");
// Optional: ZBOSS platform libraries
// println!("cargo:rustc-link-lib=static=zb_ti_platform_zed");
}
}
.cargo/config.toml
[build]
target = "thumbv6m-none-eabi"
[unstable]
build-std = ["core", "alloc"]
Flashing
probe-rs
probe-rs run --chip CC2340R5 target/thumbv6m-none-eabi/release/cc2340-sensor
TI UniFlash
- Open UniFlash and select CC2340R5
- Load the
.hexor.binfile - Connect via XDS110 debug probe (on LaunchPad) and flash
MAC Backend Notes
The CC2340 MAC backend lives in zigbee-mac/src/cc2340/:
zigbee-mac/src/cc2340/
├── mod.rs # Cc2340Mac struct, MacDriver trait impl
└── driver.rs # Cc2340Driver — FFI bindings to TI RCL
Feature Flag
zigbee-mac = { features = ["cc2340"] }
Architecture
MacDriver trait methods
│
▼
Cc2340Mac (mod.rs)
├── PIB state (addresses, channel, config)
├── Frame construction
└── Cc2340Driver (driver.rs)
├── FFI → rcl_cc23x0r5.a (TI precompiled)
│ ├── RCL_init / RCL_open / RCL_close
│ ├── RCL_Command_submit / RCL_Command_pend
│ └── RCL_readRssi
├── IEEE 802.15.4 commands via RCL_CmdIeeeRxTx
├── TX completion: RCL callback → TX_SIGNAL
└── RX completion: RCL callback → RX_SIGNAL
TI RCL Overview
TI’s Radio Control Layer (RCL) is the abstraction between application code and the Low-level Radio Frontend (LRF). The LRF runs precompiled radio firmware patches that handle the actual RF modulation. The CC2340 backend interfaces with RCL through C FFI to submit IEEE 802.15.4 TX/RX commands.
Stubs Mode
When built with --features stubs, all FFI symbols (RCL_init, RCL_open,
rf_setChannel, etc.) are replaced with no-op stubs. This lets CI verify
the full Rust code compiles without needing TI’s proprietary libraries.
Example Walkthrough
The cc2340-sensor example implements a Zigbee 3.0 temperature & humidity
end device for the LP-EM-CC2340R5 LaunchPad.
Key Elements
GPIO (direct register access):
#![allow(unused)]
fn main() {
const GPIO_BASE: u32 = 0x4000_6000;
fn gpio_set_output(pin: u8) {
let doe_reg = (GPIO_BASE + 0x0C) as *mut u32;
// ...
}
}
Time driver stub:
#![allow(unused)]
fn main() {
// Minimal stub — a production firmware would use SysTick or RTC
impl Driver for Cc2340TimeDriver {
fn now(&self) -> u64 { 0 }
fn schedule_wake(&self, _at: u64, _waker: &core::task::Waker) {}
}
}
Device setup:
#![allow(unused)]
fn main() {
let mac = Cc2340Mac::new();
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Zigbee-RS")
.model("CC2340-Sensor")
.endpoint(1, PROFILE_HOME_AUTOMATION, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0402) // Temperature
.cluster_server(0x0405) // Humidity
})
.build();
}
Logging Note
On Cortex-M0+ (no native CAS atomics), log::set_logger() is unavailable.
log::info!() etc. compile as no-ops without a registered logger. For real
debug output, use probe-rs RTT or SWD semihosting.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Linker error: undefined RCL_* | TI SDK not linked | Set CC2340_SDK_DIR or use --features stubs |
portable-atomic errors | Missing feature | Ensure features = ["unsafe-assume-single-core"] |
| No debug output | No logger on Cortex-M0+ | Use probe-rs RTT for debug |
| Flash fails | Wrong chip selected | Verify CC2340R5 in probe-rs or UniFlash |
| RAM overflow | 36 KB limit | Reduce stack size, optimize allocations |
| Build without stubs fails | Missing SDK libraries | Download TI SimpleLink F3 SDK |
Roadmap
To bring the CC2340 backend to full RF operation:
- Embassy time driver — implement using CC2340R5 RTC or SysTick
- Proper GPIO HAL — replace register-level access with a Rust HAL
- Link real RCL — test with actual TI SDK libraries
- Interrupt wiring — connect RCL callbacks to Embassy signals
- Power management — leverage CC2340’s ultra-low-power sleep modes
Telink B91 & TLSR8258
Telink’s B91 (RISC-V) and TLSR8258 (tc32 ISA) are popular SoCs in commercial
Zigbee products. The zigbee-rs Telink backend supports both chips through a
single TelinkMac driver.
TLSR8258 uses a pure-Rust 802.15.4 radio driver — all 12 radio functions use direct register access with no vendor blob or FFI. This makes TLSR8258 the second pure-Rust radio platform in zigbee-rs (after PHY6222).
B91 still uses FFI bindings to the Telink driver library (libdrivers_b91.a)
for radio access.
Hardware Overview
Telink B91 (TLSR9218)
| Spec | Value |
|---|---|
| Core | RISC-V 32-bit, up to 96 MHz |
| Flash | 512 KB |
| SRAM | 256 KB |
| Radio | BLE 5.0 + IEEE 802.15.4 |
| Target | riscv32imc-unknown-none-elf |
| I/O | UART ×2, SPI, I2C, ADC, PWM, USB |
Telink TLSR8258
| Spec | Value |
|---|---|
| Core | tc32 (Telink custom ISA) |
| Flash | 512 KB |
| SRAM | 64 KB |
| Radio | BLE + IEEE 802.15.4 |
| Cargo target | thumbv6m-none-eabi (stand-in for tc32) |
| Real toolchain | modern-tc32 (custom Rust + LLVM for tc32) |
| Radio driver | 🦀 Pure Rust — direct register access, no vendor library |
The TLSR8258 uses Telink’s proprietary tc32 instruction set. For
cargo check/cargo build, we usethumbv6m-none-eabias a compilation stand-in. Real production builds use the modern-tc32 toolchain, which provides a custom Rust compiler with nativetc32-unknown-none-elftarget support.
Common Products Using These Chips
- TLSR8258: Sonoff SNZB-02/SNZB-03/SNZB-04, many Tuya Zigbee sensors, IKEA TRÅDFRI devices
- B91: Next-generation Telink Zigbee 3.0 modules, TL321x/TL721x variants
Memory Maps
B91:
FLASH : ORIGIN = 0x20000000, LENGTH = 512K
RAM : ORIGIN = 0x00000000, LENGTH = 256K
TLSR8258:
FLASH : ORIGIN = 0x00000000, LENGTH = 512K
RAM : ORIGIN = 0x00840000, LENGTH = 64K
Current Status
TLSR8258 — Pure Rust Radio ✅
The TLSR8258 backend is fully functional with a pure-Rust 802.15.4 radio
driver. All radio control is done via volatile memory-mapped register access
at 0x800000+ — no libdrivers_8258.a or vendor SDK needed.
The pure-Rust driver replaces all 12 FFI functions that were previously stubbed:
| Function | Implementation |
|---|---|
| Channel set | Direct RF frequency register write |
| TX power | PA register lookup table |
| TX/RX | DMA-based with hardware packet format |
| CCA | RSSI measurement via RF status register |
| ED scan | Energy detection via RSSI averaging |
| IRQ handling | RF IRQ mask/status registers |
| Radio sleep | Disable RF + DMA + IRQ (~5-8 mA saved) |
| CPU suspend | Timer-wake suspend mode (~3 µA) |
Power management is integrated into the driver:
- Radio sleep — disables RF transceiver, DMA channels, and RF IRQs between polls, saving ~5-8 mA
- CPU suspend — enters tc32 suspend mode with timer wake, drawing only ~3 µA (vs ~1.5 mA for WFI idle)
- Two-tier sleep — WFI for fast poll intervals, CPU suspend for slow poll intervals (identical architecture to PHY6222’s AON sleep)
B91 — FFI Stubs
⚡ The B91 backend compiles and produces valid RISC-V machine code. Real RF operation requires linking
libdrivers_b91.afrom the Telink Zigbee SDK.
The B91 backend is architecturally complete:
- Full
MacDrivertrait implementation with CSMA-CA, ED scan, indirect TX queue - FFI bindings to Telink RF driver library
- Frame construction, PIB management, frame-pending bit for SED support
- Real time drivers reading hardware system timer at 0x140200
- GPIO register-mapped I/O, RF ISR routing, WFI-based sleep
- Example firmware with GPIO, LED, button handling, and sensor reporting
What’s needed for B91 real RF operation:
- Telink Zigbee SDK (
tl_zigbee_sdk) - Driver library (
libdrivers_b91.a)
Prerequisites
Rust Toolchain
For B91:
rustup default nightly
rustup update nightly
rustup target add riscv32imc-unknown-none-elf
rustup component add rust-src
For TLSR8258 (CI builds):
rustup default nightly
rustup update nightly
rustup target add thumbv6m-none-eabi
rustup component add rust-src
For TLSR8258 (real tc32 firmware):
Install the modern-tc32 toolchain,
which provides a custom Rust compiler with native tc32-unknown-none-elf target.
Telink SDK (for B91 real RF)
Download the Telink Zigbee SDK (only needed for B91 — TLSR8258 uses pure-Rust radio driver):
export TELINK_SDK_DIR=/path/to/tl_zigbee_sdk
Building
Telink B91
With stubs (CI mode):
cd examples/telink-b91-sensor
cargo build --release --features stubs
With Telink SDK (real radio):
cd examples/telink-b91-sensor
TELINK_SDK_DIR=/path/to/sdk cargo build --release
Telink TLSR8258
CI build (thumbv6m stand-in — no tc32 toolchain needed):
cd examples/telink-tlsr8258-sensor
cargo build --release
The radio driver uses pure-Rust register access, so no FFI stubs or vendor libraries are needed. This verifies the Rust code compiles but does NOT produce flashable tc32 firmware.
Real tc32 firmware (with modern-tc32 toolchain):
# Install the TC32 Rust toolchain
# See: https://github.com/modern-tc32/examples_rust
export TC32_TOOLCHAIN=/path/to/toolchains/tc32-stage1
export TC32_SDK_DIR=/path/to/tl_zigbee_sdk
export TC32_LLVM_BIN=$TC32_TOOLCHAIN/llvm/bin
cd examples/telink-tlsr8258-sensor
$TC32_TOOLCHAIN/bin/cargo build --release
This produces a real tc32-unknown-none-elf binary flashable to TLSR8258
hardware. The build.rs automatically compiles Telink SDK C sources with
clang --target=tc32 and links libsoft-fp.a.
CI Build Commands
From .github/workflows/ci.yml:
B91:
# Toolchain: nightly with riscv32imc-unknown-none-elf + rust-src + llvm-tools
cd examples/telink-b91-sensor
cargo build --release --features stubs
# Firmware artifacts
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
TLSR8258:
# Toolchain: nightly with thumbv6m-none-eabi + rust-src + llvm-tools
cd examples/telink-tlsr8258-sensor
cargo build --release # no --features stubs needed!
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
Build Scripts
B91 build.rs:
#![allow(unused)]
fn main() {
// Links libdrivers_b91.a when TELINK_SDK_DIR is set
if let Ok(sdk_dir) = std::env::var("TELINK_SDK_DIR") {
let lib_path = format!("{}/platform/lib", sdk_dir);
println!("cargo:rustc-link-search=native={}", lib_path);
println!("cargo:rustc-link-lib=static=drivers_b91");
}
}
TLSR8258 build.rs:
#![allow(unused)]
fn main() {
// In CI mode (thumbv6m stand-in): just links memory.x
// In modern-tc32 mode: compiles Telink SDK C sources with clang --target=tc32,
// links libsoft-fp.a from the SDK.
// No libdrivers_8258.a needed — radio uses pure-Rust register access.
}
.cargo/config.toml
B91:
[build]
target = "riscv32imc-unknown-none-elf"
[unstable]
build-std = ["core", "alloc"]
TLSR8258 (CI):
[build]
# tc32 stand-in — real builds use modern-tc32 toolchain
target = "thumbv6m-none-eabi"
[unstable]
build-std = ["core", "alloc"]
With modern-tc32, the real target
tc32-unknown-none-elfis used instead.
Flashing
B91 — Telink BDT (Burning & Debug Tool)
- Connect via Telink’s Swire debug interface
- Use the Telink BDT GUI to flash the
.binfile - Alternatively, use Telink’s command-line
tl_check_fw+tl_bulk_pgmtools
TLSR8258 — Telink BDT or OTA
For commercial products (Sonoff SNZB-02 etc.), OTA updates through Zigbee are the typical approach. For development:
- Use Telink BDT via Swire debug pins
- Flash the
.binto address 0x0000
J-Link (B91 only)
Some B91 development boards support SWD debug via J-Link:
# If supported by your board:
probe-rs run --chip TLSR9218 target/riscv32imc-unknown-none-elf/release/telink-b91-sensor
MAC Backend Notes
Both B91 and TLSR8258 share a single MAC backend in zigbee-mac/src/telink/:
zigbee-mac/src/telink/
├── mod.rs # TelinkMac struct, MacDriver trait impl
└── driver.rs # TelinkDriver — pure-Rust register access (TLSR8258) / FFI (B91)
Feature Flag
# Same feature for both B91 and TLSR8258
zigbee-mac = { features = ["telink"] }
Architecture
MacDriver trait methods
│
▼
TelinkMac (mod.rs)
├── PIB state (addresses, channel, config)
├── Frame construction
└── TelinkDriver (driver.rs)
├── Direct register access (TLSR8258) — volatile MMIO at 0x800000+
│ ├── RF frequency/channel/power registers
│ ├── DMA-based TX/RX with hardware packet format
│ ├── CCA via RSSI, ED scan, LQI
│ └── Radio sleep (disable RF+DMA+IRQ) / CPU suspend (~3 µA)
├── FFI → tl_zigbee_sdk MAC PHY (B91 only)
├── TX completion: rf_tx_irq_handler() → TX_SIGNAL
└── RX completion: rf_rx_irq_handler() → RX_SIGNAL
Packet Format
TX buffer layout:
[0..3] dmaLen (u32, LE — DMA header)
[4] rfLen (payload length + 2 for CRC)
[5..] payload (802.15.4 MAC frame)
RX buffer layout:
[0..3] dmaLen (u32, DMA transfer length)
[4] rssi (raw RSSI byte)
[5..11] reserved (7 bytes)
[12] payloadLen (802.15.4 PSDU length)
[13..] payload (MAC frame)
Radio Features
- 2.4 GHz IEEE 802.15.4 compliant
- Hardware CRC generation and checking
- Configurable TX power (chip-dependent power table)
- RSSI / LQI measurement
- Energy Detection (ED) scan
- CCA (Clear Channel Assessment) with configurable threshold
- DMA-based TX/RX with hardware packet format
Example Walkthrough
B91 Sensor
The telink-b91-sensor example is a Zigbee 3.0 end device for the B91
development board with GPIO-based button and LED control.
Pin assignments (B91 devboard):
- GPIO2 — Button (join/leave)
- GPIO3 — Green LED
- GPIO4 — Blue LED
Device setup:
#![allow(unused)]
fn main() {
let mac = TelinkMac::new();
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Zigbee-RS")
.model("B91-Sensor")
.endpoint(1, PROFILE_HOME_AUTOMATION, 0x0302, |ep| {
ep.cluster_server(0x0000)
.cluster_server(0x0402)
.cluster_server(0x0405)
})
.build();
}
TLSR8258 Sensor
The telink-tlsr8258-sensor example targets TLSR8258-based products (Sonoff
SNZB-02 etc.). It uses the pure-Rust radio driver — no vendor SDK or FFI
stubs are needed. The code structure is similar to the B91 example, but the
TLSR8258 version includes integrated power management:
- Radio sleep between polls (disable RF + DMA + IRQ, ~5-8 mA saved)
- CPU suspend during slow poll intervals (~3 µA with timer wake)
- Two-tier sleep: WFI for fast polls, CPU suspend for slow polls
Time driver note: Both examples include a working Embassy time driver
that reads the hardware system timer (TLSR8258: register 0x740, B91: register
0x140200). The 32-bit timer is extended to 64-bit with wraparound detection.
The schedule_wake() alarm is not yet wired to a hardware compare interrupt,
so Embassy uses polling mode.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Linker error: undefined rf_* | Telink SDK not linked (B91 only) | Set TELINK_SDK_DIR or use --features stubs |
portable-atomic errors | Missing feature flag | Ensure features = ["unsafe-assume-single-core"] |
| TLSR8258 real build fails | modern-tc32 toolchain needed | Install from modern-tc32 |
| B91 wrong target | Using riscv32imac | B91 CI uses riscv32imc-unknown-none-elf (no atomics) |
| No debug output | No logger registered | Use Telink UART or BDT for debug output |
| BDT can’t connect | Swire not connected | Check debug interface wiring |
Roadmap
To bring the B91 backend to full RF operation:
Embassy time driver — implement using Telink system timer✅- Link real SDK — test B91 with
tl_zigbee_sdkdriver libraries Interrupt wiring — connect RF IRQ handler to Embassy signals✅- B91 HAL crate — community
embassy-telink-b91effort TLSR8258 Rust target — explore custom target JSON for tc32 ISA✅TLSR8258 pure-Rust radio — replace all FFI with register access✅TLSR8258 power management — radio sleep + CPU suspend✅modern-tc32 toolchain — real tc32 builds with custom Rust compiler✅
Building for Real TLSR8258 Hardware
modern-tc32 Toolchain
The modern-tc32 project provides a complete Rust toolchain for native tc32 builds:
- Custom Rust compiler with
tc32-unknown-none-elftarget - LLVM backend with TC32 support (
clang --target=tc32) - Prebuilt
core/allocfor the TC32 target
Setup: see modern-tc32/examples_rust
tc32 ISA Compatibility Discovery
Through binary analysis, we discovered that tc32 is Thumb-1 with Telink extensions:
- ~92% of tc32 instructions have identical binary encoding to ARM Thumb-1
- The ~8% tc32-only opcodes (
tmcsr,tmrss,treti) are used only in startup assembly, IRQ entry/exit, and power management — not in application code - Rust/LLVM
thumbv6mcodegen produces 100% valid tc32 machine code (verified: 1720 instructions, 0 unknown opcodes)
This means Rust can produce native TLSR8258 firmware.
Custom Target Spec
A custom target JSON is provided at targets/tc32-none-eabi.json. It uses
the thumbv6m LLVM backend but overrides the linker to tc32-elf-ld.
With the modern-tc32 toolchain, you can build directly:
cd examples/telink-tlsr8258-sensor
$TC32_TOOLCHAIN/bin/cargo build --release
Or use the legacy custom target approach:
# Build with the custom tc32 target (requires tc32-elf-ld in PATH)
cd examples/telink-tlsr8258-sensor
cargo +nightly build --release \
--target ../../targets/tc32-none-eabi.json \
-Z build-std=core,alloc -Z json-target-spec
Build with modern-tc32
The recommended approach for real TLSR8258 firmware:
# Install modern-tc32 toolchain
# See: https://github.com/modern-tc32/examples_rust
export TC32_TOOLCHAIN=/path/to/toolchains/tc32-stage1
export TC32_SDK_DIR=/path/to/tl_zigbee_sdk
export TC32_LLVM_BIN=$TC32_TOOLCHAIN/llvm/bin
cd examples/telink-tlsr8258-sensor
$TC32_TOOLCHAIN/bin/cargo build --release
The build.rs automatically:
- Compiles Telink SDK C sources with
clang --target=tc32 - Links
libsoft-fp.afrom the SDK - Handles startup code and linker script
- Creates a flashable binary
Legacy: Build Script
A helper script build-tc32.sh is also available for manual builds:
- Compiles Rust code with the tc32 target
- Assembles tc32 startup code (
cstartup_8258.S) - Links everything with
tc32-elf-ld - Creates a flashable
.binwithtc32-elf-objcopy
cd examples/telink-tlsr8258-sensor
TELINK_SDK_DIR=/path/to/tl_zigbee_sdk ./build-tc32.sh
Prerequisites
- modern-tc32 toolchain (recommended) or Telink tc32-elf-gcc toolchain (from Telink IDE or SDK)
TC32_SDK_DIRenvironment variable pointing totl_zigbee_sdk(only needed for soft-float math library and startup code)- Rust nightly with
rust-srccomponent (for legacy build path only)
Alternative: Static Library Approach
If you prefer to integrate Rust into an existing Telink C project:
# Build Rust as a static library
cargo +nightly build --release --target thumbv6m-none-eabi \
-Z build-std=core,alloc --crate-type staticlib
Then link the resulting .a into your tc32-gcc C project. The C side
handles hardware initialization and calls zigbee_init() / zigbee_tick()
from the Rust library.
PHY6222
The Phyplus PHY6222 is the only zigbee-rs platform with a 100% pure-Rust radio driver — no vendor SDK, no binary blobs, no C FFI. All radio hardware access uses direct register writes in Rust, derived from the open-source PHY6222 SDK.
Hardware Overview
The PHY6222 and PHY6252 are part of the PHY62x2 chip family from Phyplus Microelectronics. They share the same register layout, SDK, and flash tools — our driver works for both. Key differences:
| Spec | PHY6222 | PHY6252 |
|---|---|---|
| Core | ARM Cortex-M0, 48 MHz | ARM Cortex-M0, 48 MHz |
| Flash | 512 KB | 256 KB |
| SRAM | 64 KB | 64 KB |
| ROM | 96 KB | 96 KB |
| BLE | 5.0 | 5.2 |
| Radio | 2.4 GHz BLE + IEEE 802.15.4 | 2.4 GHz BLE + IEEE 802.15.4 |
| TX power | -20 dBm to +10 dBm | -20 dBm to +10 dBm |
| Target | thumbv6m-none-eabi | thumbv6m-none-eabi |
Note: PHY6252 specs are from the official Ai-Thinker PB-03/PB-03F datasheets (256 KB flash, 64 KB SRAM, 96 KB ROM). PHY6222 is reported as 512 KB flash in the pvvx/THB2 SDK (flash dump size
0x80000). Both chips use the same register map and are binary-compatible.
Why PHY6222?
- Ultra low cost — modules available for ~$1.50 (PB-03F with PHY6252)
- Widely deployed — used in Tuya THB2, TH05F, BTH01 sensor devices (PHY6222)
- Open-source SDK — register documentation available from community efforts
- Pure Rust — the only zigbee-rs backend with zero vendor dependencies
Common Boards and Modules
| Board | Form Factor | LED Pins | Button |
|---|---|---|---|
| Ai-Thinker PB-03F | Module, castellated (PHY6252) | P11 (R), P12 (G), P14 (B) | P15 (PROG) |
| Tuya THB2 | Sensor enclosure (PHY6222) | Varies | Varies |
| Tuya TH05F | Temp/humidity sensor (PHY6222) | Varies | Varies |
| BTH01 | BLE thermometer (PHY6222) | Varies | Varies |
Memory Map
FLASH : ORIGIN = 0x11001000, LENGTH = 508K ← 4 KB reserved for bootloader
RAM : ORIGIN = 0x1FFF0000, LENGTH = 64K
The PHY6222 maps flash at 0x1100_0000 and SRAM at 0x1FFF_0000. The first
4 KB of flash is reserved for the bootloader/OTA header.
Prerequisites
Rust Toolchain
rustup default nightly
rustup update nightly
# Add the Cortex-M0 target
rustup target add thumbv6m-none-eabi
# rust-src for build-std
rustup component add rust-src
No Vendor SDK Required!
Unlike every other embedded zigbee-rs platform, PHY6222 needs no vendor libraries, no SDK download, no environment variables. Everything is in Rust.
Flash Tool
The PHY6222 is typically flashed via UART bootloader. Community tools:
- PHY62xx_Flash — open-source serial flasher
- Ai-Thinker Flash Tool — GUI tool for PB-03F modules
- OpenOCD — via SWD debug interface (if exposed)
Building
Full Build (no stubs, no vendor SDK)
cd examples/phy6222-sensor
cargo build --release
That’s it. No --features stubs, no SDK_DIR environment variable, no
binary blobs. The firmware compiles entirely from Rust source.
CI Build Command
From .github/workflows/ci.yml:
# Toolchain: nightly with thumbv6m-none-eabi + rust-src + llvm-tools
cd examples/phy6222-sensor
cargo build --release
# Firmware artifact extraction
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
Build Script (build.rs)
The simplest build.rs of all platforms:
fn main() {
println!("cargo:rustc-link-arg=-Tlink.x");
}
No vendor library discovery, no conditional linking — just the linker script.
.cargo/config.toml
[build]
target = "thumbv6m-none-eabi"
[unstable]
build-std = ["core", "alloc"]
Release Profile
[profile.release]
opt-level = "s" # Optimize for size (256 KB flash on PHY6252)
lto = true # Link-Time Optimization
Flashing
UART Bootloader
Most PHY6222 boards have a UART bootloader accessible by holding the PROG button during power-on:
# Using community serial flasher
phy62xx_flash --port /dev/ttyUSB0 target/thumbv6m-none-eabi/release/phy6222-sensor.bin
SWD Debug (if available)
Some boards expose SWD pins for debug:
# With a CMSIS-DAP or J-Link probe
openocd -f interface/cmsis-dap.cfg -f target/phy6222.cfg \
-c "program phy6222-sensor.bin 0x11001000 verify reset exit"
OTA Update
For deployed Tuya devices, firmware can be updated over Zigbee OTA. The OTA cluster is defined in zigbee-rs but the actual upgrade flow is not yet implemented.
MAC Backend Notes
The PHY6222 MAC backend lives in zigbee-mac/src/phy6222/:
zigbee-mac/src/phy6222/
├── mod.rs # Phy6222Mac struct, MacDriver trait impl
└── driver.rs # Phy6222Driver — pure-Rust register-level radio driver
Feature Flag
zigbee-mac = { features = ["phy6222"] }
Architecture — 100% Rust
MacDriver trait methods
│
▼
Phy6222Mac (mod.rs)
├── PIB state (addresses, channel, config)
├── Frame construction
└── Phy6222Driver (driver.rs) — PURE RUST
├── RF PHY registers (0x40030000..0x40030110)
│ ├── rf_phy_bb_cfg() → baseband for Zigbee mode
│ ├── rf_phy_ana_cfg() → PLL, LNA, PA configuration
│ └── set_channel() → frequency synthesis
├── LL HW registers (0x40031000..0x40031060)
│ ├── ll_hw_set_stx() → single TX mode
│ ├── ll_hw_set_srx() → single RX mode
│ └── ll_hw_trigger() → start operation
├── TX FIFO (0x40031400) → write frame data
├── RX FIFO (0x40031C00) → read received frames
└── IRQ → Embassy Signal for async completion
Register Map Overview
The PHY6222 radio is controlled through three register regions:
| Region | Base Address | Purpose |
|---|---|---|
| RF PHY | 0x4003_0000 | Analog/baseband configuration (PLL, LNA, PA, modulation) |
| LL HW | 0x4003_1000 | Link-Layer hardware engine (TX/RX control, IRQ, CRC) |
| TX FIFO | 0x4003_1400 | Frame data write port |
| RX FIFO | 0x4003_1C00 | Received frame read port |
| CLK CTRL | 0x4000_F040 | Crystal/clock configuration |
| GPIO | 0x4000_8000 | GPIO control (LEDs, buttons) |
Key Register Operations
Channel setting (frequency synthesis):
#![allow(unused)]
fn main() {
// Channel 11–26 maps to 2405–2480 MHz
// PHY6222 uses BLE-style frequency register offset
let freq_offset = 2405 + (channel - 11) * 5;
sub_write_reg(BB_HW_BASE + 0x28, 23, 17, freq_offset);
}
TX operation:
#![allow(unused)]
fn main() {
fn ll_hw_set_stx() {
reg_write(LL_HW_BASE + 0x00, 0x05); // Single TX mode
}
fn ll_hw_trigger() {
reg_write(LL_HW_BASE + 0x04, 0x01); // Start operation
}
}
RX operation:
#![allow(unused)]
fn main() {
fn ll_hw_set_srx(timeout_us: u32) {
reg_write(LL_HW_BASE + 0x00, 0x06); // Single RX mode
reg_write(LL_HW_BASE + 0x0C, timeout_us); // RX window
}
}
IRQ handling:
#![allow(unused)]
fn main() {
// Interrupt status bits
const LIRQ_MD: u32 = 0x01; // Mode-done (TX/RX complete)
const LIRQ_COK: u32 = 0x02; // CRC OK on received packet
const LIRQ_CERR: u32 = 0x04; // CRC error
const LIRQ_RTO: u32 = 0x08; // RX timeout
}
Register Map Source
The register definitions are derived from the open-source PHY6222 SDK,
specifically rf_phy_driver.c and ll_hw_drv.c from the
pvvx/THB2 repository.
What Makes This Unique
| Aspect | PHY6222 | Other Platforms |
|---|---|---|
| Vendor SDK | Not needed | Required |
| Binary blobs | Zero | liblmac154.a, rcl.a, etc. |
| FFI calls | Zero | Dozens of extern "C" functions |
| Auditability | Full — all Rust | Opaque vendor libraries |
| Build deps | Just cargo | SDK downloads, env vars, ABI patches |
Example Walkthrough
The phy6222-sensor example implements a Zigbee 3.0 temperature & humidity
end device for PHY6222-based boards with full SED (Sleepy End Device)
architecture.
Features
- Flash NV storage — network state saved to last 2 sectors of 512KB flash
using shared
LogStructuredNv<FlashDriver>engine. Survives reboots — no re-pairing needed. - NWK Leave handler — if the coordinator sends a Leave command, the device auto-erases NV and does a fresh join.
- Default reporting — temperature and humidity report every 60–300s, battery every 300–3600s. Device reports data even before ZHA sends ConfigureReporting.
- Identify cluster (0x0003) — supports Identify, IdentifyQuery, and TriggerEffect commands. LED toggles during identify mode.
- Real battery ADC — reads battery voltage via the PHY6222 ADC peripheral.
- Simulated temp/humidity — placeholder values that cycle; replace with I²C sensor driver for real readings.
Initialization
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
// Initialize GPIO for LEDs (PB-03F: active low)
gpio_set_output(pins::LED_G); // P12
// Create MAC driver — pure Rust, no vendor SDK!
let mac = Phy6222Mac::new();
// Flash NV storage (last 2 sectors of 512KB flash)
let mut nv = flash_nv::create_nv();
Device Setup
#![allow(unused)]
fn main() {
let mut device = ZigbeeDevice::builder(mac)
.device_type(DeviceType::EndDevice)
.manufacturer("Zigbee-RS")
.model("PHY6222-Sensor")
.sw_build("0.1.0")
.channels(zigbee_types::ChannelMask::ALL_2_4GHZ)
.endpoint(1, PROFILE_HOME_AUTOMATION, 0x0302, |ep| {
ep.cluster_server(0x0000) // Basic
.cluster_server(0x0003) // Identify
.cluster_server(0x0001) // Power Configuration
.cluster_server(0x0402) // Temperature Measurement
.cluster_server(0x0405) // Relative Humidity
})
.build();
// Restore from NV or fresh join
if device.restore_state(&nv) {
device.user_action(UserAction::Rejoin);
} else {
device.user_action(UserAction::Join);
}
// Default reporting so device reports without ZHA interview
setup_default_reporting(&mut device);
}
Main Loop
The main loop handles button presses (PROG button on GPIO15), updates simulated sensor values, polls the parent for indirect frames, and handles NWK Leave commands:
#![allow(unused)]
fn main() {
loop {
let pressed = !gpio_read(pins::BTN); // Active low
if pressed && !button_was_pressed {
device.user_action(UserAction::Toggle);
}
button_was_pressed = pressed;
// Poll parent for indirect frames (SED core)
if device.is_joined() {
for _ in 0..4 {
match device.poll().await {
Ok(Some(ind)) => {
if let Some(ev) = device.process_incoming(&ind, &mut cls).await {
match &ev {
StackEvent::LeaveRequested => {
device.factory_reset(Some(&mut nv)).await;
device.user_action(UserAction::Join);
break;
}
_ => {}
}
}
}
_ => break,
}
}
}
// Update simulated sensor readings
temp_cluster.set_temperature(temp_hundredths);
hum_cluster.set_humidity(hum_hundredths);
// Identify LED toggle
identify_cluster.tick(tick_elapsed);
if identify_cluster.is_identifying() {
gpio_write(pins::LED_G, !gpio_read(pins::LED_G));
}
Timer::after(Duration::from_secs(30)).await;
}
}
Adding a Real I²C Sensor
PHY6222-based Tuya devices typically include one of these I²C sensors:
| Sensor | Measures | I²C Address |
|---|---|---|
| CHT8215 | Temp + Humidity | 0x40 |
| CHT8310 | Temp + Humidity | 0x40 |
| SHT30 | Temp + Humidity | 0x44 |
| AHT20 | Temp + Humidity | 0x38 |
A real firmware would initialize the PHY6222 I²C peripheral and use an
embedded-hal 1.0 compatible sensor driver.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
portable-atomic errors | Missing feature | Ensure features = ["unsafe-assume-single-core"] |
| Flash fails | Not in bootloader mode | Hold PROG button during power-on |
| No serial output | No logger registered | log::info!() is no-op on Cortex-M0+ without logger |
| Radio not working | TP calibration defaults | Production firmware needs proper PLL lock sequence |
| Wrong memory layout | OTA header offset | Ensure FLASH ORIGIN = 0x11001000 (after 4 KB bootloader) |
| 64 KB SRAM overflow | Large buffers | Optimize with opt-level = "s" and LTO; PHY6252 has only 256 KB flash |
Known Limitations
- Simplified TP calibration — the pure-Rust driver uses default calibration values. Production firmware would need a proper PLL lock sequence with per-chip calibration data.
- Simulated temperature/humidity — the example uses cycling placeholder values. Replace with an I²C sensor driver (CHT8215, SHT30, etc.) for real readings. Battery voltage IS read from the real ADC.
- No OTA flow — the OTA cluster is defined but no actual firmware upgrade path is implemented yet.
Power Management
The PHY6222 sensor implements a comprehensive two-tier sleep architecture that achieves ~3+ years battery life on 2×AAA. See the Power Management chapter for the full framework.
Two-Tier Sleep Architecture
| Tier | Phase | Sleep Mode | Current | Wake Source |
|---|---|---|---|---|
| 1 | Fast poll (250 ms, first 120 s) | Radio off + WFE | ~1.5 mA | Timer |
| 2 | Slow poll (30 s, steady state) | AON system sleep | ~3 µA | RTC |
Tier 1 — Light sleep (fast poll): After joining or button press, the device polls every 250 ms for 120 seconds. Between polls, the radio is powered down and the CPU enters WFE via Embassy’s timer:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake();
}
Tier 2 — AON system sleep (slow poll): In steady state, the device enters full system sleep between 30-second polls. The AON domain’s 32 kHz RC oscillator runs the RTC for timed wake:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
device.save_state(&mut nv);
phy6222_hal::gpio::prepare_for_sleep(1 << pins::BTN);
phy6222_hal::flash::enter_deep_sleep();
phy6222_hal::sleep::set_ram_retention(phy6222_hal::regs::RET_SRAM0);
phy6222_hal::sleep::config_rtc_wakeup(
phy6222_hal::sleep::ms_to_rtc_ticks(poll_ms as u32),
);
phy6222_hal::sleep::enter_system_sleep();
}
On wake, the firmware detects the system-sleep reset, restores flash from deep power-down, and performs a fast restore of Zigbee network state from NV.
Radio Sleep/Wake
The MAC driver provides radio_sleep() and radio_wake() methods that power
down the radio transceiver between polls, saving ~5–8 mA.
Flash Deep Power-Down
Before system sleep, flash is put into deep power-down mode using JEDEC commands (0xB9 enter, 0xAB release), reducing flash standby current from ~15 µA to ~1 µA.
GPIO Leak Prevention
Before entering system sleep, all unused GPIO pins are configured as inputs with pull-down resistors. Only essential pins (e.g., the button) retain their pull-up. This prevents floating-pin leakage current.
AON Sleep Module (phy6222-hal::sleep)
| Function | Purpose |
|---|---|
config_rtc_wakeup(ticks) | Set RTC compare channel 0 for timed wake |
set_ram_retention(banks) | Select SRAM banks to retain during sleep |
enter_system_sleep() | Enter AON system sleep (~3 µA, does not return) |
was_sleep_reset() | Check if current boot was a wake from system sleep |
clear_sleep_flag() | Clear the sleep-wake flag after detection |
ms_to_rtc_ticks(ms) | Convert milliseconds to 32 kHz RC ticks |
Reportable Change Thresholds
The sensor configures ZCL reportable change thresholds to suppress unnecessary TX events:
| Attribute | Min Interval | Max Interval | Change Threshold |
|---|---|---|---|
| Temperature | 60 s | 300 s | ±0.5 °C |
| Humidity | 60 s | 300 s | ±1% |
| Battery | 300 s | 3600 s | ±2% |
Battery Life Estimate (2×AAA, ~1200 mAh)
| State | Current | Duty Cycle |
|---|---|---|
| AON system sleep (radio/flash off) | ~3 µA | ~99.8% |
| Radio RX (poll every 30 s) | ~8 mA | ~0.03% |
| Radio TX (report every 60 s) | ~10 mA | ~0.005% |
| Average (steady state) | ~6–10 µA | |
| Estimated battery life | ~3+ years |
Why Pure Rust Matters
The PHY6222 backend demonstrates that vendor SDKs are not a fundamental requirement for Zigbee radio operation. With enough register documentation (open-source or reverse-engineered), a complete 802.15.4 radio driver can be written in safe, auditable Rust. This approach:
- Eliminates supply chain risk — no opaque binary blobs
- Simplifies the build — just
cargo build, no SDK downloads - Enables full auditing — every line of radio code is visible and reviewable
- Reduces binary size — no unused vendor code linked in
- Serves as a reference — shows the path for pure-Rust drivers on other chips
EFR32 (MG1 & MG21)
The Silicon Labs EFR32 Mighty Gecko family is one of the most widely deployed Zigbee platforms — found in IKEA TRÅDFRI modules, Sonoff ZBDongle-E, and many commercial products. zigbee-rs supports both Series 1 (EFR32MG1P) and Series 2 (EFR32MG21) with pure-Rust radio drivers — no GSDK, no RAIL library, no binary blobs.
Hardware Overview
| Spec | EFR32MG1P (Series 1) | EFR32MG21 (Series 2) |
|---|---|---|
| Core | ARM Cortex-M4F @ 40 MHz | ARM Cortex-M33 @ 80 MHz |
| Flash | 256 KB | 512 KB |
| SRAM | 32 KB | 64 KB |
| Radio | 2.4 GHz IEEE 802.15.4 + BLE | 2.4 GHz IEEE 802.15.4 + BLE |
| Security | CRYPTO engine | Secure Element (SE) + TrustZone |
| Target | thumbv7em-none-eabihf | thumbv8m.main-none-eabihf |
| Flash page size | 2 KB | 8 KB |
Why EFR32?
- Ubiquitous — used in IKEA TRÅDFRI, Sonoff, and many commercial Zigbee products
- Pure Rust — zigbee-rs needs no GSDK, no RAIL library, no vendor blobs
- Well-documented — Silicon Labs reference manuals available for register-level programming
- Excellent radio — high sensitivity, good range, mature 802.15.4 support
Common Boards and Modules
| Board | Series | Form Factor | Notes |
|---|---|---|---|
| IKEA TRÅDFRI modules | Series 1 | PCB modules | EFR32MG1P, widely available |
| Thunderboard Sense (BRD4151A) | Series 1 | Dev board | EFR32MG1P + sensors |
| BRD4100A | Series 1 | Radio board | EFR32MG1P evaluation |
| BRD4180A | Series 2 | Radio board | EFR32MG21A020F1024IM32 |
| BRD4181A | Series 2 | Radio board | EFR32MG21A020F512IM32 |
| Sonoff ZBDongle-E | Series 2 | USB dongle | EFR32MG21, popular coordinator |
Series 1 vs Series 2 Register Differences
The two series have different peripheral base addresses, requiring separate
MAC modules (efr32/ and efr32s2/):
| Peripheral | Series 1 Base | Series 2 Base |
|---|---|---|
| Radio (RAC, FRC, MODEM, etc.) | 0x40080000–0x40087FFF | 0x40090000–0x40095FFF |
| CMU (Clock Management Unit) | 0x400E4000 | 0x40008000 |
| GPIO | 0x4000A000 | 0x4003C000 |
| MSC (Flash Controller) | 0x400E0000 | 0x40030000 |
Prerequisites
Rust Toolchain
rustup default nightly
rustup update nightly
# Series 1 (MG1P — Cortex-M4F)
rustup target add thumbv7em-none-eabihf
# Series 2 (MG21 — Cortex-M33)
rustup target add thumbv8m.main-none-eabihf
# rust-src for build-std
rustup component add rust-src
No Vendor SDK Required!
Unlike the traditional Silicon Labs development flow (which requires GSDK + RAIL library + Simplicity Studio), the zigbee-rs EFR32 backends need no vendor libraries, no SDK download, no environment variables. Everything is in Rust.
Debug Probe
Any ARM SWD debugger works:
- J-Link — included with Silicon Labs dev kits
- ST-Link — widely available, inexpensive
- DAPLink / CMSIS-DAP — open-source, many options
- probe-rs — recommended Rust-native tool
Building
EFR32MG1P (Series 1)
cd examples/efr32mg1-sensor
cargo build --release
EFR32MG21 (Series 2)
cd examples/efr32mg21-sensor
cargo build --release
No --features stubs required — both projects build without any external
libraries.
CI Build
Both targets build in CI alongside the other 11 firmware targets. The CI
workflow extracts .bin and .hex artifacts from the ELF output:
OBJCOPY=$(find $(rustc --print sysroot) -name llvm-objcopy | head -1)
$OBJCOPY -O binary $ELF ${ELF}.bin
$OBJCOPY -O ihex $ELF ${ELF}.hex
Flashing
EFR32MG1P (Series 1)
# With probe-rs
probe-rs run --chip EFR32MG1P target/thumbv7em-none-eabihf/release/efr32mg1-sensor
# With openocd
openocd -f interface/cmsis-dap.cfg -f target/efm32.cfg \
-c "program target/thumbv7em-none-eabihf/release/efr32mg1-sensor verify reset exit"
# With Simplicity Commander (Silicon Labs tool)
commander flash target/thumbv7em-none-eabihf/release/efr32mg1-sensor.hex
EFR32MG21 (Series 2)
# With probe-rs
probe-rs run --chip EFR32MG21A020F512IM32 \
target/thumbv8m.main-none-eabihf/release/efr32mg21-sensor
# With openocd
openocd -f interface/cmsis-dap.cfg -f target/efm32.cfg \
-c "program target/thumbv8m.main-none-eabihf/release/efr32mg21-sensor verify reset exit"
# With Simplicity Commander
commander flash target/thumbv8m.main-none-eabihf/release/efr32mg21-sensor.hex
Pure-Rust Radio Driver
Both EFR32 backends use direct register access for the radio — no RAIL library, no co-processor mailbox protocol. The radio hardware consists of several interconnected blocks:
| Block | Function |
|---|---|
| RAC | Radio Controller — state machine, PA |
| FRC | Frame Controller — CRC, format |
| MODEM | O-QPSK modulation/demodulation |
| SYNTH | PLL frequency synthesizer |
| AGC | Automatic gain control, RSSI |
| BUFC | TX/RX buffer controller |
All blocks are configured via memory-mapped registers. The Series 1 and Series 2 register maps are structurally similar but use different base addresses (see table above), which is why they live in separate MAC modules.
MAC Backend Structure
zigbee-mac/src/
├── efr32/ # Series 1 (EFR32MG1P)
│ ├── mod.rs # Efr32Mac struct, MacDriver trait impl
│ └── driver.rs # Efr32Driver — pure-Rust register-level radio driver
└── efr32s2/ # Series 2 (EFR32MG21)
├── mod.rs # Efr32S2Mac struct, MacDriver trait impl
└── driver.rs # Efr32S2Driver — pure-Rust register-level radio driver
Feature Flags
# Series 1 (MG1P)
zigbee-mac = { features = ["efr32"] }
# Series 2 (MG21)
zigbee-mac = { features = ["efr32s2"] }
Power Management
Both EFR32 platforms implement radio sleep/wake via the CMU (Clock Management Unit), which gates the radio peripheral clocks to save power between polls:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep(); // CMU clock gate — radio off
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake(); // CMU clock enable, re-apply channel
}
The CMU register addresses differ between Series 1 and Series 2 (see register table above), but the sleep/wake interface is identical.
Note: Full deep sleep (EM2/EM3/EM4 energy modes) is not yet implemented. Currently only radio clock gating is used for power reduction between polls.
See the Power Management chapter for the full cross-platform power framework.
What the Examples Demonstrate
Both efr32mg1-sensor and efr32mg21-sensor implement a Zigbee 3.0
temperature & humidity end device with:
- Pure-Rust IEEE 802.15.4 radio driver (no RAIL/GSDK)
- Embassy async runtime (SysTick time driver)
- Proper interrupt vector table (34 IRQs for MG1P, 51 for MG21)
- Button-driven network join/leave with edge detection
- LED status indication + Identify blink
- ZCL Temperature Measurement + Relative Humidity + Identify clusters
- Flash NV storage — network state persists across reboots
- Default reporting with reportable change thresholds
- Radio sleep/wake for power management
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
probe-rs can’t connect | Wrong chip name | Use EFR32MG1P (S1) or EFR32MG21A020F512IM32 (S2) |
| Flash write fails (MG21) | Wrong page size | Series 2 uses 8 KB pages (vs 2 KB for Series 1) |
| Radio not working | Register init approximations | See known limitations — init registers need verification |
| Build fails with linker errors | Wrong target | Use thumbv7em-none-eabihf (S1) or thumbv8m.main-none-eabihf (S2) |
| No serial output | No logger configured | Add a defmt or RTT logger for debug output |
Known Limitations
- Scaffold radio init — the pure-Rust radio register values are simplified approximations. The exact register sequences for 802.15.4 mode need verification against the EFR32xG1/xG21 Reference Manuals or extraction from the RAIL library source.
- No deep sleep — only radio clock gating is implemented; full EM2/EM3/EM4 energy modes are not yet supported.
- Simulated sensors — temperature and humidity values are placeholders. Replace with I²C sensor drivers for real readings.
Why Pure Rust on EFR32 Matters
The traditional Silicon Labs development flow requires:
- GSDK (Gecko SDK) — a large multi-GB SDK download
- RAIL library — pre-compiled radio abstraction layer (binary blob)
- Simplicity Studio — Eclipse-based IDE
The zigbee-rs pure-Rust approach eliminates all of this. The radio is configured entirely through documented memory-mapped registers, making the firmware:
- Fully auditable — every line of radio code is visible
- Trivially reproducible — just
cargo build, no SDK setup - Vendor-independent — no binary blobs, no license restrictions
- Small — no unused vendor code linked in
This is the 3rd and 4th pure-Rust radio driver in zigbee-rs (after PHY6222 and TLSR8258), demonstrating that the approach scales across chip families.
Power Management
Battery-powered Zigbee devices spend most of their life asleep. The
zigbee-runtime crate provides a PowerManager that decides when to sleep,
how long to sleep, and what kind of sleep to use — while still meeting
Zigbee’s poll and reporting deadlines.
PowerMode
Every device declares its power strategy through the PowerMode enum
(zigbee_runtime::power::PowerMode):
#![allow(unused)]
fn main() {
pub enum PowerMode {
/// Always on — router or mains-powered end device.
AlwaysOn,
/// Sleepy End Device — periodic wake for polling.
Sleepy {
/// Poll interval in milliseconds.
poll_interval_ms: u32,
/// How long to stay awake after activity (ms).
wake_duration_ms: u32,
},
/// Deep sleep — wake only on timer or external event.
DeepSleep {
/// Wake interval in seconds.
wake_interval_s: u32,
},
}
}
| Mode | Typical Use | Radio | CPU | RAM |
|---|---|---|---|---|
AlwaysOn | Routers, mains-powered EDs | On | On | Retained |
Sleepy | Battery sensors, remotes | Off between polls | Halted | Retained |
DeepSleep | Ultra-low-power sensors | Off | Off | Off (RTC only) |
Set the power mode when you build your device:
#![allow(unused)]
fn main() {
use zigbee_runtime::power::{PowerManager, PowerMode};
let pm = PowerManager::new(PowerMode::Sleepy {
poll_interval_ms: 7_500, // poll parent every 7.5 s
wake_duration_ms: 200, // stay awake 200 ms after activity
});
}
SleepDecision
Each iteration of the event loop calls PowerManager::decide(now_ms). The
manager returns one of three verdicts:
#![allow(unused)]
fn main() {
pub enum SleepDecision {
/// Stay awake — pending work.
StayAwake,
/// Light sleep for the given duration (ms). CPU halted, RAM retained.
LightSleep(u32),
/// Deep sleep for the given duration (ms). Only RTC + wake sources active.
DeepSleep(u32),
}
}
Decision Logic
The decision tree inside decide() works as follows:
-
Pending work? — If
pending_txorpending_reportsis set, always returnStayAwake. Outgoing frames and attribute reports must be sent before the CPU is halted. -
AlwaysOn — Always
StayAwake. Routers never sleep. -
Sleepy —
- If less than
wake_duration_mshas elapsed since the last activity (Rx/Tx, sensor read, user input), stay awake. - If a MAC poll is overdue (
since_poll >= poll_interval_ms), stay awake to send the poll immediately. - Otherwise, enter
LightSleepfor the time remaining until the next poll is due.
- If less than
-
DeepSleep —
- If the last activity was within the last 1 second, stay awake (brief grace period for completing any post-wake work).
- Otherwise, enter
DeepSleepforwake_interval_s × 1000ms.
#![allow(unused)]
fn main() {
let decision = pm.decide(now_ms);
match decision {
SleepDecision::StayAwake => { /* process events */ }
SleepDecision::LightSleep(ms) => mac.sleep(ms),
SleepDecision::DeepSleep(ms) => mac.deep_sleep(ms),
}
}
Sleepy End Device (SED) Behavior
A Sleepy End Device is a Zigbee device that spends most of its time with the radio off. Its parent router buffers incoming frames and releases them when the SED sends a MAC Data Request (poll).
Poll Interval
The poll interval determines how often the SED wakes to check for buffered
data. Use PowerManager::should_poll(now_ms) to decide when to send a poll:
#![allow(unused)]
fn main() {
if pm.should_poll(now_ms) {
mac.send_data_request(parent_addr);
pm.record_poll(now_ms);
}
}
Typical poll intervals:
| Application | Poll Interval | Battery Impact |
|---|---|---|
| Light switch | 250–500 ms | High responsiveness, shorter battery |
| Door sensor | 5–10 s | Moderate |
| Temperature sensor | 30–60 s | Very low power |
Activity Tracking
Call record_activity() whenever something interesting happens — a frame is
received, a sensor is read, or a user presses a button. This resets the
wake-duration timer and prevents premature sleep:
#![allow(unused)]
fn main() {
pm.record_activity(now_ms); // keep CPU awake for at least wake_duration_ms
}
The set_pending_tx() and set_pending_reports() methods act as hard locks
that prevent sleep entirely until the work is done:
#![allow(unused)]
fn main() {
pm.set_pending_tx(true); // acquired before queueing a frame
// ... send the frame ...
pm.set_pending_tx(false); // release after MAC confirms transmission
}
How MAC Backends Implement Sleep
The PowerManager itself does not touch hardware — it only decides. The
actual sleep/wake is performed by the MAC backend:
| Platform | Light Sleep | Deep Sleep |
|---|---|---|
| ESP32-C6/H2 | esp_light_sleep_start() | esp_deep_sleep() — only RTC memory retained |
| nRF52840 | TASKS_DISABLE + __WFE (System ON, RAM retained) | System OFF (wake via GPIO/RTC) |
| TLSR8258 | radio_sleep() + WFI (~1.5 mA) | CPU suspend (~3 µA, timer wake, RAM retained) |
| PHY6222 | radio_sleep() + WFE (~1.5 mA) | AON system sleep (~3 µA, RTC wake) |
| EFR32MG1 | radio_sleep() — radio clock gating via CMU | — |
| EFR32MG21 | radio_sleep() — radio clock gating via CMU | — |
| BL702 | PDS (Power Down Sleep) | HBN (Hibernate) — wake via RTC |
The runtime event loop integrates the power manager like this (simplified):
#![allow(unused)]
fn main() {
loop {
// 1. Process all pending events
process_mac_events(&mut pm);
process_zcl_reports(&mut pm);
// 2. Ask the power manager what to do
let decision = pm.decide(now_ms());
match decision {
SleepDecision::StayAwake => continue,
SleepDecision::LightSleep(ms) => {
mac.enter_light_sleep(ms);
// CPU resumes here after wake
}
SleepDecision::DeepSleep(ms) => {
nv.persist_state(); // save everything before deep sleep
mac.enter_deep_sleep(ms);
// After deep sleep, device resets — execution restarts from main()
}
}
}
}
Important: Before entering
DeepSleep, all critical state must be persisted to NV storage — deep sleep usually causes a full CPU reset and RAM is lost. See NV Storage for details.
Platform-Specific Power Optimizations
nRF52840
The nRF52840 sensor example applies several hardware-level optimizations beyond the basic sleep/wake cycle:
DC-DC converter — The nRF52840 has internal LDO regulators that can be
replaced by an on-chip DC-DC converter for ~40% lower current draw. Both
reg0 (main 1.3 V supply) and reg1 (radio 1.8 V supply) are enabled:
#![allow(unused)]
fn main() {
config.dcdc = embassy_nrf::config::DcdcConfig {
reg0: true,
reg0_voltage: None, // keep UICR default
reg1: true,
};
}
TX power reduction — Default TX power is reduced from +8 dBm to 0 dBm, saving ~50% TX current while still providing adequate range for home use:
#![allow(unused)]
fn main() {
mac.set_tx_power(0); // 0 dBm — good range, saves ~50% TX current vs +8 dBm
}
Internal RC oscillator — The HFCLK source is set to the internal RC oscillator instead of the external crystal. The radio hardware automatically requests the XTAL when it needs high accuracy (during TX/RX), then releases it. This saves ~250 µA when the radio is idle:
#![allow(unused)]
fn main() {
config.hfclk_source = embassy_nrf::config::HfclkSource::Internal;
}
RAM bank power-down — Unused RAM banks are powered down during sleep, saving additional current. On the nRF52840-DK, ~190 KB of unused RAM can be powered off.
Polling and reporting — The sensor uses a two-phase polling scheme:
- Fast poll: 250 ms for 120 seconds after joining/activity (responsive)
- Slow poll: 30 seconds during steady state (low power)
- Report interval: 60 seconds
Radio sleep — Between polls, the radio is disabled via TASKS_DISABLE
register write and the state machine waits for DISABLED. This saves ~4-8 mA
of radio idle current. Before the next TX/RX, radio_wake() re-applies the
channel setting and re-enables the radio:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake();
}
TLSR8258
The TLSR8258 sensor implements a two-tier sleep architecture similar to the PHY6222, using the pure-Rust radio driver’s built-in power management.
Tier 1 — Light sleep (fast poll, ~1.5 mA): During fast polling (first 120 seconds after join/activity), the radio transceiver is disabled between polls and the CPU enters WFI. The radio driver disables RF, DMA channels, and RF IRQs to minimize current:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep(); // disable RF + DMA + IRQ (~5-8 mA saved)
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake(); // re-enable, re-apply channel
}
Tier 2 — CPU suspend (slow poll, ~3 µA): During slow polling (30-second intervals), the device enters tc32 CPU suspend mode. Unlike PHY6222’s system sleep (which reboots), TLSR8258 suspend mode retains all RAM and resumes execution in-place when the system timer fires:
#![allow(unused)]
fn main() {
// Radio is already sleeping from radio_sleep()
// Enter CPU suspend with timer wake
driver.cpu_suspend_ms(poll_ms);
// CPU resumes here — RAM intact, no reboot
driver.radio_wake();
}
The suspend mode uses direct register access:
- System timer wake compare register sets the wake time
- Wake source enable register selects timer wake
- Power-down control register enters suspend (
BIT(7))
Power registers used:
| Register | Address | Purpose |
|---|---|---|
REG_TMR_WKUP | 0x740 + 0x08 | Timer compare for wake |
REG_WAKEUP_EN | 0x6E | Wake source enable (timer, PAD, etc.) |
REG_PWDN_CTRL | 0x6F | Suspend/deep-sleep entry (BIT 7) |
Battery life estimate (TLSR8258, CR2032, 230 mAh):
| State | Current | Duty Cycle | Average |
|---|---|---|---|
| CPU suspend (radio off, RAM retained) | ~3 µA | ~99.8% | ~3.0 µA |
| Radio RX (poll) | ~8 mA | ~0.03% (10 ms / 30 s) | ~2.7 µA |
| Radio TX (report) | ~10 mA | ~0.005% (3 ms / 60 s) | ~0.5 µA |
| Fast poll phase (WFI, ~1.5 mA) | ~1.5 mA | ~1.5% (120 s / 2 hr) | ~22 µA |
| Total average (steady state) | ~6 µA | ||
| Estimated battery life (CR2032) | ~4+ years |
CPU suspend preserves all RAM, so no NV save/restore is needed between sleep cycles. This is a significant advantage over the PHY6222, which reboots from system sleep and requires full state restoration.
PHY6222
The PHY6222 sensor implements a two-tier sleep architecture that combines light sleep during fast polling with deep AON system sleep during slow polling.
Tier 1 — Light sleep (fast poll, ~1.5 mA):
During fast polling (first 120 seconds after join/activity), the radio is
turned off between polls and the CPU enters WFE via Embassy’s Timer::after():
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake();
}
Tier 2 — AON system sleep (slow poll, ~3 µA): During slow polling (30-second intervals), the device enters full system sleep:
#![allow(unused)]
fn main() {
// Turn off radio
device.mac_mut().radio_sleep();
// Save Zigbee state to flash NV
device.save_state(&mut nv);
// Prepare peripherals for minimum leakage
phy6222_hal::gpio::prepare_for_sleep(1 << pins::BTN);
// Flash to deep power-down (~1µA vs ~15µA standby)
phy6222_hal::flash::enter_deep_sleep();
// Configure SRAM retention and RTC wake
phy6222_hal::sleep::set_ram_retention(phy6222_hal::regs::RET_SRAM0);
phy6222_hal::sleep::config_rtc_wakeup(
phy6222_hal::sleep::ms_to_rtc_ticks(poll_ms as u32),
);
phy6222_hal::sleep::enter_system_sleep();
}
On wake, the firmware detects the system-sleep reset, restores flash from deep power-down, and does a fast restore of the Zigbee network state from NV.
Flash deep power-down — JEDEC commands 0xB9 (enter) and 0xAB (release)
reduce flash standby current from ~15 µA to ~1 µA:
#![allow(unused)]
fn main() {
phy6222_hal::flash::enter_deep_sleep(); // JEDEC 0xB9
phy6222_hal::flash::release_deep_sleep(); // JEDEC 0xAB on wake
}
GPIO leak prevention — Before system sleep, all unused GPIO pins are configured as inputs with pull-down resistors to prevent floating-pin leakage. Only essential pins (e.g., the button) retain their pull-up:
#![allow(unused)]
fn main() {
phy6222_hal::gpio::prepare_for_sleep(1 << pins::BTN);
}
Radio sleep/wake — The MAC driver provides radio_sleep() and
radio_wake() methods that power down the radio transceiver between polls,
saving ~5–8 mA:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep();
// ... sleep ...
device.mac_mut().radio_wake();
}
The phy6222-hal::sleep module provides the full AON domain API:
| Function | Purpose |
|---|---|
config_rtc_wakeup(ticks) | Set RTC compare channel 0 for timed wake |
set_ram_retention(banks) | Select SRAM banks to retain during sleep |
enter_system_sleep() | Enter AON system sleep (~3 µA, does not return) |
was_sleep_reset() | Check if current boot was a wake from system sleep |
clear_sleep_flag() | Clear the sleep-wake flag after detection |
ms_to_rtc_ticks(ms) | Convert milliseconds to 32 kHz RC ticks |
EFR32MG1 / EFR32MG21
Both EFR32 platforms use the CMU (Clock Management Unit) to gate the radio peripheral clock, providing radio sleep between polls.
Radio clock gating — The MAC driver’s radio_sleep() method disables the
radio peripheral clock via the CMU, stopping all radio activity and saving
the radio idle current (~5–8 mA). On wake, radio_wake() re-enables the
clock and re-applies the channel setting:
#![allow(unused)]
fn main() {
device.mac_mut().radio_sleep(); // CMU clock gate — radio off
Timer::after(Duration::from_millis(poll_ms)).await;
device.mac_mut().radio_wake(); // CMU clock enable, re-apply channel
}
Series 1 vs Series 2 CMU differences:
| Feature | EFR32MG1P (Series 1) | EFR32MG21 (Series 2) |
|---|---|---|
| CMU base | 0x400E4000 | 0x40008000 |
| Clock enable register | HFPERCLKEN0 | CLKEN0 |
| Radio blocks gated | RAC, FRC, MODEM, SYNTH, AGC, BUFC | RAC, FRC, MODEM, SYNTH, AGC, BUFC |
Both platforms implement the same radio_sleep() / radio_wake() interface
despite the different register layouts — the CMU abstraction is handled inside
each platform’s MAC driver (efr32/ for Series 1, efr32s2/ for Series 2).
Note: Full deep sleep (EM2/EM3/EM4 energy modes) is not yet implemented. Currently only radio clock gating is used for power reduction between polls.
Reportable Change Thresholds
Both the nRF52840 and PHY6222 sensor examples configure reportable change thresholds in the ZCL Reporting Configuration to suppress unnecessary transmissions. A report is sent only when the attribute value changes by more than the threshold or the maximum reporting interval expires:
| Attribute | Min Interval | Max Interval | Reportable Change |
|---|---|---|---|
| Temperature (0x0402) | 60 s | 300 s | ±0.5 °C (50 centidegrees) |
| Humidity (0x0405) | 60 s | 300 s | ±1% (100 centi-%) |
| Battery (0x0001) | 300 s | 3600 s | ±2% (4 in 0.5% units) |
This means a device that sits at constant temperature will only report every 5 minutes (max interval), and tiny fluctuations (e.g., ±0.1 °C) are suppressed entirely. This can reduce TX events by 80–90% in stable environments.
Power Budget Estimates
nRF52840 (CR2032, 230 mAh)
| State | Current | Duty Cycle | Average |
|---|---|---|---|
| System ON idle (DC-DC, internal RC, RAM power-down) | ~3 µA | ~99.8% | ~3.0 µA |
| Radio RX (poll, 0 dBm) | ~5 mA | ~0.03% (10 ms / 30 s) | ~1.7 µA |
| Radio TX (report, 0 dBm) | ~5 mA | ~0.005% (3 ms / 60 s) | ~0.25 µA |
| Sensor read | ~1 mA | ~0.003% | ~0.03 µA |
| Total average | ~5 µA | ||
| Estimated battery life (CR2032) | ~5+ years |
With reportable change thresholds suppressing most TX events, practical battery life approaches the self-discharge limit of the CR2032.
PHY6222 (2×AAA, ~1200 mAh)
| State | Current | Duty Cycle | Average |
|---|---|---|---|
| AON system sleep (radio off, flash off, GPIO prepared) | ~3 µA | ~99.8% | ~3.0 µA |
| Flash standby (deep power-down) | ~1 µA | — | included above |
| Radio RX (poll) | ~8 mA | ~0.03% (10 ms / 30 s) | ~2.7 µA |
| Radio TX (report) | ~10 mA | ~0.005% (3 ms / 60 s) | ~0.5 µA |
| Fast poll phase (WFE, ~1.5 mA) | ~1.5 mA | ~1.5% (120 s / 2 hr) | ~22 µA |
| Total average (steady state) | ~6–35 µA | ||
| Estimated battery life (2×AAA) | ~3+ years |
The fast-poll phase (first 120 seconds after joining or button press) draws ~1.5 mA but lasts only briefly. In steady state with 30-second slow polls and AON system sleep, the average drops below 10 µA.
Battery Optimization Tips
-
Minimize wake time. Process events as fast as possible, then sleep. A typical SED wake cycle should complete in under 10 ms.
-
Batch sensor reads with polls. Read the sensor just before sending a report, so you don’t need a separate wake cycle.
-
Use appropriate poll intervals. A door sensor that only reports on state change doesn’t need 250 ms polls — 30 seconds is fine.
-
Prefer DeepSleep for long idle periods. If the device only reports every 5 minutes, deep sleep (with NV persistence) uses orders of magnitude less power than light sleep.
-
Disable unused peripherals. Turn off ADC, I²C, and SPI buses before sleeping — stray current through pull-ups adds up.
-
Use reporting intervals instead of polling. Configure the server-side minimum/maximum reporting intervals in the ZCL Reporting Configuration so the device only wakes when it has something new to say.
-
Keep the network key frame counter in NV. Frame counters must survive reboots. If a device resets its counter to zero, the network will reject its frames as replays.
-
Enable DC-DC converters (nRF52840). Switching from the internal LDO to the DC-DC converter saves ~40% idle current.
-
Reduce TX power. For home automation, 0 dBm provides plenty of range while halving TX current compared to +8 dBm.
-
Use reportable change thresholds. Adding a minimum change threshold (e.g., ±0.5 °C for temperature) eliminates unnecessary transmissions caused by sensor noise or small fluctuations.
-
Power down flash (PHY6222). Put external or on-chip flash into deep power-down mode before system sleep — saves ~14 µA.
-
Prepare GPIOs for sleep (PHY6222). Set unused pins to input with pull-down to prevent floating-pin leakage current.
NV Storage
Zigbee devices must survive power cycles and reboots without losing their
network membership, keys, or application state. The zigbee-runtime crate
defines a NvStorage trait that platform backends implement using their
specific flash, EEPROM, or NVS hardware.
The NvStorage Trait
The NvStorage trait lives in zigbee_runtime::nv_storage and provides six
methods:
#![allow(unused)]
fn main() {
pub trait NvStorage {
/// Read an item from NV storage.
/// Returns the number of bytes read into `buf`.
fn read(&self, id: NvItemId, buf: &mut [u8]) -> Result<usize, NvError>;
/// Write an item to NV storage.
fn write(&mut self, id: NvItemId, data: &[u8]) -> Result<(), NvError>;
/// Delete an item from NV storage.
fn delete(&mut self, id: NvItemId) -> Result<(), NvError>;
/// Check if an item exists.
fn exists(&self, id: NvItemId) -> bool;
/// Get the length of a stored item.
fn item_length(&self, id: NvItemId) -> Result<usize, NvError>;
/// Compact/defragment the storage (if applicable).
fn compact(&mut self) -> Result<(), NvError>;
}
}
All methods are synchronous — flash writes on embedded targets are typically
blocking and complete in microseconds to low milliseconds. The trait does not
require alloc; buffers are caller-provided.
NvError
#![allow(unused)]
fn main() {
pub enum NvError {
NotFound, // Item does not exist
Full, // Storage is full — call compact() or free items
BufferTooSmall, // Caller buffer too small for the stored item
HardwareError, // Flash/EEPROM write or erase failed
Corrupt, // CRC or consistency check failed
}
}
NvItemId — What Gets Persisted
Every stored item is identified by an NvItemId, a #[repr(u16)] enum.
Items are organized into logical groups:
#![allow(unused)]
fn main() {
#[repr(u16)]
pub enum NvItemId {
// ── Network parameters (0x0001 – 0x000B) ──
NwkPanId = 0x0001,
NwkChannel = 0x0002,
NwkShortAddress = 0x0003,
NwkExtendedPanId = 0x0004,
NwkIeeeAddress = 0x0005,
NwkKey = 0x0006,
NwkKeySeqNum = 0x0007,
NwkFrameCounter = 0x0008,
NwkDepth = 0x0009,
NwkParentAddress = 0x000A,
NwkUpdateId = 0x000B,
// ── APS parameters (0x0020 – 0x0023) ──
ApsTrustCenterAddress = 0x0020,
ApsLinkKey = 0x0021,
ApsBindingTable = 0x0022,
ApsGroupTable = 0x0023,
// ── BDB commissioning (0x0040 – 0x0044) ──
BdbNodeIsOnNetwork = 0x0040,
BdbCommissioningMode = 0x0041,
BdbPrimaryChannelSet = 0x0042,
BdbSecondaryChannelSet = 0x0043,
BdbCommissioningGroupId = 0x0044,
// ── Application data (0x0100+) ──
AppEndpoint1 = 0x0100,
AppEndpoint2 = 0x0101,
AppEndpoint3 = 0x0102,
AppCustomBase = 0x0200, // user-defined items start here
}
}
What Each Group Contains
| Group | Items | Why It Matters |
|---|---|---|
| Network | PAN ID, channel, addresses, network key, frame counter | Without these the device would have to rejoin the network from scratch. |
| APS | TC address, link keys, binding table, group table | Link keys enable encrypted communication; bindings control where reports go. |
| BDB | On-network flag, channel sets, commissioning state | Lets the stack know whether to commission or resume on next boot. |
| Application | Endpoint-specific attribute data | Preserves user-visible state (e.g., thermostat setpoint, light on/off). |
Frame counter persistence is critical. If
NwkFrameCounteris lost on reboot, the device will transmit frames with a counter of zero. Other devices will treat these as replay attacks and silently drop them.
RamNvStorage — In-Memory Storage for Testing
For host-based tests and simulations, RamNvStorage implements NvStorage
using heapless collections — no flash hardware needed:
#![allow(unused)]
fn main() {
pub struct RamNvStorage {
items: heapless::Vec<NvItem, 64>, // up to 64 items
}
struct NvItem {
id: NvItemId,
data: heapless::Vec<u8, 128>, // up to 128 bytes per item
}
}
Usage:
#![allow(unused)]
fn main() {
use zigbee_runtime::nv_storage::{RamNvStorage, NvStorage, NvItemId};
let mut nv = RamNvStorage::new();
// Write the network channel
nv.write(NvItemId::NwkChannel, &[15]).unwrap();
// Read it back
let mut buf = [0u8; 4];
let len = nv.read(NvItemId::NwkChannel, &mut buf).unwrap();
assert_eq!(&buf[..len], &[15]);
// Check existence
assert!(nv.exists(NvItemId::NwkChannel));
assert!(!nv.exists(NvItemId::NwkKey));
}
RamNvStorage is volatile — all data is lost when the process exits. Its
compact() method is a no-op since RAM doesn’t suffer from flash wear.
Implementing Flash-Backed NV Storage
To run on real hardware you need a NvStorage implementation that writes to
non-volatile memory. Here is the pattern for a typical flash-backed store:
#![allow(unused)]
fn main() {
use zigbee_runtime::nv_storage::{NvStorage, NvItemId, NvError};
pub struct FlashNvStorage {
// Platform-specific flash handle
flash: MyFlashDriver,
// Base address of the NV partition
base_addr: u32,
// Simple item index kept in RAM for fast lookup
index: heapless::Vec<FlashItem, 64>,
}
struct FlashItem {
id: NvItemId,
offset: u32, // byte offset from base_addr
length: u16,
}
impl NvStorage for FlashNvStorage {
fn read(&self, id: NvItemId, buf: &mut [u8]) -> Result<usize, NvError> {
let item = self.index.iter()
.find(|i| i.id == id)
.ok_or(NvError::NotFound)?;
if buf.len() < item.length as usize {
return Err(NvError::BufferTooSmall);
}
self.flash.read(self.base_addr + item.offset, &mut buf[..item.length as usize])
.map_err(|_| NvError::HardwareError)?;
Ok(item.length as usize)
}
fn write(&mut self, id: NvItemId, data: &[u8]) -> Result<(), NvError> {
// Append-only: write to next free sector, update index
// On compact(), defragment and reclaim deleted entries
todo!("platform-specific flash write")
}
fn delete(&mut self, id: NvItemId) -> Result<(), NvError> {
// Mark the item as deleted in flash; reclaim space on compact()
todo!("platform-specific flash delete")
}
fn exists(&self, id: NvItemId) -> bool {
self.index.iter().any(|i| i.id == id)
}
fn item_length(&self, id: NvItemId) -> Result<usize, NvError> {
self.index.iter()
.find(|i| i.id == id)
.map(|i| i.length as usize)
.ok_or(NvError::NotFound)
}
fn compact(&mut self) -> Result<(), NvError> {
// Erase + rewrite: copy live items to a scratch sector,
// erase the primary sector, copy back.
todo!("wear-leveled compaction")
}
}
}
Platform Hints
The source code documents these target backends:
| Platform | Recommended Backend |
|---|---|
| nRF52840 | Flash-backed FlashNvStorage using NVMC (last 2 pages = 8 KB) — implemented in nrf52840-sensor |
| ESP32-C6 | EspFlashDriver via esp-storage LL API (last 2 sectors at 0x3FE000) — implemented in esp32c6-sensor |
| ESP32-H2 | Not yet implemented — network state is lost on reboot |
| STM32WB | Internal flash with wear leveling |
| Generic | Bridge via the embedded-storage traits |
Both the nRF52840 and ESP32-C6 implementations use LogStructuredNv<T> — a
log-structured NV format from zigbee-runtime that wraps a platform-specific
flash driver. It appends writes sequentially and only erases sectors during
compaction, minimizing flash wear.
ESP32-C6 NV Details
The ESP32-C6 example (esp32c6-sensor/src/flash_nv.rs) stores network state
in the last two 4 KB sectors of the 4 MB external SPI flash:
| Sector | Address | Purpose |
|---|---|---|
| Page A | 0x3FE000 – 0x3FEFFF | Primary NV page |
| Page B | 0x3FF000 – 0x3FFFFF | Secondary NV page (for compaction) |
The implementation uses esp_storage::ll functions for raw SPI flash access
(spiflash_read, spiflash_write, spiflash_erase_sector, spiflash_unlock).
#![allow(unused)]
fn main() {
// From esp32c6-sensor/src/flash_nv.rs
pub fn create_nv() -> LogStructuredNv<EspFlashDriver> {
LogStructuredNv::new(EspFlashDriver::new(), NV_PAGE_A, NV_PAGE_B)
}
}
nRF52840 NV Details
The nRF52840 example (nrf52840-sensor/src/flash_nv.rs) uses the NVMC
(Non-Volatile Memory Controller) to write to the last 2 pages (8 KB) of the
1 MB internal flash. The FlashNvStorage struct implements NvStorage and
is wrapped in LogStructuredNv for the same log-structured format.
What State Is Saved and Restored on Reboot
When the stack starts, it checks BdbNodeIsOnNetwork. If the flag is set, it
restores the following items from NV instead of starting fresh commissioning:
- Network identity —
NwkPanId,NwkChannel,NwkShortAddress,NwkExtendedPanId - Security material —
NwkKey,NwkKeySeqNum,NwkFrameCounter,ApsLinkKey,ApsTrustCenterAddress - Topology —
NwkParentAddress,NwkDepth,NwkUpdateId - Bindings and groups —
ApsBindingTable,ApsGroupTable - Application attributes —
AppEndpoint1–AppEndpoint3and anyAppCustomBase + Nitems the application registered
If any critical item is missing or corrupt (NvError::Corrupt), the stack
falls back to a fresh commissioning cycle — the device will rejoin the network
as if it were new.
Saving Before Deep Sleep
Before entering deep sleep (which typically resets the CPU), the runtime persists all dirty state:
#![allow(unused)]
fn main() {
// Pseudocode from the event loop
if let SleepDecision::DeepSleep(_) = decision {
nv.write(NvItemId::NwkFrameCounter, &fc.to_le_bytes())?;
nv.write(NvItemId::NwkShortAddress, &addr.0.to_le_bytes())?;
// ... save any changed application attributes ...
}
}
Tip: Only write items that have actually changed since the last save. Flash has limited write endurance (typically 10,000–100,000 cycles per sector), so unnecessary writes shorten the device’s lifetime.
Security
Zigbee uses a layered security model to protect data in transit. zigbee-rs implements two of the three layers — NWK-level encryption (shared network key) and APS-level encryption (per-device link keys). MAC-level security is not used for normal Zigbee 3.0 data frames.
Security Model Overview
┌────────────────────────────────────────────┐
│ APS Layer │ Optional end-to-end encryption
│ APS link keys (per device pair) │ between two specific devices.
├────────────────────────────────────────────┤
│ NWK Layer │ Mandatory hop-by-hop encryption
│ Network key (shared by all devices) │ for ALL routed frames.
├────────────────────────────────────────────┤
│ MAC Layer │ NOT used in Zigbee 3.0 for
│ (unused for normal Zigbee data frames) │ data frames — only for beacons.
└────────────────────────────────────────────┘
NWK security is always on. Every frame routed through the mesh is encrypted with the shared network key and authenticated with a 4-byte MIC (Message Integrity Code).
APS security is optional and provides end-to-end confidentiality between two specific devices. It’s used for sensitive operations like network key transport and can also be used for application-level data.
NWK Security
The NWK security implementation lives in zigbee_nwk::security.
Network Key
All devices on a Zigbee network share the same 128-bit AES network key. The coordinator generates it during network formation; joining devices receive it (encrypted) from the Trust Center.
#![allow(unused)]
fn main() {
pub type AesKey = [u8; 16];
pub struct NetworkKeyEntry {
pub key: AesKey,
pub seq_number: u8, // 0–255, rotated on key update
pub active: bool,
}
}
The stack stores up to MAX_NETWORK_KEYS (2) entries — the current active key
and the previous key (kept temporarily during key rotation so in-flight frames
encrypted with the old key can still be decrypted).
#![allow(unused)]
fn main() {
// Set a new network key (moves current key to "previous" slot)
nwk_security.set_network_key(new_key, seq_number);
// Retrieve the active key
let key = nwk_security.active_key().unwrap();
// Look up a key by its sequence number (for decrypting incoming frames)
let key = nwk_security.key_by_seq(frame_key_seq);
}
AES-128-CCM* Encryption
Zigbee uses Security Level 5: ENC-MIC-32 — the payload is encrypted and
authenticated with a 4-byte MIC. The implementation uses the RustCrypto aes
and ccm crates (pure Rust, #![no_std], no allocator):
#![allow(unused)]
fn main() {
type ZigbeeCcm = Ccm<Aes128, U4, U13>; // M=4 byte MIC, L=2, nonce=13
}
The CCM* nonce is built from the security auxiliary header:
Nonce (13 bytes) = source_address (8) || frame_counter (4) || security_control (1)
Spec quirk: The security level in the over-the-air security control byte is always
0(per Zigbee spec §4.3.1.2). The actual level (5= ENC-MIC-32) is substituted when building the nonce for encryption/decryption.
NWK Security Header
Every secured NWK frame carries an auxiliary security header:
#![allow(unused)]
fn main() {
pub struct NwkSecurityHeader {
pub security_control: u8, // always 0x2D for Zigbee PRO
pub frame_counter: u32, // replay protection
pub source_address: IeeeAddress, // 64-bit IEEE address of sender
pub key_seq_number: u8, // which network key was used
}
}
The constant NwkSecurityHeader::ZIGBEE_DEFAULT (0x2D) encodes:
- Security Level = 5 (ENC-MIC-32)
- Key Identifier = 1 (Network Key)
- Extended Nonce = 1 (source address present)
Replay Protection
Each device maintains a per-source frame counter table. Incoming frames are accepted only if their counter is strictly greater than the last seen value for that source:
#![allow(unused)]
fn main() {
// Step 1: check (before decryption, so we don't waste CPU)
if !nwk_security.check_frame_counter(&source_ieee, frame_counter) {
// Replay attack — drop the frame
return;
}
// Step 2: decrypt and verify MIC
let plaintext = nwk_security.decrypt(nwk_hdr, ciphertext, key, &sec_hdr)?;
// Step 3: commit the counter ONLY after successful verification
nwk_security.commit_frame_counter(&source_ieee, frame_counter);
}
The two-phase check-then-commit pattern prevents an attacker from advancing the counter table with forged frames that fail MIC verification.
APS Security
The APS security implementation lives in zigbee_aps::security.
Key Types
#![allow(unused)]
fn main() {
pub enum ApsKeyType {
TrustCenterMasterKey = 0x00, // pre-installed master key
TrustCenterLinkKey = 0x01, // TC ↔ device link key
NetworkKey = 0x02, // the shared network key
ApplicationLinkKey = 0x03, // app-level key between two devices
DistributedGlobalLinkKey = 0x04, // for distributed TC networks
}
}
The Default Trust Center Link Key
Every Zigbee 3.0 device ships with a well-known Trust Center link key pre-installed:
#![allow(unused)]
fn main() {
/// "ZigBeeAlliance09" in ASCII
pub const DEFAULT_TC_LINK_KEY: [u8; 16] = [
0x5A, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6C, // ZigBeeAl
0x6C, 0x69, 0x61, 0x6E, 0x63, 0x65, 0x30, 0x39, // liance09
];
}
During joining, the Trust Center encrypts the network key with this link key before sending it to the new device. Because the key is well-known, anyone within radio range can capture the network key during the join window. For production deployments, install codes provide per-device unique keys.
APS Security Header
#![allow(unused)]
fn main() {
pub struct ApsSecurityHeader {
pub security_control: u8,
pub frame_counter: u32,
pub source_address: Option<IeeeAddress>, // if extended nonce bit set
pub key_seq_number: Option<u8>, // if Key ID = Network Key
}
}
Security level constants:
| Constant | Value | Meaning |
|---|---|---|
SEC_LEVEL_NONE | 0x00 | No security |
SEC_LEVEL_MIC_32 | 0x01 | Auth only, 4-byte MIC |
SEC_LEVEL_ENC_MIC_32 | 0x05 | Encrypt + 4-byte MIC (default) |
SEC_LEVEL_ENC_MIC_64 | 0x06 | Encrypt + 8-byte MIC |
SEC_LEVEL_ENC_MIC_128 | 0x07 | Encrypt + 16-byte MIC |
Key identifier constants:
| Constant | Value | When Used |
|---|---|---|
KEY_ID_DATA_KEY | 0x00 | Link key (TC or application) |
KEY_ID_NETWORK_KEY | 0x01 | Network key |
KEY_ID_KEY_TRANSPORT | 0x02 | Key-transport key |
KEY_ID_KEY_LOAD | 0x03 | Key-load key |
Link Key Table
The ApsSecurity context manages a table of per-device link keys:
#![allow(unused)]
fn main() {
pub struct ApsSecurity {
key_table: heapless::Vec<ApsLinkKeyEntry, 16>, // MAX_KEY_TABLE_ENTRIES = 16
default_tc_link_key: AesKey,
}
pub struct ApsLinkKeyEntry {
pub partner_address: IeeeAddress,
pub key: AesKey,
pub key_type: ApsKeyType,
pub outgoing_frame_counter: u32,
pub incoming_frame_counter: u32,
}
}
Key management methods:
#![allow(unused)]
fn main() {
let mut aps_sec = ApsSecurity::new();
// The default TC link key is pre-loaded
assert_eq!(aps_sec.default_tc_link_key(), &DEFAULT_TC_LINK_KEY);
// Add an application link key for a specific partner
aps_sec.add_key(ApsLinkKeyEntry {
partner_address: partner_ieee,
key: my_app_key,
key_type: ApsKeyType::ApplicationLinkKey,
outgoing_frame_counter: 0,
incoming_frame_counter: 0,
})?;
// Look up a key
let entry = aps_sec.find_key(&partner_ieee, ApsKeyType::ApplicationLinkKey);
// Remove a key
aps_sec.remove_key(&partner_ieee, ApsKeyType::ApplicationLinkKey);
}
Network Key Distribution
When a new device joins the network, the Trust Center distributes the network key through this sequence:
- Device sends Association Request (MAC layer, unencrypted).
- Parent router forwards the request to the Trust Center.
- Trust Center encrypts the network key with the joining device’s TC link key (either the well-known default or an install-code-derived key).
- APS Transport-Key command carries the encrypted network key to the device via its parent router.
- Device decrypts the network key and stores it in NV.
- Device sends APS Update-Device to confirm it’s now secured.
After this exchange, the device can encrypt and decrypt NWK frames like all other nodes on the network.
Install Codes
Install codes provide a per-device unique link key, eliminating the security weakness of the well-known default key. An install code is:
- A 6, 8, 12, or 16-byte random value printed on the device label
- Combined with a 2-byte CRC-16
- Hashed using Matyas–Meyer–Oseas (MMO) to derive a unique 128-bit link key
- Pre-provisioned on the Trust Center before the device joins
In zigbee-rs, install code support is declared in the TrustCenter struct:
#![allow(unused)]
fn main() {
pub struct CoordinatorConfig {
// ...
pub require_install_codes: bool,
// ...
}
}
When require_install_codes is true, the Trust Center only accepts joins
from devices whose IEEE address has a pre-provisioned install-code-derived
key in the link key table.
Note: The current implementation includes a structural placeholder for install code derivation. The actual MMO hash computation is not yet implemented — only pre-provisioned keys are supported.
Key Rotation
The Trust Center can rotate the network key to limit the exposure window if a key is compromised:
#![allow(unused)]
fn main() {
// On the Trust Center
trust_center.set_network_key(new_key); // increments key_seq_number
// The NWK security context keeps both keys during transition
nwk_security.set_network_key(new_key, new_seq);
// keys[0] = new key (active), keys[1] = old key (for in-flight frames)
}
During rotation, the TC broadcasts a NWK Key-Switch command. Until all
devices have switched, the network accepts frames encrypted with either the
old or new key (matched by key_seq_number).
Summary
| Layer | Key | Scope | MIC Size | Required? |
|---|---|---|---|---|
| NWK | Network key | All devices | 4 bytes | Yes (always on) |
| APS | Link key (TC or app) | Two specific devices | 4 bytes | Optional |
| MAC | — | — | — | Not used in Zigbee 3.0 |
OTA Updates
The Over-the-Air (OTA) Upgrade cluster (cluster ID 0x0019) lets you update
device firmware over the Zigbee network. zigbee-rs implements the OTA client
state machine and provides a FirmwareWriter trait that platform backends
implement to write the downloaded image to flash.
OTA Upgrade Cluster Overview
The OTA cluster is defined in zigbee_zcl::clusters::ota. It defines
attributes that track upgrade state and commands that drive the download
protocol.
Attributes
#![allow(unused)]
fn main() {
pub const ATTR_UPGRADE_SERVER_ID: AttributeId = AttributeId(0x0000);
pub const ATTR_FILE_OFFSET: AttributeId = AttributeId(0x0001);
pub const ATTR_CURRENT_FILE_VERSION: AttributeId = AttributeId(0x0002);
pub const ATTR_CURRENT_STACK_VERSION: AttributeId = AttributeId(0x0003);
pub const ATTR_DOWNLOADED_FILE_VERSION: AttributeId = AttributeId(0x0004);
pub const ATTR_DOWNLOADED_STACK_VERSION: AttributeId = AttributeId(0x0005);
pub const ATTR_IMAGE_UPGRADE_STATUS: AttributeId = AttributeId(0x0006);
pub const ATTR_MANUFACTURER_ID: AttributeId = AttributeId(0x0007);
pub const ATTR_IMAGE_TYPE_ID: AttributeId = AttributeId(0x0008);
pub const ATTR_MIN_BLOCK_PERIOD: AttributeId = AttributeId(0x0009);
}
Commands
| Direction | Command | ID | Purpose |
|---|---|---|---|
| Client → Server | QueryNextImageRequest | 0x01 | Ask if a new image is available |
| Server → Client | QueryNextImageResponse | 0x02 | Respond with image info or “no update” |
| Client → Server | ImageBlockRequest | 0x03 | Request a data block at a given offset |
| Server → Client | ImageBlockResponse | 0x05 | Deliver a block (or tell client to wait) |
| Server → Client | ImageNotify | 0x00 | Proactively tell client an update exists |
| Client → Server | UpgradeEndRequest | 0x06 | Report download success or failure |
| Server → Client | UpgradeEndResponse | 0x07 | Tell client when to activate |
Image Upgrade Status Values
#![allow(unused)]
fn main() {
pub const STATUS_NORMAL: u8 = 0x00; // idle
pub const STATUS_DOWNLOAD_IN_PROGRESS: u8 = 0x01;
pub const STATUS_DOWNLOAD_COMPLETE: u8 = 0x02;
pub const STATUS_WAITING_TO_UPGRADE: u8 = 0x03;
pub const STATUS_COUNT_DOWN: u8 = 0x04;
pub const STATUS_WAIT_FOR_MORE: u8 = 0x05;
}
OTA Image Format
OTA images use a standard header defined in zigbee_zcl::clusters::ota_image.
The file starts with a fixed header, followed by optional fields, followed by
one or more sub-elements (the actual firmware binary, signatures, etc.).
Header Structure
#![allow(unused)]
fn main() {
pub struct OtaImageHeader {
pub magic: u32, // must be 0x0BEEF11E
pub header_version: u16, // 0x0100 for ZCL 7+
pub header_length: u16, // total header size in bytes
pub field_control: OtaHeaderFieldControl,
pub manufacturer_code: u16,
pub image_type: u16, // manufacturer-specific
pub file_version: u32, // new firmware version
pub stack_version: u16,
pub header_string: [u8; 32], // human-readable description
pub total_image_size: u32, // header + payload
// Optional fields (controlled by field_control bits)
pub security_credential_version: Option<u8>,
pub min_hardware_version: Option<u16>,
pub max_hardware_version: Option<u16>,
}
}
The minimum header size is 56 bytes (OTA_HEADER_MIN_SIZE). The magic
number 0x0BEEF11E is checked during parsing to reject corrupt or non-OTA
files.
Field Control Bits
#![allow(unused)]
fn main() {
pub struct OtaHeaderFieldControl {
pub security_credential: bool, // bit 0: credential version present
pub device_specific: bool, // bit 1: device-specific file
pub hardware_versions: bool, // bit 2: HW version range present
}
}
Sub-Elements
After the header, the image contains sub-elements, each with a 6-byte header (2-byte tag + 4-byte length):
#![allow(unused)]
fn main() {
pub struct OtaSubElement {
pub tag: OtaTagId,
pub length: u32,
}
pub enum OtaTagId {
UpgradeImage = 0x0000, // the actual firmware binary
EcdsaCert = 0x0001, // signing certificate
EcdsaSignature = 0x0002, // ECDSA signature
ImageIntegrity = 0x0003, // hash for integrity check
PictureData = 0x0004, // optional picture data
}
}
The UpgradeImage sub-element contains the raw firmware binary that gets
written to the device’s update flash slot.
Upgrade Flow
The OTA client state machine (OtaState) drives the entire process:
┌───────┐
│ Idle │
└───┬───┘
│ QueryNextImageRequest
▼
┌───────────┐
│ QuerySent │
└─────┬─────┘
server has │ no update
new image │ available
┌──────────┴──────────┐
▼ ▼
┌──────────────┐ (back to Idle)
│ Downloading │
│ offset=0 │◄─────────────┐
│ total=N │ │
└──────┬───────┘ ┌─────────────────┐
│ │ WaitForData │
│ block resp │ (server busy) │
├───────────►│ delay N secs │
│ └─────────────────┘
│ all blocks
▼
┌───────────┐
│ Verifying │ ── verify hash/size
└─────┬─────┘
│
▼
┌────────────────────┐
│ WaitingActivate │ ── UpgradeEndRequest sent
└────────┬───────────┘
│ UpgradeEndResponse (activate now)
▼
┌──────────┐
│ Done │ ── reboot and run new firmware
└──────────┘
OtaState Enum
#![allow(unused)]
fn main() {
pub enum OtaState {
Idle,
QuerySent,
Downloading { offset: u32, total_size: u32 },
Verifying,
WaitingActivate,
WaitForData {
delay_secs: u32,
elapsed: u32,
download_offset: u32,
download_total: u32,
},
Done,
Failed,
}
}
OtaAction — What the Runtime Should Do Next
After processing each OTA command, the engine returns an OtaAction:
#![allow(unused)]
fn main() {
pub enum OtaAction {
SendQuery(QueryNextImageRequest),
SendBlockRequest(ImageBlockRequest),
WriteBlock { offset: u32, data: heapless::Vec<u8, 64> },
SendEndRequest(UpgradeEndRequest),
ActivateImage,
Wait(u32),
None,
}
}
The runtime event loop dispatches these actions to the MAC layer (for sending
ZCL commands) or to the FirmwareWriter (for writing blocks to flash).
Block Size
The default block size is 48 bytes (DEFAULT_BLOCK_SIZE), chosen to fit
within a single MAC frame without requiring APS fragmentation. On networks with
reliable links, this can be tuned up to ~64 bytes.
Rate Limiting (WaitForData)
If the OTA server is busy or needs to throttle downloads, it responds with a
WaitForData status instead of image data. The client pauses for the specified
number of seconds, then resumes from the saved offset.
FirmwareWriter Trait
The FirmwareWriter trait (zigbee_runtime::firmware_writer) abstracts the
platform-specific flash operations needed to store a downloaded firmware image:
#![allow(unused)]
fn main() {
pub trait FirmwareWriter {
/// Erase the firmware update slot, preparing it for writes.
fn erase_slot(&mut self) -> Result<(), FirmwareError>;
/// Write a block of data at the given offset within the update slot.
fn write_block(&mut self, offset: u32, data: &[u8]) -> Result<(), FirmwareError>;
/// Verify the written image (size check + optional hash).
fn verify(
&mut self,
expected_size: u32,
expected_hash: Option<&[u8]>,
) -> Result<(), FirmwareError>;
/// Mark the new image as pending activation (bootloader swap on reboot).
fn activate(&mut self) -> Result<(), FirmwareError>;
/// Return the maximum image size this slot can hold.
fn slot_size(&self) -> u32;
/// Abort an in-progress update and revert.
fn abort(&mut self) -> Result<(), FirmwareError>;
}
}
FirmwareError
#![allow(unused)]
fn main() {
pub enum FirmwareError {
EraseFailed,
WriteFailed,
VerifyFailed, // hash mismatch or size mismatch
OutOfRange, // offset beyond slot boundary
ImageTooLarge, // image exceeds slot_size()
ActivateFailed, // boot flag not set
HardwareError,
}
}
Platform Implementations
| Platform | Slot Location | Notes |
|---|---|---|
| nRF52840 | Secondary flash bank via NVMC | Dual-bank swap with nRF bootloader |
| ESP32 | OTA partition via esp-storage | ESP-IDF OTA partition table |
| BL702 | XIP flash via bl702-pac | Single-bank with staging area |
| Mock | RAM buffer (heapless::Vec<u8, 262144>) | For host testing — 256 KB max |
MockFirmwareWriter (for Testing)
#![allow(unused)]
fn main() {
use zigbee_runtime::firmware_writer::MockFirmwareWriter;
let mut writer = MockFirmwareWriter::new(128_000); // 128 KB slot
writer.erase_slot().unwrap();
writer.write_block(0, &firmware_chunk_0).unwrap();
writer.write_block(chunk_0_len, &firmware_chunk_1).unwrap();
writer.verify(total_size, None).unwrap();
writer.activate().unwrap();
assert!(writer.is_activated());
assert_eq!(writer.bytes_written(), total_size);
}
The mock writer enforces sequential writes (offset must equal the number of
bytes already written) and requires erase_slot() before any writes, just
like real flash hardware.
Integration with Bootloaders
OTA is a two-part process: the Zigbee stack downloads and writes the image, then the bootloader handles the swap and boot.
Typical Flow
FirmwareWriter::erase_slot()— erase the secondary/staging flash area.FirmwareWriter::write_block()— called once per OTA block (48 bytes each, potentially thousands of calls for a large image).FirmwareWriter::verify()— check the written size and optional hash.FirmwareWriter::activate()— set a boot flag or swap marker telling the bootloader to run the new image on next boot.- Reboot — the runtime triggers a system reset.
- Bootloader — detects the pending update flag, validates the new image (CRC, signature), and swaps it into the primary slot.
Bootloader Examples
| Platform | Bootloader | Swap Method |
|---|---|---|
| nRF52840 | MCUboot / nRF Bootloader | Dual-bank swap |
| ESP32 | ESP-IDF bootloader | OTA partition switch |
| BL702 | BL702 ROM bootloader | XIP remap |
Rollback: If the new firmware fails to start (e.g., crashes in a loop), most bootloaders support automatic rollback — they detect that the new image never confirmed itself and revert to the previous working image.
Coordinator & Router
The zigbee top-level crate provides role-specific modules for coordinators
and routers. A coordinator forms the network and acts as the Trust Center;
a router relays frames and manages child devices. Both are built on top of
the same layered stack.
zigbee/src/
├── coordinator.rs — CoordinatorConfig, Coordinator
├── router.rs — RouterConfig, Router, ChildDevice
├── trust_center.rs — TrustCenter, TcLinkKeyEntry
└── lib.rs — re-exports all sub-crates
Coordinator
CoordinatorConfig
#![allow(unused)]
fn main() {
pub struct CoordinatorConfig {
/// Channel mask for formation (ED scan).
pub channel_mask: ChannelMask,
/// Extended PAN ID (0 = auto-generate from IEEE address).
pub extended_pan_id: IeeeAddress,
/// Whether to use centralized security (Trust Center).
pub centralized_security: bool,
/// Whether to use install codes for joining.
pub require_install_codes: bool,
/// Maximum number of child devices.
pub max_children: u8,
/// Maximum network depth.
pub max_depth: u8,
/// Default permit-join duration after formation (seconds, 0 = closed).
pub initial_permit_join_duration: u8,
}
}
The defaults are sensible for development:
#![allow(unused)]
fn main() {
CoordinatorConfig::default()
// channel_mask: ChannelMask::ALL_2_4GHZ
// extended_pan_id: [0; 8] (auto-generate)
// centralized_security: true
// require_install_codes: false
// max_children: 20
// max_depth: 5
// initial_permit_join_duration: 0 (joining closed until explicitly opened)
}
Coordinator State
The Coordinator struct manages network-level state:
#![allow(unused)]
fn main() {
pub struct Coordinator {
config: CoordinatorConfig,
network_key: [u8; 16],
frame_counter: u32,
child_count: u8,
next_address_seed: u16,
formed: bool,
}
}
Key methods:
#![allow(unused)]
fn main() {
let mut coord = Coordinator::new(CoordinatorConfig::default());
// Generate a network key (should use hardware RNG in production)
coord.generate_network_key();
// Or set a specific key
coord.set_network_key([0xAB; 16]);
// Check if the network has been formed
assert!(!coord.is_formed());
coord.mark_formed();
assert!(coord.is_formed());
// Allocate a short address for a joining device
let addr = coord.allocate_address(); // ShortAddress(1), then 2, 3, ...
// Check capacity
assert!(coord.can_accept_child());
// Get next frame counter for secured frame transmission
let fc = coord.next_frame_counter();
}
Address Allocation
The coordinator uses stochastic address assignment — it assigns sequential
addresses starting from 1, wrapping around before the reserved range
(0xFFF8–0xFFFF). A production implementation should use random addresses
with collision detection.
Trust Center
The Trust Center (TC) is responsible for all security-related decisions on a centralized-security network. In zigbee-rs, it’s a separate struct that the coordinator owns.
TrustCenter State
#![allow(unused)]
fn main() {
pub struct TrustCenter {
network_key: [u8; 16],
key_seq_number: u8,
link_keys: [Option<TcLinkKeyEntry>; 32], // up to 32 joined devices
require_install_codes: bool,
frame_counter: u32,
}
pub struct TcLinkKeyEntry {
pub ieee_address: IeeeAddress,
pub key: [u8; 16],
pub key_type: TcKeyType,
pub incoming_frame_counter: u32,
pub verified: bool,
pub active: bool,
}
pub enum TcKeyType {
DefaultGlobal, // ZigBeeAlliance09
InstallCode, // derived from device install code
ApplicationDefined, // provisioned by the application
}
}
Well-Known Keys
#![allow(unused)]
fn main() {
/// "ZigBeeAlliance09" — the default TC link key every Zigbee 3.0 device knows.
pub const DEFAULT_TC_LINK_KEY: [u8; 16] = [
0x5A, 0x69, 0x67, 0x42, 0x65, 0x65, 0x41, 0x6C,
0x6C, 0x69, 0x61, 0x6E, 0x63, 0x65, 0x30, 0x39,
];
/// Distributed security global link key (for TC-less networks).
pub const DISTRIBUTED_SECURITY_KEY: [u8; 16] = [
0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7,
0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE, 0xDF,
];
}
Key Management
#![allow(unused)]
fn main() {
let mut tc = TrustCenter::new(network_key);
// Get the link key for a device (falls back to DEFAULT_TC_LINK_KEY)
let key = tc.link_key_for_device(&device_ieee);
// Pre-provision a unique key for a device
tc.set_link_key(device_ieee, unique_key, TcKeyType::ApplicationDefined)?;
// After APSME-VERIFY-KEY completes successfully
tc.mark_key_verified(&device_ieee);
// Remove a device (e.g., on leave)
tc.remove_link_key(&device_ieee);
// Rotate the network key (increments key_seq_number)
tc.set_network_key(new_key);
println!("New key seq: {}", tc.key_seq_number());
}
Join Authorization
The should_accept_join() method checks whether a device is allowed to join:
#![allow(unused)]
fn main() {
// When require_install_codes is false: accept everyone
assert!(tc.should_accept_join(&any_device));
// When require_install_codes is true: only accept pre-provisioned devices
tc.set_require_install_codes(true);
assert!(!tc.should_accept_join(&unknown_device));
}
Replay Protection
The TC tracks per-device incoming frame counters:
#![allow(unused)]
fn main() {
// Returns true if the counter is valid (strictly increasing)
let ok = tc.update_frame_counter(&device_ieee, frame_counter);
if !ok {
// Replay attack detected — drop the frame
}
}
Router
RouterConfig
#![allow(unused)]
fn main() {
pub struct RouterConfig {
/// Maximum number of child end devices.
pub max_children: u8,
/// Maximum number of child routers.
pub max_routers: u8,
/// Whether to accept join requests.
pub permit_joining: bool,
/// Link status period (in seconds).
pub link_status_period: u16,
}
}
Defaults:
#![allow(unused)]
fn main() {
RouterConfig::default()
// max_children: 20
// max_routers: 5
// permit_joining: false
// link_status_period: 15 seconds
}
Child Management
The router maintains a table of up to 32 child devices:
#![allow(unused)]
fn main() {
pub struct ChildDevice {
pub ieee_address: IeeeAddress,
pub short_address: ShortAddress,
pub is_ffd: bool, // full-function device (router-capable)
pub rx_on_when_idle: bool, // false for sleepy end devices
pub timeout: u16, // seconds before declaring child lost
pub age: u16, // seconds since last communication
pub active: bool,
}
}
Managing children:
#![allow(unused)]
fn main() {
let mut router = Router::new(RouterConfig::default());
// Add a child
router.add_child(ieee, short_addr, /*is_ffd=*/false, /*rx_on=*/false)?;
// Look up children
let child = router.find_child(short_addr);
let child = router.find_child_by_ieee(&ieee);
// Check if a destination is our child
if router.is_child(dest_addr) {
// Buffer the frame for the next poll (if sleepy)
}
// Record activity (resets age timer)
router.child_activity(short_addr);
// Age all children (call periodically, e.g., once per second)
router.age_children(1);
// Sleepy children that exceed their timeout are automatically removed.
// Remove a child explicitly
router.remove_child(short_addr);
}
Capacity Check
#![allow(unused)]
fn main() {
if router.can_accept_child() {
// Accept the association request
}
println!("Active children: {}", router.child_count());
}
Network Formation Flow
When a coordinator forms a new network, the following sequence occurs:
1. Energy Detection (ED) scan
└── Scan channels in channel_mask to find the quietest one.
2. Active scan
└── Check for existing networks to avoid PAN ID conflicts.
3. Select channel + PAN ID
└── Choose the channel with lowest noise and a unique PAN ID.
4. Generate network key
└── coord.generate_network_key() (should use hardware RNG)
5. Start as coordinator
└── coord.mark_formed()
└── Begin transmitting beacons.
6. (Optional) Open permit joining
└── initial_permit_join_duration > 0
Permit Joining
Joining is controlled at two levels:
- Router/Coordinator level —
RouterConfig::permit_joiningor the coordinator’sinitial_permit_join_duration. - Trust Center level —
TrustCenter::should_accept_join()decides whether the security credentials are acceptable.
Both must allow the join for a device to successfully associate.
The permit-join window is typically opened for a limited time (e.g., 180 seconds) when the user triggers a “pairing mode” action, then automatically closes.
Current Implementation Status
The coordinator, router, and trust center modules provide the data structures and core logic for network management. The following is the current state:
| Feature | Status |
|---|---|
| Coordinator config and state | ✅ Implemented |
| Network key generation | ✅ Placeholder (needs hardware RNG) |
| Address allocation (stochastic) | ✅ Sequential (needs random + conflict check) |
| Trust Center link key table | ✅ Implemented (32 entries) |
| Install code derivation (MMO hash) | ⚠️ Structural placeholder |
| Router child management | ✅ Implemented (32 children) |
| Child aging and timeout | ✅ Implemented |
| Route discovery (AODV) | ⚠️ Defined in zigbee-nwk routing module |
| Link status messages | ⚠️ Config present, sending not yet wired |
| Distributed security (TC-less) | ⚠️ Key defined, mode not fully supported |
Contributing: The coordinator and router modules are a good place to start contributing — they have clear APIs, well-defined behavior from the Zigbee spec, and several
TODOitems for production hardening.
API Quick Reference
One-page cheat sheet for the zigbee-rs public API, organized by crate.
zigbee-types — Core Addressing Types
| Type | Description |
|---|---|
IeeeAddress = [u8; 8] | 64-bit IEEE/EUI-64 address |
ShortAddress(u16) | 16-bit network address. Constants: BROADCAST (0xFFFF), UNASSIGNED (0xFFFE), COORDINATOR (0x0000) |
PanId(u16) | PAN identifier. Constant: BROADCAST (0xFFFF) |
MacAddress | Either Short(PanId, ShortAddress) or Extended(PanId, IeeeAddress) |
Channel | 2.4 GHz channels Ch11–Ch26. from_number(u8), number() → u8 |
ChannelMask(u32) | Bitmask of channels. Constants: ALL_2_4GHZ (0x07FFF800), PREFERRED. Methods: contains(Channel), iter() |
TxPower(i8) | Transmit power in dBm |
zigbee-mac — MAC Layer Driver
MacDriver Trait
| Method | Description |
|---|---|
mlme_scan(MlmeScanRequest) → Result<MlmeScanConfirm> | Active/energy/orphan scan |
mlme_associate(MlmeAssociateRequest) → Result<MlmeAssociateConfirm> | Associate with coordinator |
mlme_associate_response(MlmeAssociateResponse) → Result<()> | Respond to association request (ZC/ZR) |
mlme_disassociate(MlmeDisassociateRequest) → Result<()> | Leave the network |
mlme_reset(set_default_pib: bool) → Result<()> | Reset MAC sublayer |
mlme_start(MlmeStartRequest) → Result<()> | Start network as coordinator |
mlme_get(PibAttribute) → Result<PibValue> | Read a PIB attribute |
mlme_set(PibAttribute, PibValue) → Result<()> | Write a PIB attribute |
mlme_poll() → Result<Option<MacFrame>> | Poll parent for pending data (ZED) |
mcps_data(McpsDataRequest) → Result<McpsDataConfirm> | Transmit a MAC frame |
mcps_data_indication() → Result<McpsDataIndication> | Receive a MAC frame |
capabilities() → MacCapabilities | Query radio capabilities |
MacCapabilities
| Field | Type | Description |
|---|---|---|
coordinator | bool | Can act as PAN coordinator |
router | bool | Can route frames |
hardware_security | bool | Hardware AES-CCM* support |
max_payload | u16 | Max MAC payload bytes |
tx_power_min / tx_power_max | TxPower | TX power range |
MacError Variants
NoBeacon, InvalidParameter, RadioError, ChannelAccessFailure, NoAck, FrameTooLong, Unsupported, SecurityError, TransactionOverflow, TransactionExpired, ScanInProgress, TrackingOff, AssociationDenied, PanAtCapacity, Other, NoData
Platform Drivers
Feature-gated MAC implementations: esp (ESP32-C6/H2), nrf (nRF52840/52833), bl702, cc2340, telink, phy6222, mock (testing)
zigbee-nwk — Network Layer
NwkLayer<M: MacDriver>
| Method | Description |
|---|---|
new(mac, device_type) → Self | Create NWK layer |
set_rx_on_when_idle(bool) | Set RX-on-when-idle (router=true, sleepy ZED=false) |
rx_on_when_idle() → bool | Query RX-on-when-idle |
nib() → &Nib | Read Network Information Base |
nib_mut() → &mut Nib | Write Network Information Base |
is_joined() → bool | Whether device has joined a network |
device_type() → DeviceType | Coordinator / Router / EndDevice |
mac() → &M / mac_mut() → &mut M | Access underlying MAC driver |
security() → &NwkSecurity | Read network security state |
security_mut() → &mut NwkSecurity | Write network security state |
neighbor_table() → &NeighborTable | Read neighbor table |
routing_table() → &RoutingTable | Read routing table |
find_short_by_ieee(&IeeeAddress) → Option<ShortAddress> | Resolve IEEE → short address |
find_ieee_by_short(ShortAddress) → Option<IeeeAddress> | Resolve short → IEEE address |
update_neighbor_address(ShortAddress, IeeeAddress) | Update address mapping in neighbor table |
DeviceType
Coordinator, Router, EndDevice
zigbee-aps — Application Support Sub-layer
Constants
| Constant | Value | Description |
|---|---|---|
ZDO_ENDPOINT | 0x00 | ZDO endpoint |
MIN_APP_ENDPOINT | 0x01 | First application endpoint |
MAX_APP_ENDPOINT | 0xF0 | Last application endpoint |
BROADCAST_ENDPOINT | 0xFF | Broadcast to all endpoints |
PROFILE_HOME_AUTOMATION | 0x0104 | HA profile ID |
PROFILE_SMART_ENERGY | 0x0109 | SE profile ID |
PROFILE_ZLL | 0xC05E | ZLL profile ID |
ApsLayer<M: MacDriver>
| Method | Description |
|---|---|
new(nwk) → Self | Create APS layer wrapping NWK |
next_aps_counter() → u8 | Get next APS frame counter |
is_aps_duplicate(src_addr, counter) → bool | Detect duplicate APS frames |
age_dup_table() | Age out old duplicate entries |
register_ack_pending(counter, dst, frame) → Option<usize> | Track pending APS ACK |
confirm_ack(src, counter) → bool | Confirm ACK received |
take_ack_status(counter) → Option<bool> | Consume ACK status |
age_ack_table() → Vec<Vec<u8>> | Age ACKs, return retransmit candidates |
nwk() → &NwkLayer<M> / nwk_mut() | Access NWK layer |
aib() → &Aib / aib_mut() | Access APS Information Base |
binding_table() → &BindingTable / binding_table_mut() | Access binding table |
group_table() → &GroupTable / group_table_mut() | Access group table |
security() → &ApsSecurity / security_mut() | Access APS security |
fragment_rx() → &FragmentReassembly / fragment_rx_mut() | Access fragment reassembly |
BindingTable
| Method | Description |
|---|---|
new() → Self | Create empty binding table |
add(entry) → Result<(), BindingEntry> | Add a binding entry |
remove(src, ep, cluster, dst) → bool | Remove a binding |
find_by_source(src, ep, cluster) → Iterator<&BindingEntry> | Find bindings for a source |
find_by_cluster(cluster_id) → Iterator<&BindingEntry> | Find bindings for a cluster |
find_by_endpoint(ep) → Iterator<&BindingEntry> | Find bindings for an endpoint |
entries() → &[BindingEntry] | All entries |
len(), is_empty(), is_full(), clear() | Capacity management |
BindingEntry
| Constructor | Description |
|---|---|
unicast(src_addr, src_ep, cluster, dst_addr, dst_ep) → Self | Unicast binding |
group(src_addr, src_ep, cluster, group_addr) → Self | Group binding |
GroupTable
| Method | Description |
|---|---|
new() → Self | Create empty group table |
add_group(group_addr, endpoint) → bool | Add endpoint to group |
remove_group(group_addr, endpoint) → bool | Remove endpoint from group |
remove_all_groups(endpoint) | Remove endpoint from all groups |
find(group_addr) → Option<&GroupEntry> | Find a group entry |
is_member(group_addr, endpoint) → bool | Check group membership |
groups() → &[GroupEntry] | All groups |
zigbee-zdo — Zigbee Device Object
ZdoLayer<M: MacDriver>
Device & Service Discovery
| Method | Description |
|---|---|
simple_desc_req(dst, ep) → Result<SimpleDescriptor> | Query endpoint descriptor |
active_ep_req(dst) → Result<Vec<u8>> | Query active endpoints |
match_desc_req(dst, profile, in_clusters, out_clusters) → Result<Vec<u8>> | Find matching endpoints |
device_annce(nwk_addr, ieee_addr) → Result<()> | Announce device on network |
Binding Management
| Method | Description |
|---|---|
bind_req(dst, entry) → Result<()> | Create remote binding |
unbind_req(dst, entry) → Result<()> | Remove remote binding |
Network Management
| Method | Description |
|---|---|
mgmt_permit_joining_req(dst, duration, tc) → Result<()> | Open/close joining |
nlme_network_discovery(mask, duration) → Result<Vec<NetworkDescriptor>> | Scan for networks |
nlme_join(network) → Result<ShortAddress> | Join a network |
nlme_rejoin(network) → Result<ShortAddress> | Rejoin a network |
nlme_network_formation(mask, duration) → Result<()> | Form a new network (ZC) |
nlme_permit_joining(duration) → Result<()> | Set local permit join |
nlme_start_router() → Result<()> | Start router functionality |
nlme_reset(warm_start) → Result<()> | Reset network layer |
Descriptor Management
| Method | Description |
|---|---|
register_endpoint(SimpleDescriptor) → Result<()> | Register a local endpoint |
endpoints() → &[SimpleDescriptor] | List registered endpoints |
find_endpoint(ep) → Option<&SimpleDescriptor> | Find endpoint descriptor |
set_node_descriptor(NodeDescriptor) / node_descriptor() | Node descriptor access |
set_power_descriptor(PowerDescriptor) / power_descriptor() | Power descriptor access |
set_local_nwk_addr(ShortAddress) / local_nwk_addr() | Local network address |
set_local_ieee_addr(IeeeAddress) / local_ieee_addr() | Local IEEE address |
Internal
| Method | Description |
|---|---|
new(aps) → Self | Create ZDO layer wrapping APS |
next_seq() → u8 | Next ZDP sequence number |
deliver_response(cluster, tsn, payload) → bool | Deliver a ZDP response |
aps() → &ApsLayer<M> / aps_mut() | Access APS layer |
nwk() → &NwkLayer<M> / nwk_mut() | Access NWK layer |
zigbee-bdb — Base Device Behavior
BdbLayer<M: MacDriver>
| Method | Description |
|---|---|
new(zdo) → Self | Create BDB layer wrapping ZDO |
zdo() → &ZdoLayer<M> / zdo_mut() | Access ZDO layer |
attributes() → &BdbAttributes / attributes_mut() | BDB commissioning attributes |
state() → &BdbState | Current BDB state machine state |
is_on_network() → bool | Whether device has joined |
reset_attributes() | Reset BDB attributes to defaults |
BdbStatus
Success, InProgress, NotOnNetwork, NotPermitted, NoScanResponse, FormationFailure, SteeringFailure, NoIdentifyResponse, BindingTableFull, TouchlinkFailure, TargetFailure, Timeout
zigbee-zcl — Zigbee Cluster Library
Cluster Trait
#![allow(unused)]
fn main() {
pub trait Cluster {
fn cluster_id(&self) -> ClusterId;
fn handle_command(&mut self, cmd_id: CommandId, payload: &[u8])
-> Result<Vec<u8, 64>, ZclStatus>;
fn attributes(&self) -> &dyn AttributeStoreAccess;
fn attributes_mut(&mut self) -> &mut dyn AttributeStoreMutAccess;
fn received_commands(&self) -> Vec<u8, 32>; // optional
fn generated_commands(&self) -> Vec<u8, 32>; // optional
}
}
AttributeStoreAccess / AttributeStoreMutAccess Traits
| Method | Description |
|---|---|
get(AttributeId) → Option<&ZclValue> | Read attribute value |
set(AttributeId, ZclValue) → Result<()> | Write attribute with validation |
set_raw(AttributeId, ZclValue) → Result<()> | Write attribute without validation |
find(AttributeId) → Option<&AttributeDefinition> | Find attribute metadata |
all_ids() → Vec<AttributeId, 32> | List all attribute IDs |
Key Cluster IDs
| ID | Constant | Name |
|---|---|---|
0x0000 | BASIC | Basic |
0x0001 | POWER_CONFIG | Power Configuration |
0x0003 | IDENTIFY | Identify |
0x0004 | GROUPS | Groups |
0x0005 | SCENES | Scenes |
0x0006 | ON_OFF | On/Off |
0x0008 | LEVEL_CONTROL | Level Control |
0x0019 | OTA_UPGRADE | OTA Upgrade |
0x0020 | POLL_CONTROL | Poll Control |
0x0300 | COLOR_CONTROL | Color Control |
0x0402 | TEMPERATURE | Temperature Measurement |
0x0405 | HUMIDITY | Relative Humidity |
0x0406 | OCCUPANCY | Occupancy Sensing |
0x0500 | IAS_ZONE | IAS Zone |
0x0702 | METERING | Metering |
0x0B04 | ELECTRICAL_MEASUREMENT | Electrical Measurement |
See ZCL Cluster Table for the complete list of all 46 clusters.
ReportingEngine
| Method | Description |
|---|---|
new() → Self | Create empty engine |
configure(ReportingConfig) → Result<()> | Add/update a reporting configuration |
configure_for_cluster(ep, cluster, config) → Result<()> | Configure for specific cluster |
tick(elapsed_secs) | Advance timers |
check_and_report(store) → Option<ReportAttributes> | Check if any reports are due |
check_and_report_cluster(ep, cluster, store) → Option<ReportAttributes> | Check for specific cluster |
get_config(ep, cluster, direction, attr) → Option<&ReportingConfig> | Read reporting config |
zigbee-runtime — Device Runtime & Event Loop
ZigbeeDevice<M: MacDriver>
Lifecycle
| Method | Description |
|---|---|
builder(mac) → DeviceBuilder<M> | Start building a device |
start() → Result<u16, StartError> | Join network, returns short address |
leave() → Result<()> | Leave the network |
factory_reset(nv) | Factory reset, optionally clear NV storage |
user_action(UserAction) | Inject a user action (Join/Leave/Toggle/PermitJoin/FactoryReset) |
State Queries
| Method | Description |
|---|---|
is_joined() → bool | Network join status |
short_address() → u16 | Current short address |
channel() → u8 | Current channel |
pan_id() → u16 | Current PAN ID |
device_type() → DeviceType | Coordinator/Router/EndDevice |
endpoints() → &[EndpointConfig] | Registered endpoints |
manufacturer_name() → &str | Manufacturer string |
model_identifier() → &str | Model string |
channel_mask() → ChannelMask | Configured channel mask |
sw_build_id() → &str | Software build ID |
date_code() → &str | Date code |
is_sleepy() → bool | Whether device is a sleepy end device |
Data Path
| Method | Description |
|---|---|
receive() → Result<McpsDataIndication> | Wait for incoming frame |
poll() → Result<Option<McpsDataIndication>> | Poll parent (ZED) |
process_incoming(indication, clusters) → Option<StackEvent> | Process a received frame through the stack |
send_zcl_frame(dst, dst_ep, src_ep, cluster, data) → Result<()> | Send a ZCL frame |
Reporting & Persistence
| Method | Description |
|---|---|
reporting() → &ReportingEngine / reporting_mut() | Access reporting engine |
check_and_send_cluster_reports(ep, cluster, store) → bool | Check and transmit due reports |
save_state(nv) | Persist device state to NV storage |
restore_state(nv) → bool | Restore state from NV storage |
power() → &PowerManager / power_mut() | Access power manager |
bdb() → &BdbLayer<M> / bdb_mut() | Access BDB layer |
DeviceBuilder<M: MacDriver>
| Method | Description |
|---|---|
new(mac) → Self | Create builder with MAC driver |
device_type(DeviceType) → Self | Set device type |
manufacturer(&'static str) → Self | Set manufacturer name |
model(&'static str) → Self | Set model identifier |
sw_build(&'static str) → Self | Set software build ID |
date_code(&'static str) → Self | Set date code |
channels(ChannelMask) → Self | Set channel mask |
power_mode(PowerMode) → Self | Set power mode (AlwaysOn/Sleepy/DeepSleep) |
endpoint(ep, profile, device_id, configure_fn) → Self | Add an endpoint |
build() → ZigbeeDevice<M> | Build the device |
EndpointBuilder
| Method | Description |
|---|---|
cluster_server(cluster_id) → Self | Add a server cluster |
cluster_client(cluster_id) → Self | Add a client cluster |
device_version(version) → Self | Set device version |
Device Templates
Pre-configured DeviceBuilder shortcuts in zigbee_runtime::templates:
| Template | Description |
|---|---|
temperature_sensor(mac) | Temperature sensor (0x0402) |
temperature_humidity_sensor(mac) | Temp + humidity (0x0402, 0x0405) |
on_off_light(mac) | On/Off light (0x0006) |
dimmable_light(mac) | Dimmable light (0x0006, 0x0008) |
color_temperature_light(mac) | Color temp light (0x0006, 0x0008, 0x0300) |
contact_sensor(mac) | Contact sensor (IAS Zone 0x0500) |
occupancy_sensor(mac) | Occupancy sensor (0x0406) |
smart_plug(mac) | Smart plug (0x0006, 0x0B04, 0x0702) |
thermostat(mac) | Thermostat (0x0201) |
StackEvent Enum
| Variant | Description |
|---|---|
Joined { short_address, channel, pan_id } | Successfully joined network |
Left | Left the network |
AttributeReport { src_addr, endpoint, cluster_id, attr_id } | Received an attribute report |
CommandReceived { src_addr, endpoint, cluster_id, command_id, seq_number, payload } | Received a cluster command |
CommissioningComplete { success } | BDB commissioning finished |
DefaultResponse { src_addr, endpoint, cluster_id, command_id, status } | Received a default response |
PermitJoinChanged { open } | Permit join state changed |
ReportSent | An attribute report was transmitted |
OtaImageAvailable { version, size } | OTA image available |
OtaProgress { percent } | OTA download progress |
OtaComplete / OtaFailed | OTA finished |
OtaDelayedActivation { delay_secs } | OTA activation delayed |
FactoryResetRequested | Factory reset requested |
PowerManager
| Method | Description |
|---|---|
new(PowerMode) → Self | Create power manager |
mode() → PowerMode | Current power mode |
record_activity(now_ms) | Record activity to prevent premature sleep |
record_poll(now_ms) | Record data poll time |
set_pending_tx(bool) | Mark pending transmission |
set_pending_reports(bool) | Mark pending reports |
decide(now_ms) → SleepDecision | Decide: StayAwake / LightSleep(ms) / DeepSleep(ms) |
should_poll(now_ms) → bool | Whether it’s time to poll parent |
PowerMode / SleepDecision
| Variant | Description |
|---|---|
PowerMode::AlwaysOn | ZC/ZR — never sleep |
PowerMode::Sleepy { poll_interval_ms, wake_duration_ms } | ZED with periodic polling |
PowerMode::DeepSleep { wake_interval_s } | ZED with deep sleep cycles |
SleepDecision::StayAwake | Don’t sleep |
SleepDecision::LightSleep(ms) | Light sleep for N ms |
SleepDecision::DeepSleep(ms) | Deep sleep for N ms |
UserAction
Join, Leave, Toggle, PermitJoin(u8), FactoryReset
zigbee — Top-Level Crate
Re-exports all sub-crates and provides high-level coordinator/router/trust center components.
Re-exports
#![allow(unused)]
fn main() {
pub use zigbee_runtime::ZigbeeDevice;
pub use zigbee_types::{Channel, ChannelMask, IeeeAddress, MacAddress, PanId, ShortAddress};
pub use zigbee_aps as aps;
pub use zigbee_bdb as bdb;
pub use zigbee_mac as mac;
pub use zigbee_nwk as nwk;
pub use zigbee_runtime as runtime;
pub use zigbee_types as types;
pub use zigbee_zcl as zcl;
pub use zigbee_zdo as zdo;
}
Coordinator
| Method | Description |
|---|---|
new(CoordinatorConfig) → Self | Create coordinator |
generate_network_key() | Generate random 128-bit network key |
network_key() → &[u8; 16] / set_network_key(key) | Network key access |
allocate_address() → ShortAddress | Allocate address for joining device |
can_accept_child() → bool | Check child capacity |
is_formed() → bool / mark_formed() | Network formation state |
next_frame_counter() → u32 | Next NWK frame counter |
CoordinatorConfig
| Field | Type | Description |
|---|---|---|
channel_mask | ChannelMask | Channels to form on |
extended_pan_id | IeeeAddress | Extended PAN ID |
centralized_security | bool | Use centralized Trust Center |
require_install_codes | bool | Require install codes for joining |
max_children | u8 | Max direct children |
max_depth | u8 | Max network depth |
initial_permit_join_duration | u8 | Permit join duration at startup (seconds) |
Router
| Method | Description |
|---|---|
new(RouterConfig) → Self | Create router |
add_child(ieee, short, is_ffd, rx_on) → Result<()> | Register a child device |
remove_child(addr) | Remove a child |
find_child(addr) → Option<&ChildDevice> | Find child by short address |
find_child_by_ieee(&ieee) → Option<&ChildDevice> | Find child by IEEE address |
is_child(addr) → bool | Check if address is a child |
can_accept_child() → bool | Check capacity |
age_children(elapsed_seconds) | Age children, detect timeouts |
child_activity(addr) | Record child activity |
child_count() → u8 | Number of children |
is_started() → bool / mark_started() | Router started state |
TrustCenter
| Method | Description |
|---|---|
new(network_key) → Self | Create Trust Center with network key |
network_key() → &[u8; 16] / set_network_key(key) | Network key access |
key_seq_number() → u8 | Current key sequence number |
set_require_install_codes(bool) | Enable/disable install code requirement |
link_key_for_device(&ieee) → [u8; 16] | Get link key for device |
set_link_key(ieee, key, TcKeyType) → Result<()> | Set device-specific link key |
remove_link_key(&ieee) | Remove a link key |
mark_key_verified(&ieee) | Mark key as verified |
should_accept_join(&ieee) → bool | Check join policy for device |
update_frame_counter(&ieee, counter) → bool | Update incoming frame counter |
next_frame_counter() → u32 | Next outgoing frame counter |
device_count() → usize | Number of registered devices |
PIB Attributes Reference
The PAN Information Base (PIB) is the MAC-layer configuration interface defined by IEEE 802.15.4. The zigbee-mac crate exposes PIB attributes through the MacDriver trait’s mlme_get() and mlme_set() methods.
#![allow(unused)]
fn main() {
use zigbee_mac::{PibAttribute, PibValue, MacDriver};
// Read current channel
let channel = mac.mlme_get(PibAttribute::PhyCurrentChannel).await?;
// Set short address after joining
mac.mlme_set(
PibAttribute::MacShortAddress,
PibValue::ShortAddress(ShortAddress(0x1234)),
).await?;
}
PibAttribute Variants
Addressing (set during join)
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacShortAddress | 0x53 | ShortAddress | 0xFFFF (unassigned) | Own 16-bit network short address. Set by the coordinator during association. |
MacPanId | 0x50 | PanId | 0xFFFF (not associated) | PAN identifier of the network. Set during join or network formation. |
MacExtendedAddress | 0x6F | ExtendedAddress | Hardware-programmed | Own 64-bit IEEE address. Usually read-only, burned into radio hardware. |
MacCoordShortAddress | 0x4B | ShortAddress | 0xFFFF | Short address of the parent coordinator or router. Set during association. |
MacCoordExtendedAddress | 0x4A | ExtendedAddress | [0; 8] | Extended address of the parent coordinator or router. |
Network Configuration
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacAssociatedPanCoord | 0x56 | Bool | false | true if this device is the PAN coordinator. Set during network formation. |
MacRxOnWhenIdle | 0x52 | Bool | true | Whether the radio receiver is on during idle. Set true for coordinators and routers, false for sleepy end devices. Controls how the device is addressed in network discovery. |
MacAssociationPermit | 0x41 | Bool | false | Whether the device is accepting association requests (join permit open). Set by permit_joining commands. |
Beacon (Non-Beacon Mode)
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacBeaconOrder | 0x47 | U8 | 15 | Beacon order. Always 15 for Zigbee PRO (non-beacon mode). Do not change. |
MacSuperframeOrder | 0x54 | U8 | 15 | Superframe order. Always 15 for Zigbee PRO. Do not change. |
MacBeaconPayload | 0x45 | Payload | Empty | Beacon payload bytes. Contains NWK beacon content for coordinators and routers. Max 52 bytes. |
MacBeaconPayloadLength | 0x46 | U8 | 0 | Length of the beacon payload in bytes. |
TX/RX Tuning
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacAutoRequest | 0x42 | Bool | true | Automatically send data request after receiving a beacon with the pending bit set. Used by end devices to retrieve buffered data from their parent. |
MacMaxCsmaBackoffs | 0x4E | U8 | 4 | Maximum number of CSMA-CA backoff attempts before declaring channel access failure. Range: 0–5. |
MacMinBe | 0x4F | U8 | 3 | Minimum backoff exponent for CSMA-CA (2.4 GHz default: 3). Lower values mean more aggressive channel access. |
MacMaxBe | 0x57 | U8 | 5 | Maximum backoff exponent for CSMA-CA. Range: 3–8. |
MacMaxFrameRetries | 0x59 | U8 | 3 | Number of retransmission attempts after an ACK failure. Range: 0–7. |
MacMaxFrameTotalWaitTime | 0x58 | U32 | PHY-dependent | Maximum time (in symbols) to wait for an indirect transmission frame. Used by end devices polling their parent. |
MacResponseWaitTime | 0x5A | U8 | 32 | Maximum time to wait for a response frame (in units of aBaseSuperframeDuration). Used during association. |
Sequence Numbers
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacDsn | 0x4C | U8 | Random | Data/command frame sequence number. Incremented automatically per transmission. |
MacBsn | 0x49 | U8 | Random | Beacon sequence number. Incremented per beacon transmission. |
Indirect TX (Coordinator/Router)
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacTransactionPersistenceTime | 0x55 | U16 | 0x01F4 | How long (in unit periods) a coordinator stores indirect frames for sleepy children before discarding. |
Debug / Special
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
MacPromiscuousMode | 0x51 | Bool | false | When true, the radio receives all frames regardless of addressing. Used for sniffing/debugging. |
PHY Attributes (via MAC GET/SET)
| Attribute | ID | PibValue Type | Default | Description |
|---|---|---|---|---|
PhyCurrentChannel | 0x00 | U8 | 11 | Current 2.4 GHz channel (11–26). Set during network formation or join. |
PhyChannelsSupported | 0x01 | U32 | 0x07FFF800 | Bitmask of supported channels. For 2.4 GHz Zigbee: bits 11–26 set. Read-only on most hardware. |
PhyTransmitPower | 0x02 | I8 | Hardware-dependent | TX power in dBm. Range depends on radio hardware (typically −20 to +20 dBm). |
PhyCcaMode | 0x03 | U8 | 1 | Clear Channel Assessment mode. Mode 1 = energy above threshold. Rarely changed. |
PhyCurrentPage | 0x04 | U8 | 0 | Channel page. Always 0 for 2.4 GHz Zigbee. Do not change. |
PibValue Variants
The PibValue enum is the value container for all PIB GET/SET operations:
| Variant | Contained Type | Used By |
|---|---|---|
Bool(bool) | bool | MacAssociatedPanCoord, MacRxOnWhenIdle, MacAssociationPermit, MacAutoRequest, MacPromiscuousMode |
U8(u8) | u8 | MacBeaconOrder, MacSuperframeOrder, MacBeaconPayloadLength, MacMaxCsmaBackoffs, MacMinBe, MacMaxBe, MacMaxFrameRetries, MacResponseWaitTime, MacDsn, MacBsn, PhyCurrentChannel, PhyCcaMode, PhyCurrentPage |
U16(u16) | u16 | MacTransactionPersistenceTime |
U32(u32) | u32 | MacMaxFrameTotalWaitTime, PhyChannelsSupported |
I8(i8) | i8 | PhyTransmitPower |
ShortAddress(ShortAddress) | ShortAddress (newtype over u16) | MacShortAddress, MacCoordShortAddress |
PanId(PanId) | PanId (newtype over u16) | MacPanId |
ExtendedAddress(IeeeAddress) | [u8; 8] | MacExtendedAddress, MacCoordExtendedAddress |
Payload(PibPayload) | PibPayload (max 52 bytes) | MacBeaconPayload |
PibPayload
Fixed-capacity beacon payload buffer:
#![allow(unused)]
fn main() {
pub struct PibPayload {
buf: [u8; 52],
len: usize,
}
impl PibPayload {
pub fn new() -> Self; // Empty payload
pub fn from_slice(data: &[u8]) -> Option<Self>; // None if > 52 bytes
pub fn as_slice(&self) -> &[u8]; // Current content
}
}
Convenience Accessors on PibValue
Each variant has a corresponding accessor that returns Option:
| Method | Returns |
|---|---|
as_bool() | Option<bool> |
as_u8() | Option<u8> |
as_u16() | Option<u16> |
as_u32() | Option<u32> |
as_short_address() | Option<ShortAddress> |
as_pan_id() | Option<PanId> |
as_extended_address() | Option<IeeeAddress> |
PHY Constants & Helpers
#![allow(unused)]
fn main() {
/// Base superframe duration in symbols (960)
pub const A_BASE_SUPERFRAME_DURATION: u32 = 960;
/// Symbol rate at 2.4 GHz in symbols/second
pub const SYMBOL_RATE_2_4GHZ: u32 = 62_500;
/// Calculate scan duration per channel in symbols
pub fn scan_duration_symbols(exponent: u8) -> u32;
/// Calculate scan duration per channel in microseconds
pub fn scan_duration_us(exponent: u8) -> u64;
}
| Scan Duration (exponent) | Symbols | Milliseconds | Typical Use |
|---|---|---|---|
| 0 | 1,920 | 30.7 | Ultra-fast scan |
| 2 | 4,800 | 76.8 | Quick scan |
| 3 | 8,640 | 138 | Default for Zigbee |
| 4 | 16,320 | 261 | Standard scan |
| 5 | 31,680 | 507 | Extended scan |
| 8 | 247,296 | 3,957 | Deep scan |
Usage Patterns
Reading current network state
#![allow(unused)]
fn main() {
let short = mac.mlme_get(PibAttribute::MacShortAddress).await?
.as_short_address().unwrap();
let pan = mac.mlme_get(PibAttribute::MacPanId).await?
.as_pan_id().unwrap();
let channel = mac.mlme_get(PibAttribute::PhyCurrentChannel).await?
.as_u8().unwrap();
}
Configuring TX performance
#![allow(unused)]
fn main() {
// More aggressive: fewer backoffs, more retries
mac.mlme_set(PibAttribute::MacMaxCsmaBackoffs, PibValue::U8(3)).await?;
mac.mlme_set(PibAttribute::MacMaxFrameRetries, PibValue::U8(5)).await?;
mac.mlme_set(PibAttribute::MacMinBe, PibValue::U8(2)).await?;
}
Setting up a sleepy end device
#![allow(unused)]
fn main() {
// Disable RX during idle for battery saving
mac.mlme_set(PibAttribute::MacRxOnWhenIdle, PibValue::Bool(false)).await?;
// Enable auto data request after beacon
mac.mlme_set(PibAttribute::MacAutoRequest, PibValue::Bool(true)).await?;
}
Adjusting TX power
#![allow(unused)]
fn main() {
// Set to +4 dBm
mac.mlme_set(PibAttribute::PhyTransmitPower, PibValue::I8(4)).await?;
// Read back actual power (may be clamped by hardware)
let actual = mac.mlme_get(PibAttribute::PhyTransmitPower).await?;
}
ZCL Cluster Table
Complete reference of all ZCL clusters implemented in zigbee-zcl. Sorted by cluster ID and grouped by category.
General Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0000 | Basic | ZclVersion (0x0000), ManufacturerName (0x0004), ModelIdentifier (0x0005), DateCode (0x0006), PowerSource (0x0007), SwBuildId (0x4000) | ResetToFactoryDefaults (0x00) | basic |
0x0001 | Power Configuration | BatteryVoltage (0x0020), BatteryPercentageRemaining (0x0021), BatteryAlarmMask (0x0035), BatterySize (0x0031), BatteryAlarmState (0x003E) | — | power_config |
0x0002 | Device Temperature Configuration | CurrentTemperature (0x0000), MinTempExperienced (0x0001), MaxTempExperienced (0x0002), DeviceTempAlarmMask (0x0010) | — | device_temp_config |
0x0003 | Identify | IdentifyTime (0x0000) | Identify (0x00), IdentifyQuery (0x01), TriggerEffect (0x40) | identify |
0x0004 | Groups | NameSupport (0x0000) | AddGroup (0x00), ViewGroup (0x01), GetGroupMembership (0x02), RemoveGroup (0x03), RemoveAllGroups (0x04), AddGroupIfIdentifying (0x05) | groups |
0x0005 | Scenes | SceneCount (0x0000), CurrentScene (0x0001), CurrentGroup (0x0002), SceneValid (0x0003) | AddScene (0x00), ViewScene (0x01), RemoveScene (0x02), RemoveAllScenes (0x03), StoreScene (0x04), RecallScene (0x05), GetSceneMembership (0x06) | scenes |
0x0006 | On/Off | OnOff (0x0000), GlobalSceneControl (0x4000), OnTime (0x4001), OffWaitTime (0x4002), StartUpOnOff (0x4003) | Off (0x00), On (0x01), Toggle (0x02), OffWithEffect (0x40), OnWithRecallGlobalScene (0x41), OnWithTimedOff (0x42) | on_off |
0x0007 | On/Off Switch Configuration | SwitchType (0x0000), SwitchActions (0x0010) | — | on_off_switch_config |
0x0008 | Level Control | CurrentLevel (0x0000), RemainingTime (0x0001), MinLevel (0x0002), MaxLevel (0x0003), OnOffTransitionTime (0x0010), OnLevel (0x0011), StartupCurrentLevel (0x4000) | MoveToLevel (0x00), Move (0x01), Step (0x02), Stop (0x03), MoveToLevelWithOnOff (0x04), MoveWithOnOff (0x05), StepWithOnOff (0x06), StopWithOnOff (0x07) | level_control |
0x0009 | Alarms | AlarmCount (0x0000) | ResetAlarm (0x00), ResetAllAlarms (0x01), GetAlarm (0x02), ResetAlarmLog (0x03) | alarms |
0x000A | Time | Time (0x0000), TimeStatus (0x0001), TimeZone (0x0002), DstStart (0x0003), DstEnd (0x0004), LocalTime (0x0007) | — | time |
0x000C | Analog Input (Basic) | PresentValue (0x0055), StatusFlags (0x006F), MinPresentValue (0x0045), MaxPresentValue (0x0041), EngineeringUnits (0x0075) | — | analog_input |
0x000D | Analog Output (Basic) | PresentValue (0x0055), StatusFlags (0x006F), RelinquishDefault (0x0068), EngineeringUnits (0x0075) | — | analog_output |
0x000E | Analog Value (Basic) | PresentValue (0x0055), StatusFlags (0x006F), RelinquishDefault (0x0068), EngineeringUnits (0x0075) | — | analog_value |
0x000F | Binary Input (Basic) | PresentValue (0x0055), StatusFlags (0x006F), Polarity (0x0054), ActiveText (0x0004), InactiveText (0x002E) | — | binary_input |
0x0010 | Binary Output (Basic) | PresentValue (0x0055), StatusFlags (0x006F), Polarity (0x0054), RelinquishDefault (0x0068) | — | binary_output |
0x0011 | Binary Value (Basic) | PresentValue (0x0055), StatusFlags (0x006F), RelinquishDefault (0x0068) | — | binary_value |
0x0012 | Multistate Input (Basic) | PresentValue (0x0055), NumberOfStates (0x004A), StatusFlags (0x006F) | — | multistate_input |
0x0019 | OTA Upgrade | UpgradeServerId (0x0000), FileOffset (0x0001), CurrentFileVersion (0x0002), ImageUpgradeStatus (0x0006), ManufacturerId (0x0007), ImageTypeId (0x0008) | ImageNotify (0x00), QueryNextImageReq (0x01), QueryNextImageRsp (0x02), ImageBlockReq (0x03), ImageBlockRsp (0x05), UpgradeEndReq (0x06), UpgradeEndRsp (0x07) | ota |
0x0020 | Poll Control | CheckInInterval (0x0000), LongPollInterval (0x0001), ShortPollInterval (0x0002), FastPollTimeout (0x0003) | CheckIn (0x00), CheckInResponse (0x00), FastPollStop (0x01), SetLongPollInterval (0x02), SetShortPollInterval (0x03) | poll_control |
0x0021 | Green Power | GppMaxProxyTableEntries (0x0010), ProxyTable (0x0011), GpsFunctionality (0x0005), GpsSinkTable (0x0000), GpsSecurityLevel (0x0004) | GpNotification (0x00), GpPairing (0x01), GpProxyCommissioningMode (0x02), GpCommissioningNotification (0x04), GpResponse (0x06), GpPairingConfiguration (0x09) | green_power |
Closures Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0101 | Door Lock | LockState (0x0000), LockType (0x0001), ActuatorEnabled (0x0002), DoorState (0x0003), OperatingMode (0x0025), Language (0x0021) | LockDoor (0x00), UnlockDoor (0x01), Toggle (0x02), UnlockWithTimeout (0x03), SetPinCode (0x05), GetPinCode (0x06), ClearPinCode (0x07) | door_lock |
0x0102 | Window Covering | WindowCoveringType (0x0000), CurrentPositionLiftPercentage (0x0008), CurrentPositionTiltPercentage (0x0009), ConfigStatus (0x0007), Mode (0x0017) | UpOpen (0x00), DownClose (0x01), Stop (0x02), GoToLiftValue (0x04), GoToLiftPercentage (0x05), GoToTiltValue (0x07), GoToTiltPercentage (0x08) | window_covering |
HVAC Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0201 | Thermostat | LocalTemperature (0x0000), OccupiedCoolingSetpoint (0x0011), OccupiedHeatingSetpoint (0x0012), SystemMode (0x001C), ControlSequenceOfOperation (0x001B), ThermostatRunningMode (0x001E) | SetpointRaiseLower (0x00), SetWeeklySchedule (0x01), GetWeeklySchedule (0x02), ClearWeeklySchedule (0x03) | thermostat |
0x0202 | Fan Control | FanMode (0x0000), FanModeSequence (0x0001) | — | fan_control |
0x0204 | Thermostat User Interface | TemperatureDisplayMode (0x0000), KeypadLockout (0x0001), ScheduleProgrammingVisibility (0x0002) | — | thermostat_ui |
Lighting Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0300 | Color Control | CurrentHue (0x0000), CurrentSaturation (0x0001), CurrentX (0x0003), CurrentY (0x0004), ColorTemperatureMireds (0x0007), ColorMode (0x0008), EnhancedCurrentHue (0x4000), ColorCapabilities (0x400A) | MoveToHue (0x00), MoveToSaturation (0x03), MoveToHueAndSaturation (0x06), MoveToColor (0x07), MoveToColorTemperature (0x0A), EnhancedMoveToHue (0x40), ColorLoopSet (0x44), StopMoveStep (0x47) | color_control |
0x0301 | Ballast Configuration | PhysicalMinLevel (0x0000), PhysicalMaxLevel (0x0001), BallastStatus (0x0002), MinLevel (0x0010), MaxLevel (0x0011), LampQuantity (0x0020) | — | ballast_config |
Measurement & Sensing Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0400 | Illuminance Measurement | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003), LightSensorType (0x0004) | — | illuminance |
0x0401 | Illuminance Level Sensing | LevelStatus (0x0000), LightSensorType (0x0001), IlluminanceTargetLevel (0x0010) | — | illuminance_level |
0x0402 | Temperature Measurement | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003) | — | temperature |
0x0403 | Pressure Measurement | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003), ScaledValue (0x0010), Scale (0x0014) | — | pressure |
0x0404 | Flow Measurement | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003) | — | flow_measurement |
0x0405 | Relative Humidity | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003) | — | humidity |
0x0406 | Occupancy Sensing | Occupancy (0x0000), OccupancySensorType (0x0001), OccupancySensorTypeBitmap (0x0002), PirOToUDelay (0x0010), PirUToODelay (0x0011) | — | occupancy |
0x0408 | Soil Moisture | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003) | — | soil_moisture |
0x040D | Carbon Dioxide (CO₂) | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003) | — | carbon_dioxide |
0x042A | PM2.5 Measurement | MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003) | — | pm25 |
Security (IAS) Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0500 | IAS Zone | ZoneState (0x0000), ZoneType (0x0001), ZoneStatus (0x0002), IasCieAddress (0x0010), ZoneId (0x0011), CurrentZoneSensitivityLevel (0x0013) | C→S: ZoneEnrollResponse (0x00), InitiateNormalOpMode (0x01), InitiateTestMode (0x02) — S→C: ZoneStatusChangeNotification (0x00), ZoneEnrollRequest (0x01) | ias_zone |
0x0501 | IAS ACE | PanelStatus (0xFF00) | C→S: Arm (0x00), Bypass (0x01), Emergency (0x02), Fire (0x03), Panic (0x04), GetZoneIdMap (0x05), GetPanelStatus (0x07) — S→C: ArmResponse (0x00), PanelStatusChanged (0x04) | ias_ace |
0x0502 | IAS WD | MaxDuration (0x0000) | StartWarning (0x00), Squawk (0x01) | ias_wd |
Smart Energy Clusters
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x0702 | Metering | CurrentSummationDelivered (0x0000), CurrentSummationReceived (0x0001), UnitOfMeasure (0x0300), Multiplier (0x0301), Divisor (0x0302), InstantaneousDemand (0x0400), MeteringDeviceType (0x0308) | — | metering |
0x0B04 | Electrical Measurement | MeasurementType (0x0000), RmsVoltage (0x0505), RmsCurrent (0x0508), ActivePower (0x050B), ReactivePower (0x050E), ApparentPower (0x050F), PowerFactor (0x0510), AcVoltageMultiplier (0x0600), AcVoltageDivisor (0x0601) | — | electrical |
0x0B05 | Diagnostics | NumberOfResets (0x0000), MacRxBcast (0x0100), MacTxBcast (0x0101), MacRxUcast (0x0102), MacTxUcast (0x0103), MacTxUcastFail (0x0105), LastMessageLqi (0x011C), LastMessageRssi (0x011D) | — | diagnostics |
Touchlink
| ID | Name | Key Attributes | Key Commands | Module |
|---|---|---|---|---|
0x1000 | Touchlink Commissioning | TouchlinkState (0xFF00) | C→S: ScanRequest (0x00), IdentifyRequest (0x06), ResetToFactoryNewRequest (0x07), NetworkStartRequest (0x10), NetworkJoinRouterRequest (0x12), NetworkJoinEndDeviceRequest (0x14) — S→C: ScanResponse (0x01), NetworkStartResponse (0x11) | touchlink |
Summary by Category
| Category | Count | Cluster ID Range |
|---|---|---|
| General | 21 | 0x0000 – 0x0021 |
| Closures | 2 | 0x0101 – 0x0102 |
| HVAC | 3 | 0x0201 – 0x0204 |
| Lighting | 2 | 0x0300 – 0x0301 |
| Measurement & Sensing | 10 | 0x0400 – 0x042A |
| Security (IAS) | 3 | 0x0500 – 0x0502 |
| Smart Energy | 3 | 0x0702 – 0x0B05 |
| Touchlink | 1 | 0x1000 |
| Total | 45 |
Note: The
ota_imagemodule is a helper for OTA image parsing and is not a separate cluster.
IAS Zone Types
Common ZoneType values for the IAS Zone cluster (0x0500):
| Value | Name | Typical Device |
|---|---|---|
0x0000 | Standard CIE | CIE device |
0x000D | Motion Sensor | PIR sensor |
0x0015 | Contact Switch | Door/window sensor |
0x0028 | Fire Sensor | Smoke detector |
0x002A | Water Sensor | Leak detector |
0x002B | CO Sensor | Carbon monoxide detector |
0x002D | Personal Emergency | Panic button |
0x010F | Remote Control | Keyfob |
0x0115 | Key Fob | Key fob |
0x021D | Keypad | Security keypad |
0x0225 | Standard Warning | Siren/strobe |
Thermostat System Modes
| Value | Mode |
|---|---|
0x00 | Off |
0x01 | Auto |
0x03 | Cool |
0x04 | Heat |
0x05 | Emergency Heat |
0x07 | Fan Only |
Metering Unit Types
| Value | Unit |
|---|---|
0x00 | kWh (electric) |
0x01 | m³ (gas) |
0x02 | ft³ |
0x03 | CCF |
0x04 | US Gallons |
0x05 | Imperial Gallons |
0x06 | BTU |
0x07 | Liters |
0x08 | kPa (gauge) |
Color Control Modes
| Value | Mode | Description |
|---|---|---|
0x00 | Hue/Saturation | HSV color space |
0x01 | XY | CIE 1931 color space |
0x02 | Color Temperature | Mireds (reciprocal megakelvins) |
Error & Status Types
This chapter catalogues every error and status enum in zigbee-rs, organised by stack layer from bottom (MAC) to top (BDB). Use the hex codes to correlate with Zigbee specification tables and on-the-wire values.
MAC Layer
MacError
Crate: zigbee-mac · Spec ref: IEEE 802.15.4 status codes
General-purpose error returned by MAC primitives. No numeric discriminants — these are Rust-only symbolic values.
| Variant | Meaning |
|---|---|
NoBeacon | No beacon received during an active or passive scan |
InvalidParameter | A primitive was called with an out-of-range parameter |
RadioError | The radio hardware reported an unrecoverable error |
ChannelAccessFailure | CSMA-CA failed — the channel remained busy for all back-off attempts |
NoAck | No acknowledgement frame was received after transmission |
FrameTooLong | The assembled MPDU exceeds the PHY maximum frame size |
Unsupported | The requested operation is not supported by this radio backend |
SecurityError | Frame security processing (encryption / MIC) failed |
TransactionOverflow | The indirect-transmit queue is full |
TransactionExpired | An indirect frame was not collected before the persistence timer expired |
ScanInProgress | A scan request was issued while another scan is already active |
TrackingOff | Superframe tracking was lost (beacon-enabled networks) |
AssociationDenied | The coordinator denied the association request |
PanAtCapacity | The coordinator indicated the PAN is at capacity |
Other | Catch-all for unmapped / unknown errors |
NoData | A data request (poll) returned no pending frame within the timeout |
AssociationStatus
Crate: zigbee-mac · File: primitives.rs · Spec ref: IEEE 802.15.4 Table 83
Returned by the coordinator in an Association Response command.
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Association was successful |
PanAtCapacity | 0x01 | PAN is at capacity — no room for new devices |
PanAccessDenied | 0x02 | Access to the PAN is denied |
RadioError (platform-specific)
Each radio backend defines its own RadioError enum. The variants are similar
but not identical across platforms.
CC2340 backend
| Variant | Meaning |
|---|---|
TxFailed | Transmission failed (generic) |
ChannelBusy | CCA indicated a busy channel |
NoAck | No acknowledgement from the receiver |
Timeout | Operation timed out |
HardwareError | Radio hardware fault |
BL702 backend
| Variant | Meaning |
|---|---|
CcaFailure | Clear Channel Assessment failure — channel is busy |
TxAborted | TX was aborted by hardware |
HardwareError | Radio hardware error during TX |
InvalidFrame | Frame too long or too short |
CrcError | Received frame failed CRC check |
NotInitialized | Radio driver has not been initialized |
Telink backend
| Variant | Meaning |
|---|---|
CcaFailure | CCA failure — channel is busy |
TxAborted | TX was aborted |
HardwareError | Radio hardware error |
InvalidFrame | Frame too long or too short |
CrcError | Received frame failed CRC check |
NotInitialized | Radio not initialized |
RclCommandStatus (CC2340 only)
Crate: zigbee-mac · File: cc2340/driver.rs
Low-level Radio Control Layer status on the CC2340.
| Variant | Code | Meaning |
|---|---|---|
Idle | 0x0000 | Command is idle |
Active | 0x0001 | Command is currently executing |
Finished | 0x0101 | Command completed successfully |
ChannelBusy | 0x0801 | CCA failed — channel busy |
NoAck | 0x0802 | No acknowledgement received |
RxErr | 0x0803 | Receive error |
Error | 0x0F00 | Generic hardware error |
Lmac154TxStatus (BL702 only)
Crate: zigbee-mac · File: bl702/driver.rs
TX completion status from the BL702 lower-MAC.
| Variant | Code | Meaning |
|---|---|---|
TxFinished | 0 | Transmission completed successfully |
CsmaFailed | 1 | CSMA-CA procedure failed |
TxAborted | 2 | TX was aborted |
HwError | 3 | Hardware error |
NWK Layer
NwkStatus
Crate: zigbee-nwk · Spec ref: Zigbee spec Table 3-70
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Operation completed successfully |
InvalidParameter | 0xC1 | A parameter was out of range or invalid |
InvalidRequest | 0xC2 | The request is invalid in the current state |
NotPermitted | 0xC3 | Operation not permitted (e.g. security policy) |
StartupFailure | 0xC4 | Network startup (formation or join) failed |
AlreadyPresent | 0xC5 | An entry already exists (e.g. duplicate address) |
SyncFailure | 0xC6 | Synchronisation with the parent lost |
NeighborTableFull | 0xC7 | The neighbour table has no room for a new entry |
UnknownDevice | 0xC8 | The specified device is not in the neighbour table |
UnsupportedAttribute | 0xC9 | NIB attribute identifier is not recognized |
NoNetworks | 0xCA | No networks were found during the scan |
MaxFrmCounterReached | 0xCC | The outgoing frame counter has reached its maximum |
NoKey | 0xCD | No matching network key found for decryption |
BadCcmOutput | 0xCE | CCM* encryption / decryption produced invalid output |
RouteDiscoveryFailed | 0xD0 | Route discovery did not find a path to the destination |
RouteError | 0xD1 | A routing error occurred (e.g. link failure) |
BtTableFull | 0xD2 | The broadcast transaction table is full |
FrameNotBuffered | 0xD3 | A frame could not be buffered for later transmission |
FrameTooLong | 0xD4 | The NWK frame exceeds the maximum allowed size |
RouteStatus
Crate: zigbee-nwk · File: routing.rs
Internal status of a routing table entry.
| Variant | Meaning |
|---|---|
Active | Route is valid and in use |
DiscoveryUnderway | Route discovery has been initiated |
DiscoveryFailed | Route discovery completed without finding a route |
Inactive | Route exists but is not currently active |
ValidationUnderway | Route is being validated (e.g. many-to-one route) |
APS Layer
ApsStatus
Crate: zigbee-aps · Spec ref: Zigbee spec Table 2-27
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Request executed successfully |
AsduTooLong | 0xA0 | ASDU is too large and fragmentation is not supported |
DefragDeferred | 0xA1 | A fragmented frame could not be defragmented at this time |
DefragUnsupported | 0xA2 | Device does not support fragmentation / defragmentation |
IllegalRequest | 0xA3 | A parameter value was out of range |
InvalidBinding | 0xA4 | UNBIND request failed — binding table entry not found |
InvalidParameter | 0xA5 | GET/SET request used an unknown attribute identifier |
NoAck | 0xA6 | APS-level acknowledged transmission received no ACK |
NoBoundDevice | 0xA7 | Indirect (binding) transmission found no bound devices |
NoShortAddress | 0xA8 | Group-addressed transmission found no matching group entry |
TableFull | 0xA9 | Binding table or group table is full |
UnsecuredKey | 0xAA | Frame was secured with a link key not in the key table |
UnsupportedAttribute | 0xAB | GET/SET request used an unsupported attribute identifier |
SecurityFail | 0xAD | An unsecured frame was received when security was required |
DecryptionError | 0xAE | APS frame decryption or authentication failed |
InsufficientSpace | 0xAF | Not enough buffer space for the requested operation |
NotFound | 0xB0 | No matching entry in the binding table |
ZCL Layer
ZclStatus
Crate: zigbee-zcl · Spec ref: ZCL Rev 8, Table 2-12
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Operation completed successfully |
Failure | 0x01 | Generic failure |
NotAuthorized | 0x7E | Sender is not authorized for this operation |
ReservedFieldNotZero | 0x7F | A reserved field in the frame was non-zero |
MalformedCommand | 0x80 | The command frame is malformed |
UnsupClusterCommand | 0x81 | Cluster-specific command is not supported |
UnsupGeneralCommand | 0x82 | General ZCL command is not supported |
UnsupManufacturerClusterCommand | 0x83 | Manufacturer-specific cluster command is not supported |
UnsupManufacturerGeneralCommand | 0x84 | Manufacturer-specific general command is not supported |
InvalidField | 0x85 | A field in the command contains an invalid value |
UnsupportedAttribute | 0x86 | The specified attribute is not supported on this cluster |
InvalidValue | 0x87 | The attribute value is out of range or otherwise invalid |
ReadOnly | 0x88 | Attribute is read-only and cannot be written |
InsufficientSpace | 0x89 | Not enough space to fulfil the request |
DuplicateExists | 0x8A | A duplicate entry already exists |
NotFound | 0x8B | The requested element was not found |
UnreportableAttribute | 0x8C | The attribute does not support reporting |
InvalidDataType | 0x8D | The data type does not match the attribute’s type |
InvalidSelector | 0x8E | The selector (index) for a structured attribute is invalid |
WriteOnly | 0x8F | Attribute is write-only and cannot be read |
InconsistentStartupState | 0x90 | Startup attribute set is inconsistent |
DefinedOutOfBand | 0x91 | Value was already defined by an out-of-band mechanism |
Inconsistent | 0x92 | Supplied values are inconsistent |
ActionDenied | 0x93 | The requested action has been denied |
Timeout | 0x94 | The operation timed out |
Abort | 0x95 | Operation was aborted |
InvalidImage | 0x96 | OTA image is invalid |
WaitForData | 0x97 | Server is not ready — try again later |
NoImageAvailable | 0x98 | No OTA image is available for this device |
RequireMoreImage | 0x99 | More image data is required to continue |
NotificationPending | 0x9A | A notification is pending delivery |
HardwareFailure | 0xC0 | Hardware failure on the device |
SoftwareFailure | 0xC1 | Software failure on the device |
CalibrationError | 0xC2 | Calibration error |
UnsupportedCluster | 0xC3 | The cluster is not supported |
ZclFrameError
Crate: zigbee-zcl · File: frame.rs
Errors during ZCL frame parsing.
| Variant | Meaning |
|---|---|
TooShort | Buffer too short to contain a valid ZCL header |
PayloadTooLarge | Payload exceeds maximum buffer size |
InvalidFrameType | Frame type bits are invalid / reserved |
OtaImageError
Crate: zigbee-zcl · File: clusters/ota_image.rs
Errors when parsing an OTA Upgrade image header.
| Variant | Meaning |
|---|---|
TooShort | Data too short for the OTA header |
BadMagic | Magic number does not match the OTA file identifier |
UnsupportedVersion | Header version is not supported |
BadHeaderLength | Header length field does not match actual data |
ImageTooLarge | Image size exceeds available storage |
ZDO Layer
ZdpStatus
Crate: zigbee-zdo · Spec ref: Zigbee spec Table 2-96
Returned in every ZDP response frame. Also aliased as ZdoStatus.
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Request completed successfully |
InvRequestType | 0x80 | The request type is invalid |
DeviceNotFound | 0x81 | The addressed device could not be found |
InvalidEp | 0x82 | The endpoint is invalid or not active |
NotActive | 0x83 | The endpoint is not in the active state |
NotSupported | 0x84 | The requested operation is not supported |
Timeout | 0x85 | The operation timed out |
NoMatch | 0x86 | No descriptor matched the request |
TableFull | 0x87 | The internal table (binding, etc.) is full |
NoEntry | 0x88 | No matching entry was found |
NoDescriptor | 0x89 | The requested descriptor is not available |
ZdoError
Crate: zigbee-zdo · File: lib.rs
Errors originating from ZDO internal processing.
| Variant | Meaning |
|---|---|
BufferTooSmall | Serialisation buffer is too small for the frame |
InvalidLength | Input data is shorter than the frame format requires |
InvalidData | A parsed field contains a reserved or invalid value |
ApsError(ApsStatus) | The underlying APS layer returned an error (wraps ApsStatus) |
TableFull | An internal fixed-capacity table is full |
BDB Layer
BdbStatus
Crate: zigbee-bdb · Spec ref: BDB spec Table 4
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Commissioning completed successfully |
InProgress | 0x01 | Commissioning is currently in progress |
NotOnNetwork | 0x02 | Node is not on a network (required for this operation) |
NotPermitted | 0x03 | Operation is not supported by this device type |
NoScanResponse | 0x04 | No beacons received during network steering |
FormationFailure | 0x05 | Network formation failed |
SteeringFailure | 0x06 | Network steering failed after all retries |
NoIdentifyResponse | 0x07 | No Identify Query response during Finding & Binding |
BindingTableFull | 0x08 | Binding table full or cluster matching failed |
TouchlinkFailure | 0x09 | Touchlink commissioning failed or is not supported |
TargetFailure | 0x0A | Target device is not in identifying mode |
Timeout | 0x0B | The operation timed out |
BdbCommissioningStatus
Crate: zigbee-bdb · File: attributes.rs · Spec ref: BDB spec Table 4
Attribute value recording the result of the last commissioning attempt.
Similar to BdbStatus but used as a persistent attribute rather than a
one-shot return value.
| Variant | Code | Meaning |
|---|---|---|
Success | 0x00 | Last commissioning attempt succeeded (default) |
InProgress | 0x01 | Commissioning is in progress |
NoNetwork | 0x02 | Device is not on a network |
TlTargetFailure | 0x03 | Touchlink target failure |
TlNotAddressAssignment | 0x04 | Touchlink address assignment failure |
TlNoScanResponse | 0x05 | Touchlink scan received no response |
NotPermitted | 0x06 | Operation not permitted for this device type |
SteeringFormationFailure | 0x07 | Network steering or formation failed |
NoIdentifyQueryResponse | 0x08 | Finding & Binding received no Identify response |
BindingTableFull | 0x09 | Binding table is full |
NoScanResponse | 0x0A | No scan response received |
Runtime / Support
StartError
Crate: zigbee-runtime · File: event_loop.rs
High-level errors from device start / join / leave operations.
| Variant | Meaning |
|---|---|
InitFailed | BDB initialization failed |
CommissioningFailed | BDB commissioning (steering or formation) failed |
FirmwareError
Crate: zigbee-runtime · File: firmware_writer.rs
Errors during OTA firmware write operations.
| Variant | Meaning |
|---|---|
EraseFailed | Flash erase operation failed |
WriteFailed | Flash write operation failed |
VerifyFailed | Verification failed (hash or size mismatch) |
OutOfRange | Offset is out of range for the firmware slot |
ImageTooLarge | Firmware slot is not large enough for the image |
ActivateFailed | Activation failed (e.g. boot flag not set) |
HardwareError | Flash hardware error |
NvError
Crate: zigbee-runtime · File: nv_storage.rs
Non-volatile storage errors.
| Variant | Meaning |
|---|---|
NotFound | Requested item was not found |
Full | Storage is full |
BufferTooSmall | Item is too large for the provided buffer |
HardwareError | Hardware error during read or write |
Corrupt | Data corruption detected |
Glossary
Zigbee and IEEE 802.15.4 terminology used throughout zigbee-rs.
| Term | Definition |
|---|---|
| APS | Application Support Sub-layer. Provides addressing, binding, group management, and reliable delivery between application endpoints. Implemented in the zigbee-aps crate. |
| BDB | Base Device Behavior. Defines standard commissioning procedures (steering, formation, Finding & Binding, Touchlink) that all Zigbee 3.0 devices must support. Implemented in the zigbee-bdb crate. |
| Binding | A persistent link in the APS binding table that maps a local cluster to a remote device or group. Bindings enable indirect addressing so an application can send data without knowing the destination address at compile time. |
| Channel | One of the sixteen IEEE 802.15.4 radio channels (11–26) in the 2.4 GHz band. Zigbee PRO primarily uses channels 11, 15, 20, and 25 for network formation. |
| Cluster | A ZCL construct grouping related attributes and commands (e.g. On/Off, Temperature Measurement). Each cluster has a 16-bit ID and is hosted on an endpoint. Defined and parsed in the zigbee-zcl crate. |
| Commissioning | The process of getting a device onto a network and configured. BDB defines four methods: network steering, network formation, Finding & Binding, and Touchlink. |
| Coordinator | The device that forms the Zigbee network, assigns the PAN ID, and often acts as the Trust Center. It always has short address 0x0000. |
| ED (End Device) | A Zigbee device that does not route traffic. It communicates only through its parent (a router or the coordinator) and may sleep to save power. See also SED. |
| Endpoint | A numbered application-level port (1–240) on a Zigbee node. Each endpoint hosts a set of input and output clusters. Endpoint 0 is reserved for the ZDO. |
| Extended PAN ID | A 64-bit IEEE address used to uniquely identify a Zigbee network, distinguishing it from other PANs that may share the same 16-bit PAN ID. |
| FFD | Full-Function Device. An IEEE 802.15.4 device capable of acting as a PAN coordinator or router. Zigbee coordinators and routers are FFDs. |
| Formation | The BDB commissioning step where a coordinator creates a new Zigbee network by selecting a channel and PAN ID, then starting the network. |
| Green Power | A Zigbee feature allowing ultra-low-power devices (e.g. energy-harvesting switches) to transmit without joining the network. Green Power frames are proxied by nearby routers. |
| Group | A 16-bit multicast address. Devices that belong to the same group all receive frames sent to that group ID, enabling one-to-many communication within a cluster. |
| IEEE Address | The globally unique 64-bit hardware address of a Zigbee device (also called the EUI-64 or extended address). Used during joining and for link-key establishment. |
| Install Code | A per-device secret (typically printed on the device or packaging) used to derive a unique link key during commissioning. Provides out-of-band security for Trust Center joining. |
| Link Key | A 128-bit AES key shared between two specific devices for APS-level encryption. Can be derived from an install code or provisioned by the Trust Center. |
| MAC | Medium Access Control. The lowest layer in zigbee-rs, responsible for frame formatting, CSMA-CA channel access, acknowledgements, and scanning. Implemented in the zigbee-mac crate with per-platform radio backends. |
| Network Key | A 128-bit AES key shared by all devices in the Zigbee network. Provides NWK-layer encryption and is distributed (encrypted) by the Trust Center. |
| NIB | NWK Information Base. A set of attributes maintained by the NWK layer (e.g. short address, PAN ID, security material). Accessed via NwkGet / NwkSet primitives. |
| NWK | Network layer. Handles mesh routing, 16-bit address assignment, broadcast, and network-level security. Implemented in the zigbee-nwk crate. |
| OTA | Over-The-Air upgrade. A ZCL cluster (0x0019) that allows firmware images to be distributed wirelessly. Parsed by zigbee-zcl and applied via zigbee-runtime’s firmware writer. |
| PAN | Personal Area Network. The logical Zigbee network formed by a coordinator and all devices that have joined it. |
| PAN ID | A 16-bit identifier for a PAN, used in MAC frame headers to distinguish traffic from overlapping networks. |
| PIB | PAN Information Base. A set of MAC-layer attributes (e.g. current channel, short address, frame counter) defined by IEEE 802.15.4. |
| Poll Control | A ZCL cluster (0x0020) that lets a server manage when a sleepy end device polls its parent. Useful for battery-powered devices that need timely command delivery. |
| Profile | A Zigbee application profile defining which clusters a device type must support. Zigbee 3.0 uses a single unified profile (HA profile 0x0104). |
| RFD | Reduced-Function Device. An IEEE 802.15.4 device that cannot route or act as a coordinator — it can only be an end device. |
| Router | A Zigbee device that participates in mesh routing, relays frames for other devices, and can allow new devices to join through it. Routers are always powered on. |
| SED (Sleepy End Device) | An end device that spends most of its time asleep to conserve battery. It wakes periodically to poll its parent for pending messages. |
| Short Address | A 16-bit network address assigned to a device when it joins the network. Used in NWK and MAC frame headers for compact addressing. |
| Steering | The BDB commissioning step where a device scans for open networks, joins one, and authenticates with the Trust Center. |
| Touchlink | A proximity-based commissioning mechanism (BDB chapter 8). A device physically close to a Touchlink initiator can be commissioned without an existing network. |
| Trust Center | The device (usually the coordinator) responsible for network security policy: distributing the network key, authorising joins, and managing link keys. |
| ZCL | Zigbee Cluster Library. Defines the standard set of clusters, attributes, commands, and data types used by Zigbee applications. Implemented in the zigbee-zcl crate. |
| ZDO | Zigbee Device Object. The management entity on endpoint 0 that handles device and service discovery, binding, and network management. Implemented in the zigbee-zdo crate. |
| ZDP | Zigbee Device Profile. The protocol (request/response commands) used to communicate with the ZDO on a remote device. ZdpStatus codes are returned in every ZDP response. |
| Zigbee PRO | The Zigbee PRO feature set (also called Zigbee PRO 2023 in the latest revision). It includes mesh networking, frequency agility, and stochastic addressing. zigbee-rs implements the Zigbee PRO stack. |