Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 from heapless replace Vec and HashMap, so every buffer size is known at compile time.
  • async/await on 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 MacDriver trait. Swap an impl and 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:

CrateRole
zigbee-typesCore types — IeeeAddress, ShortAddress, PanId, ChannelMask
zigbee-macIEEE 802.15.4 MAC layer + 10 hardware backends
zigbee-nwkNetwork layer — AODV + tree routing, NWK security, NIB
zigbee-apsApplication Support — binding, groups, APS security
zigbee-zdoZigbee Device Objects — discovery, binding, network management
zigbee-bdbBase Device Behavior — steering, formation, commissioning
zigbee-zclZigbee Cluster Library — 33 clusters, foundation frames, reporting
zigbee-runtimeDevice builder, power management, NV storage, device templates
zigbeeTop-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:

BackendTargetNotes
MockMacHost (macOS / Linux / Windows)Full protocol simulation, no hardware
ESP32-C6riscv32imac-unknown-none-elfNative 802.15.4 via esp-ieee802154
ESP32-H2riscv32imac-unknown-none-elfNative 802.15.4 via esp-ieee802154
nRF52840thumbv7em-none-eabihf802.15.4 radio peripheral
nRF52833thumbv7em-none-eabihf802.15.4 radio peripheral
BL702riscv32imac-unknown-none-elfVendor lmac154 FFI
CC2340thumbv6m-none-eabiTI SimpleLink SDK stubs
Telink B91riscv32imac-unknown-none-elfTelink SDK stubs
TLSR8258riscv32-unknown-none-elfTelink SDK stubs (tc32 ISA)
PHY6222thumbv6m-none-eabiPure 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-sensor example 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:

  1. Getting Started — Install the toolchain, run the mock examples, and build your first device. No hardware required.

  2. Core Concepts — Walk through each protocol layer: the Device Builder, the event loop, MAC, NWK, APS, ZDO, and BDB commissioning.

  3. 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.

  4. Platform Guides — Hardware-specific instructions for ESP32, nRF52, BL702, CC2340, Telink, and PHY6222. Covers wiring, flashing, and debugging on each chip.

  5. Advanced Topics — Power management for sleepy end devices, NV storage, security, OTA updates, and coordinator/router operation.

  6. 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:

  1. MLME-RESET — initialize the radio
  2. MLME-SCAN(Active) — discover nearby PANs
  3. MLME-ASSOCIATE — request a short address from the coordinator
  4. MLME-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:

  1. Performs an energy detection scan to pick the quietest channel
  2. Forms the network with MLME-START as PAN coordinator
  3. Sets up a Trust Center with the default link key
  4. Simulates three devices joining the network
  5. Builds a coordinator ZigbeeDevice with 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 DeviceBuilder API, 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 with cargo 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:

FieldValueMeaning
Endpoint1Application endpoints are 1–240
Profile ID0x0104Home Automation
Device ID0x0302Temperature 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 exposes MeasuredValue, MinMeasuredValue, and MaxMeasuredValue attributes.

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:

  1. MAC reset — initialize the radio
  2. Active scan — find nearby coordinators via beacons
  3. 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

SectionPurpose
MockMac setupCreates 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().awaitRuns 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 a HumidityCluster. See the mock-sensor example for a complete temp+humidity device.
  • Use a templatezigbee_runtime::templates::temperature_sensor(mac) gives you a pre-configured DeviceBuilder with Basic, Power Config, Identify, and Temperature clusters already set up.
  • Run on real hardware — swap MockMac for 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

CrateRole
zigbee-typesCore types shared by all layers: IeeeAddress, ShortAddress, PanId, ChannelMask, MacAddress. No dependencies.
zigbee-macIEEE 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-nwkNetwork layer. Frame parsing, AODV + tree routing, NWK security (AES-CCM*), the NIB (Network Information Base), and the NwkLayer<M: MacDriver> wrapper.
zigbee-apsApplication Support Sub-layer. APS frame encode/decode, binding table, group table, APS security, fragmentation, and duplicate detection.
zigbee-zdoZigbee Device Objects (endpoint 0). Handles discovery (Active_EP_req, Simple_Desc_req, Match_Desc_req), binding, and network management requests.
zigbee-bdbBase Device Behavior. Implements BDB commissioning: network steering (end devices join), network formation (coordinators create), Finding & Binding, and Touchlink.
zigbee-zclZigbee Cluster Library. 33 clusters, foundation commands (Read/Write/Report/Discover Attributes), attribute storage engine, and reporting engine.
zigbee-runtimeThe integration layer your application uses. Provides DeviceBuilder, ZigbeeDevice, the event loop (tick() / process_incoming()), NV storage abstraction, power management, and pre-built device templates.
zigbeeTop-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_std throughout — no heap allocation, no std::thread, no OS.
  • async without Send/Sync — the MacDriver trait uses async fn methods with no Send bounds, matching Embassy’s single-core executor model.
  • stack_tick() polling — your main loop calls device.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 uses embassy_futures::select to race device.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. No alloc crate needed.
  • Const generics — limits like MAX_ENDPOINTS (8) and MAX_CLUSTERS_PER_ENDPOINT (16) are const values, so the compiler knows the exact memory footprint at build time.
  • Static allocationZigbeeDevice and all its nested layers (BdbLayer<M>ZdoLayerApsLayerNwkLayer<M>M) live on the stack or in a static cell. There is no Box, Rc, or Arc.
  • 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

ComponentApproximate Size
ZigbeeDevice (full stack)~4–6 KB
Each ZCL cluster instance100–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?

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:

FieldDefault
device_typeDeviceType::EndDevice
channel_maskChannelMask::ALL_2_4GHZ
power_modePowerMode::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:

MethodDescription
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

TemplateDevice IDTypeKey Clusters
temperature_sensor0x0302EndDeviceBasic, PowerCfg, Identify, Temp
temperature_humidity_sensor0x0302EndDevice+ Relative Humidity
on_off_light0x0100RouterBasic, Identify, Groups, Scenes, On/Off
dimmable_light0x0101Router+ Level Control
color_temperature_light0x010CRouter+ Color Control
contact_sensor0x0402EndDeviceBasic, PowerCfg, Identify, IAS Zone
occupancy_sensor0x0107EndDeviceBasic, PowerCfg, Identify, Occupancy
smart_plug0x0009RouterBasic, Identify, Groups, Scenes, On/Off, Electrical Meas
thermostat0x0301RouterBasic, 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 to EndDevice.

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:

  1. Creates the NWK layer with the MAC driver and device type
  2. Sets rx_on_when_idle based on power mode
  3. Wraps NWK in the APS layer
  4. Wraps APS in the ZDO layer and registers all endpoint descriptors
  5. Sets the node descriptor (logical type, power descriptor)
  6. Wraps ZDO in the BDB layer for commissioning
  7. Creates the ReportingEngine for automatic attribute reporting
  8. 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:

  1. Start the event loop — call tick() and process_incoming() in a loop to drive the stack
  2. Register cluster instances — pass ClusterRef slices to tick() so the runtime can handle attribute reads/writes and send reports
  3. 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 processing
  • device.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:

PhaseWhat It Does
1. User actionsDrains the pending_action queue — processes Join, Leave, Toggle, PermitJoin, FactoryReset
2. ZCL responsesSends any queued ZCL response frames (from sync process_incoming() handling)
3. Join checkIf not joined to a network, returns Idle early
4. APS maintenanceAges the APS ACK table, retransmits unacknowledged frames, ages duplicate-detection and fragment tables
5. Reporting timersTicks the ZCL reporting engine by elapsed_secs seconds
5b. Find & BindHandles Finding & Binding target requests (sets IdentifyTime)
5c. F&B initiatorTicks the F&B initiator response window
6. Attribute reportsFor 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 needs tick() called again within ms milliseconds. 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:

  1. NWK layer — parses the NWK header, checks addressing, decrypts if NWK-secured
  2. APS layer — handles APS framing, duplicate detection, fragmentation reassembly
  3. ZDO (endpoint 0) — handles device interview commands (Node_Desc_req, Active_EP_req, Simple_Desc_req, etc.) and sends responses automatically
  4. 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 clusters slice to both tick() and process_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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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

MethodDescription
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:

BackendFeature FlagChip(s)Notes
ESP32esp32c6, esp32h2ESP32-C6, ESP32-H2Espressif IEEE 802.15.4 radio, esp-ieee802154 HAL
nRFnrf52840, nrf52833nRF52840, nRF52833Nordic 802.15.4 radio, embassy-nrf peripherals
BL702bl702BL702, BL706Bouffalo Lab 802.15.4 radio
CC2340cc2340CC2340R5TI SimpleLink, Cortex-M0+ with 802.15.4
TelinktelinkB91 (TLSR9518), TLSR8258Telink 802.15.4 radios — TLSR8258 is pure Rust (direct register access), B91 uses FFI
PHY6222phy6222PHY6222Phyplus BLE+802.15.4 combo SoC
EFR32MG1efr32EFR32MG1PSilicon Labs Series 1, Cortex-M4F — pure Rust (direct register access)
EFR32MG21efr32s2EFR32MG21Silicon Labs Series 2, Cortex-M33 — pure Rust (direct register access)
MockmockIn-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 efr32s2 module (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 esp or nrf backends 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:

TypeDescription
ScanType::EdMeasure noise energy on each channel
ScanType::ActiveSend beacon requests, collect responses
ScanType::PassiveListen for beacons without transmitting
ScanType::OrphanSearch for our coordinator after losing sync

Scan duration: The time spent on each channel is aBaseSuperframeDuration × (2^n + 1) symbols. Typical values:

ExponentTime per channelUse case
3~138 msFast scan
5~530 msNormal scan
7~2.1 sThorough 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)

AttributeIDDescriptionDefault
MacShortAddress0x53Own 16-bit NWK address0xFFFF (unassigned)
MacPanId0x50PAN ID of our network0xFFFF (not associated)
MacExtendedAddress0x6FOwn 64-bit IEEE addressFrom hardware
MacCoordShortAddress0x4BParent’s short address
MacCoordExtendedAddress0x4AParent’s extended address

Network Configuration

AttributeIDDescription
MacAssociatedPanCoord0x56Is this the PAN coordinator?
MacRxOnWhenIdle0x52Receive during idle (false = sleepy)
MacAssociationPermit0x41Accepting join requests?

Beacon (always 15/15 for Zigbee PRO non-beacon mode)

AttributeIDDescription
MacBeaconOrder0x47Beacon interval (always 15)
MacSuperframeOrder0x54Superframe duration (always 15)
MacBeaconPayload0x45Beacon payload bytes
MacBeaconPayloadLength0x46Length of beacon payload

TX/RX Tuning

AttributeIDDescriptionDefault
MacMaxCsmaBackoffs0x4EMax CSMA-CA retries4
MacMinBe0x4FMin backoff exponent3
MacMaxBe0x57Max backoff exponent5
MacMaxFrameRetries0x59Max ACK retries3

PHY Attributes (accessed via MAC GET/SET)

AttributeIDDescription
PhyCurrentChannel0x00Operating channel (11-26)
PhyChannelsSupported0x01Supported channels bitmask
PhyTransmitPower0x02TX power in dBm
PhyCcaMode0x03Clear Channel Assessment mode
PhyCurrentPage0x04Channel 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:

  1. Sets macAutoRequest = false (don’t auto-request data during scan)
  2. Sends an Active Scan via MAC — beacon requests on each channel
  3. Collects beacon responses as PanDescriptor structs
  4. Filters for Zigbee PRO beacons (protocol_id == 0, stack_profile == 2)
  5. Converts to NetworkDescriptor structs
  6. Sorts by LQI (best signal first)
  7. 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:

  1. Select the best network (highest LQI, open for joining, has capacity)
  2. Configure MAC: set channel, PAN ID, coordinator address
  3. Send MLME-ASSOCIATE.request to the chosen router/coordinator
  4. Receive MLME-ASSOCIATE.confirm with our assigned short address
  5. Update NIB: PAN ID, channel, short address, parent address
  6. Add parent to neighbor table with Relationship::Parent
  7. 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:

  1. ED Scan — measures energy (noise) on each channel
  2. Pick quietest channel — lowest energy = least interference
  3. Generate PAN ID — random 16-bit ID, avoiding 0xFFFF
  4. Configure MAC — set short address to 0x0000 (coordinator), set PAN ID
  5. Start PANMLME-START.request begins beacon transmission
  6. 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:

  1. Router needs to send to destination D but has no route
  2. Broadcasts a Route Request (RREQ) with destination D
  3. Each receiving router re-broadcasts the RREQ, recording path cost
  4. When RREQ reaches D (or a router with a route to D), a Route Reply (RREP) is unicast back along the best path
  5. 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:

StatusMeaning
ActiveRoute is valid and ready for forwarding
DiscoveryUnderwayRoute request broadcast, awaiting reply
DiscoveryFailedNo route reply received within timeout
InactiveRoute expired or was removed
ValidationUnderwayRoute 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
}

LQI (Link Quality Indicator, 0–255) is converted to an outgoing cost (1–7) used by the routing algorithm:

LQI RangeCostQuality
201–2551Excellent
151–2002Good
101–1503Fair
51–1005Poor
0–507Very 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

FieldTypeDescriptionDefault
extended_pan_idIeeeAddress64-bit network identifier[0; 8]
pan_idPanId16-bit PAN ID0xFFFF
network_addressShortAddressOur 16-bit address0xFFFF
logical_channelu8Operating channel (11-26)0

Network Parameters

FieldTypeDescriptionDefault
stack_profileu80x02 = Zigbee PRO0x02
depthu8Our depth in network tree0
max_depthu8Maximum network depth15
max_routersu8Max child routers5
max_childrenu8Max child end devices20
update_idu8Network update counter0

Addressing

FieldTypeDescriptionDefault
ieee_addressIeeeAddressOur 64-bit IEEE address[0; 8]
parent_addressShortAddressParent’s NWK address0xFFFF
address_assignAddressAssignMethodTreeBased or StochasticStochastic

Routing

FieldTypeDescriptionDefault
use_tree_routingboolEnable tree routing fallbackfalse
source_routingboolEnable source routingfalse
route_discovery_retriesu8Max RREQ retries3

Security

FieldTypeDescriptionDefault
security_levelu85 = ENC-MIC-325
security_enabledboolNWK encryption on/offtrue
active_key_seq_numberu8Active key index0
outgoing_frame_counteru32Outgoing frame counter0

Permit Joining

FieldTypeDescriptionDefault
permit_joiningboolAccept new join requestsfalse
permit_joining_durationu8Time 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, but next_frame_counter() returns None to 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:

  1. check_frame_counter(source, counter) — verifies the counter is strictly greater than the last seen value
  2. 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:

CapabilityHow
Find networksActive scan + beacon parsing
JoinMAC association + short address assignment
Form (coordinator)ED scan + PAN creation
Route (mesh)AODV on-demand route discovery
Route (tree)CSkip hierarchical forwarding
Track neighborsNeighbor table with LQI-based costs
EncryptAES-128-CCM* with network key + frame counter
Prevent replayPer-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

MethodReturnsPurpose
aps.nwk()&NwkLayer<M>Read NWK state (NIB, neighbor table, …)
aps.nwk_mut()&mut NwkLayer<M>Send NWK frames, join/leave
aps.aib()&AibRead APS Information Base attributes
aps.aib_mut()&mut AibWrite AIB attributes
aps.binding_table()&BindingTableInspect binding entries
aps.binding_table_mut()&mut BindingTableAdd/remove bindings
aps.group_table()&GroupTableInspect group memberships
aps.group_table_mut()&mut GroupTableAdd/remove groups
aps.security()&ApsSecurityInspect link keys
aps.security_mut()&mut ApsSecurityAdd/remove link keys
aps.fragment_rx()&FragmentReassemblyInspect 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:

AddressMeaning
0xFFFFAll devices
0xFFFDAll rx-on-when-idle devices (routers + mains-powered EDs)
0xFFFCAll 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:

  1. Sender side: When ApsTxOptions::fragmentation_permitted is true and the payload exceeds the NWK maximum transfer unit, the APS layer splits it into numbered blocks.

  2. Receiver side: The FragmentReassembly module 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.

  3. 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.

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:

VariantValueMeaning
Success0x00Request executed successfully
AsduTooLong0xA0Payload too large and fragmentation not supported
DefragDeferred0xA1Received fragment could not be defragmented
DefragUnsupported0xA2Device does not support fragmentation
IllegalRequest0xA3A parameter value was out of range
InvalidBinding0xA4Unbind failed — entry not found
InvalidParameter0xA5Unknown AIB attribute identifier
NoAck0xA6APS ACK not received (after retries)
NoBoundDevice0xA7Indirect send but no matching binding entry
NoShortAddress0xA8Group send but no matching group entry
TableFull0xA9Binding or group table is full
UnsecuredKey0xAAFrame secured with link key but key not found
UnsupportedAttribute0xABUnknown AIB attribute in GET/SET
SecurityFail0xADUnsecured frame received
DecryptionError0xAEAPS frame decryption or authentication failed
InsufficientSpace0xAFNot enough buffers for the operation
NotFound0xB0No 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:

  1. Registers the frame in the ACK table (up to 8 slots)
  2. Starts a retry counter (default: 3 retries)
  3. If no ACK arrives within one tick, retransmits the original frame
  4. 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

MethodReturnsPurpose
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()&NodeDescriptorThis device’s node descriptor
zdo.power_descriptor()&PowerDescriptorThis 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:

ServiceRequestResponse
NWK_addr0x00000x8000
IEEE_addr0x00010x8001
Node_Desc0x00020x8002
Power_Desc0x00030x8003
Simple_Desc0x00040x8004
Active_EP0x00050x8005
Match_Desc0x00060x8006
Device_annce0x0013
Bind0x00210x8021
Unbind0x00220x8022
Mgmt_Lqi0x00310x8031
Mgmt_Rtg0x00320x8032
Mgmt_Bind0x00330x8033
Mgmt_Leave0x00340x8034
Mgmt_Permit_Joining0x00360x8036
Mgmt_NWK_Update0x00380x8038

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:

VariantValueMeaning
Success0x00Request completed successfully
InvRequestType0x80Invalid request type field
DeviceNotFound0x81No device with the requested address
InvalidEp0x82Endpoint is not valid (0 or > 240)
NotActive0x83Endpoint exists but is not active
NotSupported0x84Request not supported on this device
Timeout0x85Request timed out
NoMatch0x86No matching descriptors found
TableFull0x87Binding / neighbor / routing table is full
NoEntry0x88No matching entry found (unbind, remove)
NoDescriptor0x89Requested 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

ModeWhat it doesWho uses it
Network SteeringJoin an existing network (or open it for others)End Devices, Routers
Network FormationCreate a new PAN from scratchCoordinators
Finding & BindingAutomatically create bindings between compatible endpointsAll device types
TouchlinkJoin 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:

ConstantValueMethod
CommissioningMode::TOUCHLINK0x01Touchlink
CommissioningMode::STEERING0x02Network Steering
CommissioningMode::FORMATION0x04Network Formation
CommissioningMode::FINDING_BINDING0x08Finding & Binding
CommissioningMode::ALL0x0FAll 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

MethodReturnsPurpose
bdb.zdo()&ZdoLayer<M>Access ZDO and below
bdb.zdo_mut()&mut ZdoLayer<M>Mutable ZDO access
bdb.attributes()&BdbAttributesRead BDB attributes
bdb.attributes_mut()&mut BdbAttributesConfigure BDB behavior
bdb.state()&BdbStateCurrent state machine state
bdb.is_on_network()boolWhether 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 TypeAvailable Modes
CoordinatorSteering + Formation + Finding & Binding
RouterSteering + Finding & Binding + Touchlink
End DeviceSteering + 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:

  1. Reset of lower layers (NLME-RESET)
  2. Detection of device type (Coordinator / Router / End Device)
  3. Setting node_commissioning_capability based on device type
  4. Syncing node_is_on_a_network with 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_coordinator is set to true
  • aps.aib().aps_trust_center_address is 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_id is not 0xFFFF, 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 (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() returns Err(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
}
#![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

VariantValueMeaning
Success0x00Commissioning completed successfully
InProgress0x01Commissioning is currently running
NotOnNetwork0x02Operation requires network membership
NotPermitted0x03Not supported by this device type
NoScanResponse0x04No beacons received during steering
FormationFailure0x05Network formation failed
SteeringFailure0x06Steering failed after all retries
NoIdentifyResponse0x07No Identify Query response during F&B
BindingTableFull0x08Binding table full during F&B
TouchlinkFailure0x09Touchlink failed or not supported
TargetFailure0x0ATarget not in identifying mode
Timeout0x0BOperation 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:

  1. Leave the current network (if joined)
  2. Reset NWK + MAC layers (clears neighbor table, security, routing)
  3. Clear APS binding table and group table
  4. 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:

  1. Scan the last-known channel for the previous network
  2. Attempt NLME-JOIN with Rejoin method (uses stored NWK key)
  3. Broadcast Device_annce
  4. 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

IDCommandDirectionResponse ID
0x00Read AttributesClient → Server0x01
0x01Read Attributes ResponseServer → Client
0x02Write AttributesClient → Server0x04
0x03Write Attributes UndividedClient → Server0x04
0x04Write Attributes ResponseServer → Client
0x05Write Attributes No ResponseClient → Server
0x06Configure ReportingClient → Server0x07
0x07Configure Reporting ResponseServer → Client
0x08Read Reporting ConfigurationClient → Server0x09
0x09Read Reporting Configuration ResponseServer → Client
0x0AReport AttributesServer → Client
0x0BDefault ResponseEither
0x0CDiscover AttributesClient → Server0x0D
0x0DDiscover Attributes ResponseServer → Client
0x11Discover Commands ReceivedClient → Server0x12
0x13Discover Commands GeneratedClient → Server0x14
0x15Discover Attributes ExtendedClient → Server0x16

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 requested
  • statusSuccess, UnsupportedAttribute, or WriteOnly
  • data_type / value — present only when status == 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:

FieldDescription
directionSend (0x00) or Receive (0x01)
attribute_idWhich attribute to report
data_typeZCL data type of the attribute
min_intervalMinimum seconds between reports
max_intervalMaximum seconds between reports (0xFFFF = disable periodic)
reportable_changeMinimum 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:

  1. Parses the incoming ZCL frame and checks frame_control.frame_type
  2. Foundation frames (frame_type = 0b00) are dispatched to the appropriate handler:
    • Read Attributes → process_read_dyn()
    • Write Attributes → process_write_dyn() or process_write_undivided_dyn()
    • Configure Reporting → ReportingEngine::configure_for_cluster()
    • Discover → process_discover_dyn() / process_discover_extended_dyn()
  3. Cluster-specific frames (frame_type = 0b01) are dispatched to your cluster’s handle_command()
  4. 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 AttributeAccess modes
  • 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.

AttributeIDTypeAccessDescription
ZCLVersion0x0000U8ReadZCL revision (8)
ApplicationVersion0x0001U8ReadApplication version
StackVersion0x0002U8ReadStack version
HWVersion0x0003U8ReadHardware version
ManufacturerName0x0004StringReadManufacturer name
ModelIdentifier0x0005StringReadModel identifier
DateCode0x0006StringReadDate code
PowerSource0x0007Enum8ReadPower source (0x01=mains, 0x03=battery)
LocationDescription0x0010StringR/WUser-settable location
SWBuildID0x4000StringReadSoftware 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.

AttributeIDTypeAccessDescription
BatteryVoltage0x0020U8ReportVoltage in 100 mV units
BatteryPercentageRemaining0x0021U8ReportPercentage in 0.5% units
BatterySize0x0031Enum8R/WBattery size (3=AA, 4=AAA, …)
BatteryQuantity0x0033U8R/WNumber of battery cells
BatteryRatedVoltage0x0034U8R/WRated voltage (100 mV units)
BatteryAlarmMask0x0035Bitmap8R/WAlarm enable bits
BatteryVoltageMinThreshold0x0036U8R/WLow-voltage threshold
BatteryAlarmState0x003EBitmap32ReadActive 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).

AttributeIDTypeAccess
IdentifyTime0x0000U16R/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.

AttributeIDTypeAccess
NameSupport0x0000U8Read

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.

AttributeIDTypeAccess
SceneCount0x0000U8Read
CurrentScene0x0001U8Read
CurrentGroup0x0002U16Read
SceneValid0x0003BoolRead
NameSupport0x0004U8Read
LastConfiguredBy0x0005IEEERead

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.

AttributeIDTypeAccessDescription
OnOff0x0000BoolReportCurrent state
GlobalSceneControl0x4000BoolReadGlobal scene recall flag
OnTime0x4001U16R/WTimed-on remaining (1/10s)
OffWaitTime0x4002U16R/WOff-wait remaining (1/10s)
StartUpOnOff0x4003Enum8R/WStartup 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.

AttributeIDTypeAccessDescription
CurrentLevel0x0000U8ReportCurrent brightness (0–254)
RemainingTime0x0001U16ReadTransition time left (1/10s)
MinLevel0x0002U8ReadMinimum level
MaxLevel0x0003U8ReadMaximum level
OnOffTransitionTime0x0010U16R/WDefault transition time
OnLevel0x0011U8R/WLevel when turned on
StartUpCurrentLevel0x4000U8R/WLevel 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).

AttributeIDTypeAccessDescription
MeasuredValue0x0000I16ReportCurrent temperature × 100
MinMeasuredValue0x0001I16ReadMinimum measurable
MaxMeasuredValue0x0002I16ReadMaximum measurable
Tolerance0x0003U16ReadMeasurement 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).

AttributeIDTypeAccessDescription
MeasuredValue0x0000U16ReportCurrent humidity × 100
MinMeasuredValue0x0001U16ReadMinimum measurable
MaxMeasuredValue0x0002U16ReadMaximum measurable
Tolerance0x0003U16ReadMeasurement 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.

AttributeIDTypeAccessDescription
MeasuredValue0x0000I16ReportPressure in 0.1 kPa
MinMeasuredValue0x0001I16ReadMinimum measurable
MaxMeasuredValue0x0002I16ReadMaximum measurable
Tolerance0x0003U16ReadMeasurement tolerance
ScaledValue0x0010I16ReportHigh-precision pressure
MinScaledValue0x0011I16ReadMinimum scaled
MaxScaledValue0x0012I16ReadMaximum scaled
ScaledTolerance0x0013U16ReadScaled tolerance
Scale0x0014I8Read10^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.

AttributeIDTypeAccess
MeasuredValue0x0000U16Report
MinMeasuredValue0x0001U16Read
MaxMeasuredValue0x0002U16Read
Tolerance0x0003U16Read

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.

AttributeIDTypeAccess
MeasuredValue0x0000U16Report
MinMeasuredValue0x0001U16Read
MaxMeasuredValue0x0002U16Read
Tolerance0x0003U16Read
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::flow_measurement::FlowMeasurementCluster;
}

Occupancy Sensing (0x0406)

Binary occupancy detection with configurable sensor type.

AttributeIDTypeAccessDescription
Occupancy0x0000Bitmap8ReportBit 0 = occupied
OccupancySensorType0x0001Enum8Read0=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).

AttributeIDTypeAccess
MeasurementType0x0000Bitmap32Read
RmsVoltage0x0505U16Report
RmsCurrent0x0508U16Report
ActivePower0x050BI16Report
PowerFactor0x0510I8Read
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::electrical::ElectricalMeasurementCluster;
}

PM2.5 Measurement (0x042A)

Particulate matter (PM2.5) concentration.

AttributeIDTypeAccess
MeasuredValue0x0000U16Report
MinMeasuredValue0x0001U16Read
MaxMeasuredValue0x0002U16Read
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::pm25::Pm25Cluster;
}

Carbon Dioxide (0x040D)

CO₂ concentration measurement in PPM.

AttributeIDTypeAccess
MeasuredValue0x0000U16Report
MinMeasuredValue0x0001U16Read
MaxMeasuredValue0x0002U16Read
#![allow(unused)]
fn main() {
use zigbee_zcl::clusters::carbon_dioxide::CarbonDioxideCluster;
}

Soil Moisture (0x0408)

Soil moisture level in 0.01% units.

AttributeIDTypeAccess
MeasuredValue0x0000U16Report
MinMeasuredValue0x0001U16Read
MaxMeasuredValue0x0002U16Read
#![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

AttributeIDTypeAccessDescription
CurrentHue0x0000U8ReportHue (0–254)
CurrentSaturation0x0001U8ReportSaturation (0–254)
RemainingTime0x0002U16ReadTransition time remaining (1/10s)
CurrentX0x0003U16ReportCIE x chromaticity (0–65279)
CurrentY0x0004U16ReportCIE y chromaticity (0–65279)
ColorTemperatureMireds0x0007U16ReportColor temp in mireds
ColorMode0x0008Enum8ReadActive mode (0=Hue/Sat, 1=XY, 2=Temp)
Options0x000FBitmap8R/WProcessing flags
EnhancedCurrentHue0x4000U16Read16-bit enhanced hue
EnhancedColorMode0x4001Enum8ReadEnhanced mode indicator
ColorLoopActive0x4002U8ReadColor loop running (0/1)
ColorLoopDirection0x4003U8ReadLoop direction (0=decrement, 1=increment)
ColorLoopTime0x4004U16ReadLoop period in seconds
ColorCapabilities0x400ABitmap16ReadSupported features bitmask
ColorTempPhysicalMin0x400BU16ReadMin supported mireds (e.g. 153 = 6500K)
ColorTempPhysicalMax0x400CU16ReadMax 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

IDCommandDescription
0x00MoveToHueTransition to target hue
0x01MoveHueContinuous hue movement
0x02StepHueStep hue by increment
0x03MoveToSaturationTransition to target saturation
0x04MoveSaturationContinuous saturation movement
0x05StepSaturationStep saturation by increment
0x06MoveToHueAndSaturationTransition both
0x07MoveToColorTransition to XY color
0x08MoveColorContinuous XY movement
0x09StepColorStep XY by increments
0x0AMoveToColorTemperatureTransition to color temp
0x40EnhancedMoveToHue16-bit hue transition
0x41EnhancedMoveHue16-bit continuous hue
0x42EnhancedStepHue16-bit hue step
0x43EnhancedMoveToHueAndSaturation16-bit hue + sat
0x44ColorLoopSetStart/stop color loop
0x47StopMoveStepStop all transitions
0x4BMoveColorTemperatureContinuous temp movement
0x4CStepColorTemperatureStep 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:

  1. The cluster calculates start value, target value, and transition time
  2. Starts a Transition in the TransitionManager
  3. Each tick() call interpolates the current value linearly
  4. Attribute store is updated with the interpolated value
  5. RemainingTime attribute 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.

AttributeIDTypeAccessDescription
PhysicalMinLevel0x0000U8ReadMinimum light output
PhysicalMaxLevel0x0001U8ReadMaximum light output
BallastStatus0x0002Bitmap8ReadStatus flags
MinLevel0x0010U8R/WConfigured minimum
MaxLevel0x0011U8R/WConfigured 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

AttributeIDTypeAccessDescription
LocalTemperature0x0000I16ReportCurrent sensor reading
OutdoorTemperature0x0001I16ReadOutdoor temp (optional)
Occupancy0x0002U8ReadOccupancy bitmap
AbsMinHeatSetpointLimit0x0003I16ReadAbsolute minimum heat SP (700 = 7°C)
AbsMaxHeatSetpointLimit0x0004I16ReadAbsolute maximum heat SP (3000 = 30°C)
AbsMinCoolSetpointLimit0x0005I16ReadAbsolute minimum cool SP (1600 = 16°C)
AbsMaxCoolSetpointLimit0x0006I16ReadAbsolute maximum cool SP (3200 = 32°C)
OccupiedCoolingSetpoint0x0011I16R/WActive cooling setpoint (2600 = 26°C)
OccupiedHeatingSetpoint0x0012I16R/WActive heating setpoint (2000 = 20°C)
MinHeatSetpointLimit0x0015I16R/WConfigurable heat SP minimum
MaxHeatSetpointLimit0x0016I16R/WConfigurable heat SP maximum
MinCoolSetpointLimit0x0017I16R/WConfigurable cool SP minimum
MaxCoolSetpointLimit0x0018I16R/WConfigurable cool SP maximum
ControlSequenceOfOperation0x001BEnum8R/W0x04 = Cooling and Heating
SystemMode0x001CEnum8R/WCurrent operating mode
ThermostatRunningMode0x001EEnum8ReadComputed running mode

System Modes

ValueModeDescription
0x00OffSystem disabled
0x01AutoAutomatic heat/cool switching
0x03CoolCooling only
0x04HeatHeating only
0x05Emergency HeatEmergency/auxiliary heating
0x07Fan OnlyFan without heating/cooling

Commands

IDDirectionCommand
0x00Client→ServerSetpointRaiseLower
0x01Client→ServerSetWeeklySchedule
0x02Client→ServerGetWeeklySchedule
0x03Client→ServerClearWeeklySchedule
0x00Server→ClientGetWeeklyScheduleResponse

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

AttributeIDTypeAccessDescription
FanMode0x0000Enum8R/WCurrent fan mode
FanModeSequence0x0001Enum8R/WAvailable mode sequence

Fan Modes

ValueMode
0x00Off
0x01Low
0x02Medium
0x03High
0x04On
0x05Auto
0x06Smart

Fan Mode Sequences

ValueSequence
0x00Low/Med/High
0x01Low/High
0x02Low/Med/High/Auto
0x03Low/High/Auto
0x04On/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

AttributeIDTypeAccessDescription
TemperatureDisplayMode0x0000Enum8R/W0=Celsius, 1=Fahrenheit
KeypadLockout0x0001Enum8R/W0=No lockout, 1–5=lockout levels
ScheduleProgrammingVisibility0x0002Enum8R/W0=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

AttributeIDTypeAccessDescription
LockState0x0000Enum8Report0=NotFullyLocked, 1=Locked, 2=Unlocked
LockType0x0001Enum8ReadDeadBolt(0), Magnetic(1), Other(2), etc.
ActuatorEnabled0x0002BoolReadActuator operational
DoorState0x0003Enum8ReportOpen(0), Closed(1), Jammed(2), etc.
DoorOpenEvents0x0004U32R/WDoor-open counter
DoorClosedEvents0x0005U32R/WDoor-close counter
OpenPeriod0x0006U16R/WAuto-close period
NumPINUsersSupported0x0012U16ReadMax PIN users
MaxPINCodeLength0x0017U8ReadMax PIN length (default 8)
MinPINCodeLength0x0018U8ReadMin PIN length (default 4)
Language0x0021StringR/WDisplay language
AutoRelockTime0x0023U32R/WAuto-relock delay in seconds
OperatingMode0x0025Enum8R/WNormal(0), Vacation(1), Privacy(2), etc.

Commands (Client → Server)

IDCommandDescription
0x00LockDoorLock the door
0x01UnlockDoorUnlock (starts auto-relock timer)
0x02ToggleToggle lock/unlock
0x03UnlockWithTimeoutUnlock with auto-relock
0x05SetPINCodeSet user PIN
0x06GetPINCodeRetrieve user PIN
0x07ClearPINCodeDelete a user’s PIN
0x08ClearAllPINCodesDelete all PINs
0x09SetUserStatusEnable/disable a user
0x0AGetUserStatusQuery 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

AttributeIDTypeAccessDescription
WindowCoveringType0x0000Enum8ReadCovering type
ConfigStatus0x0007Bitmap8ReadConfiguration flags
CurrentPositionLiftPercentage0x0008U8ReportLift position (0–100%)
CurrentPositionTiltPercentage0x0009U8ReportTilt position (0–100%)
InstalledOpenLimitLift0x0010U16ReadOpen limit
InstalledClosedLimitLift0x0011U16ReadClosed limit
Mode0x0017Bitmap8R/WOperating mode flags

Covering Types

ValueType
0x00Roller Shade
0x04Drapery
0x05Awning
0x06Shutter
0x07Tilt Blind (tilt only)
0x08Tilt Blind (lift + tilt)
0x09Projector Screen

Commands

IDCommand
0x00UpOpen
0x01DownClose
0x02Stop
0x05GoToLiftPercentage
0x08GoToTiltPercentage
#![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

AttributeIDTypeAccessDescription
ZoneState0x0000Enum8ReadNotEnrolled(0) / Enrolled(1)
ZoneType0x0001Enum16ReadSensor type code
ZoneStatus0x0002Bitmap16ReadAlarm and tamper bits
IAS_CIE_Address0x0010IEEER/WCIE’s IEEE address
ZoneID0x0011U8ReadAssigned zone ID
NumZoneSensitivityLevels0x0012U8ReadSupported sensitivity levels
CurrentZoneSensitivityLevel0x0013U8R/WActive sensitivity

Zone Types

ValueType
0x0000Standard CIE
0x000DMotion Sensor
0x0015Contact Switch
0x0028Fire Sensor
0x002AWater Sensor
0x002BCO Sensor
0x002DPersonal Emergency
0x010FRemote Control
0x0115Key Fob
0x021DKeypad
0x0225Standard Warning

Zone Status Bits

BitMeaning
0Alarm1 (zone-type specific)
1Alarm2 (zone-type specific)
2Tamper
3Battery low
4Supervision reports
5Restore reports
6Trouble
7AC (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

AttributeIDTypeAccessDescription
CurrentSummationDelivered0x0000U48ReportTotal energy delivered to premises
CurrentSummationReceived0x0001U48ReportTotal energy exported (solar, etc.)
UnitOfMeasure0x0300Enum8ReadMeasurement unit
Multiplier0x0301U24ReadValue multiplier
Divisor0x0302U24ReadValue divisor
SummationFormatting0x0303Bitmap8ReadDisplay format
DemandFormatting0x0304Bitmap8ReadDemand display format
MeteringDeviceType0x0308Bitmap8ReadDevice type
InstantaneousDemand0x0400I32ReportCurrent power draw (signed)
PowerFactor0x0510I8ReadPower factor (-100 to +100)

Unit of Measure Values

ValueUnitDescription
0x00kWhKilowatt hours
0x01Cubic meters
0x02ft³Cubic feet
0x03CCFCentum cubic feet
0x04US galUS gallons
0x05IMP galImperial gallons
0x06BTUBritish thermal units
0x07LLiters
0x08kPaKilopascals (gas pressure)

Metering Device Types

ValueType
0x00Electric metering
0x01Gas metering
0x02Water 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 kWh
  • InstantaneousDemand = 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

ModeReadsWritesReporting
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 Response
  • Ok(vec_with_data) — success, runtime sends a cluster-specific response
  • Err(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:

FeatureHow It Works
Read AttributesCalls attributes().get() for each requested ID
Write AttributesCalls attributes_mut().set() with access control
Write UndividedValidates all writes first, then applies atomically
Configure ReportingStores config in ReportingEngine
Report AttributesChecks values via attributes() on each tick
Discover AttributesEnumerates from attributes().all_ids()
Discover CommandsCalls received_commands() / generated_commands()
Default ResponseGenerated 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-C6ESP32-H2
CoreRISC-V (single, 160 MHz)RISC-V (single, 96 MHz)
Flash4 MB (external SPI)4 MB (external SPI)
SRAM512 KB320 KB
RadioWiFi 6 + BLE 5 + 802.15.4BLE 5 + 802.15.4
Targetriscv32imac-unknown-none-elfriscv32imac-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,alloc flag is configured in each example’s .cargo/config.toml under [unstable], so a plain cargo build --release also 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

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:

  1. Select your chip (ESP32-C6 or ESP32-H2)
  2. Click Connect and choose the serial port
  3. 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:

  1. Hold the BOOT button
  2. Press and release RESET (while holding BOOT)
  3. Release BOOT
  4. 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

FeatureChipCargo.toml dependency
esp32c6ESP32-C6zigbee-mac = { features = ["esp32c6"] }
esp32h2ESP32-H2zigbee-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

  1. EspMac wraps Ieee802154Driver and implements the MacDriver trait
  2. Ieee802154Driver wraps esp_radio::ieee802154::Ieee802154 for synchronous TX and polling-based RX
  3. The EUI-64 address is read from the chip’s eFuse factory MAC
  4. Scanning uses real beacon parsing — the radio enters RX mode and collects beacon frames across channels 11–26
  5. 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 0x3FE0000x3FFFFF, 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

SymptomCauseFix
espflash can’t find deviceNot in download modeHold BOOT → press RESET → release BOOT
espflash timeoutUSB-UART bridge issueTry a different USB cable/port
Build error: rust-src not foundMissing componentrustup component add rust-src
Linker error: linkall.x not foundesp-hal version mismatchCheck esp-hal version matches esp-radio
Serial output garbledWrong baud rateDefault is 115200 — check monitor settings
Device doesn’t join networkCoordinator not in permit-join modeEnable permit joining on your coordinator
No beacon foundWrong channelEnsure 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

nRF52840nRF52833
CoreARM Cortex-M4F, 64 MHzARM Cortex-M4F, 64 MHz
Flash1024 KB512 KB
RAM256 KB128 KB
RadioBLE 5.3 + 802.15.4 + NFCBLE 5.3 + 802.15.4 + NFC
Targetthumbv7em-none-eabihfthumbv7em-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-uf2 example 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 with probe-rs list if detection fails.

UF2 Drag-and-Drop Flash

For boards with UF2 bootloaders (nice!nano, ProMicro, MDK Dongle):

  1. Build the firmware:

    cd examples/nrf52840-sensor-uf2
    cargo build --release
    
  2. 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
    
  3. Enter bootloader mode: Double-tap the RESET button on the board. A USB mass storage device appears (e.g., NICENANO).

  4. Copy the .uf2 file to the USB drive. The board flashes automatically and reboots into your firmware.

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

FeatureChipCargo.toml dependency
nrf52840nRF52840zigbee-mac = { features = ["nrf52840"] }
nrf52833nRF52833zigbee-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

  1. NrfMac<T: Instance> wraps Embassy’s Radio<T> and implements MacDriver
  2. Radio TX/RX is fully interrupt-driven with DMA — no polling needed
  3. Hardware auto-ACK is enabled for frames with the ACK request bit
  4. Hardware address filtering is configured through the radio peripheral
  5. The factory-programmed IEEE address is read from FICR registers
  6. Embassy’s time-driver-rtc1 provides 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:

PhasePoll IntervalDurationCurrent
Fast poll250 ms120 s after join/activityHigher (responsive)
Slow poll30 sSteady stateVery 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:

FeatureBoardLEDFlash Origin
board-promicroProMicro / nice!nanoP0.15 (HIGH)0x26000
board-mdkMakerdiary MDK DongleP0.22 (LOW)0x1000
board-nrf-dongleNordic PCA10059P0.06 (LOW)0x1000
board-nrf-dkNordic DK (PCA10056)P0.13 (LOW)0x0000

This variant auto-joins on boot (no button press needed) and includes a logdefmt 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

SymptomCauseFix
probe-rs can’t find deviceProbe not connectedCheck USB; run probe-rs list
probe-rs permission deniedMissing udev rules (Linux)See probe-rs setup
292 / RAM overflowToo many features enabledCheck Embassy feature flags, reduce arena size
defmt output garbledVersion mismatchEnsure defmt, defmt-rtt, panic-probe versions match
UF2 board not appearingNot in bootloaderDouble-tap RESET quickly; look for USB drive
Device doesn’t joinCoordinator not permittingEnable permit-join on coordinator
No temperature readingTEMP interrupt not boundEnsure 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

SpecValue
CoreRISC-V 32-bit (RV32IMAF), 144 MHz
Flash512 KB (XIP)
SRAM128 KB (112 KB usable after cache)
RadioBLE 5.0 + IEEE 802.15.4
Targetriscv32imac-unknown-none-elf
I/OUART ×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 library
  • libbl702_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)

  1. Download BLDevCube from Bouffalo
  2. Select BL702 chip
  3. Load the .bin file
  4. 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

SymptomCauseFix
Linker error: undefined lmac154_*Vendor libraries not linkedSet BL_IOT_SDK_DIR or use --features stubs
Float ABI mismatchVendor .a uses hard-floatStrip ELF float-ABI flag from .a files
blflash can’t connectNot in boot modeHold BOOT pin low during reset
No UART outputWrong UART pins or baudCheck board schematic; default 115200 baud
Timer not workingTIMER_CH0 not initializedEnsure time_driver::init() runs before Embassy
Build fails without stubsMissing vendor lib env varsSet 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

SpecValue
CoreARM Cortex-M0+, 48 MHz
Flash512 KB
RAM36 KB SRAM
Radio2.4 GHz IEEE 802.15.4 + BLE 5.4
Targetthumbv6m-none-eabi
I/OUART, SPI, I2C, ADC, GPIO
PackageQFN 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 MacDriver trait 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

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

  1. Open UniFlash and select CC2340R5
  2. Load the .hex or .bin file
  3. 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

SymptomCauseFix
Linker error: undefined RCL_*TI SDK not linkedSet CC2340_SDK_DIR or use --features stubs
portable-atomic errorsMissing featureEnsure features = ["unsafe-assume-single-core"]
No debug outputNo logger on Cortex-M0+Use probe-rs RTT for debug
Flash failsWrong chip selectedVerify CC2340R5 in probe-rs or UniFlash
RAM overflow36 KB limitReduce stack size, optimize allocations
Build without stubs failsMissing SDK librariesDownload TI SimpleLink F3 SDK

Roadmap

To bring the CC2340 backend to full RF operation:

  1. Embassy time driver — implement using CC2340R5 RTC or SysTick
  2. Proper GPIO HAL — replace register-level access with a Rust HAL
  3. Link real RCL — test with actual TI SDK libraries
  4. Interrupt wiring — connect RCL callbacks to Embassy signals
  5. 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

SpecValue
CoreRISC-V 32-bit, up to 96 MHz
Flash512 KB
SRAM256 KB
RadioBLE 5.0 + IEEE 802.15.4
Targetriscv32imc-unknown-none-elf
I/OUART ×2, SPI, I2C, ADC, PWM, USB
SpecValue
Coretc32 (Telink custom ISA)
Flash512 KB
SRAM64 KB
RadioBLE + IEEE 802.15.4
Cargo targetthumbv6m-none-eabi (stand-in for tc32)
Real toolchainmodern-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 use thumbv6m-none-eabi as a compilation stand-in. Real production builds use the modern-tc32 toolchain, which provides a custom Rust compiler with native tc32-unknown-none-elf target 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:

FunctionImplementation
Channel setDirect RF frequency register write
TX powerPA register lookup table
TX/RXDMA-based with hardware packet format
CCARSSI measurement via RF status register
ED scanEnergy detection via RSSI averaging
IRQ handlingRF IRQ mask/status registers
Radio sleepDisable RF + DMA + IRQ (~5-8 mA saved)
CPU suspendTimer-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.a from the Telink Zigbee SDK.

The B91 backend is architecturally complete:

  • Full MacDriver trait 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.

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

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

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-elf is used instead.

Flashing

  1. Connect via Telink’s Swire debug interface
  2. Use the Telink BDT GUI to flash the .bin file
  3. Alternatively, use Telink’s command-line tl_check_fw + tl_bulk_pgm tools

For commercial products (Sonoff SNZB-02 etc.), OTA updates through Zigbee are the typical approach. For development:

  1. Use Telink BDT via Swire debug pins
  2. Flash the .bin to address 0x0000

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

SymptomCauseFix
Linker error: undefined rf_*Telink SDK not linked (B91 only)Set TELINK_SDK_DIR or use --features stubs
portable-atomic errorsMissing feature flagEnsure features = ["unsafe-assume-single-core"]
TLSR8258 real build failsmodern-tc32 toolchain neededInstall from modern-tc32
B91 wrong targetUsing riscv32imacB91 CI uses riscv32imc-unknown-none-elf (no atomics)
No debug outputNo logger registeredUse Telink UART or BDT for debug output
BDT can’t connectSwire not connectedCheck debug interface wiring

Roadmap

To bring the B91 backend to full RF operation:

  1. Embassy time driver — implement using Telink system timer
  2. Link real SDK — test B91 with tl_zigbee_sdk driver libraries
  3. Interrupt wiring — connect RF IRQ handler to Embassy signals
  4. B91 HAL crate — community embassy-telink-b91 effort
  5. TLSR8258 Rust target — explore custom target JSON for tc32 ISA
  6. TLSR8258 pure-Rust radio — replace all FFI with register access
  7. TLSR8258 power management — radio sleep + CPU suspend
  8. 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-elf target
  • LLVM backend with TC32 support (clang --target=tc32)
  • Prebuilt core/alloc for 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 thumbv6m codegen 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:

  1. Compiles Telink SDK C sources with clang --target=tc32
  2. Links libsoft-fp.a from the SDK
  3. Handles startup code and linker script
  4. Creates a flashable binary

Legacy: Build Script

A helper script build-tc32.sh is also available for manual builds:

  1. Compiles Rust code with the tc32 target
  2. Assembles tc32 startup code (cstartup_8258.S)
  3. Links everything with tc32-elf-ld
  4. Creates a flashable .bin with tc32-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_DIR environment variable pointing to tl_zigbee_sdk (only needed for soft-float math library and startup code)
  • Rust nightly with rust-src component (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:

SpecPHY6222PHY6252
CoreARM Cortex-M0, 48 MHzARM Cortex-M0, 48 MHz
Flash512 KB256 KB
SRAM64 KB64 KB
ROM96 KB96 KB
BLE5.05.2
Radio2.4 GHz BLE + IEEE 802.15.42.4 GHz BLE + IEEE 802.15.4
TX power-20 dBm to +10 dBm-20 dBm to +10 dBm
Targetthumbv6m-none-eabithumbv6m-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

BoardForm FactorLED PinsButton
Ai-Thinker PB-03FModule, castellated (PHY6252)P11 (R), P12 (G), P14 (B)P15 (PROG)
Tuya THB2Sensor enclosure (PHY6222)VariesVaries
Tuya TH05FTemp/humidity sensor (PHY6222)VariesVaries
BTH01BLE thermometer (PHY6222)VariesVaries

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:

RegionBase AddressPurpose
RF PHY0x4003_0000Analog/baseband configuration (PLL, LNA, PA, modulation)
LL HW0x4003_1000Link-Layer hardware engine (TX/RX control, IRQ, CRC)
TX FIFO0x4003_1400Frame data write port
RX FIFO0x4003_1C00Received frame read port
CLK CTRL0x4000_F040Crystal/clock configuration
GPIO0x4000_8000GPIO 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

AspectPHY6222Other Platforms
Vendor SDKNot neededRequired
Binary blobsZeroliblmac154.a, rcl.a, etc.
FFI callsZeroDozens of extern "C" functions
AuditabilityFull — all RustOpaque vendor libraries
Build depsJust cargoSDK 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:

SensorMeasuresI²C Address
CHT8215Temp + Humidity0x40
CHT8310Temp + Humidity0x40
SHT30Temp + Humidity0x44
AHT20Temp + Humidity0x38

A real firmware would initialize the PHY6222 I²C peripheral and use an embedded-hal 1.0 compatible sensor driver.

Troubleshooting

SymptomCauseFix
portable-atomic errorsMissing featureEnsure features = ["unsafe-assume-single-core"]
Flash failsNot in bootloader modeHold PROG button during power-on
No serial outputNo logger registeredlog::info!() is no-op on Cortex-M0+ without logger
Radio not workingTP calibration defaultsProduction firmware needs proper PLL lock sequence
Wrong memory layoutOTA header offsetEnsure FLASH ORIGIN = 0x11001000 (after 4 KB bootloader)
64 KB SRAM overflowLarge buffersOptimize 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

TierPhaseSleep ModeCurrentWake Source
1Fast poll (250 ms, first 120 s)Radio off + WFE~1.5 mATimer
2Slow poll (30 s, steady state)AON system sleep~3 µARTC

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)

FunctionPurpose
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:

AttributeMin IntervalMax IntervalChange Threshold
Temperature60 s300 s±0.5 °C
Humidity60 s300 s±1%
Battery300 s3600 s±2%

Battery Life Estimate (2×AAA, ~1200 mAh)

StateCurrentDuty 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:

  1. Eliminates supply chain risk — no opaque binary blobs
  2. Simplifies the build — just cargo build, no SDK downloads
  3. Enables full auditing — every line of radio code is visible and reviewable
  4. Reduces binary size — no unused vendor code linked in
  5. 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

SpecEFR32MG1P (Series 1)EFR32MG21 (Series 2)
CoreARM Cortex-M4F @ 40 MHzARM Cortex-M33 @ 80 MHz
Flash256 KB512 KB
SRAM32 KB64 KB
Radio2.4 GHz IEEE 802.15.4 + BLE2.4 GHz IEEE 802.15.4 + BLE
SecurityCRYPTO engineSecure Element (SE) + TrustZone
Targetthumbv7em-none-eabihfthumbv8m.main-none-eabihf
Flash page size2 KB8 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

BoardSeriesForm FactorNotes
IKEA TRÅDFRI modulesSeries 1PCB modulesEFR32MG1P, widely available
Thunderboard Sense (BRD4151A)Series 1Dev boardEFR32MG1P + sensors
BRD4100ASeries 1Radio boardEFR32MG1P evaluation
BRD4180ASeries 2Radio boardEFR32MG21A020F1024IM32
BRD4181ASeries 2Radio boardEFR32MG21A020F512IM32
Sonoff ZBDongle-ESeries 2USB dongleEFR32MG21, popular coordinator

Series 1 vs Series 2 Register Differences

The two series have different peripheral base addresses, requiring separate MAC modules (efr32/ and efr32s2/):

PeripheralSeries 1 BaseSeries 2 Base
Radio (RAC, FRC, MODEM, etc.)0x40080000–0x40087FFF0x40090000–0x40095FFF
CMU (Clock Management Unit)0x400E40000x40008000
GPIO0x4000A0000x4003C000
MSC (Flash Controller)0x400E00000x40030000

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:

BlockFunction
RACRadio Controller — state machine, PA
FRCFrame Controller — CRC, format
MODEMO-QPSK modulation/demodulation
SYNTHPLL frequency synthesizer
AGCAutomatic gain control, RSSI
BUFCTX/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

SymptomCauseFix
probe-rs can’t connectWrong chip nameUse EFR32MG1P (S1) or EFR32MG21A020F512IM32 (S2)
Flash write fails (MG21)Wrong page sizeSeries 2 uses 8 KB pages (vs 2 KB for Series 1)
Radio not workingRegister init approximationsSee known limitations — init registers need verification
Build fails with linker errorsWrong targetUse thumbv7em-none-eabihf (S1) or thumbv8m.main-none-eabihf (S2)
No serial outputNo logger configuredAdd 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:

  1. Fully auditable — every line of radio code is visible
  2. Trivially reproducible — just cargo build, no SDK setup
  3. Vendor-independent — no binary blobs, no license restrictions
  4. 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,
    },
}
}
ModeTypical UseRadioCPURAM
AlwaysOnRouters, mains-powered EDsOnOnRetained
SleepyBattery sensors, remotesOff between pollsHaltedRetained
DeepSleepUltra-low-power sensorsOffOffOff (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:

  1. Pending work? — If pending_tx or pending_reports is set, always return StayAwake. Outgoing frames and attribute reports must be sent before the CPU is halted.

  2. AlwaysOn — Always StayAwake. Routers never sleep.

  3. Sleepy

    • If less than wake_duration_ms has 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 LightSleep for the time remaining until the next poll is due.
  4. DeepSleep

    • If the last activity was within the last 1 second, stay awake (brief grace period for completing any post-wake work).
    • Otherwise, enter DeepSleep for wake_interval_s × 1000 ms.
#![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:

ApplicationPoll IntervalBattery Impact
Light switch250–500 msHigh responsiveness, shorter battery
Door sensor5–10 sModerate
Temperature sensor30–60 sVery 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:

PlatformLight SleepDeep Sleep
ESP32-C6/H2esp_light_sleep_start()esp_deep_sleep() — only RTC memory retained
nRF52840TASKS_DISABLE + __WFE (System ON, RAM retained)System OFF (wake via GPIO/RTC)
TLSR8258radio_sleep() + WFI (~1.5 mA)CPU suspend (~3 µA, timer wake, RAM retained)
PHY6222radio_sleep() + WFE (~1.5 mA)AON system sleep (~3 µA, RTC wake)
EFR32MG1radio_sleep() — radio clock gating via CMU
EFR32MG21radio_sleep() — radio clock gating via CMU
BL702PDS (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:

RegisterAddressPurpose
REG_TMR_WKUP0x740 + 0x08Timer compare for wake
REG_WAKEUP_EN0x6EWake source enable (timer, PAD, etc.)
REG_PWDN_CTRL0x6FSuspend/deep-sleep entry (BIT 7)

Battery life estimate (TLSR8258, CR2032, 230 mAh):

StateCurrentDuty CycleAverage
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:

FunctionPurpose
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:

FeatureEFR32MG1P (Series 1)EFR32MG21 (Series 2)
CMU base0x400E40000x40008000
Clock enable registerHFPERCLKEN0CLKEN0
Radio blocks gatedRAC, FRC, MODEM, SYNTH, AGC, BUFCRAC, 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:

AttributeMin IntervalMax IntervalReportable Change
Temperature (0x0402)60 s300 s±0.5 °C (50 centidegrees)
Humidity (0x0405)60 s300 s±1% (100 centi-%)
Battery (0x0001)300 s3600 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)

StateCurrentDuty CycleAverage
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)

StateCurrentDuty CycleAverage
AON system sleep (radio off, flash off, GPIO prepared)~3 µA~99.8%~3.0 µA
Flash standby (deep power-down)~1 µAincluded 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

  1. Minimize wake time. Process events as fast as possible, then sleep. A typical SED wake cycle should complete in under 10 ms.

  2. Batch sensor reads with polls. Read the sensor just before sending a report, so you don’t need a separate wake cycle.

  3. Use appropriate poll intervals. A door sensor that only reports on state change doesn’t need 250 ms polls — 30 seconds is fine.

  4. 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.

  5. Disable unused peripherals. Turn off ADC, I²C, and SPI buses before sleeping — stray current through pull-ups adds up.

  6. 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.

  7. 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.

  8. Enable DC-DC converters (nRF52840). Switching from the internal LDO to the DC-DC converter saves ~40% idle current.

  9. Reduce TX power. For home automation, 0 dBm provides plenty of range while halving TX current compared to +8 dBm.

  10. 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.

  11. Power down flash (PHY6222). Put external or on-chip flash into deep power-down mode before system sleep — saves ~14 µA.

  12. 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

GroupItemsWhy It Matters
NetworkPAN ID, channel, addresses, network key, frame counterWithout these the device would have to rejoin the network from scratch.
APSTC address, link keys, binding table, group tableLink keys enable encrypted communication; bindings control where reports go.
BDBOn-network flag, channel sets, commissioning stateLets the stack know whether to commission or resume on next boot.
ApplicationEndpoint-specific attribute dataPreserves user-visible state (e.g., thermostat setpoint, light on/off).

Frame counter persistence is critical. If NwkFrameCounter is 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:

PlatformRecommended Backend
nRF52840Flash-backed FlashNvStorage using NVMC (last 2 pages = 8 KB) — implemented in nrf52840-sensor
ESP32-C6EspFlashDriver via esp-storage LL API (last 2 sectors at 0x3FE000) — implemented in esp32c6-sensor
ESP32-H2Not yet implemented — network state is lost on reboot
STM32WBInternal flash with wear leveling
GenericBridge 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:

SectorAddressPurpose
Page A0x3FE0000x3FEFFFPrimary NV page
Page B0x3FF0000x3FFFFFSecondary 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:

  1. Network identityNwkPanId, NwkChannel, NwkShortAddress, NwkExtendedPanId
  2. Security materialNwkKey, NwkKeySeqNum, NwkFrameCounter, ApsLinkKey, ApsTrustCenterAddress
  3. TopologyNwkParentAddress, NwkDepth, NwkUpdateId
  4. Bindings and groupsApsBindingTable, ApsGroupTable
  5. Application attributesAppEndpoint1AppEndpoint3 and any AppCustomBase + N items 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
}
}

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:

ConstantValueMeaning
SEC_LEVEL_NONE0x00No security
SEC_LEVEL_MIC_320x01Auth only, 4-byte MIC
SEC_LEVEL_ENC_MIC_320x05Encrypt + 4-byte MIC (default)
SEC_LEVEL_ENC_MIC_640x06Encrypt + 8-byte MIC
SEC_LEVEL_ENC_MIC_1280x07Encrypt + 16-byte MIC

Key identifier constants:

ConstantValueWhen Used
KEY_ID_DATA_KEY0x00Link key (TC or application)
KEY_ID_NETWORK_KEY0x01Network key
KEY_ID_KEY_TRANSPORT0x02Key-transport key
KEY_ID_KEY_LOAD0x03Key-load key

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:

  1. Device sends Association Request (MAC layer, unencrypted).
  2. Parent router forwards the request to the Trust Center.
  3. 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).
  4. APS Transport-Key command carries the encrypted network key to the device via its parent router.
  5. Device decrypts the network key and stores it in NV.
  6. 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

LayerKeyScopeMIC SizeRequired?
NWKNetwork keyAll devices4 bytesYes (always on)
APSLink key (TC or app)Two specific devices4 bytesOptional
MACNot 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

DirectionCommandIDPurpose
Client → ServerQueryNextImageRequest0x01Ask if a new image is available
Server → ClientQueryNextImageResponse0x02Respond with image info or “no update”
Client → ServerImageBlockRequest0x03Request a data block at a given offset
Server → ClientImageBlockResponse0x05Deliver a block (or tell client to wait)
Server → ClientImageNotify0x00Proactively tell client an update exists
Client → ServerUpgradeEndRequest0x06Report download success or failure
Server → ClientUpgradeEndResponse0x07Tell 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

PlatformSlot LocationNotes
nRF52840Secondary flash bank via NVMCDual-bank swap with nRF bootloader
ESP32OTA partition via esp-storageESP-IDF OTA partition table
BL702XIP flash via bl702-pacSingle-bank with staging area
MockRAM 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

  1. FirmwareWriter::erase_slot() — erase the secondary/staging flash area.
  2. FirmwareWriter::write_block() — called once per OTA block (48 bytes each, potentially thousands of calls for a large image).
  3. FirmwareWriter::verify() — check the written size and optional hash.
  4. FirmwareWriter::activate() — set a boot flag or swap marker telling the bootloader to run the new image on next boot.
  5. Reboot — the runtime triggers a system reset.
  6. Bootloader — detects the pending update flag, validates the new image (CRC, signature), and swaps it into the primary slot.

Bootloader Examples

PlatformBootloaderSwap Method
nRF52840MCUboot / nRF BootloaderDual-bank swap
ESP32ESP-IDF bootloaderOTA partition switch
BL702BL702 ROM bootloaderXIP 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 (0xFFF80xFFFF). 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:

  1. Router/Coordinator levelRouterConfig::permit_joining or the coordinator’s initial_permit_join_duration.
  2. Trust Center levelTrustCenter::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:

FeatureStatus
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 TODO items for production hardening.

API Quick Reference

One-page cheat sheet for the zigbee-rs public API, organized by crate.


zigbee-types — Core Addressing Types

TypeDescription
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)
MacAddressEither Short(PanId, ShortAddress) or Extended(PanId, IeeeAddress)
Channel2.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

MethodDescription
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() → MacCapabilitiesQuery radio capabilities

MacCapabilities

FieldTypeDescription
coordinatorboolCan act as PAN coordinator
routerboolCan route frames
hardware_securityboolHardware AES-CCM* support
max_payloadu16Max MAC payload bytes
tx_power_min / tx_power_maxTxPowerTX 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>

MethodDescription
new(mac, device_type) → SelfCreate NWK layer
set_rx_on_when_idle(bool)Set RX-on-when-idle (router=true, sleepy ZED=false)
rx_on_when_idle() → boolQuery RX-on-when-idle
nib() → &NibRead Network Information Base
nib_mut() → &mut NibWrite Network Information Base
is_joined() → boolWhether device has joined a network
device_type() → DeviceTypeCoordinator / Router / EndDevice
mac() → &M / mac_mut() → &mut MAccess underlying MAC driver
security() → &NwkSecurityRead network security state
security_mut() → &mut NwkSecurityWrite network security state
neighbor_table() → &NeighborTableRead neighbor table
routing_table() → &RoutingTableRead 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

ConstantValueDescription
ZDO_ENDPOINT0x00ZDO endpoint
MIN_APP_ENDPOINT0x01First application endpoint
MAX_APP_ENDPOINT0xF0Last application endpoint
BROADCAST_ENDPOINT0xFFBroadcast to all endpoints
PROFILE_HOME_AUTOMATION0x0104HA profile ID
PROFILE_SMART_ENERGY0x0109SE profile ID
PROFILE_ZLL0xC05EZLL profile ID

ApsLayer<M: MacDriver>

MethodDescription
new(nwk) → SelfCreate APS layer wrapping NWK
next_aps_counter() → u8Get next APS frame counter
is_aps_duplicate(src_addr, counter) → boolDetect 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) → boolConfirm 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

MethodDescription
new() → SelfCreate empty binding table
add(entry) → Result<(), BindingEntry>Add a binding entry
remove(src, ep, cluster, dst) → boolRemove 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

ConstructorDescription
unicast(src_addr, src_ep, cluster, dst_addr, dst_ep) → SelfUnicast binding
group(src_addr, src_ep, cluster, group_addr) → SelfGroup binding

GroupTable

MethodDescription
new() → SelfCreate empty group table
add_group(group_addr, endpoint) → boolAdd endpoint to group
remove_group(group_addr, endpoint) → boolRemove 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) → boolCheck group membership
groups() → &[GroupEntry]All groups

zigbee-zdo — Zigbee Device Object

ZdoLayer<M: MacDriver>

Device & Service Discovery

MethodDescription
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

MethodDescription
bind_req(dst, entry) → Result<()>Create remote binding
unbind_req(dst, entry) → Result<()>Remove remote binding

Network Management

MethodDescription
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

MethodDescription
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

MethodDescription
new(aps) → SelfCreate ZDO layer wrapping APS
next_seq() → u8Next ZDP sequence number
deliver_response(cluster, tsn, payload) → boolDeliver 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>

MethodDescription
new(zdo) → SelfCreate BDB layer wrapping ZDO
zdo() → &ZdoLayer<M> / zdo_mut()Access ZDO layer
attributes() → &BdbAttributes / attributes_mut()BDB commissioning attributes
state() → &BdbStateCurrent BDB state machine state
is_on_network() → boolWhether 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

MethodDescription
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

IDConstantName
0x0000BASICBasic
0x0001POWER_CONFIGPower Configuration
0x0003IDENTIFYIdentify
0x0004GROUPSGroups
0x0005SCENESScenes
0x0006ON_OFFOn/Off
0x0008LEVEL_CONTROLLevel Control
0x0019OTA_UPGRADEOTA Upgrade
0x0020POLL_CONTROLPoll Control
0x0300COLOR_CONTROLColor Control
0x0402TEMPERATURETemperature Measurement
0x0405HUMIDITYRelative Humidity
0x0406OCCUPANCYOccupancy Sensing
0x0500IAS_ZONEIAS Zone
0x0702METERINGMetering
0x0B04ELECTRICAL_MEASUREMENTElectrical Measurement

See ZCL Cluster Table for the complete list of all 46 clusters.

ReportingEngine

MethodDescription
new() → SelfCreate 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

MethodDescription
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

MethodDescription
is_joined() → boolNetwork join status
short_address() → u16Current short address
channel() → u8Current channel
pan_id() → u16Current PAN ID
device_type() → DeviceTypeCoordinator/Router/EndDevice
endpoints() → &[EndpointConfig]Registered endpoints
manufacturer_name() → &strManufacturer string
model_identifier() → &strModel string
channel_mask() → ChannelMaskConfigured channel mask
sw_build_id() → &strSoftware build ID
date_code() → &strDate code
is_sleepy() → boolWhether device is a sleepy end device

Data Path

MethodDescription
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

MethodDescription
reporting() → &ReportingEngine / reporting_mut()Access reporting engine
check_and_send_cluster_reports(ep, cluster, store) → boolCheck and transmit due reports
save_state(nv)Persist device state to NV storage
restore_state(nv) → boolRestore state from NV storage
power() → &PowerManager / power_mut()Access power manager
bdb() → &BdbLayer<M> / bdb_mut()Access BDB layer

DeviceBuilder<M: MacDriver>

MethodDescription
new(mac) → SelfCreate builder with MAC driver
device_type(DeviceType) → SelfSet device type
manufacturer(&'static str) → SelfSet manufacturer name
model(&'static str) → SelfSet model identifier
sw_build(&'static str) → SelfSet software build ID
date_code(&'static str) → SelfSet date code
channels(ChannelMask) → SelfSet channel mask
power_mode(PowerMode) → SelfSet power mode (AlwaysOn/Sleepy/DeepSleep)
endpoint(ep, profile, device_id, configure_fn) → SelfAdd an endpoint
build() → ZigbeeDevice<M>Build the device

EndpointBuilder

MethodDescription
cluster_server(cluster_id) → SelfAdd a server cluster
cluster_client(cluster_id) → SelfAdd a client cluster
device_version(version) → SelfSet device version

Device Templates

Pre-configured DeviceBuilder shortcuts in zigbee_runtime::templates:

TemplateDescription
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

VariantDescription
Joined { short_address, channel, pan_id }Successfully joined network
LeftLeft 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
ReportSentAn attribute report was transmitted
OtaImageAvailable { version, size }OTA image available
OtaProgress { percent }OTA download progress
OtaComplete / OtaFailedOTA finished
OtaDelayedActivation { delay_secs }OTA activation delayed
FactoryResetRequestedFactory reset requested

PowerManager

MethodDescription
new(PowerMode) → SelfCreate power manager
mode() → PowerModeCurrent 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) → SleepDecisionDecide: StayAwake / LightSleep(ms) / DeepSleep(ms)
should_poll(now_ms) → boolWhether it’s time to poll parent

PowerMode / SleepDecision

VariantDescription
PowerMode::AlwaysOnZC/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::StayAwakeDon’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

MethodDescription
new(CoordinatorConfig) → SelfCreate coordinator
generate_network_key()Generate random 128-bit network key
network_key() → &[u8; 16] / set_network_key(key)Network key access
allocate_address() → ShortAddressAllocate address for joining device
can_accept_child() → boolCheck child capacity
is_formed() → bool / mark_formed()Network formation state
next_frame_counter() → u32Next NWK frame counter

CoordinatorConfig

FieldTypeDescription
channel_maskChannelMaskChannels to form on
extended_pan_idIeeeAddressExtended PAN ID
centralized_securityboolUse centralized Trust Center
require_install_codesboolRequire install codes for joining
max_childrenu8Max direct children
max_depthu8Max network depth
initial_permit_join_durationu8Permit join duration at startup (seconds)

Router

MethodDescription
new(RouterConfig) → SelfCreate 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) → boolCheck if address is a child
can_accept_child() → boolCheck capacity
age_children(elapsed_seconds)Age children, detect timeouts
child_activity(addr)Record child activity
child_count() → u8Number of children
is_started() → bool / mark_started()Router started state

TrustCenter

MethodDescription
new(network_key) → SelfCreate Trust Center with network key
network_key() → &[u8; 16] / set_network_key(key)Network key access
key_seq_number() → u8Current 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) → boolCheck join policy for device
update_frame_counter(&ieee, counter) → boolUpdate incoming frame counter
next_frame_counter() → u32Next outgoing frame counter
device_count() → usizeNumber 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)

AttributeIDPibValue TypeDefaultDescription
MacShortAddress0x53ShortAddress0xFFFF (unassigned)Own 16-bit network short address. Set by the coordinator during association.
MacPanId0x50PanId0xFFFF (not associated)PAN identifier of the network. Set during join or network formation.
MacExtendedAddress0x6FExtendedAddressHardware-programmedOwn 64-bit IEEE address. Usually read-only, burned into radio hardware.
MacCoordShortAddress0x4BShortAddress0xFFFFShort address of the parent coordinator or router. Set during association.
MacCoordExtendedAddress0x4AExtendedAddress[0; 8]Extended address of the parent coordinator or router.

Network Configuration

AttributeIDPibValue TypeDefaultDescription
MacAssociatedPanCoord0x56Boolfalsetrue if this device is the PAN coordinator. Set during network formation.
MacRxOnWhenIdle0x52BooltrueWhether 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.
MacAssociationPermit0x41BoolfalseWhether the device is accepting association requests (join permit open). Set by permit_joining commands.

Beacon (Non-Beacon Mode)

AttributeIDPibValue TypeDefaultDescription
MacBeaconOrder0x47U815Beacon order. Always 15 for Zigbee PRO (non-beacon mode). Do not change.
MacSuperframeOrder0x54U815Superframe order. Always 15 for Zigbee PRO. Do not change.
MacBeaconPayload0x45PayloadEmptyBeacon payload bytes. Contains NWK beacon content for coordinators and routers. Max 52 bytes.
MacBeaconPayloadLength0x46U80Length of the beacon payload in bytes.

TX/RX Tuning

AttributeIDPibValue TypeDefaultDescription
MacAutoRequest0x42BooltrueAutomatically send data request after receiving a beacon with the pending bit set. Used by end devices to retrieve buffered data from their parent.
MacMaxCsmaBackoffs0x4EU84Maximum number of CSMA-CA backoff attempts before declaring channel access failure. Range: 0–5.
MacMinBe0x4FU83Minimum backoff exponent for CSMA-CA (2.4 GHz default: 3). Lower values mean more aggressive channel access.
MacMaxBe0x57U85Maximum backoff exponent for CSMA-CA. Range: 3–8.
MacMaxFrameRetries0x59U83Number of retransmission attempts after an ACK failure. Range: 0–7.
MacMaxFrameTotalWaitTime0x58U32PHY-dependentMaximum time (in symbols) to wait for an indirect transmission frame. Used by end devices polling their parent.
MacResponseWaitTime0x5AU832Maximum time to wait for a response frame (in units of aBaseSuperframeDuration). Used during association.

Sequence Numbers

AttributeIDPibValue TypeDefaultDescription
MacDsn0x4CU8RandomData/command frame sequence number. Incremented automatically per transmission.
MacBsn0x49U8RandomBeacon sequence number. Incremented per beacon transmission.

Indirect TX (Coordinator/Router)

AttributeIDPibValue TypeDefaultDescription
MacTransactionPersistenceTime0x55U160x01F4How long (in unit periods) a coordinator stores indirect frames for sleepy children before discarding.

Debug / Special

AttributeIDPibValue TypeDefaultDescription
MacPromiscuousMode0x51BoolfalseWhen true, the radio receives all frames regardless of addressing. Used for sniffing/debugging.

PHY Attributes (via MAC GET/SET)

AttributeIDPibValue TypeDefaultDescription
PhyCurrentChannel0x00U811Current 2.4 GHz channel (11–26). Set during network formation or join.
PhyChannelsSupported0x01U320x07FFF800Bitmask of supported channels. For 2.4 GHz Zigbee: bits 11–26 set. Read-only on most hardware.
PhyTransmitPower0x02I8Hardware-dependentTX power in dBm. Range depends on radio hardware (typically −20 to +20 dBm).
PhyCcaMode0x03U81Clear Channel Assessment mode. Mode 1 = energy above threshold. Rarely changed.
PhyCurrentPage0x04U80Channel 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:

VariantContained TypeUsed By
Bool(bool)boolMacAssociatedPanCoord, MacRxOnWhenIdle, MacAssociationPermit, MacAutoRequest, MacPromiscuousMode
U8(u8)u8MacBeaconOrder, MacSuperframeOrder, MacBeaconPayloadLength, MacMaxCsmaBackoffs, MacMinBe, MacMaxBe, MacMaxFrameRetries, MacResponseWaitTime, MacDsn, MacBsn, PhyCurrentChannel, PhyCcaMode, PhyCurrentPage
U16(u16)u16MacTransactionPersistenceTime
U32(u32)u32MacMaxFrameTotalWaitTime, PhyChannelsSupported
I8(i8)i8PhyTransmitPower
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:

MethodReturns
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)SymbolsMillisecondsTypical Use
01,92030.7Ultra-fast scan
24,80076.8Quick scan
38,640138Default for Zigbee
416,320261Standard scan
531,680507Extended scan
8247,2963,957Deep 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

IDNameKey AttributesKey CommandsModule
0x0000BasicZclVersion (0x0000), ManufacturerName (0x0004), ModelIdentifier (0x0005), DateCode (0x0006), PowerSource (0x0007), SwBuildId (0x4000)ResetToFactoryDefaults (0x00)basic
0x0001Power ConfigurationBatteryVoltage (0x0020), BatteryPercentageRemaining (0x0021), BatteryAlarmMask (0x0035), BatterySize (0x0031), BatteryAlarmState (0x003E)power_config
0x0002Device Temperature ConfigurationCurrentTemperature (0x0000), MinTempExperienced (0x0001), MaxTempExperienced (0x0002), DeviceTempAlarmMask (0x0010)device_temp_config
0x0003IdentifyIdentifyTime (0x0000)Identify (0x00), IdentifyQuery (0x01), TriggerEffect (0x40)identify
0x0004GroupsNameSupport (0x0000)AddGroup (0x00), ViewGroup (0x01), GetGroupMembership (0x02), RemoveGroup (0x03), RemoveAllGroups (0x04), AddGroupIfIdentifying (0x05)groups
0x0005ScenesSceneCount (0x0000), CurrentScene (0x0001), CurrentGroup (0x0002), SceneValid (0x0003)AddScene (0x00), ViewScene (0x01), RemoveScene (0x02), RemoveAllScenes (0x03), StoreScene (0x04), RecallScene (0x05), GetSceneMembership (0x06)scenes
0x0006On/OffOnOff (0x0000), GlobalSceneControl (0x4000), OnTime (0x4001), OffWaitTime (0x4002), StartUpOnOff (0x4003)Off (0x00), On (0x01), Toggle (0x02), OffWithEffect (0x40), OnWithRecallGlobalScene (0x41), OnWithTimedOff (0x42)on_off
0x0007On/Off Switch ConfigurationSwitchType (0x0000), SwitchActions (0x0010)on_off_switch_config
0x0008Level ControlCurrentLevel (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
0x0009AlarmsAlarmCount (0x0000)ResetAlarm (0x00), ResetAllAlarms (0x01), GetAlarm (0x02), ResetAlarmLog (0x03)alarms
0x000ATimeTime (0x0000), TimeStatus (0x0001), TimeZone (0x0002), DstStart (0x0003), DstEnd (0x0004), LocalTime (0x0007)time
0x000CAnalog Input (Basic)PresentValue (0x0055), StatusFlags (0x006F), MinPresentValue (0x0045), MaxPresentValue (0x0041), EngineeringUnits (0x0075)analog_input
0x000DAnalog Output (Basic)PresentValue (0x0055), StatusFlags (0x006F), RelinquishDefault (0x0068), EngineeringUnits (0x0075)analog_output
0x000EAnalog Value (Basic)PresentValue (0x0055), StatusFlags (0x006F), RelinquishDefault (0x0068), EngineeringUnits (0x0075)analog_value
0x000FBinary Input (Basic)PresentValue (0x0055), StatusFlags (0x006F), Polarity (0x0054), ActiveText (0x0004), InactiveText (0x002E)binary_input
0x0010Binary Output (Basic)PresentValue (0x0055), StatusFlags (0x006F), Polarity (0x0054), RelinquishDefault (0x0068)binary_output
0x0011Binary Value (Basic)PresentValue (0x0055), StatusFlags (0x006F), RelinquishDefault (0x0068)binary_value
0x0012Multistate Input (Basic)PresentValue (0x0055), NumberOfStates (0x004A), StatusFlags (0x006F)multistate_input
0x0019OTA UpgradeUpgradeServerId (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
0x0020Poll ControlCheckInInterval (0x0000), LongPollInterval (0x0001), ShortPollInterval (0x0002), FastPollTimeout (0x0003)CheckIn (0x00), CheckInResponse (0x00), FastPollStop (0x01), SetLongPollInterval (0x02), SetShortPollInterval (0x03)poll_control
0x0021Green PowerGppMaxProxyTableEntries (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

IDNameKey AttributesKey CommandsModule
0x0101Door LockLockState (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
0x0102Window CoveringWindowCoveringType (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

IDNameKey AttributesKey CommandsModule
0x0201ThermostatLocalTemperature (0x0000), OccupiedCoolingSetpoint (0x0011), OccupiedHeatingSetpoint (0x0012), SystemMode (0x001C), ControlSequenceOfOperation (0x001B), ThermostatRunningMode (0x001E)SetpointRaiseLower (0x00), SetWeeklySchedule (0x01), GetWeeklySchedule (0x02), ClearWeeklySchedule (0x03)thermostat
0x0202Fan ControlFanMode (0x0000), FanModeSequence (0x0001)fan_control
0x0204Thermostat User InterfaceTemperatureDisplayMode (0x0000), KeypadLockout (0x0001), ScheduleProgrammingVisibility (0x0002)thermostat_ui

Lighting Clusters

IDNameKey AttributesKey CommandsModule
0x0300Color ControlCurrentHue (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
0x0301Ballast ConfigurationPhysicalMinLevel (0x0000), PhysicalMaxLevel (0x0001), BallastStatus (0x0002), MinLevel (0x0010), MaxLevel (0x0011), LampQuantity (0x0020)ballast_config

Measurement & Sensing Clusters

IDNameKey AttributesKey CommandsModule
0x0400Illuminance MeasurementMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003), LightSensorType (0x0004)illuminance
0x0401Illuminance Level SensingLevelStatus (0x0000), LightSensorType (0x0001), IlluminanceTargetLevel (0x0010)illuminance_level
0x0402Temperature MeasurementMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003)temperature
0x0403Pressure MeasurementMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003), ScaledValue (0x0010), Scale (0x0014)pressure
0x0404Flow MeasurementMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003)flow_measurement
0x0405Relative HumidityMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003)humidity
0x0406Occupancy SensingOccupancy (0x0000), OccupancySensorType (0x0001), OccupancySensorTypeBitmap (0x0002), PirOToUDelay (0x0010), PirUToODelay (0x0011)occupancy
0x0408Soil MoistureMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003)soil_moisture
0x040DCarbon Dioxide (CO₂)MeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003)carbon_dioxide
0x042APM2.5 MeasurementMeasuredValue (0x0000), MinMeasuredValue (0x0001), MaxMeasuredValue (0x0002), Tolerance (0x0003)pm25

Security (IAS) Clusters

IDNameKey AttributesKey CommandsModule
0x0500IAS ZoneZoneState (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
0x0501IAS ACEPanelStatus (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
0x0502IAS WDMaxDuration (0x0000)StartWarning (0x00), Squawk (0x01)ias_wd

Smart Energy Clusters

IDNameKey AttributesKey CommandsModule
0x0702MeteringCurrentSummationDelivered (0x0000), CurrentSummationReceived (0x0001), UnitOfMeasure (0x0300), Multiplier (0x0301), Divisor (0x0302), InstantaneousDemand (0x0400), MeteringDeviceType (0x0308)metering
0x0B04Electrical MeasurementMeasurementType (0x0000), RmsVoltage (0x0505), RmsCurrent (0x0508), ActivePower (0x050B), ReactivePower (0x050E), ApparentPower (0x050F), PowerFactor (0x0510), AcVoltageMultiplier (0x0600), AcVoltageDivisor (0x0601)electrical
0x0B05DiagnosticsNumberOfResets (0x0000), MacRxBcast (0x0100), MacTxBcast (0x0101), MacRxUcast (0x0102), MacTxUcast (0x0103), MacTxUcastFail (0x0105), LastMessageLqi (0x011C), LastMessageRssi (0x011D)diagnostics

IDNameKey AttributesKey CommandsModule
0x1000Touchlink CommissioningTouchlinkState (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

CategoryCountCluster ID Range
General210x00000x0021
Closures20x01010x0102
HVAC30x02010x0204
Lighting20x03000x0301
Measurement & Sensing100x04000x042A
Security (IAS)30x05000x0502
Smart Energy30x07020x0B05
Touchlink10x1000
Total45

Note: The ota_image module 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):

ValueNameTypical Device
0x0000Standard CIECIE device
0x000DMotion SensorPIR sensor
0x0015Contact SwitchDoor/window sensor
0x0028Fire SensorSmoke detector
0x002AWater SensorLeak detector
0x002BCO SensorCarbon monoxide detector
0x002DPersonal EmergencyPanic button
0x010FRemote ControlKeyfob
0x0115Key FobKey fob
0x021DKeypadSecurity keypad
0x0225Standard WarningSiren/strobe

Thermostat System Modes

ValueMode
0x00Off
0x01Auto
0x03Cool
0x04Heat
0x05Emergency Heat
0x07Fan Only

Metering Unit Types

ValueUnit
0x00kWh (electric)
0x01m³ (gas)
0x02ft³
0x03CCF
0x04US Gallons
0x05Imperial Gallons
0x06BTU
0x07Liters
0x08kPa (gauge)

Color Control Modes

ValueModeDescription
0x00Hue/SaturationHSV color space
0x01XYCIE 1931 color space
0x02Color TemperatureMireds (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.

VariantMeaning
NoBeaconNo beacon received during an active or passive scan
InvalidParameterA primitive was called with an out-of-range parameter
RadioErrorThe radio hardware reported an unrecoverable error
ChannelAccessFailureCSMA-CA failed — the channel remained busy for all back-off attempts
NoAckNo acknowledgement frame was received after transmission
FrameTooLongThe assembled MPDU exceeds the PHY maximum frame size
UnsupportedThe requested operation is not supported by this radio backend
SecurityErrorFrame security processing (encryption / MIC) failed
TransactionOverflowThe indirect-transmit queue is full
TransactionExpiredAn indirect frame was not collected before the persistence timer expired
ScanInProgressA scan request was issued while another scan is already active
TrackingOffSuperframe tracking was lost (beacon-enabled networks)
AssociationDeniedThe coordinator denied the association request
PanAtCapacityThe coordinator indicated the PAN is at capacity
OtherCatch-all for unmapped / unknown errors
NoDataA 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.

VariantCodeMeaning
Success0x00Association was successful
PanAtCapacity0x01PAN is at capacity — no room for new devices
PanAccessDenied0x02Access 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

VariantMeaning
TxFailedTransmission failed (generic)
ChannelBusyCCA indicated a busy channel
NoAckNo acknowledgement from the receiver
TimeoutOperation timed out
HardwareErrorRadio hardware fault

BL702 backend

VariantMeaning
CcaFailureClear Channel Assessment failure — channel is busy
TxAbortedTX was aborted by hardware
HardwareErrorRadio hardware error during TX
InvalidFrameFrame too long or too short
CrcErrorReceived frame failed CRC check
NotInitializedRadio driver has not been initialized
VariantMeaning
CcaFailureCCA failure — channel is busy
TxAbortedTX was aborted
HardwareErrorRadio hardware error
InvalidFrameFrame too long or too short
CrcErrorReceived frame failed CRC check
NotInitializedRadio not initialized

RclCommandStatus (CC2340 only)

Crate: zigbee-mac · File: cc2340/driver.rs

Low-level Radio Control Layer status on the CC2340.

VariantCodeMeaning
Idle0x0000Command is idle
Active0x0001Command is currently executing
Finished0x0101Command completed successfully
ChannelBusy0x0801CCA failed — channel busy
NoAck0x0802No acknowledgement received
RxErr0x0803Receive error
Error0x0F00Generic hardware error

Lmac154TxStatus (BL702 only)

Crate: zigbee-mac · File: bl702/driver.rs

TX completion status from the BL702 lower-MAC.

VariantCodeMeaning
TxFinished0Transmission completed successfully
CsmaFailed1CSMA-CA procedure failed
TxAborted2TX was aborted
HwError3Hardware error

NWK Layer

NwkStatus

Crate: zigbee-nwk · Spec ref: Zigbee spec Table 3-70

VariantCodeMeaning
Success0x00Operation completed successfully
InvalidParameter0xC1A parameter was out of range or invalid
InvalidRequest0xC2The request is invalid in the current state
NotPermitted0xC3Operation not permitted (e.g. security policy)
StartupFailure0xC4Network startup (formation or join) failed
AlreadyPresent0xC5An entry already exists (e.g. duplicate address)
SyncFailure0xC6Synchronisation with the parent lost
NeighborTableFull0xC7The neighbour table has no room for a new entry
UnknownDevice0xC8The specified device is not in the neighbour table
UnsupportedAttribute0xC9NIB attribute identifier is not recognized
NoNetworks0xCANo networks were found during the scan
MaxFrmCounterReached0xCCThe outgoing frame counter has reached its maximum
NoKey0xCDNo matching network key found for decryption
BadCcmOutput0xCECCM* encryption / decryption produced invalid output
RouteDiscoveryFailed0xD0Route discovery did not find a path to the destination
RouteError0xD1A routing error occurred (e.g. link failure)
BtTableFull0xD2The broadcast transaction table is full
FrameNotBuffered0xD3A frame could not be buffered for later transmission
FrameTooLong0xD4The NWK frame exceeds the maximum allowed size

RouteStatus

Crate: zigbee-nwk · File: routing.rs

Internal status of a routing table entry.

VariantMeaning
ActiveRoute is valid and in use
DiscoveryUnderwayRoute discovery has been initiated
DiscoveryFailedRoute discovery completed without finding a route
InactiveRoute exists but is not currently active
ValidationUnderwayRoute is being validated (e.g. many-to-one route)

APS Layer

ApsStatus

Crate: zigbee-aps · Spec ref: Zigbee spec Table 2-27

VariantCodeMeaning
Success0x00Request executed successfully
AsduTooLong0xA0ASDU is too large and fragmentation is not supported
DefragDeferred0xA1A fragmented frame could not be defragmented at this time
DefragUnsupported0xA2Device does not support fragmentation / defragmentation
IllegalRequest0xA3A parameter value was out of range
InvalidBinding0xA4UNBIND request failed — binding table entry not found
InvalidParameter0xA5GET/SET request used an unknown attribute identifier
NoAck0xA6APS-level acknowledged transmission received no ACK
NoBoundDevice0xA7Indirect (binding) transmission found no bound devices
NoShortAddress0xA8Group-addressed transmission found no matching group entry
TableFull0xA9Binding table or group table is full
UnsecuredKey0xAAFrame was secured with a link key not in the key table
UnsupportedAttribute0xABGET/SET request used an unsupported attribute identifier
SecurityFail0xADAn unsecured frame was received when security was required
DecryptionError0xAEAPS frame decryption or authentication failed
InsufficientSpace0xAFNot enough buffer space for the requested operation
NotFound0xB0No matching entry in the binding table

ZCL Layer

ZclStatus

Crate: zigbee-zcl · Spec ref: ZCL Rev 8, Table 2-12

VariantCodeMeaning
Success0x00Operation completed successfully
Failure0x01Generic failure
NotAuthorized0x7ESender is not authorized for this operation
ReservedFieldNotZero0x7FA reserved field in the frame was non-zero
MalformedCommand0x80The command frame is malformed
UnsupClusterCommand0x81Cluster-specific command is not supported
UnsupGeneralCommand0x82General ZCL command is not supported
UnsupManufacturerClusterCommand0x83Manufacturer-specific cluster command is not supported
UnsupManufacturerGeneralCommand0x84Manufacturer-specific general command is not supported
InvalidField0x85A field in the command contains an invalid value
UnsupportedAttribute0x86The specified attribute is not supported on this cluster
InvalidValue0x87The attribute value is out of range or otherwise invalid
ReadOnly0x88Attribute is read-only and cannot be written
InsufficientSpace0x89Not enough space to fulfil the request
DuplicateExists0x8AA duplicate entry already exists
NotFound0x8BThe requested element was not found
UnreportableAttribute0x8CThe attribute does not support reporting
InvalidDataType0x8DThe data type does not match the attribute’s type
InvalidSelector0x8EThe selector (index) for a structured attribute is invalid
WriteOnly0x8FAttribute is write-only and cannot be read
InconsistentStartupState0x90Startup attribute set is inconsistent
DefinedOutOfBand0x91Value was already defined by an out-of-band mechanism
Inconsistent0x92Supplied values are inconsistent
ActionDenied0x93The requested action has been denied
Timeout0x94The operation timed out
Abort0x95Operation was aborted
InvalidImage0x96OTA image is invalid
WaitForData0x97Server is not ready — try again later
NoImageAvailable0x98No OTA image is available for this device
RequireMoreImage0x99More image data is required to continue
NotificationPending0x9AA notification is pending delivery
HardwareFailure0xC0Hardware failure on the device
SoftwareFailure0xC1Software failure on the device
CalibrationError0xC2Calibration error
UnsupportedCluster0xC3The cluster is not supported

ZclFrameError

Crate: zigbee-zcl · File: frame.rs

Errors during ZCL frame parsing.

VariantMeaning
TooShortBuffer too short to contain a valid ZCL header
PayloadTooLargePayload exceeds maximum buffer size
InvalidFrameTypeFrame type bits are invalid / reserved

OtaImageError

Crate: zigbee-zcl · File: clusters/ota_image.rs

Errors when parsing an OTA Upgrade image header.

VariantMeaning
TooShortData too short for the OTA header
BadMagicMagic number does not match the OTA file identifier
UnsupportedVersionHeader version is not supported
BadHeaderLengthHeader length field does not match actual data
ImageTooLargeImage 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.

VariantCodeMeaning
Success0x00Request completed successfully
InvRequestType0x80The request type is invalid
DeviceNotFound0x81The addressed device could not be found
InvalidEp0x82The endpoint is invalid or not active
NotActive0x83The endpoint is not in the active state
NotSupported0x84The requested operation is not supported
Timeout0x85The operation timed out
NoMatch0x86No descriptor matched the request
TableFull0x87The internal table (binding, etc.) is full
NoEntry0x88No matching entry was found
NoDescriptor0x89The requested descriptor is not available

ZdoError

Crate: zigbee-zdo · File: lib.rs

Errors originating from ZDO internal processing.

VariantMeaning
BufferTooSmallSerialisation buffer is too small for the frame
InvalidLengthInput data is shorter than the frame format requires
InvalidDataA parsed field contains a reserved or invalid value
ApsError(ApsStatus)The underlying APS layer returned an error (wraps ApsStatus)
TableFullAn internal fixed-capacity table is full

BDB Layer

BdbStatus

Crate: zigbee-bdb · Spec ref: BDB spec Table 4

VariantCodeMeaning
Success0x00Commissioning completed successfully
InProgress0x01Commissioning is currently in progress
NotOnNetwork0x02Node is not on a network (required for this operation)
NotPermitted0x03Operation is not supported by this device type
NoScanResponse0x04No beacons received during network steering
FormationFailure0x05Network formation failed
SteeringFailure0x06Network steering failed after all retries
NoIdentifyResponse0x07No Identify Query response during Finding & Binding
BindingTableFull0x08Binding table full or cluster matching failed
TouchlinkFailure0x09Touchlink commissioning failed or is not supported
TargetFailure0x0ATarget device is not in identifying mode
Timeout0x0BThe 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.

VariantCodeMeaning
Success0x00Last commissioning attempt succeeded (default)
InProgress0x01Commissioning is in progress
NoNetwork0x02Device is not on a network
TlTargetFailure0x03Touchlink target failure
TlNotAddressAssignment0x04Touchlink address assignment failure
TlNoScanResponse0x05Touchlink scan received no response
NotPermitted0x06Operation not permitted for this device type
SteeringFormationFailure0x07Network steering or formation failed
NoIdentifyQueryResponse0x08Finding & Binding received no Identify response
BindingTableFull0x09Binding table is full
NoScanResponse0x0ANo scan response received

Runtime / Support

StartError

Crate: zigbee-runtime · File: event_loop.rs

High-level errors from device start / join / leave operations.

VariantMeaning
InitFailedBDB initialization failed
CommissioningFailedBDB commissioning (steering or formation) failed

FirmwareError

Crate: zigbee-runtime · File: firmware_writer.rs

Errors during OTA firmware write operations.

VariantMeaning
EraseFailedFlash erase operation failed
WriteFailedFlash write operation failed
VerifyFailedVerification failed (hash or size mismatch)
OutOfRangeOffset is out of range for the firmware slot
ImageTooLargeFirmware slot is not large enough for the image
ActivateFailedActivation failed (e.g. boot flag not set)
HardwareErrorFlash hardware error

NvError

Crate: zigbee-runtime · File: nv_storage.rs

Non-volatile storage errors.

VariantMeaning
NotFoundRequested item was not found
FullStorage is full
BufferTooSmallItem is too large for the provided buffer
HardwareErrorHardware error during read or write
CorruptData corruption detected

Glossary

Zigbee and IEEE 802.15.4 terminology used throughout zigbee-rs.

TermDefinition
APSApplication Support Sub-layer. Provides addressing, binding, group management, and reliable delivery between application endpoints. Implemented in the zigbee-aps crate.
BDBBase 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.
BindingA 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.
ChannelOne 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.
ClusterA 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.
CommissioningThe process of getting a device onto a network and configured. BDB defines four methods: network steering, network formation, Finding & Binding, and Touchlink.
CoordinatorThe 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.
EndpointA 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 IDA 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.
FFDFull-Function Device. An IEEE 802.15.4 device capable of acting as a PAN coordinator or router. Zigbee coordinators and routers are FFDs.
FormationThe BDB commissioning step where a coordinator creates a new Zigbee network by selecting a channel and PAN ID, then starting the network.
Green PowerA 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.
GroupA 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 AddressThe 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 CodeA 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 KeyA 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.
MACMedium 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 KeyA 128-bit AES key shared by all devices in the Zigbee network. Provides NWK-layer encryption and is distributed (encrypted) by the Trust Center.
NIBNWK Information Base. A set of attributes maintained by the NWK layer (e.g. short address, PAN ID, security material). Accessed via NwkGet / NwkSet primitives.
NWKNetwork layer. Handles mesh routing, 16-bit address assignment, broadcast, and network-level security. Implemented in the zigbee-nwk crate.
OTAOver-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.
PANPersonal Area Network. The logical Zigbee network formed by a coordinator and all devices that have joined it.
PAN IDA 16-bit identifier for a PAN, used in MAC frame headers to distinguish traffic from overlapping networks.
PIBPAN Information Base. A set of MAC-layer attributes (e.g. current channel, short address, frame counter) defined by IEEE 802.15.4.
Poll ControlA 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.
ProfileA Zigbee application profile defining which clusters a device type must support. Zigbee 3.0 uses a single unified profile (HA profile 0x0104).
RFDReduced-Function Device. An IEEE 802.15.4 device that cannot route or act as a coordinator — it can only be an end device.
RouterA 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 AddressA 16-bit network address assigned to a device when it joins the network. Used in NWK and MAC frame headers for compact addressing.
SteeringThe BDB commissioning step where a device scans for open networks, joins one, and authenticates with the Trust Center.
TouchlinkA proximity-based commissioning mechanism (BDB chapter 8). A device physically close to a Touchlink initiator can be commissioned without an existing network.
Trust CenterThe device (usually the coordinator) responsible for network security policy: distributing the network key, authorising joins, and managing link keys.
ZCLZigbee Cluster Library. Defines the standard set of clusters, attributes, commands, and data types used by Zigbee applications. Implemented in the zigbee-zcl crate.
ZDOZigbee Device Object. The management entity on endpoint 0 that handles device and service discovery, binding, and network management. Implemented in the zigbee-zdo crate.
ZDPZigbee 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 PROThe 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.